Skip to main content

audio_lab/signal/data/
axis.rs

1use ndarray::{Array1, CowArray, Ix1};
2
3use super::DataError;
4
5/// The x axis of [`RealData`](super::RealData) and
6/// [`ComplexData`](super::ComplexData): time steps, frequency bins, or any
7/// other strictly increasing positions.
8///
9/// A uniform axis stores only `start`, `step` and the length, and computes
10/// values on demand. An explicit axis stores every value. Both kinds are
11/// strictly increasing, which the constructors check.
12#[derive(Clone, Debug)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize), serde(transparent))]
14pub struct XAxis(Repr);
15
16/// Deserialized values go through the same checks as the constructors, so an
17/// edited or corrupted file cannot produce an unordered axis.
18#[cfg(feature = "serde")]
19impl<'de> serde::Deserialize<'de> for XAxis {
20    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
21        match Repr::deserialize(deserializer)? {
22            Repr::Uniform { start, step, len } => XAxis::uniform(start, step, len),
23            Repr::Explicit(values) => XAxis::explicit(values),
24        }
25        .map_err(serde::de::Error::custom)
26    }
27}
28
29#[derive(Clone, Debug)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31enum Repr {
32    /// `x[i] = start + i * step`
33    Uniform {
34        start: f64,
35        step: f64,
36        len: usize,
37    },
38    Explicit(Array1<f64>),
39}
40
41impl XAxis {
42    /// A uniform axis `x[i] = start + i * step` with `len` points.
43    ///
44    /// `start` must be finite and `step` must be finite and positive.
45    pub fn uniform(start: f64, step: f64, len: usize) -> Result<Self, DataError> {
46        if !start.is_finite() || !step.is_finite() || step <= 0.0 {
47            return Err(DataError::InvalidUniformAxis { start, step });
48        }
49        Ok(Self(Repr::Uniform { start, step, len }))
50    }
51
52    /// An axis with the given values, which must be strictly increasing.
53    pub fn explicit(values: Array1<f64>) -> Result<Self, DataError> {
54        if !values.windows(2).into_iter().all(|pair| pair[1] > pair[0]) {
55            return Err(DataError::NotIncreasing);
56        }
57        Ok(Self(Repr::Explicit(values)))
58    }
59
60    /// The axis `0, 1, 2, …, len - 1`.
61    pub fn indices(len: usize) -> Self {
62        Self(Repr::Uniform {
63            start: 0.0,
64            step: 1.0,
65            len,
66        })
67    }
68
69    pub fn len(&self) -> usize {
70        match &self.0 {
71            Repr::Uniform { len, .. } => *len,
72            Repr::Explicit(values) => values.len(),
73        }
74    }
75
76    pub fn is_empty(&self) -> bool {
77        self.len() == 0
78    }
79
80    /// `Some((start, step))` if the axis is uniform.
81    pub fn as_uniform(&self) -> Option<(f64, f64)> {
82        match &self.0 {
83            Repr::Uniform { start, step, .. } => Some((*start, *step)),
84            Repr::Explicit(_) => None,
85        }
86    }
87
88    /// The value at `index`.
89    ///
90    /// # Panics
91    ///
92    /// Panics if `index >= self.len()`.
93    pub fn get(&self, index: usize) -> f64 {
94        match &self.0 {
95            Repr::Uniform { start, step, len } => {
96                assert!(
97                    index < *len,
98                    "index {index} out of bounds for axis of length {len}"
99                );
100                start + index as f64 * step
101            }
102            Repr::Explicit(values) => values[index],
103        }
104    }
105
106    pub fn iter(&self) -> impl ExactSizeIterator<Item = f64> + '_ {
107        (0..self.len()).map(move |index| match &self.0 {
108            Repr::Uniform { start, step, .. } => start + index as f64 * step,
109            Repr::Explicit(values) => values[index],
110        })
111    }
112
113    /// The values as an array. Borrowed for an explicit axis, computed for a
114    /// uniform one.
115    pub fn to_cow_array(&self) -> CowArray<'_, f64, Ix1> {
116        match &self.0 {
117            Repr::Uniform { .. } => CowArray::from(self.to_array()),
118            Repr::Explicit(values) => CowArray::from(values.view()),
119        }
120    }
121
122    /// The values as an owned array.
123    pub fn to_array(&self) -> Array1<f64> {
124        match &self.0 {
125            Repr::Uniform { .. } => Array1::from_iter(self.iter()),
126            Repr::Explicit(values) => values.clone(),
127        }
128    }
129
130    /// Index of the value closest to `x`. On a tie the lower index wins.
131    /// Returns `0` for an empty axis or a NaN `x`.
132    pub fn nearest_index(&self, x: f64) -> usize {
133        let len = self.len();
134        if len == 0 || x.is_nan() {
135            return 0;
136        }
137
138        let upper = match &self.0 {
139            Repr::Uniform { start, step, .. } => {
140                // First index whose value is not below x, then fix up for rounding.
141                let mut upper = ((x - start) / step).ceil().clamp(0.0, len as f64) as usize;
142                while upper > 0 && self.get(upper - 1) >= x {
143                    upper -= 1;
144                }
145                while upper < len && self.get(upper) < x {
146                    upper += 1;
147                }
148                upper
149            }
150            Repr::Explicit(values) => values
151                .as_slice()
152                .expect("owned 1-D array is contiguous")
153                .partition_point(|&value| value < x),
154        };
155
156        if upper == 0 {
157            return 0;
158        }
159        if upper == len {
160            return len - 1;
161        }
162
163        let lower = upper - 1;
164        if x - self.get(lower) <= self.get(upper) - x {
165            lower
166        } else {
167            upper
168        }
169    }
170}
171
172impl PartialEq for XAxis {
173    fn eq(&self, other: &Self) -> bool {
174        if self.len() != other.len() {
175            return false;
176        }
177        if let (
178            Repr::Uniform {
179                start: a_start,
180                step: a_step,
181                len,
182            },
183            Repr::Uniform {
184                start: b_start,
185                step: b_step,
186                ..
187            },
188        ) = (&self.0, &other.0)
189        {
190            // With 0 or 1 points the step does not affect any value.
191            return *len == 0 || (a_start == b_start && (*len == 1 || a_step == b_step));
192        }
193        self.iter().zip(other.iter()).all(|(a, b)| a == b)
194    }
195}
196
197impl approx::AbsDiffEq for XAxis {
198    type Epsilon = f64;
199
200    fn default_epsilon() -> Self::Epsilon {
201        f64::EPSILON
202    }
203
204    fn abs_diff_eq(&self, other: &Self, epsilon: Self::Epsilon) -> bool {
205        self.len() == other.len()
206            && self
207                .iter()
208                .zip(other.iter())
209                .all(|(a, b)| a.abs_diff_eq(&b, epsilon))
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use ndarray::array;
216
217    use super::*;
218
219    #[test]
220    fn constructors_validate_input() {
221        assert!(XAxis::uniform(0.0, 0.5, 4).is_ok());
222        assert!(XAxis::uniform(0.0, 0.0, 4).is_err());
223        assert!(XAxis::uniform(0.0, -1.0, 4).is_err());
224        assert!(XAxis::uniform(f64::NAN, 1.0, 4).is_err());
225        assert!(XAxis::uniform(0.0, f64::INFINITY, 4).is_err());
226
227        assert!(XAxis::explicit(array![0.0, 1.0, 5.0]).is_ok());
228        assert!(matches!(
229            XAxis::explicit(array![0.0, 1.0, 1.0]),
230            Err(DataError::NotIncreasing)
231        ));
232    }
233
234    #[test]
235    fn uniform_and_explicit_axes_expose_the_same_values() {
236        let uniform = XAxis::uniform(1.0, 0.25, 5).unwrap();
237        let explicit = XAxis::explicit(array![1.0, 1.25, 1.5, 1.75, 2.0]).unwrap();
238
239        assert_eq!(uniform.len(), 5);
240        assert_eq!(uniform.as_uniform(), Some((1.0, 0.25)));
241        assert_eq!(explicit.as_uniform(), None);
242        assert_eq!(uniform.get(3), 1.75);
243        assert_eq!(
244            uniform.iter().collect::<Vec<_>>(),
245            explicit.iter().collect::<Vec<_>>()
246        );
247        assert_eq!(uniform.to_array(), explicit.to_array());
248        assert_eq!(uniform.to_cow_array(), explicit.to_cow_array());
249        assert_eq!(uniform, explicit);
250        assert_eq!(
251            XAxis::indices(3),
252            XAxis::explicit(array![0.0, 1.0, 2.0]).unwrap()
253        );
254    }
255
256    #[test]
257    fn equality_compares_values() {
258        let a = XAxis::uniform(0.0, 1.0, 3).unwrap();
259        assert_ne!(a, XAxis::uniform(0.0, 2.0, 3).unwrap());
260        assert_ne!(a, XAxis::uniform(0.0, 1.0, 4).unwrap());
261        // Step does not matter for a single point.
262        assert_eq!(
263            XAxis::uniform(2.0, 1.0, 1).unwrap(),
264            XAxis::uniform(2.0, 5.0, 1).unwrap()
265        );
266        assert_eq!(
267            XAxis::uniform(2.0, 1.0, 0).unwrap(),
268            XAxis::explicit(Array1::zeros(0)).unwrap()
269        );
270    }
271
272    #[cfg(feature = "serde")]
273    #[test]
274    fn deserialization_round_trips_and_validates() {
275        for axis in [
276            XAxis::uniform(0.5, 0.25, 4).unwrap(),
277            XAxis::explicit(array![0.0, 1.0, 5.0]).unwrap(),
278        ] {
279            let json = serde_json::to_string(&axis).unwrap();
280            let back: XAxis = serde_json::from_str(&json).unwrap();
281            assert_eq!(back, axis);
282            assert_eq!(back.as_uniform(), axis.as_uniform());
283        }
284
285        // Valid shape, invalid values: rejected instead of producing an
286        // unordered axis.
287        let unordered = r#"{"Explicit":{"v":1,"dim":[3],"data":[0.0,2.0,1.0]}}"#;
288        let error = serde_json::from_str::<XAxis>(unordered).unwrap_err();
289        assert!(error.to_string().contains("not increasing"), "{error}");
290
291        let zero_step = r#"{"Uniform":{"start":0.0,"step":0.0,"len":3}}"#;
292        assert!(serde_json::from_str::<XAxis>(zero_step).is_err());
293
294        // Signals deserialize through the same check.
295        let signal = crate::signal::TimeSignal::zeros(1, 3, 8.0).unwrap();
296        let json = serde_json::to_string(&signal).unwrap();
297        assert_eq!(
298            serde_json::from_str::<crate::signal::TimeSignal>(&json).unwrap(),
299            signal
300        );
301    }
302
303    #[test]
304    #[should_panic(expected = "out of bounds")]
305    fn uniform_get_panics_out_of_bounds() {
306        XAxis::uniform(0.0, 1.0, 3).unwrap().get(3);
307    }
308
309    #[test]
310    fn nearest_index_matches_linear_scan_for_both_kinds() {
311        let linear_scan = |axis: &XAxis, x: f64| {
312            axis.iter()
313                .enumerate()
314                .min_by(|(_, a), (_, b)| {
315                    (a - x)
316                        .abs()
317                        .partial_cmp(&(b - x).abs())
318                        .unwrap_or(std::cmp::Ordering::Equal)
319                })
320                .map(|(index, _)| index)
321                .unwrap()
322        };
323
324        let axes = [
325            XAxis::uniform(0.0, 0.1, 11).unwrap(),
326            XAxis::uniform(-3.0, 1.5, 7).unwrap(),
327            XAxis::explicit(array![0.0, 1.0, 2.5, 4.0, 10.0]).unwrap(),
328        ];
329        for axis in &axes {
330            let lo = axis.get(0) - 2.0;
331            let hi = axis.get(axis.len() - 1) + 2.0;
332            for i in 0..=200 {
333                let x = lo + (hi - lo) * i as f64 / 200.0;
334                assert_eq!(axis.nearest_index(x), linear_scan(axis, x), "x = {x}");
335            }
336            for i in 0..axis.len() {
337                // Exact hits and midpoints between neighbours.
338                assert_eq!(axis.nearest_index(axis.get(i)), i);
339                if i + 1 < axis.len() {
340                    let mid = 0.5 * (axis.get(i) + axis.get(i + 1));
341                    assert_eq!(axis.nearest_index(mid), linear_scan(axis, mid));
342                }
343            }
344            assert_eq!(axis.nearest_index(f64::NAN), 0);
345            assert_eq!(axis.nearest_index(f64::INFINITY), axis.len() - 1);
346            assert_eq!(axis.nearest_index(f64::NEG_INFINITY), 0);
347        }
348        assert_eq!(XAxis::indices(0).nearest_index(1.0), 0);
349    }
350}