Skip to main content

wxy_domain/
normalise.rs

1//! Normalisation: find a cheaper, equivalent mapping for what was stored.
2//!
3//! Readers record the stored form (the "costume" in regular-in-what).
4//! Normalisation tests whether it is really a lower rung, within a stated
5//! tolerance, and if so adds the cheaper mapping *alongside* the stored one
6//! and marks it primary. Every test returns a [`Report`], accepted or not,
7//! so it is always visible why a grid was labelled what it was.
8//!
9//! Three tests are provided:
10//!
11//! - [`regular_axis`]: a 1D coordinate vector is evenly spaced (R2 to R1;
12//!   GDAL's `IsRegularlySpaced` and `GuessGeoTransform`);
13//! - [`regular_axis_through`]: evenly spaced after a 1D change of frame
14//!   (AVISO's latitude vector is regular in Mercator y);
15//! - [`regular_grid_through`]: 2D coordinate arrays are an affine grid
16//!   after a change of frame (cryptic curvilinear to R1, the NSIDC case).
17//!
18//! The frame change is a plain function supplied by the caller, so this
19//! crate needs no CRS backend.
20
21use std::fmt;
22
23use crate::affine::Affine;
24use crate::domain::Domain;
25use crate::frame::Frame;
26use crate::mapping::{CellConvention, Mapping};
27use crate::positions::{CoordArray, Positions, Rung};
28
29/// How far stored coordinates may sit from the fitted mapping.
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub enum Tolerance {
32    /// One unit in the last place of `f32` at the largest magnitude in the
33    /// coordinates: what rounding to 32-bit storage can produce.
34    Float32,
35    /// As `Float32`, for `f64`, scaled by 16 for accumulated arithmetic.
36    Float64,
37    /// An absolute distance in the frame's units.
38    Absolute(f64),
39    /// A fraction of one step (cell size).
40    RelativeToStep(f64),
41}
42
43impl Tolerance {
44    fn value(&self, max_abs: f64, step: f64) -> f64 {
45        match *self {
46            Tolerance::Float32 => ulp_f32(max_abs),
47            Tolerance::Float64 => 16.0 * max_abs.max(f64::MIN_POSITIVE) * f64::EPSILON,
48            Tolerance::Absolute(t) => t,
49            Tolerance::RelativeToStep(f) => f * step.abs(),
50        }
51    }
52}
53
54/// Spacing of `f32` values at magnitude `m`.
55fn ulp_f32(m: f64) -> f64 {
56    let m = m.abs() as f32;
57    if m == 0.0 || !m.is_finite() {
58        return f32::MIN_POSITIVE as f64;
59    }
60    let next = f32::from_bits(m.to_bits() + 1);
61    (next - m) as f64
62}
63
64/// The outcome of one normalisation test.
65#[derive(Debug, Clone, PartialEq)]
66pub struct Report {
67    /// Domain dims the test covered.
68    pub dims: Vec<usize>,
69    pub from: Rung,
70    /// The rung found, when accepted.
71    pub to: Option<Rung>,
72    /// The frame the stored coordinates were in.
73    pub frame: Frame,
74    /// The frame the test found regularity in, when it changed frame.
75    pub through: Option<String>,
76    /// Largest distance between a stored coordinate and the fitted mapping,
77    /// in the tested frame's units.
78    pub max_deviation: f64,
79    /// The same, as a fraction of one step.
80    pub max_deviation_steps: f64,
81    /// The tolerance applied, in the tested frame's units.
82    pub tolerance: f64,
83    pub accepted: bool,
84}
85
86impl fmt::Display for Report {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        let verdict = if self.accepted {
89            "regular"
90        } else {
91            "not regular"
92        };
93        write!(
94            f,
95            "dims {:?}: {:?} -> {}{}; max deviation {:.3e} ({:.2e} steps), tolerance {:.3e}",
96            self.dims,
97            self.from,
98            verdict,
99            self.through
100                .as_ref()
101                .map(|t| format!(" in {t}"))
102                .unwrap_or_default(),
103            self.max_deviation,
104            self.max_deviation_steps,
105            self.tolerance
106        )
107    }
108}
109
110/// Fit `origin + i * step` to a coordinate vector through its end values,
111/// returning `(origin_at_index_0, step, max_deviation)`.
112fn fit_line(c: &[f64], cell: CellConvention) -> Option<(f64, f64, f64)> {
113    let n = c.len();
114    if n < 2 || c.iter().any(|v| !v.is_finite()) {
115        return None;
116    }
117    let step = (c[n - 1] - c[0]) / (n - 1) as f64;
118    if step == 0.0 {
119        return None;
120    }
121    let max_dev = c
122        .iter()
123        .enumerate()
124        .map(|(k, v)| (v - (c[0] + k as f64 * step)).abs())
125        .fold(0.0, f64::max);
126    let origin = match cell {
127        CellConvention::Centre => c[0] - 0.5 * step,
128        CellConvention::Edge => c[0],
129    };
130    Some((origin, step, max_dev))
131}
132
133/// Test whether a 1D coordinate vector is evenly spaced.
134pub fn regular_axis(
135    coords: &[f64],
136    cell: CellConvention,
137    tolerance: Tolerance,
138) -> (Option<Positions>, Report) {
139    regular_axis_inner(coords, cell, tolerance, None)
140}
141
142/// Test whether a 1D coordinate vector is evenly spaced after applying `f`
143/// to every value (for example latitude to Mercator y). `through` names the
144/// frame `f` maps into. On success the returned positions are in that frame.
145pub fn regular_axis_through(
146    coords: &[f64],
147    f: impl Fn(f64) -> f64,
148    through: &str,
149    cell: CellConvention,
150    tolerance: Tolerance,
151) -> (Option<Positions>, Report) {
152    let mapped: Vec<f64> = coords.iter().map(|&v| f(v)).collect();
153    regular_axis_inner(&mapped, cell, tolerance, Some(through.to_string()))
154}
155
156fn regular_axis_inner(
157    coords: &[f64],
158    cell: CellConvention,
159    tolerance: Tolerance,
160    through: Option<String>,
161) -> (Option<Positions>, Report) {
162    let max_abs = coords.iter().fold(0.0f64, |m, v| m.max(v.abs()));
163    let mut report = Report {
164        dims: vec![],
165        from: Rung::Rectilinear,
166        to: None,
167        frame: Frame::Index,
168        through,
169        max_deviation: f64::NAN,
170        max_deviation_steps: f64::NAN,
171        tolerance: f64::NAN,
172        accepted: false,
173    };
174    let Some((origin, step, max_dev)) = fit_line(coords, cell) else {
175        return (None, report);
176    };
177    let tol = tolerance.value(max_abs, step);
178    report.max_deviation = max_dev;
179    report.max_deviation_steps = max_dev / step.abs();
180    report.tolerance = tol;
181    if max_dev <= tol {
182        report.accepted = true;
183        report.to = Some(Rung::Regular);
184        (Some(Positions::Regular1 { origin, step }), report)
185    } else {
186        (None, report)
187    }
188}
189
190/// Test whether 2D coordinate arrays (centres, shaped `[nrow, ncol]`) form
191/// an affine grid after mapping each `(x, y)` pair through `f`. `through`
192/// names the frame `f` maps into. `None` from `f` fails the test.
193///
194/// On success returns the affine transform in the `through` frame, for
195/// index order `[row, col]` as [`Positions::Affine`] expects.
196pub fn regular_grid_through(
197    x: &CoordArray,
198    y: &CoordArray,
199    f: impl Fn(f64, f64) -> Option<(f64, f64)>,
200    through: &str,
201    tolerance: Tolerance,
202) -> (Option<Affine>, Report) {
203    let mut report = Report {
204        dims: vec![],
205        from: Rung::Curvilinear,
206        to: None,
207        frame: Frame::Index,
208        through: Some(through.to_string()),
209        max_deviation: f64::NAN,
210        max_deviation_steps: f64::NAN,
211        tolerance: f64::NAN,
212        accepted: false,
213    };
214    if x.shape.len() != 2 || x.shape != y.shape || x.shape[0] < 2 || x.shape[1] < 2 {
215        return (None, report);
216    }
217    let (nr, nc) = (x.shape[0], x.shape[1]);
218    let mut px = Vec::with_capacity(nr * nc);
219    let mut py = Vec::with_capacity(nr * nc);
220    for k in 0..nr * nc {
221        match f(x.values[k], y.values[k]) {
222            Some((a, b)) if a.is_finite() && b.is_finite() => {
223                px.push(a);
224                py.push(b);
225            }
226            _ => return (None, report),
227        }
228    }
229    let at = |r: usize, c: usize| (px[r * nc + c], py[r * nc + c]);
230    let (x00, y00) = at(0, 0);
231    let (x01, y01) = at(0, nc - 1);
232    let (x10, y10) = at(nr - 1, 0);
233    // Per-index steps along col and row, from the three corner centres.
234    let dxc = (x01 - x00) / (nc - 1) as f64;
235    let dyc = (y01 - y00) / (nc - 1) as f64;
236    let dxr = (x10 - x00) / (nr - 1) as f64;
237    let dyr = (y10 - y00) / (nr - 1) as f64;
238    let mut max_dev = 0.0f64;
239    for r in 0..nr {
240        for c in 0..nc {
241            let (a, b) = at(r, c);
242            let ex = x00 + c as f64 * dxc + r as f64 * dxr;
243            let ey = y00 + c as f64 * dyc + r as f64 * dyr;
244            max_dev = max_dev.max((a - ex).hypot(b - ey));
245        }
246    }
247    let step = dxc.hypot(dyc).min(dxr.hypot(dyr));
248    let max_abs = px.iter().chain(&py).fold(0.0f64, |m, v| m.max(v.abs()));
249    let tol = tolerance.value(max_abs, step);
250    report.max_deviation = max_dev;
251    report.max_deviation_steps = max_dev / step;
252    report.tolerance = tol;
253    if max_dev > tol {
254        return (None, report);
255    }
256    report.accepted = true;
257    report.to = Some(Rung::Regular);
258    // Centre of cell (0, 0) is at index (0.5, 0.5); move to the outer edge.
259    let x0 = x00 - 0.5 * dxc - 0.5 * dxr;
260    let y0 = y00 - 0.5 * dyc - 0.5 * dyr;
261    (Some(Affine([x0, dxc, dxr, y0, dyc, dyr])), report)
262}
263
264/// The conservative default (design doc 02, gate 2 default 3): every 1D
265/// rectilinear mapping that is evenly spaced within `Float32` tolerance
266/// gains a regular mapping, marked primary. Stored mappings are kept.
267pub fn normalise_domain(domain: &mut Domain) -> Vec<Report> {
268    normalise_domain_with(domain, Tolerance::Float32)
269}
270
271/// As [`normalise_domain`], with a chosen tolerance.
272pub fn normalise_domain_with(domain: &mut Domain, tolerance: Tolerance) -> Vec<Report> {
273    let mut reports = Vec::new();
274    let candidates: Vec<usize> = domain
275        .mappings
276        .iter()
277        .enumerate()
278        .filter(|(_, m)| matches!(&m.positions, Positions::Rectilinear(v) if v.len() == 1))
279        .map(|(i, _)| i)
280        .collect();
281    for i in candidates {
282        let m = &domain.mappings[i];
283        let Positions::Rectilinear(axes) = &m.positions else {
284            unreachable!()
285        };
286        let (found, mut report) = regular_axis(&axes[0], m.cell, tolerance);
287        report.dims = m.dims.clone();
288        report.frame = m.frame.clone();
289        if let Some(p) = found {
290            let derived = Mapping::new(m.dims.clone(), p, m.frame.clone())
291                .with_cell(m.cell)
292                .with_provenance("normalise");
293            let j = domain.add_mapping(derived);
294            domain.set_primary(j);
295        }
296        reports.push(report);
297    }
298    reports
299}
300
301/// An affine transform from the primary mappings of a row dim and a col
302/// dim, when both are R1 (`Regular1`) in the same frame.
303pub fn affine_from_axes(domain: &Domain, row: usize, col: usize) -> Option<(Affine, Frame)> {
304    let r = domain.primary_for(row)?;
305    let c = domain.primary_for(col)?;
306    if r.frame != c.frame {
307        return None;
308    }
309    match (&r.positions, &c.positions) {
310        (
311            Positions::Regular1 {
312                origin: y0,
313                step: dy,
314            },
315            Positions::Regular1 {
316                origin: x0,
317                step: dx,
318            },
319        ) => Some((Affine([*x0, *dx, 0.0, *y0, 0.0, *dy]), r.frame.clone())),
320        _ => None,
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn ulp_f32_at_180() {
330        // f32 values between 128 and 256 are spaced 2^-16 apart.
331        assert_eq!(ulp_f32(180.0), 2f64.powi(-16));
332    }
333
334    #[test]
335    fn exact_axis_is_regular() {
336        let c: Vec<f64> = (0..10).map(|i| 0.5 + i as f64).collect();
337        let (p, r) = regular_axis(&c, CellConvention::Centre, Tolerance::Float64);
338        assert!(r.accepted);
339        match p.unwrap() {
340            Positions::Regular1 { origin, step } => {
341                assert_eq!(origin, 0.0);
342                assert_eq!(step, 1.0);
343            }
344            _ => panic!(),
345        }
346    }
347
348    #[test]
349    fn stretched_axis_is_not_regular() {
350        let c: Vec<f64> = (0..10).map(|i| (i as f64).powf(1.2)).collect();
351        let (p, r) = regular_axis(&c, CellConvention::Centre, Tolerance::Float32);
352        assert!(p.is_none());
353        assert!(!r.accepted);
354        assert!(r.max_deviation > r.tolerance);
355    }
356}