Skip to main content

connectivity_core/
window.rs

1use ndarray::ArrayView1;
2
3/// Shape of the annulus that each level contributes around a focal cell. Cells cut by the
4/// annulus boundary count with their covered area fraction in both modes.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum WindowMode {
7    /// Source-centred circular annuli (the Python default).
8    Circular,
9    /// Source-centred square annuli.
10    Square,
11}
12
13impl WindowMode {
14    /// Parse `"circular"` or `"square"`, ignoring case and surrounding whitespace. The error
15    /// is the message the Python binding raises as `ValueError`.
16    pub fn parse(value: &str) -> Result<Self, String> {
17        match value.trim().to_ascii_lowercase().as_str() {
18            "square" => Ok(Self::Square),
19            "circular" => Ok(Self::Circular),
20            other => Err(format!(
21                "Invalid window_mode '{other}'. Expected 'square' or 'circular'."
22            )),
23        }
24    }
25}
26
27#[inline]
28pub(crate) fn square_frac(
29    r0: f64,
30    r1: f64,
31    c0: f64,
32    c1: f64,
33    center_r: f64,
34    center_c: f64,
35    inner: f64,
36    outer: f64,
37) -> f32 {
38    let footprint_area = (r1 - r0) * (c1 - c0);
39    if footprint_area <= 0.0 {
40        return 0.0;
41    }
42
43    let outer_area = square_overlap(r0, r1, c0, c1, center_r, center_c, outer);
44    if outer_area <= 0.0 {
45        return 0.0;
46    }
47
48    let inner_area = square_overlap(r0, r1, c0, c1, center_r, center_c, inner);
49    let annulus_area = (outer_area - inner_area).max(0.0);
50    (annulus_area / footprint_area).clamp(0.0, 1.0) as f32
51}
52
53#[inline]
54fn square_overlap(
55    r0: f64,
56    r1: f64,
57    c0: f64,
58    c1: f64,
59    center_r: f64,
60    center_c: f64,
61    half_extent: f64,
62) -> f64 {
63    if half_extent <= 0.0 {
64        return 0.0;
65    }
66
67    let sr0 = center_r - half_extent;
68    let sr1 = center_r + half_extent;
69    let sc0 = center_c - half_extent;
70    let sc1 = center_c + half_extent;
71
72    let rows = r1.min(sr1) - r0.max(sr0);
73    if rows <= 0.0 {
74        return 0.0;
75    }
76
77    let cols = c1.min(sc1) - c0.max(sc0);
78    if cols <= 0.0 {
79        return 0.0;
80    }
81
82    rows * cols
83}
84
85#[inline]
86pub(crate) fn circle_frac(
87    r0: f64,
88    r1: f64,
89    c0: f64,
90    c1: f64,
91    center_r: f64,
92    center_c: f64,
93    inner: f64,
94    outer: f64,
95) -> (f32, bool) {
96    let footprint_area = (r1 - r0) * (c1 - c0);
97    if footprint_area <= 0.0 {
98        return (0.0, false);
99    }
100
101    let outer_area = circle_overlap(r0, r1, c0, c1, center_r, center_c, outer);
102    if outer_area <= 0.0 {
103        return (0.0, false);
104    }
105
106    let inner_area = circle_overlap(r0, r1, c0, c1, center_r, center_c, inner);
107    let annulus_area = (outer_area - inner_area).max(0.0);
108    let fraction = (annulus_area / footprint_area).clamp(0.0, 1.0) as f32;
109    let is_outer_edge =
110        annulus_area > 0.0 && crosses_circle(r0, r1, c0, c1, center_r, center_c, outer);
111
112    (fraction, is_outer_edge)
113}
114
115#[inline]
116fn circle_overlap(
117    r0: f64,
118    r1: f64,
119    c0: f64,
120    c1: f64,
121    center_r: f64,
122    center_c: f64,
123    radius: f64,
124) -> f64 {
125    let rect_area = (r1 - r0) * (c1 - c0);
126    if rect_area <= 0.0 || radius <= 0.0 {
127        return 0.0;
128    }
129
130    let x0 = c0 - center_c;
131    let x1 = c1 - center_c;
132    let y0 = r0 - center_r;
133    let y1 = r1 - center_r;
134    let radius_sq = radius * radius;
135
136    if min_dist_sq(x0, x1, y0, y1) >= radius_sq {
137        return 0.0;
138    }
139    if max_dist_sq(x0, x1, y0, y1) <= radius_sq {
140        return rect_area;
141    }
142
143    let x_parts = split_at_zero(x0, x1);
144    let y_parts = split_at_zero(y0, y1);
145    let mut area = 0.0;
146
147    for &(xa, xb) in &x_parts {
148        if xb <= xa {
149            continue;
150        }
151        let (qx0, qx1) = if xb <= 0.0 { (-xb, -xa) } else { (xa, xb) };
152
153        for &(ya, yb) in &y_parts {
154            if yb <= ya {
155                continue;
156            }
157            let (qy0, qy1) = if yb <= 0.0 { (-yb, -ya) } else { (ya, yb) };
158            area += q1_rect_area(qx0, qx1, qy0, qy1, radius);
159        }
160    }
161
162    area.clamp(0.0, rect_area)
163}
164
165#[inline]
166fn q1_rect_area(x0: f64, x1: f64, y0: f64, y1: f64, radius: f64) -> f64 {
167    if x1 <= x0 || y1 <= y0 {
168        return 0.0;
169    }
170
171    q1_corner_area(x1, y1, radius) - q1_corner_area(x0, y1, radius) - q1_corner_area(x1, y0, radius)
172        + q1_corner_area(x0, y0, radius)
173}
174
175#[inline]
176fn q1_corner_area(x: f64, y: f64, radius: f64) -> f64 {
177    if x <= 0.0 || y <= 0.0 || radius <= 0.0 {
178        return 0.0;
179    }
180
181    let x = x.min(radius);
182    if x <= 0.0 {
183        return 0.0;
184    }
185
186    if y >= radius {
187        return arc_integral(x, radius);
188    }
189
190    let x_flat = (radius * radius - y * y).max(0.0).sqrt();
191    let rect_width = x.min(x_flat);
192    let mut area = rect_width * y;
193    if x > x_flat {
194        area += arc_integral(x, radius) - arc_integral(x_flat, radius);
195    }
196
197    area
198}
199
200#[inline]
201fn arc_integral(x: f64, radius: f64) -> f64 {
202    let x = x.clamp(0.0, radius);
203    let y = (radius * radius - x * x).max(0.0).sqrt();
204    let angle = (x / radius).clamp(-1.0, 1.0).asin();
205    0.5 * (x * y + radius * radius * angle)
206}
207
208#[inline]
209fn split_at_zero(a: f64, b: f64) -> [(f64, f64); 2] {
210    if a < 0.0 && b > 0.0 {
211        [(a, 0.0), (0.0, b)]
212    } else {
213        [(a, b), (0.0, 0.0)]
214    }
215}
216
217#[inline]
218fn crosses_circle(
219    r0: f64,
220    r1: f64,
221    c0: f64,
222    c1: f64,
223    center_r: f64,
224    center_c: f64,
225    radius: f64,
226) -> bool {
227    if radius <= 0.0 {
228        return false;
229    }
230
231    let x0 = c0 - center_c;
232    let x1 = c1 - center_c;
233    let y0 = r0 - center_r;
234    let y1 = r1 - center_r;
235    let radius_sq = radius * radius;
236    let eps = (radius_sq.abs() + 1.0) * 1.0e-12;
237
238    min_dist_sq(x0, x1, y0, y1) <= radius_sq + eps && max_dist_sq(x0, x1, y0, y1) >= radius_sq - eps
239}
240
241#[inline]
242fn min_dist_sq(x0: f64, x1: f64, y0: f64, y1: f64) -> f64 {
243    let dx = if x0 > 0.0 {
244        x0
245    } else if x1 < 0.0 {
246        -x1
247    } else {
248        0.0
249    };
250    let dy = if y0 > 0.0 {
251        y0
252    } else if y1 < 0.0 {
253        -y1
254    } else {
255        0.0
256    };
257    dx * dx + dy * dy
258}
259
260#[inline]
261fn max_dist_sq(x0: f64, x1: f64, y0: f64, y1: f64) -> f64 {
262    let dx = x0.abs().max(x1.abs());
263    let dy = y0.abs().max(y1.abs());
264    dx * dx + dy * dy
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn rect_inside_disk() {
273        let area = circle_overlap(9.0, 11.0, 9.0, 11.0, 10.0, 10.0, 3.0);
274        assert!((area - 4.0).abs() < 1.0e-12);
275    }
276
277    #[test]
278    fn quarter_disk() {
279        let radius = 3.0;
280        let area = circle_overlap(0.0, radius, 0.0, radius, 0.0, 0.0, radius);
281        let expected = std::f64::consts::PI * radius * radius * 0.25;
282        assert!((area - expected).abs() < 1.0e-12);
283    }
284
285    // Scenarios summed together match one-at-a-time similarities bit for bit
286    #[test]
287    fn sims_match_scalar() {
288        let mut rng = crate::testkit::Rng::new(3);
289        let value = |rng: &mut crate::testkit::Rng| match rng.below(40) {
290            0 => f32::NAN,
291            1 => f32::INFINITY,
292            _ => (rng.unit() * 4.0 - 2.0) as f32,
293        };
294        for _ in 0..2000 {
295            let bands = 1 + rng.below(25);
296            let current: Vec<f32> = (0..bands).map(|_| value(&mut rng)).collect();
297            let n_scen = 1 + rng.below(12);
298            let cells: Vec<Vec<f32>> = (0..n_scen)
299                .map(|_| {
300                    let len = if rng.chance(0.1) { rng.below(30) } else { bands };
301                    (0..len).map(|_| value(&mut rng)).collect()
302                })
303                .collect();
304            let refs: Vec<&[f32]> = cells.iter().map(|c| c.as_slice()).collect();
305            let mut out = Vec::new();
306            for group in refs.chunks(8) {
307                similarities(&current, group, f32::NAN, &mut out);
308            }
309            for (k, cell) in cells.iter().enumerate() {
310                let expected = similarity(&ArrayView1::from(&current[..]), &ArrayView1::from(&cell[..]));
311                assert_eq!(out[k].to_bits(), expected.unwrap_or(f32::NAN).to_bits());
312            }
313        }
314    }
315
316    #[test]
317    fn flags_outer_rim_only() {
318        let (_, outer_edge) = circle_frac(-0.5, 0.5, 2.5, 3.5, 0.0, 0.0, 1.0, 3.0);
319        assert!(outer_edge);
320
321        let (_, inner_edge) = circle_frac(-0.5, 0.5, 0.5, 1.5, 0.0, 0.0, 1.0, 3.0);
322        assert!(!inner_edge);
323    }
324}
325
326// Calcualte similarity of the transgrid layers and dealing with NaNs
327// Any cell with nan coniditon is ignored in the step before this; now any nan transgrid
328// will be processed as None and dealt with later in the metrics.rs
329#[inline]
330pub(crate) fn similarity(a: &ArrayView1<f32>, b: &ArrayView1<f32>) -> Option<f32> {
331    let mut l1: f32 = 0.0;
332
333    // loop over each transform gird; return None even for one nan transgrid
334    for (&x, &y) in a.iter().zip(b.iter()) {
335        if x.is_finite() && y.is_finite() {
336            l1 += (x - y).abs();
337        } else {
338            return None;
339        }
340    }
341
342    Some((-l1).exp())
343}
344
345/// `similarity` of one cell for every scenario at once, pushed to `out` (`missing` for None).
346/// Each scenario's L1 distance is summed in band order exactly as in `similarity`; advancing
347/// the scenarios together only overlaps independent sums. A non-finite pair makes that
348/// scenario's result missing, as the early return does.
349#[inline]
350pub(crate) fn similarities(current: &[f32], cells: &[&[f32]], missing: f32, out: &mut Vec<f32>) {
351    const LANES: usize = 8;
352    for group in cells.chunks(LANES) {
353        let n = current.len().min(group[0].len());
354        if group.iter().any(|cell| cell.len().min(current.len()) != n) {
355            for cell in group {
356                let sim = similarity(&ArrayView1::from(current), &ArrayView1::from(*cell));
357                out.push(sim.unwrap_or(missing));
358            }
359            continue;
360        }
361        let mut l1 = [0.0f32; LANES];
362        let mut finite = [true; LANES];
363        for (b, &x) in current[..n].iter().enumerate() {
364            for (k, cell) in group.iter().enumerate() {
365                let y = cell[b];
366                finite[k] &= x.is_finite() & y.is_finite();
367                l1[k] += (x - y).abs();
368            }
369        }
370        for k in 0..group.len() {
371            out.push(if finite[k] { (-l1[k]).exp() } else { missing });
372        }
373    }
374}