Skip to main content

u_analytics/
counter.rs

1//! Increments of a cumulative counter, each classified by what explains it.
2//!
3//! A cumulative counter -- an energy, gas or water meter's register, an
4//! hour meter, a production or byte counter -- is read at increasing times,
5//! and what is wanted is how much it advanced between readings. A reading
6//! below the one before it has more than one explanation: the register
7//! passed its maximum and started again from 0 (a *wrap*), it was reset or
8//! replaced, or the reading is wrong. These functions do not pick one by
9//! assumption. Each pair of neighbouring readings is classified:
10//!
11//! - [`IncrementKind::Normal`] -- the value did not fall; the increase is the
12//!   difference.
13//! - [`IncrementKind::Declared`] -- the caller declared a reset or a
14//!   replacement inside the pair ([`Discontinuity`]); the increase is counted
15//!   from the new start value (plus, when the old register's last value is
16//!   given, what it advanced before the change).
17//! - [`IncrementKind::Wrap`] -- the value fell and a known `modulus` (the
18//!   value at which the register returns to 0) explains it: the increase is
19//!   `modulus − previous + next`. With `max_rate`, a wrap is accepted only
20//!   when that increase is one the counter can make in the time between the
21//!   readings.
22//! - [`IncrementKind::Unexplained`] -- the value fell and nothing given
23//!   explains it: the increase is `None`, not a guess.
24//!
25//! Monitoring systems usually read every fall as a reset and count from 0
26//! (Prometheus' `increase()` and `rate()`); that is right for process
27//! counters that restart with their process, and wrong for a meter whose
28//! readings settle a bill. Classifying, rather than assuming, leaves the
29//! choice to the caller, who can see what was not explained.
30//!
31//! [`counter_totals`] sums the increments over consecutive intervals (a day,
32//! a quarter of an hour), with the number of pairs left unexplained in each.
33//!
34//! # References
35//!
36//! - Prometheus documentation, "Query functions": `increase()`, `rate()` --
37//!   counter resets are compensated by assuming a restart from 0.
38//! - IEC 62056-6-2 (DLMS/COSEM), register objects with a scaler and a
39//!   wrap-around value -- the `modulus` here.
40
41/// A reset or replacement of the counter, declared by the caller.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct Discontinuity {
44    /// When it happened. It belongs to the pair of readings around it: after
45    /// the earlier reading, at or before the later one.
46    pub time: f64,
47    /// The register's value right after it -- 0 for a reset to zero, the new
48    /// register's reading for a replacement.
49    pub start: f64,
50    /// The old register's last value, when it was read: the increase then
51    /// also counts what the counter advanced from the earlier reading up to
52    /// the change. `None`: that part is unknown and not counted.
53    pub last: Option<f64>,
54}
55
56/// What explains an increment (see the [module docs](self)).
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum IncrementKind {
59    /// The value did not fall.
60    Normal,
61    /// A declared reset or replacement lies inside the pair.
62    Declared,
63    /// The value fell and the register's `modulus` explains it.
64    Wrap,
65    /// The value fell and nothing given explains it.
66    Unexplained,
67}
68
69/// How much the counter advanced between two neighbouring readings.
70#[derive(Debug, Clone, Copy, PartialEq)]
71pub struct CounterIncrement {
72    /// The earlier reading's time.
73    pub start: f64,
74    /// The later reading's time.
75    pub end: f64,
76    /// The increase; `None` when the pair is [`IncrementKind::Unexplained`].
77    pub increase: Option<f64>,
78    /// What explains it.
79    pub kind: IncrementKind,
80}
81
82/// The increments that end in one interval `[start, end)`.
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct CounterTotal {
85    /// The interval's start (included).
86    pub start: f64,
87    /// The interval's end (excluded).
88    pub end: f64,
89    /// The sum of the known increases of the pairs ending in the interval.
90    /// With `unexplained > 0` it leaves those pairs out, so it is a lower
91    /// bound of the true advance only if their advance was not negative.
92    pub increase: f64,
93    /// The pairs ending in the interval.
94    pub pairs: usize,
95    /// How many of them are [`IncrementKind::Unexplained`].
96    pub unexplained: usize,
97}
98
99/// Why readings, settings or intervals were refused.
100#[derive(Debug, Clone, Copy, PartialEq)]
101pub enum CounterError {
102    /// `times` and `values` differ in length.
103    LengthMismatch {
104        /// The number of times.
105        times: usize,
106        /// The number of values.
107        values: usize,
108    },
109    /// Fewer than two readings: there is no pair.
110    TooFewReadings {
111        /// The number given.
112        got: usize,
113    },
114    /// A NaN or an infinity. `parameter` names the input (`times`, `values`,
115    /// `discontinuities.time`, ...), `index` its position.
116    NotFinite {
117        /// The input.
118        parameter: &'static str,
119        /// The position in it; `None` for a single setting.
120        index: Option<usize>,
121    },
122    /// A time not after the one before it (`times`, or interval `edges`).
123    Unordered {
124        /// The input.
125        parameter: &'static str,
126        /// The position of the time that is not after its predecessor.
127        index: usize,
128    },
129    /// `modulus` or `max_rate` not above 0.
130    NotPositive {
131        /// The setting.
132        parameter: &'static str,
133        /// The value given.
134        got: f64,
135    },
136    /// A value outside `[0, modulus)`: a register with that wrap value never
137    /// shows it.
138    OutsideModulus {
139        /// The input (`values`, `discontinuities.start`, `discontinuities.last`).
140        parameter: &'static str,
141        /// The position.
142        index: usize,
143        /// The value given.
144        got: f64,
145        /// The modulus.
146        modulus: f64,
147    },
148    /// A discontinuity whose time lies in no pair: at or before the first
149    /// reading, or after the last.
150    OutsideReadings {
151        /// The discontinuity's position.
152        index: usize,
153        /// Its time.
154        got: f64,
155        /// The first reading's time: a discontinuity must be after it.
156        first: f64,
157        /// The last reading's time: a discontinuity must be at or before it.
158        last: f64,
159    },
160    /// Two discontinuities in one pair of readings: what the counter showed
161    /// between them was not read.
162    TwoInOnePair {
163        /// The second one's position.
164        index: usize,
165    },
166}
167
168impl std::fmt::Display for CounterError {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        match *self {
171            Self::LengthMismatch { times, values } => {
172                write!(f, "{times} times but {values} values; each reading needs both")
173            }
174            Self::TooFewReadings { got } => {
175                write!(f, "at least 2 readings are needed, got {got}")
176            }
177            Self::NotFinite {
178                parameter,
179                index: Some(i),
180            } => write!(f, "{parameter}[{i}] is not a finite number"),
181            Self::NotFinite {
182                parameter,
183                index: None,
184            } => write!(f, "{parameter} is not a finite number"),
185            Self::Unordered { parameter, index } => write!(
186                f,
187                "{parameter}[{index}] is not after {parameter}[{}]; times must increase",
188                index - 1
189            ),
190            Self::NotPositive { parameter, got } => {
191                write!(f, "{parameter} must be > 0, got {got}")
192            }
193            Self::OutsideModulus {
194                parameter,
195                index,
196                got,
197                modulus,
198            } => write!(
199                f,
200                "{parameter}[{index}] is {got}; a register that wraps at {modulus} shows values in [0, {modulus})"
201            ),
202            Self::OutsideReadings {
203                index,
204                got,
205                first,
206                last,
207            } => write!(
208                f,
209                "discontinuities[{index}] is at {got}, outside every pair of readings: \
210                 it must be after {first} and at or before {last}"
211            ),
212            Self::TwoInOnePair { index } => write!(
213                f,
214                "discontinuities[{index}] falls in the same pair of readings as the one \
215                 before it; what the counter showed between them was not read"
216            ),
217        }
218    }
219}
220
221impl std::error::Error for CounterError {}
222
223/// The settings of [`counter_increments`].
224#[derive(Debug, Clone, Default, PartialEq)]
225pub struct CounterOptions {
226    /// The value at which the register returns to 0 (`1_000_000` for a
227    /// six-digit register): a fall is then a wrap. `None`: a fall is a wrap
228    /// only if a discontinuity is declared, otherwise unexplained.
229    pub modulus: Option<f64>,
230    /// The most the counter can advance per unit of time. A wrap whose
231    /// increase is more than `max_rate × (end − start)` is unexplained rather
232    /// than a wrap -- a reset near the top of the register looks like a wrap
233    /// otherwise.
234    pub max_rate: Option<f64>,
235    /// Resets and replacements the caller knows of, in time order.
236    pub discontinuities: Vec<Discontinuity>,
237}
238
239/// The increment between each pair of neighbouring readings, classified.
240///
241/// `times` must increase strictly; `values` are the register's readings at
242/// those times. The result has one entry per pair, in time order.
243///
244/// # Errors
245///
246/// Lengths that differ, fewer than two readings, a value or time that is not
247/// finite, times that do not increase, a `modulus` or `max_rate` not above 0,
248/// a value outside `[0, modulus)`, or a discontinuity outside the readings or
249/// sharing a pair with another.
250///
251/// # Example
252///
253/// ```
254/// use u_analytics::counter::{counter_increments, CounterOptions, IncrementKind};
255///
256/// // A six-digit register that passes 999 999 between the second and the
257/// // third reading.
258/// let times = [0.0, 1.0, 2.0];
259/// let values = [999_990.0, 999_995.0, 3.0];
260/// let options = CounterOptions { modulus: Some(1_000_000.0), ..Default::default() };
261/// let steps = counter_increments(&times, &values, &options).unwrap();
262/// assert_eq!(steps[1].kind, IncrementKind::Wrap);
263/// assert_eq!(steps[1].increase, Some(8.0));
264///
265/// // Without the modulus the fall is not explained -- and not guessed.
266/// let steps = counter_increments(&times, &values, &CounterOptions::default()).unwrap();
267/// assert_eq!(steps[1].kind, IncrementKind::Unexplained);
268/// assert_eq!(steps[1].increase, None);
269/// ```
270pub fn counter_increments(
271    times: &[f64],
272    values: &[f64],
273    options: &CounterOptions,
274) -> Result<Vec<CounterIncrement>, CounterError> {
275    if times.len() != values.len() {
276        return Err(CounterError::LengthMismatch {
277            times: times.len(),
278            values: values.len(),
279        });
280    }
281    if times.len() < 2 {
282        return Err(CounterError::TooFewReadings { got: times.len() });
283    }
284    check_times("times", times)?;
285    for (i, v) in values.iter().enumerate() {
286        if !v.is_finite() {
287            return Err(CounterError::NotFinite {
288                parameter: "values",
289                index: Some(i),
290            });
291        }
292    }
293    for (parameter, setting) in [("modulus", options.modulus), ("max_rate", options.max_rate)] {
294        if let Some(v) = setting {
295            if !v.is_finite() {
296                return Err(CounterError::NotFinite {
297                    parameter,
298                    index: None,
299                });
300            }
301            if v <= 0.0 {
302                return Err(CounterError::NotPositive { parameter, got: v });
303            }
304        }
305    }
306    let within = |parameter: &'static str, index: usize, v: f64| match options.modulus {
307        Some(m) if !(0.0..m).contains(&v) => Err(CounterError::OutsideModulus {
308            parameter,
309            index,
310            got: v,
311            modulus: m,
312        }),
313        _ => Ok(()),
314    };
315    for (i, &v) in values.iter().enumerate() {
316        within("values", i, v)?;
317    }
318
319    // Each discontinuity to the pair it falls in: after the earlier reading,
320    // at or before the later one.
321    let mut declared: Vec<Option<&Discontinuity>> = vec![None; times.len() - 1];
322    for (i, d) in options.discontinuities.iter().enumerate() {
323        for (parameter, v) in [
324            ("discontinuities.time", Some(d.time)),
325            ("discontinuities.start", Some(d.start)),
326            ("discontinuities.last", d.last),
327        ] {
328            if v.is_some_and(|v| !v.is_finite()) {
329                return Err(CounterError::NotFinite {
330                    parameter,
331                    index: Some(i),
332                });
333            }
334        }
335        within("discontinuities.start", i, d.start)?;
336        if let Some(last) = d.last {
337            within("discontinuities.last", i, last)?;
338        }
339        let pair = times.partition_point(|&t| t < d.time);
340        if pair == 0 || pair == times.len() {
341            return Err(CounterError::OutsideReadings {
342                index: i,
343                got: d.time,
344                first: times[0],
345                last: times[times.len() - 1],
346            });
347        }
348        let slot = &mut declared[pair - 1];
349        if slot.is_some() {
350            return Err(CounterError::TwoInOnePair { index: i });
351        }
352        *slot = Some(d);
353    }
354
355    Ok(times
356        .windows(2)
357        .zip(values.windows(2))
358        .zip(declared)
359        .map(|((t, v), declared)| {
360            let (start, end, before, after) = (t[0], t[1], v[0], v[1]);
361            let (increase, kind) = match declared {
362                Some(d) => (
363                    Some(d.last.map_or(0.0, |last| last - before) + (after - d.start)),
364                    IncrementKind::Declared,
365                ),
366                None if after >= before => (Some(after - before), IncrementKind::Normal),
367                None => match options.modulus {
368                    Some(m) => {
369                        let wrapped = m - before + after;
370                        let possible = options
371                            .max_rate
372                            .is_none_or(|rate| wrapped <= rate * (end - start));
373                        if possible {
374                            (Some(wrapped), IncrementKind::Wrap)
375                        } else {
376                            (None, IncrementKind::Unexplained)
377                        }
378                    }
379                    None => (None, IncrementKind::Unexplained),
380                },
381            };
382            CounterIncrement {
383                start,
384                end,
385                increase,
386                kind,
387            }
388        })
389        .collect())
390}
391
392/// The increments summed over consecutive intervals `[edges[j], edges[j+1])`.
393///
394/// A pair belongs to the interval its later reading falls in -- its increase
395/// is not split across a boundary it straddles (with readings every 15
396/// minutes and daily intervals, one pair a day). Pairs ending outside every
397/// interval are left out.
398///
399/// # Errors
400///
401/// [`CounterError::TooFewReadings`] for fewer than two edges (no interval),
402/// [`CounterError::NotFinite`] / [`CounterError::Unordered`] for edges that
403/// are not finite or do not increase.
404///
405/// # Example
406///
407/// ```
408/// use u_analytics::counter::{counter_increments, counter_totals, CounterOptions};
409///
410/// let times = [0.0, 6.0, 12.0, 18.0, 24.0, 30.0];
411/// let values = [100.0, 110.0, 125.0, 130.0, 150.0, 151.0];
412/// let steps = counter_increments(&times, &values, &CounterOptions::default()).unwrap();
413/// let days = counter_totals(&steps, &[0.0, 24.0, 48.0]).unwrap();
414/// assert_eq!(days[0].increase, 30.0); // 6, 12 and 18 end in the first day
415/// assert_eq!(days[1].increase, 21.0); // 24 and 30 in the second
416/// ```
417pub fn counter_totals(
418    increments: &[CounterIncrement],
419    edges: &[f64],
420) -> Result<Vec<CounterTotal>, CounterError> {
421    if edges.len() < 2 {
422        return Err(CounterError::TooFewReadings { got: edges.len() });
423    }
424    check_times("edges", edges)?;
425    let mut totals: Vec<CounterTotal> = edges
426        .windows(2)
427        .map(|e| CounterTotal {
428            start: e[0],
429            end: e[1],
430            increase: 0.0,
431            pairs: 0,
432            unexplained: 0,
433        })
434        .collect();
435    for inc in increments {
436        let j = edges.partition_point(|&e| e <= inc.end);
437        if j == 0 || j == edges.len() {
438            continue;
439        }
440        let total = &mut totals[j - 1];
441        total.pairs += 1;
442        match inc.increase {
443            Some(x) => total.increase += x,
444            None => total.unexplained += 1,
445        }
446    }
447    Ok(totals)
448}
449
450fn check_times(parameter: &'static str, times: &[f64]) -> Result<(), CounterError> {
451    for (i, t) in times.iter().enumerate() {
452        if !t.is_finite() {
453            return Err(CounterError::NotFinite {
454                parameter,
455                index: Some(i),
456            });
457        }
458        if i > 0 && *t <= times[i - 1] {
459            return Err(CounterError::Unordered {
460                parameter,
461                index: i,
462            });
463        }
464    }
465    Ok(())
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    fn opts(modulus: Option<f64>) -> CounterOptions {
473        CounterOptions {
474            modulus,
475            ..Default::default()
476        }
477    }
478
479    #[test]
480    fn a_rising_counter_has_normal_increments() {
481        let s = counter_increments(&[0.0, 1.0, 2.0], &[5.0, 5.0, 9.5], &opts(None)).unwrap();
482        assert_eq!(s.len(), 2);
483        assert_eq!(
484            (s[0].increase, s[0].kind),
485            (Some(0.0), IncrementKind::Normal)
486        );
487        assert_eq!(
488            (s[1].increase, s[1].kind),
489            (Some(4.5), IncrementKind::Normal)
490        );
491        assert_eq!((s[1].start, s[1].end), (1.0, 2.0));
492    }
493
494    /// The consumer's day: a six-digit register (999 999.9) passing 0 read
495    /// as max − min gave 999 990; the wrap gives the true advance.
496    #[test]
497    fn a_wrap_is_counted_through_the_modulus() {
498        let s =
499            counter_increments(&[0.0, 1.0], &[999_995.0, 5.0], &opts(Some(1_000_000.0))).unwrap();
500        assert_eq!(s[0].kind, IncrementKind::Wrap);
501        assert_eq!(s[0].increase, Some(10.0));
502    }
503
504    #[test]
505    fn a_fall_without_a_modulus_is_not_guessed() {
506        let s = counter_increments(&[0.0, 1.0, 2.0], &[50.0, 10.0, 12.0], &opts(None)).unwrap();
507        assert_eq!(
508            (s[0].increase, s[0].kind),
509            (None, IncrementKind::Unexplained)
510        );
511        assert_eq!(
512            (s[1].increase, s[1].kind),
513            (Some(2.0), IncrementKind::Normal)
514        );
515    }
516
517    /// A reset near the top of the register looks like a wrap; a rate the
518    /// counter cannot reach tells them apart.
519    #[test]
520    fn a_wrap_faster_than_the_counter_can_go_is_unexplained() {
521        let o = CounterOptions {
522            modulus: Some(1000.0),
523            max_rate: Some(10.0),
524            ..Default::default()
525        };
526        // 990 → 5 in 1 time unit: a wrap would be 15 > 10.
527        let s = counter_increments(&[0.0, 1.0], &[990.0, 5.0], &o).unwrap();
528        assert_eq!(s[0].kind, IncrementKind::Unexplained);
529        // 998 → 5: a wrap of 7 is possible.
530        let s = counter_increments(&[0.0, 1.0], &[998.0, 5.0], &o).unwrap();
531        assert_eq!((s[0].kind, s[0].increase), (IncrementKind::Wrap, Some(7.0)));
532    }
533
534    #[test]
535    fn a_declared_replacement_counts_from_the_new_start() {
536        let mut o = opts(None);
537        o.discontinuities = vec![Discontinuity {
538            time: 1.5,
539            start: 0.0,
540            last: None,
541        }];
542        let s = counter_increments(&[0.0, 1.0, 2.0], &[100.0, 120.0, 7.0], &o).unwrap();
543        assert_eq!(
544            (s[1].kind, s[1].increase),
545            (IncrementKind::Declared, Some(7.0))
546        );
547        // With the old register's last reading, its last advance counts too.
548        o.discontinuities[0].last = Some(126.0);
549        let s = counter_increments(&[0.0, 1.0, 2.0], &[100.0, 120.0, 7.0], &o).unwrap();
550        assert_eq!(s[1].increase, Some(13.0));
551        // A discontinuity at the later reading's time belongs to that pair.
552        o.discontinuities[0].time = 2.0;
553        let s = counter_increments(&[0.0, 1.0, 2.0], &[100.0, 120.0, 7.0], &o).unwrap();
554        assert_eq!(s[1].kind, IncrementKind::Declared);
555    }
556
557    #[test]
558    fn a_declared_change_takes_precedence_over_a_wrap() {
559        let mut o = opts(Some(1000.0));
560        o.discontinuities = vec![Discontinuity {
561            time: 1.0,
562            start: 0.0,
563            last: None,
564        }];
565        let s = counter_increments(&[0.0, 1.0], &[500.0, 3.0], &o).unwrap();
566        assert_eq!(
567            (s[0].kind, s[0].increase),
568            (IncrementKind::Declared, Some(3.0))
569        );
570    }
571
572    #[test]
573    fn refusals_name_what_is_wrong() {
574        let e = |t: &[f64], v: &[f64], o: &CounterOptions| counter_increments(t, v, o).unwrap_err();
575        assert_eq!(
576            e(&[0.0, 1.0], &[1.0], &opts(None)),
577            CounterError::LengthMismatch {
578                times: 2,
579                values: 1
580            }
581        );
582        assert_eq!(
583            e(&[0.0], &[1.0], &opts(None)),
584            CounterError::TooFewReadings { got: 1 }
585        );
586        assert_eq!(
587            e(&[0.0, 0.0], &[1.0, 2.0], &opts(None)),
588            CounterError::Unordered {
589                parameter: "times",
590                index: 1
591            }
592        );
593        assert_eq!(
594            e(&[0.0, 1.0], &[1.0, f64::NAN], &opts(None)),
595            CounterError::NotFinite {
596                parameter: "values",
597                index: Some(1)
598            }
599        );
600        assert_eq!(
601            e(&[0.0, 1.0], &[1.0, 2.0], &opts(Some(0.0))),
602            CounterError::NotPositive {
603                parameter: "modulus",
604                got: 0.0
605            }
606        );
607        assert_eq!(
608            e(&[0.0, 1.0], &[1.0, 1000.0], &opts(Some(1000.0))),
609            CounterError::OutsideModulus {
610                parameter: "values",
611                index: 1,
612                got: 1000.0,
613                modulus: 1000.0
614            }
615        );
616        let mut o = opts(None);
617        o.discontinuities = vec![Discontinuity {
618            time: 0.0,
619            start: 0.0,
620            last: None,
621        }];
622        assert_eq!(
623            e(&[0.0, 1.0], &[1.0, 2.0], &o),
624            CounterError::OutsideReadings {
625                index: 0,
626                got: 0.0,
627                first: 0.0,
628                last: 1.0
629            }
630        );
631        o.discontinuities = vec![
632            Discontinuity {
633                time: 0.5,
634                start: 0.0,
635                last: None,
636            },
637            Discontinuity {
638                time: 0.7,
639                start: 0.0,
640                last: None,
641            },
642        ];
643        assert_eq!(
644            e(&[0.0, 1.0], &[1.0, 2.0], &o),
645            CounterError::TwoInOnePair { index: 1 }
646        );
647    }
648
649    #[test]
650    fn totals_attribute_each_pair_to_the_interval_its_end_falls_in() {
651        let s = counter_increments(
652            &[0.0, 10.0, 20.0, 30.0, 40.0],
653            &[0.0, 5.0, 2.0, 4.0, 9.0],
654            &opts(None),
655        )
656        .unwrap();
657        let t = counter_totals(&s, &[0.0, 20.0, 40.0, 60.0]).unwrap();
658        // [0, 20): the pair ending at 10. [20, 40): ending at 20 (fall,
659        // unexplained) and 30. [40, 60): ending at 40.
660        assert_eq!((t[0].increase, t[0].pairs, t[0].unexplained), (5.0, 1, 0));
661        assert_eq!((t[1].increase, t[1].pairs, t[1].unexplained), (2.0, 2, 1));
662        assert_eq!((t[2].increase, t[2].pairs, t[2].unexplained), (5.0, 1, 0));
663        assert!(counter_totals(&s, &[0.0]).is_err());
664        assert!(counter_totals(&s, &[10.0, 5.0]).is_err());
665    }
666}
667
668/// The counter functions against the process that makes the readings: a
669/// 400-count register read hourly for three days (a 3 h gap included), with
670/// the consumption of every hour drawn first and the readings derived from it,
671/// so the increase of each pair is known exactly. The register wraps three
672/// times, the meter is replaced once (declared, with the old register's last
673/// reading), and the register drops to 5 once with nothing declared -- a fall
674/// that the modulus would explain as a wrap only if the counter could advance
675/// 45.0 counts in an hour. Generated with a seeded NumPy generator.
676#[cfg(test)]
677mod generated_meter {
678    use super::*;
679
680    const TIMES: [f64; 71] = [
681        0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0,
682        17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, 24.0, 25.0, 26.0, 27.0, 28.0, 29.0, 30.0, 31.0,
683        32.0, 33.0, 34.0, 35.0, 36.0, 37.0, 38.0, 39.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0, 48.0,
684        49.0, 50.0, 51.0, 52.0, 53.0, 54.0, 55.0, 56.0, 57.0, 58.0, 59.0, 60.0, 61.0, 62.0, 63.0,
685        64.0, 65.0, 66.0, 67.0, 68.0, 69.0, 70.0, 71.0, 72.0,
686    ];
687    const VALUES: [f64; 71] = [
688        380.0, 382.0, 391.0, 23.0, 48.0, 65.0, 77.0, 108.0, 142.0, 149.0, 174.0, 183.0, 217.0,
689        250.0, 282.0, 320.0, 348.0, 363.0, 380.0, 9.0, 45.0, 56.0, 68.0, 100.0, 127.0, 144.0,
690        159.0, 178.0, 210.0, 230.0, 267.0, 17.0, 30.0, 35.0, 62.0, 78.0, 109.0, 114.0, 114.0,
691        148.0, 219.0, 239.0, 267.0, 279.0, 298.0, 310.0, 320.0, 353.0, 393.0, 15.0, 54.0, 5.0,
692        11.0, 40.0, 46.0, 58.0, 64.0, 79.0, 88.0, 90.0, 93.0, 132.0, 140.0, 148.0, 182.0, 211.0,
693        234.0, 247.0, 286.0, 300.0, 303.0,
694    ];
695    /// The consumption of each pair; `NAN` where the register was reset without a word.
696    const CONSUMED: [f64; 70] = [
697        2.0,
698        9.0,
699        32.0,
700        25.0,
701        17.0,
702        12.0,
703        31.0,
704        34.0,
705        7.0,
706        25.0,
707        9.0,
708        34.0,
709        33.0,
710        32.0,
711        38.0,
712        28.0,
713        15.0,
714        17.0,
715        29.0,
716        36.0,
717        11.0,
718        12.0,
719        32.0,
720        27.0,
721        17.0,
722        15.0,
723        19.0,
724        32.0,
725        20.0,
726        37.0,
727        15.0,
728        13.0,
729        5.0,
730        27.0,
731        16.0,
732        31.0,
733        5.0,
734        0.0,
735        34.0,
736        71.0,
737        20.0,
738        28.0,
739        12.0,
740        19.0,
741        12.0,
742        10.0,
743        33.0,
744        40.0,
745        22.0,
746        39.0,
747        f64::NAN,
748        6.0,
749        29.0,
750        6.0,
751        12.0,
752        6.0,
753        15.0,
754        9.0,
755        2.0,
756        3.0,
757        39.0,
758        8.0,
759        8.0,
760        34.0,
761        29.0,
762        23.0,
763        13.0,
764        39.0,
765        14.0,
766        3.0,
767    ];
768    const KINDS: [IncrementKind; 70] = [
769        IncrementKind::Normal,
770        IncrementKind::Normal,
771        IncrementKind::Wrap,
772        IncrementKind::Normal,
773        IncrementKind::Normal,
774        IncrementKind::Normal,
775        IncrementKind::Normal,
776        IncrementKind::Normal,
777        IncrementKind::Normal,
778        IncrementKind::Normal,
779        IncrementKind::Normal,
780        IncrementKind::Normal,
781        IncrementKind::Normal,
782        IncrementKind::Normal,
783        IncrementKind::Normal,
784        IncrementKind::Normal,
785        IncrementKind::Normal,
786        IncrementKind::Normal,
787        IncrementKind::Wrap,
788        IncrementKind::Normal,
789        IncrementKind::Normal,
790        IncrementKind::Normal,
791        IncrementKind::Normal,
792        IncrementKind::Normal,
793        IncrementKind::Normal,
794        IncrementKind::Normal,
795        IncrementKind::Normal,
796        IncrementKind::Normal,
797        IncrementKind::Normal,
798        IncrementKind::Normal,
799        IncrementKind::Declared,
800        IncrementKind::Normal,
801        IncrementKind::Normal,
802        IncrementKind::Normal,
803        IncrementKind::Normal,
804        IncrementKind::Normal,
805        IncrementKind::Normal,
806        IncrementKind::Normal,
807        IncrementKind::Normal,
808        IncrementKind::Normal,
809        IncrementKind::Normal,
810        IncrementKind::Normal,
811        IncrementKind::Normal,
812        IncrementKind::Normal,
813        IncrementKind::Normal,
814        IncrementKind::Normal,
815        IncrementKind::Normal,
816        IncrementKind::Normal,
817        IncrementKind::Wrap,
818        IncrementKind::Normal,
819        IncrementKind::Unexplained,
820        IncrementKind::Normal,
821        IncrementKind::Normal,
822        IncrementKind::Normal,
823        IncrementKind::Normal,
824        IncrementKind::Normal,
825        IncrementKind::Normal,
826        IncrementKind::Normal,
827        IncrementKind::Normal,
828        IncrementKind::Normal,
829        IncrementKind::Normal,
830        IncrementKind::Normal,
831        IncrementKind::Normal,
832        IncrementKind::Normal,
833        IncrementKind::Normal,
834        IncrementKind::Normal,
835        IncrementKind::Normal,
836        IncrementKind::Normal,
837        IncrementKind::Normal,
838        IncrementKind::Normal,
839    ];
840
841    fn options() -> CounterOptions {
842        CounterOptions {
843            modulus: Some(400.0),
844            max_rate: Some(45.0),
845            discontinuities: vec![Discontinuity {
846                time: 30.5,
847                start: 7.0,
848                last: Some(272.0),
849            }],
850        }
851    }
852
853    #[test]
854    fn every_pair_is_classified_and_counted_as_the_process_made_it() {
855        let steps = counter_increments(&TIMES, &VALUES, &options()).expect("valid readings");
856        assert_eq!(steps.len(), CONSUMED.len());
857        for (i, step) in steps.iter().enumerate() {
858            assert_eq!(step.kind, KINDS[i], "pair {i}");
859            if CONSUMED[i].is_nan() {
860                assert_eq!(step.increase, None, "pair {i}");
861            } else {
862                assert_eq!(step.increase, Some(CONSUMED[i]), "pair {i}");
863            }
864        }
865    }
866
867    #[test]
868    fn daily_totals_sum_the_pairs_ending_in_each_day() {
869        let steps = counter_increments(&TIMES, &VALUES, &options()).expect("valid readings");
870        // The pair ending at 24 belongs to the second day, the one ending at 72
871        // (the last edge) to none.
872        let totals = counter_totals(&steps, &[0.0, 24.0, 48.0, 72.0]).expect("valid edges");
873        let want: [(f64, f64, f64, usize, usize); 3] = [
874            (0.0, 24.0, 520.0, 23, 0),
875            (24.0, 48.0, 475.0, 22, 0),
876            (48.0, 72.0, 439.0, 24, 1),
877        ];
878        assert_eq!(totals.len(), want.len());
879        for (t, (start, end, increase, pairs, unexplained)) in totals.iter().zip(want) {
880            assert_eq!(
881                (t.start, t.end, t.increase, t.pairs, t.unexplained),
882                (start, end, increase, pairs, unexplained)
883            );
884        }
885    }
886}