Skip to main content

powerio_core/
time_series.rs

1use std::fmt;
2use std::sync::Arc;
3use std::time::Duration;
4
5use crate::Error;
6use crate::validation::valid_nonempty_text;
7
8/// One ordered position in a time series.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct TimePoint {
11    label: Box<str>,
12    duration: Option<Duration>,
13}
14
15impl TimePoint {
16    pub fn new(label: impl Into<String>, duration: Option<Duration>) -> Result<Self, Error> {
17        let label = label.into();
18        if !valid_nonempty_text(&label) {
19            return Err(Error::new(
20                &crate::codes::VALIDATE_TIME_POINT_INVALID_LABEL,
21                "a time point label must be nonempty and bounded",
22            ));
23        }
24        Ok(Self {
25            label: label.into_boxed_str(),
26            duration,
27        })
28    }
29
30    /// Construct an exact stored duration, rejecting a normalized nanosecond
31    /// value instead of silently carrying into the seconds field.
32    pub fn from_duration_parts(
33        label: impl Into<String>,
34        seconds: u64,
35        nanoseconds: u32,
36    ) -> Result<Self, Error> {
37        if nanoseconds >= 1_000_000_000 {
38            return Err(Error::new(
39                &crate::codes::VALIDATE_TIME_POINT_INVALID_DURATION,
40                format!("duration nanosecond remainder {nanoseconds} is at least one billion"),
41            ));
42        }
43        Self::new(label, Some(Duration::new(seconds, nanoseconds)))
44    }
45
46    #[must_use]
47    pub fn label(&self) -> &str {
48        &self.label
49    }
50
51    #[must_use]
52    pub const fn duration(&self) -> Option<Duration> {
53        self.duration
54    }
55}
56
57/// Ordered complete values of one Rust type.
58pub struct TimeSeries<T> {
59    time_points: Arc<[TimePoint]>,
60    values: Arc<[T]>,
61}
62
63impl<T> TimeSeries<T> {
64    pub fn new(time_points: Vec<TimePoint>, values: Vec<T>) -> Result<Self, Error> {
65        if time_points.len() != values.len() {
66            return Err(Error::new(
67                &crate::codes::VALIDATE_TIME_SERIES_SHAPE,
68                format!(
69                    "time series has {} values for {} time points",
70                    values.len(),
71                    time_points.len()
72                ),
73            ));
74        }
75        Ok(Self {
76            time_points: time_points.into(),
77            values: values.into(),
78        })
79    }
80
81    #[must_use]
82    pub fn time_points(&self) -> &[TimePoint] {
83        &self.time_points
84    }
85
86    #[must_use]
87    pub fn values(&self) -> &[T] {
88        &self.values
89    }
90
91    #[must_use]
92    pub fn get(&self, index: usize) -> Option<(&TimePoint, &T)> {
93        Some((self.time_points.get(index)?, self.values.get(index)?))
94    }
95
96    #[must_use]
97    pub fn time_point(&self, index: usize) -> Option<&TimePoint> {
98        self.time_points.get(index)
99    }
100
101    #[must_use]
102    pub fn value(&self, index: usize) -> Option<&T> {
103        self.values.get(index)
104    }
105
106    pub fn iter(&self) -> impl ExactSizeIterator<Item = (&TimePoint, &T)> {
107        self.time_points.iter().zip(self.values.iter())
108    }
109
110    #[must_use]
111    pub fn len(&self) -> usize {
112        self.values.len()
113    }
114
115    #[must_use]
116    pub fn is_empty(&self) -> bool {
117        self.values.is_empty()
118    }
119}
120
121impl<T> Clone for TimeSeries<T> {
122    fn clone(&self) -> Self {
123        Self {
124            time_points: Arc::clone(&self.time_points),
125            values: Arc::clone(&self.values),
126        }
127    }
128}
129
130impl<T: fmt::Debug> fmt::Debug for TimeSeries<T> {
131    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132        formatter
133            .debug_struct("TimeSeries")
134            .field("time_points", &self.time_points)
135            .field("values", &self.values)
136            .finish()
137    }
138}
139
140/// Checked flattened dimension used by type specific column builders.
141#[cfg_attr(
142    not(test),
143    expect(dead_code, reason = "used by later series constructors")
144)]
145pub(crate) fn checked_dimension_product(
146    what: &str,
147    rows: usize,
148    columns: usize,
149) -> Result<usize, Error> {
150    rows.checked_mul(columns).ok_or_else(|| {
151        Error::new(
152            &crate::codes::VALIDATE_TIME_SERIES_DIMENSION_OVERFLOW,
153            format!("{what} dimensions {rows} by {columns} overflow usize"),
154        )
155    })
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn labels_and_exact_duration_parts_are_checked() {
164        assert!(TimePoint::new("", None).is_err());
165        assert!(TimePoint::new("x".repeat(65_537), None).is_err());
166        assert!(TimePoint::from_duration_parts("t0", u64::MAX, 999_999_999).is_ok());
167        assert!(TimePoint::from_duration_parts("t0", 0, 1_000_000_000).is_err());
168    }
169
170    #[test]
171    fn shape_and_dimension_overflow_are_errors() {
172        let point = TimePoint::new("t0", None).unwrap();
173        assert!(TimeSeries::<u8>::new(vec![point], Vec::new()).is_err());
174        assert!(checked_dimension_product("column", usize::MAX, 2).is_err());
175    }
176
177    #[test]
178    fn lookup_and_iteration_preserve_value_identity() {
179        let points = vec![
180            TimePoint::new("t0", Some(Duration::from_secs(1))).unwrap(),
181            TimePoint::new("t1", Some(Duration::from_secs(2))).unwrap(),
182        ];
183        let values = vec![String::from("a"), String::from("b")];
184        let series = TimeSeries::new(points, values).unwrap();
185        let value_pointer = series.value(1).unwrap().as_ptr();
186        assert_eq!(series.get(1).unwrap().0.label(), "t1");
187        assert_eq!(series.iter().count(), 2);
188        assert_eq!(series.value(1).unwrap().as_ptr(), value_pointer);
189    }
190}