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}