Skip to main content

gridoxide_core/
dispatch.rs

1//! Which machine produced what reactive power.
2//!
3//! A `PV` bus has one reactive injection and the solver decides it. When one
4//! machine holds that bus, that injection *is* the machine's output and there
5//! is nothing to attribute. When six do — 62 of RealGrid's 417 regulated buses,
6//! 2 of FullGrid's 3 — the power flow is still right, but nothing says which
7//! machine is carrying the load.
8//!
9//! That matters most exactly where it is least visible.
10//! [`ReactiveLimits`](crate::outerloop::ReactiveLimits) clamps a bus at the
11//! *summed* capability of everything holding it, which is correct: the bus can
12//! produce what its machines jointly can. But a bus comfortably inside its
13//! summed limit can contain a machine that is not, with the others carrying it.
14//! Only a split shows that.
15//!
16//! # What this is not
17//!
18//! **This changes no answer.** It is attribution after the fact, not a
19//! constraint inside the solve. Every machine in the vendored corpus sits at
20//! the bus it regulates, so the bus is an ordinary `PV` bus, its injection is
21//! determined by the network, and the split is arithmetic on the result.
22//!
23//! The genuinely different problem — several machines at *different* buses
24//! holding one remote bus — needs `n−1` extra equations inside the Newton
25//! system (powsybl's `DISTR_Q`) because each controller bus then has its own
26//! reactive unknown. No vendored fixture has that configuration, so it is not
27//! built here.
28
29use crate::network::{YBusSparse, power_injections};
30use crate::types::{Bus, RegulatingMachine};
31
32/// One bus's machines, and what they could account for.
33#[derive(Clone, Debug, PartialEq)]
34pub struct BusDispatch {
35    /// The bus whose voltage these machines hold.
36    pub bus: usize,
37    /// What they jointly had to produce: the solved reactive injection at
38    /// `bus`, less the part that is not theirs.
39    pub required: f64,
40    /// What they could actually be given, once each one's own limits were
41    /// respected. Equal to `required` unless every machine is pinned.
42    pub attributed: f64,
43    /// `required − attributed`, and worth reading rather than ignoring.
44    ///
45    /// A non-zero value means the machines holding this bus cannot account for
46    /// the reactive power the solve put there — they are all at their limits
47    /// and it is still not enough. That is not an arithmetic failure here; it
48    /// means the **solve** placed the bus outside its machines' joint
49    /// capability, which happens because gridoxide bounds the wrong quantity.
50    ///
51    /// [`ReactiveLimits`](crate::outerloop::ReactiveLimits) compares
52    /// `Bus::q_min`/`q_max` against the bus's *net* injection, while the CGMES
53    /// importer fills those fields from the machines' *own* capability. Where a
54    /// reactive load shares the bus, the machine has to cover it too, so the
55    /// machine saturates before the net injection reaches the bound and the
56    /// clamp fires late. `pgm::PgmVoltageRegulator`'s doc comment already names
57    /// this as a deliberate simplification of the bus model; this field is the
58    /// first thing that measures it.
59    ///
60    /// Measured on RealGrid: of 416 regulated buses, 50 share their bus with
61    /// another reactive injection and 6 come out unattributable, 0.044 p.u. in
62    /// total. Small, real, and previously invisible.
63    pub unattributed: f64,
64    /// What the shares were derived from.
65    pub basis: KeyBasis,
66    pub machines: Vec<MachineDispatch>,
67}
68
69/// One machine's share of the reactive power its bus produced.
70#[derive(Clone, Debug, PartialEq)]
71pub struct MachineDispatch {
72    /// The source document's own identifier.
73    pub id: String,
74    pub at_bus: usize,
75    pub controls_bus: usize,
76    /// The allocated reactive output, per-unit.
77    pub q: f64,
78    pub q_min: f64,
79    pub q_max: f64,
80    /// This machine is at one of its own limits — which the bus-level clamp
81    /// cannot tell you, because the bus may be well inside its summed range
82    /// while this machine sits on the edge of its own.
83    pub at_limit: bool,
84    /// Its normalized share of the split, before limits were applied.
85    pub share: f64,
86}
87
88/// How the shares were arrived at, so a reader can tell a considered split from
89/// an even one.
90///
91/// The fallback is **wholesale, not per machine**: one implausible capability
92/// discards the whole basis rather than leaving a mixture of two rules, which
93/// is what `references/powsybl-open-loadflow`'s `Control.createReactiveKeys`
94/// does and for the same reason — a split half-derived from capability and half
95/// from nothing is harder to defend than either.
96#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
97pub enum KeyBasis {
98    /// The document stated a share for every machine.
99    Explicit,
100    /// Proportional to each machine's own reactive range.
101    Capability,
102    /// Equal shares — no usable capability, so nothing distinguishes them.
103    Uniform,
104}
105
106/// Bounds outside which a stated reactive range is treated as no information.
107///
108/// Transcribed from powsybl's `PlausibleValues`, in its per-unit form. A
109/// machine declaring a 0 MVAr range says nothing about how to split; one
110/// declaring 10 GVAr is a data error rather than a very large machine, and
111/// letting either drive the split would hand it the whole bus.
112const MIN_PLAUSIBLE_RANGE_PU: f64 = 0.01;
113const MAX_PLAUSIBLE_RANGE_PU: f64 = 100.0;
114
115/// The normalized split for one bus's machines, and what it was derived from.
116///
117/// Explicit keys where every machine has one; else capability, where every
118/// machine's range is finite and plausible; else uniform.
119pub fn reactive_keys(machines: &[&RegulatingMachine]) -> (Vec<f64>, KeyBasis) {
120    let uniform = |n: usize| (vec![1.0 / n as f64; n], KeyBasis::Uniform);
121    if machines.is_empty() {
122        return (Vec::new(), KeyBasis::Uniform);
123    }
124
125    let normalize = |raw: Vec<f64>, basis| {
126        let total: f64 = raw.iter().sum();
127        if total > 0.0 && total.is_finite() {
128            Some((
129                raw.into_iter().map(|k| k / total).collect::<Vec<_>>(),
130                basis,
131            ))
132        } else {
133            None
134        }
135    };
136
137    if machines
138        .iter()
139        .all(|m| m.key.is_some_and(|k| k.is_finite() && k >= 0.0))
140        && let Some(out) = normalize(
141            machines.iter().map(|m| m.key.unwrap()).collect(),
142            KeyBasis::Explicit,
143        )
144    {
145        return out;
146    }
147
148    let ranges: Vec<f64> = machines.iter().map(|m| m.q_max - m.q_min).collect();
149    if ranges
150        .iter()
151        .all(|r| (MIN_PLAUSIBLE_RANGE_PU..=MAX_PLAUSIBLE_RANGE_PU).contains(r))
152        && let Some(out) = normalize(ranges, KeyBasis::Capability)
153    {
154        return out;
155    }
156
157    uniform(machines.len())
158}
159
160/// Splits `total` across `machines` by `keys`, respecting each machine's own
161/// limits.
162///
163/// A machine whose share falls outside its own range is pinned there and its
164/// excess re-split across the rest, repeating until nothing moves. Without that
165/// the split can hand a machine more than it can produce while another sits
166/// idle — arithmetic that sums correctly and describes nothing physical.
167///
168/// It takes **asymmetric** capability to reach that. With `q_max = −q_min` a
169/// capability-proportional share is `total · range_i / Σrange`, which exceeds
170/// `q_max_i = range_i / 2` only once `total > Σq_max` — when the bus is already
171/// past its joint capability and every machine saturates together. Every
172/// machine at RealGrid's shared buses is symmetric, so this path is exercised
173/// by the unit tests rather than by that fixture.
174///
175/// If every machine ends up pinned the shares no longer sum to `total`; that is
176/// reported by the caller rather than papered over, because it means the bus
177/// itself is outside its summed capability and the *power flow* is the thing to
178/// look at, not the split.
179fn split(total: f64, machines: &[&RegulatingMachine], keys: &[f64]) -> (Vec<f64>, Vec<bool>) {
180    let n = machines.len();
181    let mut q = vec![0.0; n];
182    let mut pinned = vec![false; n];
183
184    for _ in 0..=n {
185        let free_key: f64 = (0..n).filter(|&i| !pinned[i]).map(|i| keys[i]).sum();
186        let pinned_q: f64 = (0..n).filter(|&i| pinned[i]).map(|i| q[i]).sum();
187        let remaining = total - pinned_q;
188
189        for i in 0..n {
190            if !pinned[i] {
191                // All keys pinned out is handled by the loop guard below; a zero
192                // free key with machines left means every remaining share is
193                // zero, which is a legitimate answer.
194                q[i] = if free_key > 0.0 {
195                    remaining * keys[i] / free_key
196                } else {
197                    0.0
198                };
199            }
200        }
201
202        let mut moved = false;
203        for i in 0..n {
204            if pinned[i] {
205                continue;
206            }
207            if q[i] > machines[i].q_max {
208                q[i] = machines[i].q_max;
209                pinned[i] = true;
210                moved = true;
211            } else if q[i] < machines[i].q_min {
212                q[i] = machines[i].q_min;
213                pinned[i] = true;
214                moved = true;
215            }
216        }
217        if !moved {
218            break;
219        }
220    }
221
222    (q, pinned)
223}
224
225/// Attributes each bus's solved reactive injection to the machines holding it.
226///
227/// `buses` must be a **converged** solution and `ybus` the network it came
228/// from; this reads whatever state it is handed and cannot tell a solved
229/// network from an unsolved one, so the split of a non-converged state is
230/// meaningless rather than wrong-looking. Same contract as
231/// [`AcSensitivity::new`](crate::ac_sensitivity::AcSensitivity::new).
232///
233/// `nonregulating_q` is the per-bus reactive injection that is not a regulating
234/// machine's — `cgmes::VoltageControlReport::nonregulating_q`, named rather
235/// than linked because that module is behind the `cgmes` feature and this one
236/// is not. The machines are responsible for the solved injection *minus* that.
237pub fn allocate(
238    machines: &[RegulatingMachine],
239    nonregulating_q: &[f64],
240    buses: &[Bus],
241    ybus: &YBusSparse,
242) -> Vec<BusDispatch> {
243    let (_, q_calc) = power_injections(buses, ybus);
244
245    // Grouped by the bus they *hold*, which is what shares a single reactive
246    // injection — not by where they sit. The two coincide throughout the
247    // vendored corpus and are different questions.
248    let mut order: Vec<usize> = (0..machines.len()).collect();
249    order.sort_by_key(|&i| machines[i].controls_bus);
250
251    let mut out: Vec<BusDispatch> = Vec::new();
252    let mut start = 0;
253    while start < order.len() {
254        let bus = machines[order[start]].controls_bus;
255        let mut end = start;
256        while end < order.len() && machines[order[end]].controls_bus == bus {
257            end += 1;
258        }
259        let group: Vec<&RegulatingMachine> =
260            order[start..end].iter().map(|&i| &machines[i]).collect();
261
262        // What the machines jointly produced, at the bus they hold.
263        let required = q_calc[bus] - nonregulating_q.get(bus).copied().unwrap_or(0.0);
264        let (keys, basis) = reactive_keys(&group);
265        let (shares, pinned) = split(required, &group, &keys);
266        let attributed: f64 = shares.iter().sum();
267
268        out.push(BusDispatch {
269            bus,
270            required,
271            attributed,
272            unattributed: required - attributed,
273            basis,
274            machines: group
275                .iter()
276                .enumerate()
277                .map(|(k, m)| MachineDispatch {
278                    id: m.id.clone(),
279                    at_bus: m.at_bus,
280                    controls_bus: m.controls_bus,
281                    q: shares[k],
282                    q_min: m.q_min,
283                    q_max: m.q_max,
284                    at_limit: pinned[k],
285                    share: keys[k],
286                })
287                .collect(),
288        });
289        start = end;
290    }
291
292    out
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn machine(id: &str, q_min: f64, q_max: f64, key: Option<f64>) -> RegulatingMachine {
300        RegulatingMachine {
301            id: id.to_string(),
302            at_bus: 0,
303            controls_bus: 0,
304            target_pu: 1.0,
305            q_min,
306            q_max,
307            q_scheduled: 0.0,
308            key,
309        }
310    }
311
312    #[test]
313    fn explicit_keys_are_used_and_normalized() {
314        let ms = [
315            machine("a", -1.0, 1.0, Some(3.0)),
316            machine("b", -1.0, 1.0, Some(1.0)),
317        ];
318        let (keys, basis) = reactive_keys(&ms.iter().collect::<Vec<_>>());
319        assert_eq!(basis, KeyBasis::Explicit);
320        assert!(
321            (keys[0] - 0.75).abs() < 1e-12 && (keys[1] - 0.25).abs() < 1e-12,
322            "{keys:?}"
323        );
324    }
325
326    /// One missing key discards the whole basis rather than mixing two rules —
327    /// powsybl's behaviour, and the defensible one: a split half-derived from
328    /// stated shares and half from capability is harder to justify than either
329    /// alone.
330    #[test]
331    fn one_missing_key_falls_back_wholesale() {
332        let ms = [
333            machine("a", -1.0, 1.0, Some(3.0)),
334            machine("b", -2.0, 2.0, None),
335        ];
336        let (keys, basis) = reactive_keys(&ms.iter().collect::<Vec<_>>());
337        assert_eq!(
338            basis,
339            KeyBasis::Capability,
340            "the stated key must not survive alone"
341        );
342        // 2.0 range against 4.0 range.
343        assert!((keys[0] - 1.0 / 3.0).abs() < 1e-12, "{keys:?}");
344    }
345
346    #[test]
347    fn capability_keys_are_proportional_to_range() {
348        let ms = [machine("a", -0.3, 0.3, None), machine("b", -0.1, 0.1, None)];
349        let (keys, basis) = reactive_keys(&ms.iter().collect::<Vec<_>>());
350        assert_eq!(basis, KeyBasis::Capability);
351        assert!((keys[0] - 0.75).abs() < 1e-12, "{keys:?}");
352    }
353
354    /// An unbounded or absurd range is no information, and letting it drive the
355    /// split would hand that machine the entire bus.
356    #[test]
357    fn implausible_ranges_fall_back_to_uniform() {
358        for (lo, hi) in [
359            (f64::NEG_INFINITY, f64::INFINITY),
360            (0.0, 0.0),      // no stated capability
361            (-1e6, 1e6),     // beyond any real machine
362            (-0.001, 0.001), // below the plausible floor
363        ] {
364            let ms = [machine("a", lo, hi, None), machine("b", -0.2, 0.2, None)];
365            let (keys, basis) = reactive_keys(&ms.iter().collect::<Vec<_>>());
366            assert_eq!(
367                basis,
368                KeyBasis::Uniform,
369                "range ({lo}, {hi}) should be rejected"
370            );
371            assert!((keys[0] - 0.5).abs() < 1e-12);
372        }
373    }
374
375    #[test]
376    fn a_split_sums_to_the_total_and_respects_every_limit() {
377        let ms = [machine("a", -0.3, 0.3, None), machine("b", -0.1, 0.1, None)];
378        let refs: Vec<&RegulatingMachine> = ms.iter().collect();
379        let (keys, _) = reactive_keys(&refs);
380        let (q, pinned) = split(0.2, &refs, &keys);
381        assert!((q.iter().sum::<f64>() - 0.2).abs() < 1e-12);
382        assert!(pinned.iter().all(|p| !p));
383        assert!(
384            (q[0] - 0.15).abs() < 1e-12 && (q[1] - 0.05).abs() < 1e-12,
385            "{q:?}"
386        );
387    }
388
389    /// The case the redistribution exists for, and note what it takes to reach
390    /// it: **asymmetric** capability.
391    ///
392    /// With symmetric limits (`q_max = −q_min = range/2`), a capability-
393    /// proportional share is `total · range_i / Σrange`, which exceeds
394    /// `q_max_i = range_i / 2` only when `total > Σrange / 2 = Σq_max` — that
395    /// is, only when the *bus* is already past its joint capability, at which
396    /// point every machine saturates together and there is nothing to
397    /// redistribute. So a fixture of symmetric machines can never exercise this
398    /// path, and one built that way would suggest the loop is dead code.
399    #[test]
400    fn a_saturated_machine_pins_and_the_rest_absorbs_it() {
401        let ms = [
402            machine("big", -1.0, 1.0, None),
403            machine("small", -0.3, 0.1, None),
404        ];
405        let refs: Vec<&RegulatingMachine> = ms.iter().collect();
406        let (keys, _) = reactive_keys(&refs);
407        let (q, pinned) = split(0.9, &refs, &keys);
408
409        assert!((q.iter().sum::<f64>() - 0.9).abs() < 1e-12, "{q:?}");
410        assert!(
411            pinned[1] && !pinned[0],
412            "the small machine should be the one to saturate"
413        );
414        assert!(
415            (q[1] - 0.1).abs() < 1e-12,
416            "pinned at its own max, not its share"
417        );
418        assert!(
419            (q[0] - 0.8).abs() < 1e-12,
420            "the big machine takes what is left"
421        );
422    }
423
424    /// When even every machine at its limit is not enough, the shortfall is
425    /// reported rather than absorbed — it means the *solve* placed the bus
426    /// outside its machines' joint capability.
427    #[test]
428    fn an_impossible_total_leaves_a_shortfall() {
429        let ms = [machine("a", -0.1, 0.1, None), machine("b", -0.1, 0.1, None)];
430        let refs: Vec<&RegulatingMachine> = ms.iter().collect();
431        let (keys, _) = reactive_keys(&refs);
432        let (q, pinned) = split(0.5, &refs, &keys);
433        assert!(pinned.iter().all(|p| *p));
434        assert!(
435            (q.iter().sum::<f64>() - 0.2).abs() < 1e-12,
436            "capped at joint capability"
437        );
438    }
439
440    #[test]
441    fn a_lone_machine_takes_everything() {
442        let ms = [machine("only", -1.0, 1.0, None)];
443        let refs: Vec<&RegulatingMachine> = ms.iter().collect();
444        let (keys, _) = reactive_keys(&refs);
445        assert_eq!(keys, vec![1.0]);
446        let (q, _) = split(-0.42, &refs, &keys);
447        assert!((q[0] + 0.42).abs() < 1e-12);
448    }
449}