use metering::QualityFlag;
use time::{Duration, OffsetDateTime};
use crate::units::{Current, Energy, PerPhase, Power, Soc, Voltage};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum Freshness {
Fresh,
Stale,
}
impl Freshness {
#[must_use]
pub fn is_fresh(self) -> bool {
matches!(self, Freshness::Fresh)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Measurement {
#[cfg_attr(feature = "serde", serde(with = "time::serde::rfc3339"))]
pub at: OffsetDateTime,
pub quality: QualityFlag,
pub power: Option<Power>,
pub power_per_phase: Option<PerPhase<Power>>,
pub current_per_phase: Option<PerPhase<Current>>,
pub voltage_per_phase: Option<PerPhase<Voltage>>,
pub energy_in: Option<Energy>,
pub energy_out: Option<Energy>,
pub soc: Option<Soc>,
pub temperature_c: Option<f64>,
pub frequency_hz: Option<f64>,
#[cfg_attr(feature = "serde", serde(default))]
pub available_power: Option<Power>,
}
impl Measurement {
#[must_use]
pub const fn at(at: OffsetDateTime) -> Self {
Self {
at,
quality: QualityFlag::Measured,
power: None,
power_per_phase: None,
current_per_phase: None,
voltage_per_phase: None,
energy_in: None,
energy_out: None,
soc: None,
temperature_c: None,
frequency_hz: None,
available_power: None,
}
}
#[must_use]
pub fn power(at: OffsetDateTime, power: Power) -> Self {
Self {
power: Some(power),
..Self::at(at)
}
}
#[must_use]
pub fn per_phase(at: OffsetDateTime, power: PerPhase<Power>) -> Self {
Self {
power: Some(power.total()),
power_per_phase: Some(power),
..Self::at(at)
}
}
#[must_use]
pub fn substituted(mut self) -> Self {
self.quality = QualityFlag::Substituted;
self
}
#[must_use]
pub fn age(&self, now: OffsetDateTime) -> Duration {
let age = now - self.at;
if age.is_negative() {
Duration::ZERO
} else {
age
}
}
#[must_use]
pub fn freshness(&self, now: OffsetDateTime, max_age: Duration) -> Freshness {
if self.quality == QualityFlag::Faulty || self.age(now) > max_age {
Freshness::Stale
} else {
Freshness::Fresh
}
}
#[must_use]
pub fn fresh_power(&self, now: OffsetDateTime, max_age: Duration) -> Option<Power> {
self.freshness(now, max_age)
.is_fresh()
.then_some(self.power)
.flatten()
}
#[must_use]
pub fn power_per_phase_or_split(
&self,
connection: crate::units::PhaseConnection,
mode: crate::units::PhaseMode,
) -> Option<PerPhase<Power>> {
self.power_per_phase
.or_else(|| self.power.map(|p| connection.distribute(p, mode)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
const NOW: OffsetDateTime = datetime!(2026-05-01 12:00:00 UTC);
#[test]
fn freshness_follows_the_consumers_tolerance() {
let m = Measurement::power(NOW - Duration::seconds(5), Power::from_kw(1.0));
assert_eq!(m.freshness(NOW, Duration::seconds(10)), Freshness::Fresh);
assert_eq!(m.freshness(NOW, Duration::seconds(2)), Freshness::Stale);
}
#[test]
fn a_faulty_reading_is_stale_however_new_it_is() {
let mut m = Measurement::power(NOW, Power::from_kw(1.0));
m.quality = QualityFlag::Faulty;
assert_eq!(m.freshness(NOW, Duration::minutes(5)), Freshness::Stale);
assert_eq!(m.fresh_power(NOW, Duration::minutes(5)), None);
}
#[test]
fn a_device_clock_running_ahead_does_not_produce_a_negative_age() {
let m = Measurement::power(NOW + Duration::seconds(30), Power::ZERO);
assert_eq!(m.age(NOW), Duration::ZERO);
}
#[test]
fn per_phase_fills_in_the_total() {
let m = Measurement::per_phase(
NOW,
PerPhase {
l1: Power::from_kw(1.0),
l2: Power::from_kw(2.0),
l3: Power::ZERO,
},
);
assert_eq!(m.power, Some(Power::from_kw(3.0)));
}
}