Skip to main content

gridoxide_core/
measurement.rs

1//! The measurement model state estimation is fitted against: what was
2//! observed, where, and how much it is trusted.
3//!
4//! This is deliberately the *shared front end* of both estimator methods. Both
5//! Newton-Raphson WLS and the iterative-linear method consume the same
6//! aggregated [`Measurement`] list; they differ only in what they do with it,
7//! so sensor aggregation and per-unit conversion happen exactly once, before
8//! either sees the list. For PGM documents that is
9//! gridoxide's `pgm::measurement`, which also states the
10//! aggregation rules and sign conventions it applies.
11//!
12//! # Scalar rows, not sensors
13//!
14//! A [`Measurement`] is one *scalar* observation with its own standard
15//! deviation — one row of the estimator's `z` vector and one row of `H`. A PGM
16//! power sensor therefore becomes two of them (P and Q), because
17//! Newton-Raphson WLS treats `σ_P` and `σ_Q` as independent
18//! (`se-algorithms.md`), and a voltage sensor becomes one or two depending on
19//! whether it carries an angle.
20
21use crate::branch_flow::Terminal;
22
23/// Which scalar quantity a [`Measurement`] observes.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25pub enum MeasurementKind {
26    /// Voltage magnitude at a bus, per-unit.
27    VoltageMagnitude,
28    /// Voltage angle at a bus, radians. Only ever present when a sensor
29    /// supplied one — i.e. a phasor/PMU measurement.
30    VoltageAngle,
31    /// Active power, per-unit.
32    ActivePower,
33    /// Reactive power, per-unit.
34    ReactivePower,
35    /// Real part of a measured current, per-unit.
36    ///
37    /// A current sensor is stored decomposed rather than as a magnitude and an
38    /// angle, following power-grid-model. Three reasons, the first decisive:
39    /// `arg(I)` has a branch cut, and a residual `z − h(x)` taken near ±π wraps
40    /// — gridoxide has no `phase_mod_2pi` anywhere, so a polar row would
41    /// silently chase a 2π error. `|I|` also has an unbounded derivative as
42    /// `I → 0`, which an unloaded branch reaches. And the iterative-linear
43    /// method needs a complex current regardless, so polar rows would be
44    /// recombined later and less legibly.
45    CurrentReal,
46    /// Imaginary part of a measured current, per-unit.
47    CurrentImag,
48}
49
50/// Which reference a current sensor's angle is measured against.
51///
52/// The two are not a presentation detail: they measure *different quantities*
53/// of the same terminal, and power-grid-model's own `global-current-sensor` and
54/// `local-current-sensor` fixtures are identical but for this field and converge
55/// to visibly different states.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
57pub enum AngleFrame {
58    /// The angle is absolute, against the same reference voltage phasors use:
59    /// `I = i·e^{j·i_angle}`.
60    ///
61    /// Only meaningful when something else fixes that reference, so
62    /// power-grid-model requires at least one voltage angle measurement
63    /// alongside — `se::observability` checks the same.
64    Global,
65    /// The angle is the shift between the terminal's voltage and its current,
66    /// `i_angle = θ_U − θ_I`, which is what a meter reading power factor
67    /// produces.
68    ///
69    /// Carries no absolute phase, so it neither needs nor supplies a reference.
70    /// Note the sign convention is the opposite of [`Global`](Self::Global)'s:
71    /// `I = conj(i·e^{j·i_angle})·U/|U|`.
72    Local,
73}
74
75/// What a [`Measurement`] is attached to.
76#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
77pub enum Target {
78    /// A bus: a voltage measurement, or the injection of the loads and
79    /// generators attached to it.
80    ///
81    /// This is *not* power-grid-model's "node injection" — see
82    /// [`Target::NodeInjection`]. gridoxide models sources and shunts as part
83    /// of the network rather than as appliances, so they do not appear in this
84    /// quantity at all.
85    Bus(usize),
86    /// One terminal of one branch, indexed as in
87    /// [`branch_flow::branch_params`](crate::branch_flow::branch_params).
88    BranchTerminal { branch: usize, terminal: Terminal },
89    /// The power a `source` delivers into its node.
90    ///
91    /// power-grid-model treats a source as an appliance at the node, but
92    /// gridoxide synthesizes a virtual slack bus behind a source-impedance
93    /// branch, so the source's power is that branch's flow rather than any bus
94    /// injection. `branch` is the synthesized branch, from
95    /// gridoxide's `PgmNetwork::source_branch_idx`.
96    SourceInjection { branch: usize },
97    /// The injection of the shunts attached to a bus.
98    ///
99    /// Shunts are stamped into the Y-bus diagonal, so like sources they are
100    /// structural here and absent from [`Target::Bus`]. Shunts on one bus share
101    /// a single Y-bus entry and cannot be told apart afterwards, so this is
102    /// their total.
103    ShuntInjection { bus: usize },
104    /// power-grid-model's node injection: the sum of *every* appliance at the
105    /// bus — loads, generators, sources and shunts alike.
106    ///
107    /// Equals [`Target::Bus`] plus the source and shunt injections at the same
108    /// bus, which is what makes it a distinct measurement function rather than
109    /// a relabelling.
110    NodeInjection(usize),
111    /// A current sensor on a branch terminal.
112    ///
113    /// Separate from [`Target::BranchTerminal`] rather than a flag on it,
114    /// because the frame changes the measurement *function* and because
115    /// `se::iterative` keys its row builder on `Target` — which makes
116    /// power-grid-model's "no mixing power and current on one terminal" rule
117    /// fall out of the type rather than needing to be enforced downstream.
118    BranchTerminalCurrent {
119        branch: usize,
120        terminal: Terminal,
121        frame: AngleFrame,
122    },
123}
124
125/// One scalar observation with its uncertainty, in per-unit.
126///
127/// `sigma` is a standard deviation, never a variance — the estimator weights
128/// rows by `1/σ²`, and storing the deviation keeps this in the same units the
129/// input data uses.
130#[derive(Clone, Copy, Debug, PartialEq)]
131pub struct Measurement {
132    pub kind: MeasurementKind,
133    pub target: Target,
134    pub value: f64,
135    pub sigma: f64,
136}
137
138impl Measurement {
139    /// The estimator's weight for this row, `1/σ²`.
140    pub fn weight(&self) -> f64 {
141        1.0 / (self.sigma * self.sigma)
142    }
143}