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}