Skip to main content

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}