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(×, &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(×, &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(×, &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}