gridoxide_core/continuation/direction.rs
1//! The loading direction: what "more load" means for a particular study.
2//!
3//! λ_max is a property of the *direction*, not of the network. Two studies of
4//! the same system that stress different buses, or share the pickup
5//! differently, get different noses, and neither is more correct than the
6//! other. That is why this is an explicit input with named constructors rather
7//! than something the solver infers.
8//!
9//! [`Bus`] stores only net `p_spec`/`q_spec` — every
10//! importer nets generation and load together before the solver sees them
11//! (`pgm::accumulate` is the clearest example) — so a direction is a per-bus
12//! `(Δp, Δq)` vector, and "load" has to be read off the sign of the net
13//! injection unless the caller knows better and supplies its own.
14
15use crate::types::{Bus, BusType};
16
17/// The specified injections at the converged base case, captured once.
18///
19/// Taken **after** `network::mark_unreferenced_islands` has run and never
20/// re-derived from `buses` afterwards. Both halves of that matter:
21///
22/// - a sourceless island is zeroed to `Slack, V = 0, P = Q = 0` by that pass,
23/// so snapshotting afterwards gives those buses a zero direction for free
24/// rather than stressing an island with nothing to stress it;
25/// - [`ReactiveLimits`](crate::outerloop::ReactiveLimits) rewrites `q_spec` in
26/// place when it clamps a generator, so re-deriving the base later would fold
27/// a machine's reactive limit into the *load* direction and quietly
28/// un-enforce it on the next step.
29#[derive(Clone, Debug, PartialEq)]
30pub struct BaseSpec {
31 pub p: Vec<f64>,
32 pub q: Vec<f64>,
33}
34
35impl BaseSpec {
36 pub fn capture(buses: &[Bus]) -> Self {
37 Self {
38 p: buses.iter().map(|b| b.p_spec).collect(),
39 q: buses.iter().map(|b| b.q_spec).collect(),
40 }
41 }
42}
43
44/// Per-bus `(Δp, Δq)` in per-unit: the injection change per unit of λ.
45///
46/// `s_spec(λ) = s_base + λ · Δs`, so λ = 0 is the base case and λ = 1 is the
47/// base case plus one full direction.
48#[derive(Clone, Debug, PartialEq)]
49pub struct LoadingDirection {
50 /// Indexed by [`Bus::idx`](crate::types::Bus::idx).
51 pub d_p: Vec<f64>,
52 pub d_q: Vec<f64>,
53}
54
55impl LoadingDirection {
56 /// The default: `Δp_i = p_spec_i`, `Δq_i = q_spec_i` at every bus that is a
57 /// net consumer (`p_spec < 0`), zero elsewhere. Constant power factor,
58 /// generation held at schedule.
59 ///
60 /// The slack picks up both the load increase and the incremental losses.
61 /// Its own entry is irrelevant either way — a slack bus contributes no P
62 /// equation, so nothing in the augmented system ever reads it — but at high
63 /// λ its output can become physically absurd. That is a property of the
64 /// scenario, not a defect; [`scale_loads_with_pickup`](Self::scale_loads_with_pickup)
65 /// is the realistic alternative.
66 pub fn scale_loads(buses: &[Bus]) -> Self {
67 let consumer = |b: &Bus| b.bus_type != BusType::Slack && b.p_spec < 0.0;
68 Self {
69 d_p: buses
70 .iter()
71 .map(|b| if consumer(b) { b.p_spec } else { 0.0 })
72 .collect(),
73 d_q: buses
74 .iter()
75 .map(|b| if consumer(b) { b.q_spec } else { 0.0 })
76 .collect(),
77 }
78 }
79
80 /// [`scale_loads`](Self::scale_loads) restricted to `subset`: a local or
81 /// zonal stress direction rather than a system-wide one.
82 pub fn scale_buses(buses: &[Bus], subset: &[usize]) -> Self {
83 let mut d = Self::zero(buses.len());
84 for &i in subset {
85 if i >= buses.len() || buses[i].bus_type == BusType::Slack || buses[i].p_spec >= 0.0 {
86 continue;
87 }
88 d.d_p[i] = buses[i].p_spec;
89 d.d_q[i] = buses[i].q_spec;
90 }
91 d
92 }
93
94 /// [`scale_loads`](Self::scale_loads), with the active-power pickup shared
95 /// over `weights` instead of dumped on the slack:
96 /// `Δp_gen,i = −ŵ_i · Σ_j Δp_load,j`, with `ŵ` normalized over the supplied
97 /// weights.
98 ///
99 /// This is the **analytic** equivalent of running
100 /// [`DistributedSlack`](crate::outerloop::DistributedSlack) inside the
101 /// corrector, and the analytic form is required rather than merely cheaper.
102 /// That loop moves `p_spec` as a function of the *solved state*; such a
103 /// dependence is invisible to `∂g/∂λ`, so running it inside the corrector
104 /// would silently corrupt the tangent — the one vector continuation needs
105 /// exact. Its effect is exactly linear in λ, so folding it into `Δp` is
106 /// both faithful and free.
107 ///
108 /// Weights at slack buses are dropped: a slack has no P equation, so a
109 /// share allocated to it would vanish and the pickup would no longer sum to
110 /// the load increase.
111 pub fn scale_loads_with_pickup(buses: &[Bus], weights: &[f64]) -> Self {
112 let mut d = Self::scale_loads(buses);
113 let increase: f64 = d.d_p.iter().sum(); // negative: load is a negative injection
114 let eligible = |i: usize| i < buses.len() && buses[i].bus_type != BusType::Slack;
115 let total: f64 = weights
116 .iter()
117 .enumerate()
118 .filter(|&(i, w)| eligible(i) && *w > 0.0)
119 .map(|(_, w)| *w)
120 .sum();
121 if total <= 0.0 {
122 return d;
123 }
124 for (i, w) in weights.iter().enumerate() {
125 if eligible(i) && *w > 0.0 {
126 d.d_p[i] -= increase * (w / total);
127 }
128 }
129 d
130 }
131
132 /// A study direction from a market or scenario tool, taken as given.
133 pub fn explicit(d_p: Vec<f64>, d_q: Vec<f64>) -> Self {
134 Self { d_p, d_q }
135 }
136
137 /// MATPOWER's base-case-to-target-case form: `Δ = target − base`.
138 pub fn to_target(buses: &[Bus], p_target: &[f64], q_target: &[f64]) -> Self {
139 Self {
140 d_p: buses
141 .iter()
142 .map(|b| p_target.get(b.idx).copied().unwrap_or(b.p_spec) - b.p_spec)
143 .collect(),
144 d_q: buses
145 .iter()
146 .map(|b| q_target.get(b.idx).copied().unwrap_or(b.q_spec) - b.q_spec)
147 .collect(),
148 }
149 }
150
151 pub fn zero(n: usize) -> Self {
152 Self {
153 d_p: vec![0.0; n],
154 d_q: vec![0.0; n],
155 }
156 }
157
158 /// `Σ over net consumers of −Δp_i`: the per-unit *load* increase per unit
159 /// of λ, and the multiplier behind the reported margin.
160 ///
161 /// Counts only the consuming half, so a direction with generation pickup
162 /// reports the same margin as the same load ramp without it — the margin is
163 /// how much more load the system carries, not how much more the machines
164 /// produce.
165 pub fn total_active_load_increase(&self) -> f64 {
166 self.d_p.iter().filter(|&&dp| dp < 0.0).map(|dp| -dp).sum()
167 }
168
169 /// True when nothing moves — a direction that would make continuation a
170 /// no-op, and a caller mistake worth refusing rather than looping over.
171 pub fn is_empty(&self) -> bool {
172 self.d_p.iter().chain(&self.d_q).all(|v| *v == 0.0)
173 }
174
175 /// Stop moving this bus's reactive injection.
176 ///
177 /// Called when [`ReactiveLimits`](crate::outerloop::ReactiveLimits) clamps a
178 /// generator: it has just written `q_spec = q_min|q_max`, and the next
179 /// `q_spec ← q_base + λ·Δq` write would overwrite that clamp and silently
180 /// un-enforce the limit. Zeroing `Δq` and re-basing `q_base` on the clamped
181 /// value is what makes the limit stick for the rest of the trace.
182 ///
183 /// `Δp` is deliberately untouched: under `scale_loads` a generator's `Δp` is
184 /// already zero, and under a pickup direction it is that machine's active
185 /// share, which a *reactive* limit says nothing about.
186 pub(crate) fn freeze_reactive(&mut self, bus: usize, base: &mut BaseSpec, clamped_q: f64) {
187 self.d_q[bus] = 0.0;
188 base.q[bus] = clamped_q;
189 }
190
191 /// Writes `s_spec(λ) = s_base + λ·Δs` into `buses`.
192 ///
193 /// Applied against the saved base rather than multiplicatively against the
194 /// current value, so stepping back and forth along the curve — which the
195 /// event locator does on every bisection trial — cannot accumulate drift.
196 pub(crate) fn apply(&self, base: &BaseSpec, lambda: f64, buses: &mut [Bus]) {
197 for b in buses.iter_mut() {
198 b.p_spec = base.p[b.idx] + lambda * self.d_p[b.idx];
199 b.q_spec = base.q[b.idx] + lambda * self.d_q[b.idx];
200 }
201 }
202}