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}