Skip to main content

hfx_core/
area.rs

1//! Area and weight measurement types.
2
3/// Errors from constructing measured quantities.
4#[derive(Debug, thiserror::Error)]
5pub enum MeasureError {
6    /// Returned when a value is negative.
7    #[error("value must be non-negative, got {value}")]
8    NegativeValue {
9        /// The invalid value.
10        value: f32,
11    },
12
13    /// Returned when a value is NaN or infinite.
14    #[error("value must be finite, got {value}")]
15    NonFiniteValue {
16        /// The invalid value.
17        value: f32,
18    },
19}
20
21/// A catchment area expressed in square kilometres.
22///
23/// Invariant: the wrapped value is always finite and non-negative.
24///
25/// `Eq` is intentionally not derived — deriving `Eq` on `f32` is a Rust
26/// footgun because IEEE-754 NaN != NaN. The constructor ensures the value is
27/// finite, but the derive would still be misleading. Use `PartialEq` directly
28/// or compare via [`AreaKm2::get`].
29#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
30pub struct AreaKm2(f32);
31
32impl AreaKm2 {
33    /// Construct an [`AreaKm2`] from a raw `f32`.
34    ///
35    /// # Errors
36    ///
37    /// | Condition | Error variant |
38    /// |-----------|---------------|
39    /// | `raw` is NaN or infinite | [`MeasureError::NonFiniteValue`] |
40    /// | `raw < 0.0` | [`MeasureError::NegativeValue`] |
41    pub fn new(raw: f32) -> Result<Self, MeasureError> {
42        if !raw.is_finite() {
43            return Err(MeasureError::NonFiniteValue { value: raw });
44        }
45        if raw < 0.0 {
46            return Err(MeasureError::NegativeValue { value: raw });
47        }
48        Ok(Self(raw))
49    }
50
51    /// Return the underlying `f32` value.
52    pub fn get(self) -> f32 {
53        self.0
54    }
55}
56
57/// Snap ranking weight. Higher values indicate greater hydrological dominance;
58/// adapters typically populate this with upstream drainage area in km² or cell count.
59/// Snap-aware engines rank snap candidates by descending weight, using mainstem status
60/// and distance as tie-breakers.
61///
62/// Invariant: the wrapped value is always finite and non-negative.
63///
64/// `Eq` is intentionally not derived for the same reason as [`AreaKm2`].
65#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
66pub struct Weight(f32);
67
68impl Weight {
69    /// Construct a [`Weight`] from a raw `f32`.
70    ///
71    /// # Errors
72    ///
73    /// | Condition | Error variant |
74    /// |-----------|---------------|
75    /// | `raw` is NaN or infinite | [`MeasureError::NonFiniteValue`] |
76    /// | `raw < 0.0` | [`MeasureError::NegativeValue`] |
77    pub fn new(raw: f32) -> Result<Self, MeasureError> {
78        if !raw.is_finite() {
79            return Err(MeasureError::NonFiniteValue { value: raw });
80        }
81        if raw < 0.0 {
82            return Err(MeasureError::NegativeValue { value: raw });
83        }
84        Ok(Self(raw))
85    }
86
87    /// Return the underlying `f32` value.
88    pub fn get(self) -> f32 {
89        self.0
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn area_km2_accepts_zero() {
99        let a = AreaKm2::new(0.0).unwrap();
100        assert_eq!(a.get(), 0.0);
101    }
102
103    #[test]
104    fn area_km2_accepts_positive() {
105        let a = AreaKm2::new(123.45).unwrap();
106        assert_eq!(a.get(), 123.45);
107    }
108
109    #[test]
110    fn area_km2_rejects_negative() {
111        assert!(matches!(
112            AreaKm2::new(-1.0),
113            Err(MeasureError::NegativeValue { value: _ })
114        ));
115    }
116
117    #[test]
118    fn area_km2_rejects_nan() {
119        assert!(matches!(
120            AreaKm2::new(f32::NAN),
121            Err(MeasureError::NonFiniteValue { value: _ })
122        ));
123    }
124
125    #[test]
126    fn area_km2_rejects_inf() {
127        assert!(matches!(
128            AreaKm2::new(f32::INFINITY),
129            Err(MeasureError::NonFiniteValue { value: _ })
130        ));
131    }
132
133    #[test]
134    fn area_km2_rejects_neg_inf() {
135        assert!(matches!(
136            AreaKm2::new(f32::NEG_INFINITY),
137            Err(MeasureError::NonFiniteValue { value: _ })
138        ));
139    }
140
141    #[test]
142    fn weight_accepts_zero() {
143        let w = Weight::new(0.0).unwrap();
144        assert_eq!(w.get(), 0.0);
145    }
146
147    #[test]
148    fn weight_accepts_positive() {
149        let w = Weight::new(0.75).unwrap();
150        assert_eq!(w.get(), 0.75);
151    }
152
153    #[test]
154    fn weight_rejects_negative() {
155        assert!(matches!(
156            Weight::new(-0.1),
157            Err(MeasureError::NegativeValue { value: _ })
158        ));
159    }
160
161    #[test]
162    fn weight_rejects_nan() {
163        assert!(matches!(
164            Weight::new(f32::NAN),
165            Err(MeasureError::NonFiniteValue { value: _ })
166        ));
167    }
168
169    #[test]
170    fn weight_rejects_inf() {
171        assert!(matches!(
172            Weight::new(f32::INFINITY),
173            Err(MeasureError::NonFiniteValue { value: _ })
174        ));
175    }
176
177    #[test]
178    fn area_km2_min_positive_succeeds() {
179        let a = AreaKm2::new(f32::MIN_POSITIVE).unwrap();
180        assert_eq!(a.get(), f32::MIN_POSITIVE);
181    }
182
183    #[test]
184    fn weight_neg_infinity_fails_with_non_finite_not_negative() {
185        // Finiteness is checked before the sign check, so NEG_INFINITY must
186        // produce NonFiniteValue, not NegativeValue.
187        assert!(matches!(
188            Weight::new(f32::NEG_INFINITY),
189            Err(MeasureError::NonFiniteValue { value: _ })
190        ));
191    }
192}