1use ndarray::Array2;
5
6use crate::srtm::TileSource;
7use crate::Bbox;
8
9pub fn sample(
21 source: &dyn TileSource,
22 bbox: &Bbox,
23 num_lines: usize,
24 elevation_pts: usize,
25) -> Array2<f64> {
26 let (lat0, lat1) = bbox.lats();
27 let (lon0, lon1) = bbox.longs();
28 Array2::from_shape_fn((num_lines, elevation_pts), |(r, c)| {
29 let lat = lat0 + r as f64 / num_lines as f64 * (lat1 - lat0);
30 let lon = lon0 + c as f64 / elevation_pts as f64 * (lon1 - lon0);
31 source
33 .tile(lat.floor() as i32, lon.floor() as i32)
34 .map_or(f64::NAN, |tile| tile.elevation(lat, lon))
35 })
36}
37
38pub fn sample_disc(
48 source: &dyn TileSource,
49 center_lat: f64,
50 center_lon: f64,
51 span_deg: f64,
52 n: usize,
53) -> Array2<f64> {
54 let half = span_deg / 2.0;
55 let radius_sq = half * half;
56 Array2::from_shape_fn((n, n), |(r, c)| {
57 let lat = center_lat - half + r as f64 / n as f64 * span_deg;
58 let lon = center_lon - half + c as f64 / n as f64 * span_deg;
59 let (dlat, dlon) = (lat - center_lat, lon - center_lon);
60 if dlat * dlat + dlon * dlon > radius_sq {
61 return f64::NAN; }
63 source
64 .tile(lat.floor() as i32, lon.floor() as i32)
65 .map_or(f64::NAN, |tile| tile.elevation(lat, lon))
66 })
67}
68
69#[allow(clippy::too_many_arguments)]
80pub fn sample_window(
81 data: &Array2<f64>,
82 d_lat0: f64,
83 d_lon0: f64,
84 d_span: f64,
85 lat0: f64,
86 lon0: f64,
87 lat1: f64,
88 lon1: f64,
89 num_lines: usize,
90 elevation_pts: usize,
91) -> Array2<f64> {
92 let step = d_span / data.nrows() as f64; Array2::from_shape_fn((num_lines, elevation_pts), |(i, j)| {
94 let lat = lat0 + i as f64 / num_lines as f64 * (lat1 - lat0);
95 let lon = lon0 + j as f64 / elevation_pts as f64 * (lon1 - lon0);
96 let r = ((lat - d_lat0) / step).round();
97 let c = ((lon - d_lon0) / step).round();
98 if !(r >= 0.0 && c >= 0.0) {
102 return f64::NAN;
103 }
104 data.get((r as usize, c as usize))
105 .copied()
106 .unwrap_or(f64::NAN)
107 })
108}
109
110pub fn swap_for_angle(viewpoint_angle: f64) -> bool {
113 let a = viewpoint_angle.rem_euclid(360.0);
114 (45.0 < a && a < 135.0) || (225.0 < a && a < 315.0)
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::srtm::SyntheticSource;
121
122 #[test]
123 fn sample_shape_and_values() {
124 let src = SyntheticSource { side: 1201 };
125 let bbox = Bbox::new(-72.0, 43.0, -71.0, 44.0);
126 let grid = sample(&src, &bbox, 4, 5);
127 assert_eq!(grid.dim(), (4, 5));
128 let v00 = grid[(0, 0)];
130 let expected = SyntheticSource::value(43.0, -72.0).round();
131 assert!((v00 - expected).abs() < 1e-9);
132 let lat_last = 43.0 + 3.0 / 4.0 * 1.0;
137 let lon_last = -72.0 + 4.0 / 5.0 * 1.0;
138 let tile = src.tile(43, -72).unwrap();
139 assert_eq!(grid[(3, 4)], tile.elevation(lat_last, lon_last));
140 let lat = 43.0 + 1.0 / 4.0;
143 let lon = -72.0 + 2.0 / 5.0;
144 let expected = SyntheticSource::value(lat, lon).round();
145 assert!((grid[(1, 2)] - expected).abs() < 1e-9);
146 }
147
148 struct OneTileSource;
150 impl crate::srtm::TileSource for OneTileSource {
151 fn tile(&self, lat_lo: i32, lon_lo: i32) -> Option<std::sync::Arc<crate::srtm::Tile>> {
152 if lat_lo == 43 && lon_lo == -72 {
153 SyntheticSource { side: 1201 }.tile(lat_lo, lon_lo)
154 } else {
155 None
156 }
157 }
158 }
159
160 #[test]
161 fn ocean_is_nan() {
162 let src = OneTileSource;
164 let bbox = Bbox::new(-71.5, 43.5, -70.5, 44.5);
165 let grid = sample(&src, &bbox, 5, 5);
166 assert!(grid.iter().any(|v| v.is_nan()), "missing tile -> NaN");
167 assert!(grid.iter().any(|v| v.is_finite()), "present tile -> values");
168 }
169
170 #[test]
171 fn disc_mask_is_rotation_invariant() {
172 let src = SyntheticSource { side: 1201 };
176 let grid = sample_disc(&src, 44.0, -72.0, 1.0, 40);
177 let finite = grid.iter().filter(|v| v.is_finite()).count();
178 let expected = (std::f64::consts::FRAC_PI_4 * 1600.0) as usize;
180 assert!(
181 (finite as i64 - expected as i64).abs() < 40,
182 "finite {finite} vs {expected}"
183 );
184 let outside_disc = |r: usize, c: usize| {
187 let dlat = (44.0 - 0.5 + r as f64 / 40.0) - 44.0;
188 let dlon = (-72.0 - 0.5 + c as f64 / 40.0) - -72.0;
189 dlat * dlat + dlon * dlon > 0.25
190 };
191 assert!(
192 grid.indexed_iter()
193 .filter(|&((r, c), _)| outside_disc(r, c))
194 .all(|(_, v)| v.is_nan()),
195 "every cell outside the disc must be NaN"
196 );
197 }
198
199 #[test]
200 fn anisotropic_window_samples_correct_positions() {
201 let src = SyntheticSource { side: 8 }; let _ = src;
205 let data = Array2::from_shape_fn((8, 8), |(r, c)| (r * 100 + c) as f64);
206 let (d_lat0, d_lon0, d_span) = (43.5f64, -72.5f64, 1.0f64);
208 let out = sample_window(
211 &data, d_lat0, d_lon0, d_span, 43.5, -72.5, 44.3, -71.7, 4, 8,
212 );
213 assert_eq!(out.dim(), (4, 8));
214 for (j, expected_col) in [
220 (0usize, 0usize),
221 (1, 1),
222 (2, 2),
223 (3, 2),
224 (4, 3),
225 (5, 4),
226 (6, 5),
227 (7, 6),
228 ] {
229 assert_eq!(out[(0, j)], (expected_col) as f64, "row 0 col {j}");
230 }
231 assert_eq!(out[(2, 0)], 300.0); assert_eq!(out[(3, 0)], 500.0); }
235
236 #[test]
237 fn zero_span_window_is_all_gaps() {
238 let data = Array2::from_shape_fn((4, 4), |(r, c)| (r * 4 + c) as f64);
242 let out = sample_window(&data, 43.0, -72.0, 0.0, 43.0, -72.0, 44.0, -71.0, 4, 4);
243 assert!(out.iter().all(|v| v.is_nan()), "zero span must not sample");
244 }
245
246 #[test]
247 fn angle_bands() {
248 assert!(!swap_for_angle(0.0));
249 assert!(!swap_for_angle(45.0));
250 assert!(swap_for_angle(90.0));
251 assert!(!swap_for_angle(135.0));
252 assert!(swap_for_angle(-90.0)); assert!(swap_for_angle(280.0));
254 }
255}
256
257#[cfg(test)]
258mod parity_tests {
259 use super::*;
266 use crate::srtm::DirSource;
267 use std::path::Path;
268
269 #[test]
270 fn white_mountains_matches_upstream_fixture() {
271 let bin = Path::new("../../fixtures/new_hampshire.f64.bin");
272 let srtm_dir = Path::new("../../fixtures/srtm");
273 if !bin.exists() || !srtm_dir.exists() {
274 assert!(
277 std::env::var_os("RIDGE_REQUIRE_FIXTURES").is_none(),
278 "RIDGE_REQUIRE_FIXTURES is set but the fixtures are missing; run scripts/fetch_fixtures.sh"
279 );
280 eprintln!("skipping: fixtures not fetched (scripts/fetch_fixtures.sh)");
281 return;
282 }
283 let bytes = std::fs::read(bin).unwrap();
284 let expected: Vec<f64> = bytes
285 .as_chunks::<8>()
286 .0
287 .iter()
288 .map(|&b| f64::from_le_bytes(b))
289 .collect();
290
291 let src = DirSource::new(srtm_dir);
292 let grid = sample(&src, &crate::DEFAULT_BBOX, 80, 300);
293 assert_eq!(grid.len(), expected.len());
294
295 let mut mismatches = 0usize;
299 let mut worst = 0.0f64;
300 for (&got, &want) in grid.iter().zip(&expected) {
301 if got.is_nan() && want.is_nan() {
302 continue; }
304 let delta = (got - want).abs();
305 if !got.is_nan() && !want.is_nan() && delta <= 1e-9 {
306 continue; }
308 mismatches += 1;
309 if delta.is_finite() {
312 worst = worst.max(delta);
313 }
314 }
315 let frac = mismatches as f64 / (expected.len() as f64);
318 println!(
319 "parity: {mismatches}/{} mismatches (worst delta {worst:.2e})",
320 expected.len()
321 );
322 assert!(frac < 0.01, "{mismatches} mismatches (worst delta {worst})");
323 }
324}