Skip to main content

hems_core/
units.rs

1//! Physical quantities, with one sign convention for the whole workspace.
2//!
3//! # The load convention
4//!
5//! Every power and energy value in hems uses the **load convention**: a positive
6//! value is power flowing *into* the thing being measured, a negative value is
7//! power flowing *out of* it.
8//!
9//! | Thing | Positive means | Negative means |
10//! |---|---|---|
11//! | Grid connection | import (Netzbezug) | export (Einspeisung) |
12//! | PV array | — (only at night: standby draw) | production |
13//! | Battery | charging | discharging |
14//! | Wallbox | charging the car | discharging it (V2H/V2G) |
15//! | Heat pump, household load | consumption | — |
16//!
17//! The reason to pay the small cost of "PV production is negative" is one
18//! invariant that then holds everywhere and can be tested:
19//!
20//! ```text
21//! grid connection power  ==  Σ (power of every asset behind it)
22//! ```
23//!
24//! [`crate::site::Site::balance_residual`] is that equation, and
25//! `hems-realtime` uses it to detect a missing or mis-signed meter.
26//!
27//! # Non-finite values
28//!
29//! The constructors here are infallible and cheap; they `debug_assert!` that the
30//! input is finite. The gate that matters is at the boundary where a number
31//! becomes an action: [`crate::setpoint::Setpoint::new`] refuses a non-finite
32//! command, so a NaN produced by a broken driver or a degenerate solve can never
33//! reach a device.
34
35use core::fmt;
36use core::iter::Sum;
37use core::ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign};
38
39use crate::error::UnitError;
40
41macro_rules! scalar_unit {
42    (
43        $(#[$meta:meta])*
44        $name:ident, $unit:literal, $si:literal
45    ) => {
46        $(#[$meta])*
47        #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
48        #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49        #[cfg_attr(feature = "serde", serde(transparent))]
50        pub struct $name(f64);
51
52        impl $name {
53            #[doc = concat!("Zero ", $si, ".")]
54            pub const ZERO: Self = Self(0.0);
55
56            #[doc = concat!("A value in ", $si, " (`", $unit, "`).")]
57            #[must_use]
58            pub fn new(value: f64) -> Self {
59                debug_assert!(value.is_finite(), concat!(stringify!($name), " must be finite"));
60                Self(value)
61            }
62
63            #[doc = concat!("A compile-time constant in ", $si, ", unchecked.")]
64            #[must_use]
65            pub const fn new_const(value: f64) -> Self {
66                Self(value)
67            }
68
69            #[doc = concat!("The value in ", $si, ".")]
70            #[must_use]
71            pub const fn get(self) -> f64 {
72                self.0
73            }
74
75            /// `true` when the value is finite — the precondition every setpoint
76            /// is checked against before it leaves the process.
77            #[must_use]
78            pub fn is_finite(self) -> bool {
79                self.0.is_finite()
80            }
81
82            /// The larger of two values. Propagates `NaN` rather than hiding it.
83            #[must_use]
84            pub fn max(self, other: Self) -> Self {
85                if self.0.is_nan() || other.0.is_nan() {
86                    Self(f64::NAN)
87                } else if self.0 >= other.0 {
88                    self
89                } else {
90                    other
91                }
92            }
93
94            /// The smaller of two values. Propagates `NaN` rather than hiding it.
95            #[must_use]
96            pub fn min(self, other: Self) -> Self {
97                if self.0.is_nan() || other.0.is_nan() {
98                    Self(f64::NAN)
99                } else if self.0 <= other.0 {
100                    self
101                } else {
102                    other
103                }
104            }
105
106            /// Clamped into `[lo, hi]`.
107            ///
108            /// # Panics
109            /// Panics when `lo > hi`, which is a programming error in the caller
110            /// rather than a runtime condition.
111            #[must_use]
112            pub fn clamp(self, lo: Self, hi: Self) -> Self {
113                assert!(lo.0 <= hi.0, "clamp range inverted: {lo:?} > {hi:?}");
114                self.max(lo).min(hi)
115            }
116
117            /// The magnitude, sign discarded.
118            #[must_use]
119            pub fn abs(self) -> Self {
120                Self(self.0.abs())
121            }
122
123            /// The part flowing *in* (positive side of the load convention), or zero.
124            #[must_use]
125            pub fn inflow(self) -> Self {
126                Self(self.0.max(0.0))
127            }
128
129            /// The part flowing *out* as a non-negative magnitude, or zero.
130            #[must_use]
131            pub fn outflow(self) -> Self {
132                Self((-self.0).max(0.0))
133            }
134        }
135
136        impl Add for $name {
137            type Output = Self;
138            fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) }
139        }
140        impl AddAssign for $name {
141            fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; }
142        }
143        impl Sub for $name {
144            type Output = Self;
145            fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) }
146        }
147        impl SubAssign for $name {
148            fn sub_assign(&mut self, rhs: Self) { self.0 -= rhs.0; }
149        }
150        impl Neg for $name {
151            type Output = Self;
152            fn neg(self) -> Self { Self(-self.0) }
153        }
154        impl Mul<f64> for $name {
155            type Output = Self;
156            fn mul(self, rhs: f64) -> Self { Self(self.0 * rhs) }
157        }
158        impl Mul<$name> for f64 {
159            type Output = $name;
160            fn mul(self, rhs: $name) -> $name { $name(self * rhs.0) }
161        }
162        impl Div<f64> for $name {
163            type Output = Self;
164            fn div(self, rhs: f64) -> Self { Self(self.0 / rhs) }
165        }
166        impl Div for $name {
167            type Output = f64;
168            fn div(self, rhs: Self) -> f64 { self.0 / rhs.0 }
169        }
170        impl Sum for $name {
171            fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
172                iter.fold(Self::ZERO, Add::add)
173            }
174        }
175        impl<'a> Sum<&'a $name> for $name {
176            fn sum<I: Iterator<Item = &'a Self>>(iter: I) -> Self {
177                iter.fold(Self::ZERO, |acc, v| acc + *v)
178            }
179        }
180        impl fmt::Display for $name {
181            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182                write!(f, "{:.1} {}", self.0, $unit)
183            }
184        }
185    };
186}
187
188scalar_unit!(
189    /// Active power in watts, load convention (see the module documentation).
190    Power, "W", "watts"
191);
192scalar_unit!(
193    /// Energy in watt-hours, load convention: positive is energy taken in.
194    Energy, "Wh", "watt-hours"
195);
196scalar_unit!(
197    /// Apparent power in volt-amperes — the quantity the VDE-AR-N 4100
198    /// unbalance rule (≤ 4,6 kVA Schieflast) is expressed in.
199    ApparentPower, "VA", "volt-amperes"
200);
201scalar_unit!(
202    /// Current in amperes, load convention.
203    Current, "A", "amperes"
204);
205scalar_unit!(
206    /// Voltage in volts (always positive in practice).
207    Voltage, "V", "volts"
208);
209
210impl Power {
211    /// A power given in kilowatts — the unit every datasheet and every BNetzA
212    /// Festlegung uses.
213    #[must_use]
214    pub fn from_kw(kw: f64) -> Self {
215        Self::new(kw * 1000.0)
216    }
217
218    /// The value in kilowatts.
219    #[must_use]
220    pub fn kw(self) -> f64 {
221        self.0 / 1000.0
222    }
223
224    /// The energy this power delivers if held for `duration`.
225    #[must_use]
226    pub fn over(self, duration: time::Duration) -> Energy {
227        Energy::new(self.0 * duration.as_seconds_f64() / 3600.0)
228    }
229
230    /// Single-phase current at `voltage`, ignoring power factor.
231    #[must_use]
232    pub fn to_current_1p(self, voltage: Voltage) -> Current {
233        Current::new(self.0 / voltage.get())
234    }
235
236    /// Three-phase current at `voltage` (phase-to-neutral), ignoring power factor.
237    #[must_use]
238    pub fn to_current_3p(self, voltage: Voltage) -> Current {
239        Current::new(self.0 / (3.0 * voltage.get()))
240    }
241}
242
243impl Energy {
244    /// An energy given in kilowatt-hours.
245    #[must_use]
246    pub fn from_kwh(kwh: f64) -> Self {
247        Self::new(kwh * 1000.0)
248    }
249
250    /// The value in kilowatt-hours.
251    #[must_use]
252    pub fn kwh(self) -> f64 {
253        self.0 / 1000.0
254    }
255
256    /// The constant power that would move this energy over `duration`.
257    #[must_use]
258    pub fn over(self, duration: time::Duration) -> Power {
259        Power::new(self.0 * 3600.0 / duration.as_seconds_f64())
260    }
261}
262
263impl Current {
264    /// Single-phase power drawn at `voltage`, ignoring power factor.
265    #[must_use]
266    pub fn to_power_1p(self, voltage: Voltage) -> Power {
267        Power::new(self.0 * voltage.get())
268    }
269
270    /// Three-phase power drawn at `voltage` (phase-to-neutral), ignoring power factor.
271    #[must_use]
272    pub fn to_power_3p(self, voltage: Voltage) -> Power {
273        Power::new(3.0 * self.0 * voltage.get())
274    }
275}
276
277/// The nominal phase-to-neutral voltage of a German low-voltage connection.
278pub const NOMINAL_VOLTAGE: Voltage = Voltage::new_const(230.0);
279
280// ── State of charge ──────────────────────────────────────────────────────────
281
282/// State of charge as a fraction in `[0, 1]`.
283///
284/// Constructed strictly, because a SoC outside the interval is either a driver
285/// bug or a unit mix-up (percent vs. fraction) and both are worth failing on.
286/// [`Soc::clamped`] exists for sensor noise around the endpoints.
287#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
288#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
289#[cfg_attr(feature = "serde", serde(try_from = "f64", into = "f64"))]
290pub struct Soc(f64);
291
292impl Soc {
293    /// Empty.
294    pub const EMPTY: Self = Self(0.0);
295    /// No backup reserve — the default for a battery that keeps nothing back.
296    pub const ZERO_RESERVE: Self = Self(0.0);
297    /// Full.
298    pub const FULL: Self = Self(1.0);
299
300    /// A state of charge from a fraction in `[0, 1]`.
301    ///
302    /// # Errors
303    /// [`UnitError::SocOutOfRange`] when the value is outside `[0, 1]` or not finite.
304    pub fn new(fraction: f64) -> Result<Self, UnitError> {
305        if fraction.is_finite() && (0.0..=1.0).contains(&fraction) {
306            Ok(Self(fraction))
307        } else {
308            Err(UnitError::SocOutOfRange(fraction))
309        }
310    }
311
312    /// A state of charge from a percentage in `[0, 100]`.
313    ///
314    /// # Errors
315    /// [`UnitError::SocOutOfRange`] when the value is outside `[0, 100]`.
316    pub fn from_percent(percent: f64) -> Result<Self, UnitError> {
317        Self::new(percent / 100.0).map_err(|_| UnitError::SocOutOfRange(percent))
318    }
319
320    /// A state of charge clamped into `[0, 1]` — for sensors that report 100.4 %.
321    /// A non-finite input becomes [`Soc::EMPTY`], the safe end for every decision
322    /// that reads a SoC (never discharge on a broken reading).
323    #[must_use]
324    pub fn clamped(fraction: f64) -> Self {
325        if fraction.is_finite() {
326            Self(fraction.clamp(0.0, 1.0))
327        } else {
328            Self::EMPTY
329        }
330    }
331
332    /// The fraction in `[0, 1]`.
333    #[must_use]
334    pub const fn fraction(self) -> f64 {
335        self.0
336    }
337
338    /// The percentage in `[0, 100]`.
339    #[must_use]
340    pub fn percent(self) -> f64 {
341        self.0 * 100.0
342    }
343
344    /// The energy stored in a battery of `capacity` at this state of charge.
345    #[must_use]
346    pub fn energy_in(self, capacity: Energy) -> Energy {
347        capacity * self.0
348    }
349}
350
351impl TryFrom<f64> for Soc {
352    type Error = UnitError;
353    fn try_from(value: f64) -> Result<Self, Self::Error> {
354        Self::new(value)
355    }
356}
357
358impl From<Soc> for f64 {
359    fn from(value: Soc) -> Self {
360        value.0
361    }
362}
363
364impl fmt::Display for Soc {
365    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366        write!(f, "{:.1} %", self.percent())
367    }
368}
369
370// ── Phases ───────────────────────────────────────────────────────────────────
371
372/// One of the three outer conductors of a German low-voltage connection.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
374#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
375#[cfg_attr(feature = "serde", serde(rename_all = "UPPERCASE"))]
376pub enum Phase {
377    /// Outer conductor L1.
378    L1,
379    /// Outer conductor L2.
380    L2,
381    /// Outer conductor L3.
382    L3,
383}
384
385impl Phase {
386    /// All three phases in order.
387    pub const ALL: [Phase; 3] = [Phase::L1, Phase::L2, Phase::L3];
388
389    /// The zero-based index of this phase, for array access.
390    #[must_use]
391    pub const fn index(self) -> usize {
392        match self {
393            Phase::L1 => 0,
394            Phase::L2 => 1,
395            Phase::L3 => 2,
396        }
397    }
398}
399
400impl fmt::Display for Phase {
401    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
402        match self {
403            Phase::L1 => f.write_str("L1"),
404            Phase::L2 => f.write_str("L2"),
405            Phase::L3 => f.write_str("L3"),
406        }
407    }
408}
409
410/// A value measured or commanded per outer conductor.
411///
412/// The unbalance rule of VDE-AR-N 4100 is stated per phase, so anything that has
413/// to satisfy it — a single-phase wallbox, a heating rod, the site as a whole —
414/// carries its numbers in here rather than as a scalar.
415#[derive(Debug, Clone, Copy, PartialEq, Default)]
416#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
417pub struct PerPhase<T> {
418    /// Value on L1.
419    pub l1: T,
420    /// Value on L2.
421    pub l2: T,
422    /// Value on L3.
423    pub l3: T,
424}
425
426impl<T: Copy> PerPhase<T> {
427    /// The same value on all three phases.
428    pub const fn splat(value: T) -> Self {
429        Self {
430            l1: value,
431            l2: value,
432            l3: value,
433        }
434    }
435
436    /// The value on one phase.
437    #[must_use]
438    pub const fn get(&self, phase: Phase) -> T {
439        match phase {
440            Phase::L1 => self.l1,
441            Phase::L2 => self.l2,
442            Phase::L3 => self.l3,
443        }
444    }
445
446    /// Replace the value on one phase.
447    pub const fn set(&mut self, phase: Phase, value: T) {
448        match phase {
449            Phase::L1 => self.l1 = value,
450            Phase::L2 => self.l2 = value,
451            Phase::L3 => self.l3 = value,
452        }
453    }
454
455    /// The three values, L1 first.
456    #[must_use]
457    pub const fn as_array(&self) -> [T; 3] {
458        [self.l1, self.l2, self.l3]
459    }
460
461    /// Apply `f` to every phase.
462    #[must_use]
463    pub fn map<U: Copy>(&self, mut f: impl FnMut(T) -> U) -> PerPhase<U> {
464        PerPhase {
465            l1: f(self.l1),
466            l2: f(self.l2),
467            l3: f(self.l3),
468        }
469    }
470
471    /// Combine two per-phase values elementwise.
472    #[must_use]
473    pub fn zip_with<U: Copy, V: Copy>(
474        &self,
475        other: &PerPhase<U>,
476        mut f: impl FnMut(T, U) -> V,
477    ) -> PerPhase<V> {
478        PerPhase {
479            l1: f(self.l1, other.l1),
480            l2: f(self.l2, other.l2),
481            l3: f(self.l3, other.l3),
482        }
483    }
484
485    /// Iterate over `(phase, value)` pairs.
486    pub fn iter(&self) -> impl Iterator<Item = (Phase, T)> + '_ {
487        Phase::ALL.into_iter().map(move |p| (p, self.get(p)))
488    }
489}
490
491impl PerPhase<Power> {
492    /// All three phases at zero.
493    pub const ZERO: Self = Self::splat(Power::ZERO);
494
495    /// The total across the three phases.
496    #[must_use]
497    pub fn total(&self) -> Power {
498        self.l1 + self.l2 + self.l3
499    }
500
501    /// The **Unsymmetrieleistung**: the largest difference between any two outer
502    /// conductors, expressed as apparent power.
503    ///
504    /// VDE-AR-N 4100 Abschnitt 5.5.2 caps it at 4,6 kVA for a customer
505    /// installation — the figure `metering::power_quality::UNSYMMETRIE_LIMIT_KVA`
506    /// carries with its derivation, and the same limit as the 20 A per
507    /// Außenleiter the VDE FNN Hinweis states it as.
508    ///
509    /// **Which devices count.** The requirement reaches only equipment that can
510    /// feed in or store — generation, storage, charge points — so the caller
511    /// sums those and not the household's own load. See
512    /// [`Asset::symmetry_relevant`](crate::asset::Asset::symmetry_relevant).
513    ///
514    /// **kVA, not kW.** The limit is apparent power, and an inverter at
515    /// cos φ < 1 — which VDE-AR-N 4105 requires it to be capable of — moves more
516    /// kVA than kW. This is computed from *active* power because that is what a
517    /// household driver reports, so it **understates** the unbalance exactly
518    /// when the grid has asked for reactive support. A meter that reports
519    /// apparent power per conductor should be used as it stands.
520    #[must_use]
521    pub fn unbalance(&self) -> ApparentPower {
522        let [a, b, c] = self.as_array().map(Power::get);
523        let max = a.max(b).max(c);
524        let min = a.min(b).min(c);
525        ApparentPower::new(max - min)
526    }
527}
528
529impl Add for PerPhase<Power> {
530    type Output = Self;
531    fn add(self, rhs: Self) -> Self {
532        self.zip_with(&rhs, |a, b| a + b)
533    }
534}
535
536impl Sub for PerPhase<Power> {
537    type Output = Self;
538    fn sub(self, rhs: Self) -> Self {
539        self.zip_with(&rhs, |a, b| a - b)
540    }
541}
542
543impl Sum for PerPhase<Power> {
544    fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
545        iter.fold(Self::ZERO, Add::add)
546    }
547}
548
549/// Which conductors an asset is using **right now**.
550///
551/// The wiring is [`PhaseConnection`]; this is the state a switchable device is
552/// in at this moment. They are different questions, and answering the second
553/// with the first is how an eleven-kilowatt wallbox ends up capped at the
554/// 4,6 kVA a *single-phase* device is allowed.
555#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
556#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
557#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
558pub enum PhaseMode {
559    /// One outer conductor. A charge point here draws a third of the power and
560    /// has a third of the minimum — 1,4 kW instead of 4,1 kW — which is the
561    /// whole reason switching is worth the contactor.
562    Single,
563    /// All three, symmetrically.
564    #[default]
565    Three,
566}
567
568impl PhaseMode {
569    /// The number of conductors in use.
570    #[must_use]
571    pub const fn count(self) -> u8 {
572        match self {
573            PhaseMode::Single => 1,
574            PhaseMode::Three => 3,
575        }
576    }
577
578    /// The other one.
579    #[must_use]
580    pub const fn other(self) -> Self {
581        match self {
582            PhaseMode::Single => PhaseMode::Three,
583            PhaseMode::Three => PhaseMode::Single,
584        }
585    }
586}
587
588impl fmt::Display for PhaseMode {
589    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
590        match self {
591            PhaseMode::Single => f.write_str("1p"),
592            PhaseMode::Three => f.write_str("3p"),
593        }
594    }
595}
596
597/// How many outer conductors an asset is **wired** to, and which.
598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
599#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
600#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind"))]
601pub enum PhaseConnection {
602    /// Connected to exactly one outer conductor.
603    ///
604    /// VDE-AR-N 4100 lets a single-phase device up to 4,6 kVA be connected this
605    /// way, and lets the network operator name the conductor.
606    Single {
607        /// The conductor the device sits on.
608        phase: Phase,
609    },
610    /// Connected to all three outer conductors, drawing symmetrically.
611    Three,
612    /// Able to switch between one and three phases at runtime (most modern
613    /// wallboxes). `phase` names the conductor used while in single-phase mode.
614    Switchable {
615        /// The conductor used in single-phase mode.
616        phase: Phase,
617    },
618}
619
620impl PhaseConnection {
621    /// Whether the device can change mode at all.
622    #[must_use]
623    pub const fn is_switchable(&self) -> bool {
624        matches!(self, PhaseConnection::Switchable { .. })
625    }
626
627    /// The mode this wiring is in when nothing has said otherwise.
628    ///
629    /// A switchable device starts three-phase: it is the mode that charges a car
630    /// fastest, and the one a wallbox powers up in.
631    #[must_use]
632    pub const fn default_mode(&self) -> PhaseMode {
633        match self {
634            PhaseConnection::Single { .. } => PhaseMode::Single,
635            PhaseConnection::Three | PhaseConnection::Switchable { .. } => PhaseMode::Three,
636        }
637    }
638
639    /// Whether this wiring can be in `mode`.
640    #[must_use]
641    pub const fn supports(&self, mode: PhaseMode) -> bool {
642        match self {
643            PhaseConnection::Single { .. } => matches!(mode, PhaseMode::Single),
644            PhaseConnection::Three => matches!(mode, PhaseMode::Three),
645            PhaseConnection::Switchable { .. } => true,
646        }
647    }
648
649    /// `mode` if this wiring supports it, otherwise the one it is stuck in.
650    #[must_use]
651    pub const fn clamp_mode(&self, mode: PhaseMode) -> PhaseMode {
652        if self.supports(mode) {
653            mode
654        } else {
655            self.default_mode()
656        }
657    }
658
659    /// The conductor used in single-phase mode, if there is one.
660    #[must_use]
661    pub const fn single_phase_conductor(&self) -> Option<Phase> {
662        match self {
663            PhaseConnection::Single { phase } | PhaseConnection::Switchable { phase } => {
664                Some(*phase)
665            }
666            PhaseConnection::Three => None,
667        }
668    }
669
670    /// Distribute a symmetric total across the conductors in use in `mode`.
671    #[must_use]
672    pub fn distribute(&self, total: Power, mode: PhaseMode) -> PerPhase<Power> {
673        match (self.clamp_mode(mode), self.single_phase_conductor()) {
674            (PhaseMode::Single, Some(phase)) => {
675                let mut p = PerPhase::ZERO;
676                p.set(phase, total);
677                p
678            }
679            _ => PerPhase::splat(total / 3.0),
680        }
681    }
682
683    /// The number of conductors in use in `mode`.
684    #[must_use]
685    pub const fn count(&self, mode: PhaseMode) -> u8 {
686        self.clamp_mode(mode).count()
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    #[test]
695    fn power_round_trips_through_kilowatts() {
696        assert!((Power::from_kw(4.2).kw() - 4.2).abs() < 1e-12);
697        assert_eq!(Power::from_kw(4.2), Power::new(4200.0));
698    }
699
700    #[test]
701    fn inflow_and_outflow_split_the_sign() {
702        let importing = Power::from_kw(3.0);
703        let exporting = Power::from_kw(-3.0);
704        assert_eq!(importing.inflow(), Power::from_kw(3.0));
705        assert_eq!(importing.outflow(), Power::ZERO);
706        assert_eq!(exporting.inflow(), Power::ZERO);
707        assert_eq!(exporting.outflow(), Power::from_kw(3.0));
708    }
709
710    #[test]
711    fn energy_and_power_are_inverse_over_a_quarter_hour() {
712        let quarter = time::Duration::minutes(15);
713        let e = Power::from_kw(4.0).over(quarter);
714        assert!((e.kwh() - 1.0).abs() < 1e-12);
715        assert!((e.over(quarter).kw() - 4.0).abs() < 1e-12);
716    }
717
718    #[test]
719    fn soc_rejects_out_of_range_and_clamps_on_request() {
720        assert!(Soc::new(1.004).is_err());
721        assert!(Soc::from_percent(100.4).is_err());
722        assert_eq!(Soc::clamped(1.004), Soc::FULL);
723        assert_eq!(Soc::clamped(f64::NAN), Soc::EMPTY);
724    }
725
726    #[test]
727    fn unbalance_is_the_spread_between_conductors() {
728        // A 4,6 kW single-phase wallbox on L1 and nothing else.
729        let p = PerPhase {
730            l1: Power::from_kw(4.6),
731            l2: Power::ZERO,
732            l3: Power::ZERO,
733        };
734        assert!((p.unbalance().get() - 4600.0).abs() < 1e-9);
735        // Symmetric draw has no unbalance however large it is.
736        assert_eq!(
737            PerPhase::splat(Power::from_kw(11.0)).unbalance(),
738            ApparentPower::ZERO
739        );
740    }
741
742    #[test]
743    fn switchable_connection_moves_between_one_and_three_conductors() {
744        let c = PhaseConnection::Switchable { phase: Phase::L2 };
745        let single = c.distribute(Power::from_kw(3.6), PhaseMode::Single);
746        assert_eq!(single.l2, Power::from_kw(3.6));
747        assert_eq!(single.l1, Power::ZERO);
748        let three = c.distribute(Power::from_kw(11.0), PhaseMode::Three);
749        assert!((three.l1.kw() - 11.0 / 3.0).abs() < 1e-12);
750        assert_eq!(three.total(), Power::from_kw(11.0));
751    }
752
753    #[test]
754    fn a_wiring_that_cannot_switch_ignores_a_mode_it_does_not_have() {
755        // The bug this prevents: asking a fixed three-phase connection "would you
756        // be single-phase if switched?" and getting `true`, which is how a
757        // symmetric device acquires an unbalance limit it can never breach.
758        let fixed = PhaseConnection::Three;
759        assert!(!fixed.is_switchable());
760        assert_eq!(fixed.clamp_mode(PhaseMode::Single), PhaseMode::Three);
761        assert_eq!(fixed.count(PhaseMode::Single), 3);
762        assert_eq!(
763            fixed.distribute(Power::from_kw(3.0), PhaseMode::Single).l1,
764            Power::from_kw(1.0)
765        );
766
767        let fixed_single = PhaseConnection::Single { phase: Phase::L1 };
768        assert_eq!(fixed_single.clamp_mode(PhaseMode::Three), PhaseMode::Single);
769        assert_eq!(fixed_single.count(PhaseMode::Three), 1);
770    }
771
772    #[test]
773    fn a_switchable_wallbox_starts_three_phase() {
774        let c = PhaseConnection::Switchable { phase: Phase::L1 };
775        assert_eq!(c.default_mode(), PhaseMode::Three);
776        assert!(c.supports(PhaseMode::Single) && c.supports(PhaseMode::Three));
777        assert_eq!(c.single_phase_conductor(), Some(Phase::L1));
778    }
779
780    #[test]
781    fn nan_propagates_through_min_and_max_instead_of_being_swallowed() {
782        let nan = Power::new_const(f64::NAN);
783        assert!(!nan.max(Power::ZERO).is_finite());
784        assert!(!nan.min(Power::ZERO).is_finite());
785    }
786}