Skip to main content

ridge_core/
grid.rs

1//! Sample a bounding box into an elevation grid — the Rust twin of
2//! `SRTM.py`'s `get_image(..., mode='array')` as used by ridge_map.
3
4use ndarray::Array2;
5
6use crate::srtm::TileSource;
7use crate::Bbox;
8
9/// Sample `num_lines x elevation_pts` elevations over `bbox`.
10///
11/// Mirrors the upstream sampling exactly (`/N`, not `/(N-1)`, so the last
12/// row/column stops one step short of the far corner):
13///
14/// ```text
15/// lat = lat0 + r / num_lines       * (lat1 - lat0)
16/// lon = lon0 + c / elevation_pts   * (lon1 - lon0)
17/// ```
18///
19/// Voids (open ocean, bad samples) are NaN.
20pub 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        // Missing tile (open ocean) or a void sample -> NaN.
32        source
33            .tile(lat.floor() as i32, lon.floor() as i32)
34            .map_or(f64::NAN, |tile| tile.elevation(lat, lon))
35    })
36}
37
38/// Sample a square, disc-masked region: `n x n` points over a square of
39/// side `span_deg` centered at `(center_lat, center_lon)`, with samples
40/// outside the inscribed circle (radius `span_deg / 2`) set to NaN.
41///
42/// The disc is rotation-invariant about the grid center: rotating the grid
43/// by any angle leaves the set of finite samples unchanged (same count, same
44/// extent), so an orbiting view keeps a constant amount of visible terrain —
45/// the "camera distance" is genuinely fixed and no per-angle reframing is
46/// needed. This is what the web frontend's disc mode uses.
47pub 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; // outside the disc: never sampled
62        }
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/// Sample an ANISOTROPIC display window from a preprocessed square data
70/// grid. The window covers `lat0..lat1 x lon0..lon1` at
71/// `num_lines x elevation_pts` samples (cells of
72/// `(lat1-lat0)/num_lines x (lon1-lon0)/elevation_pts` degrees), reading the
73/// data grid at nearest nodes; positions outside the data grid become NaN.
74///
75/// This is the rectangle view: the window keeps the upstream bbox extent,
76/// line count and cell shape at every angle, while the underlying square
77/// disc (rotation-invariant) supplies previously unused terrain as the
78/// window sweeps around.
79#[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; // data grid is square
93    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        // Reject anything that isn't a non-negative index, including the NaN
99        // a degenerate `step` can produce (`NaN as usize` silently saturates
100        // to 0); `get` then handles the far edge (out of bounds -> NaN).
101        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
110/// Upstream `get_elevation_data` swaps the sampling resolution when the
111/// viewpoint angle is within the (45..135 | 225..315) degree bands.
112pub 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        // First row: lat = 43.0 (bottom edge), matching upstream convention.
129        let v00 = grid[(0, 0)];
130        let expected = SyntheticSource::value(43.0, -72.0).round();
131        assert!((v00 - expected).abs() < 1e-9);
132        // Last sampled point stops short of the far corner: r/N, not r/(N-1).
133        // Compare through the same nearest-neighbor tile lookup (floating
134        // point can nudge r/N onto the adjacent grid cell, exactly like
135        // upstream SRTM.py).
136        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        // An interior point that lands exactly on a grid node (1/4, 2/5 are
141        // binary-exact fractions of the span).
142        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    /// Only serves one tile; everything else is open ocean.
149    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        // Half the box falls into the missing neighbor tile -> NaN there.
163        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        // The finite support of a disc-masked grid is identical at every
173        // angle: same count, same extent. (This is the property that makes
174        // the frontend's fixed-distance orbit work.)
175        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        // Disc covers pi/4 of the square (plus boundary-rounding slack).
179        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        // Outside the disc: strictly NaN. `grid` is 40x40 centered at
185        // (44, -72) with span 1.0, so the disc radius is 0.5.
186        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        // Data grid: 8x8 over span 1.0 centered at (44, -72); value encodes
202        // position so we can verify exactly which node each window cell read.
203        let src = SyntheticSource { side: 8 }; // unused for this test
204        let _ = src;
205        let data = Array2::from_shape_fn((8, 8), |(r, c)| (r * 100 + c) as f64);
206        // Data square: lat 43.5..44.5, lon -72.5..-71.5.
207        let (d_lat0, d_lon0, d_span) = (43.5f64, -72.5f64, 1.0f64);
208        // Window = the lower-left 4x8-degree... anisotropic: lat 43.5..44.3
209        // (4 rows over 0.8), lon -72.5..-71.7 (8 cols over 0.8).
210        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        // Sample positions: lat_i = 43.5 + i/4*0.8 -> rows round((lat-43.5)/0.125)
215        // i=0 -> row 0; i=3 -> row round(2.4)=2. lon_j = -72.5 + j/8*0.8 ->
216        // cols j -> col j exactly.
217        // Window col j -> data col round(j * 0.8 / 0.125) (window steps are
218        // finer than data steps here: 0.1 vs 0.125 degrees).
219        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        // Rows: window row i -> data row round(i * 0.2 / 0.125).
232        assert_eq!(out[(2, 0)], 300.0); // round(3.2) = 3
233        assert_eq!(out[(3, 0)], 500.0); // round(4.8) = 5
234    }
235
236    #[test]
237    fn zero_span_window_is_all_gaps() {
238        // A degenerate zero-degree span makes `step == 0`, so the coordinate
239        // math yields NaN/inf. The window must come back entirely NaN rather
240        // than silently reading cell (0, 0) (a NaN cast saturates to 0).
241        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)); // 270 rem_euclid
253        assert!(swap_for_angle(280.0));
254    }
255}
256
257#[cfg(test)]
258mod parity_tests {
259    //! Golden test against upstream: `ridge_map/test/test_data/new_hampshire.npz`
260    //! is the exact array `RidgeMap().get_elevation_data()` returns. We compare
261    //! our sampling of the same bbox against it. Requires the four SRTM tiles
262    //! in `fixtures/srtm/` (run `scripts/fetch_fixtures.sh` once) — skipped
263    //! otherwise.
264
265    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            // This test is the evidence that sampling matches upstream, so CI
275            // sets RIDGE_REQUIRE_FIXTURES and a missing fixture fails it.
276            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        // Compare every sample with upstream. A cell agrees when both sides
296        // are gaps, or both are finite and within 1e-9; anything else counts
297        // as a mismatch.
298        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; // both gaps: agree
303            }
304            let delta = (got - want).abs();
305            if !got.is_nan() && !want.is_nan() && delta <= 1e-9 {
306                continue; // both finite, within tolerance: agree
307            }
308            mismatches += 1;
309            // A gap on one side gives a NaN delta, which has no magnitude to
310            // track; only a finite disagreement can raise `worst`.
311            if delta.is_finite() {
312                worst = worst.max(delta);
313            }
314        }
315        // Small mismatches can appear where nearest-neighbor rounding sits on
316        // a floating-point knife edge; upstream has the same instability.
317        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}