Skip to main content

gridoxide_core/
qv.rs

1//! Q-V curves: how much reactive headroom does one bus have?
2//!
3//! [`continuation`](crate::continuation) asks how much further the *system* can
4//! be loaded before it collapses. This asks a narrower and more operational
5//! question about a *single* bus: how much reactive support would it take to
6//! hold this bus at a given voltage, and how much margin is there before that
7//! stops being possible?
8//!
9//! # The method, and why it needs no continuation
10//!
11//! Put a fictitious synchronous condenser at the bus — an ideal machine that
12//! holds a voltage and produces whatever reactive power that takes — by
13//! retyping the bus to `PV`. Then sweep its setpoint downward and read the
14//! reactive power it has to supply at each step:
15//!
16//! ```text
17//! Q                     the curve is traced left to right by lowering |V|
18//! |    \                 (or right to left; the sweep direction is a choice)
19//! |     \___             Q > 0: the bus must be *supported* to hold this high
20//! |         \___
21//! +-----------\------------------ |V|
22//! |            \___
23//! |                \__.--          the minimum: dQ/d|V| = 0.
24//! |                    ^           Below it no setpoint is reachable with less
25//!                    the nose      reactive power — that is the margin.
26//! ```
27//!
28//! Every point is an **ordinary power flow**. Fixing `|V|` is what makes it so:
29//! the bus's reactive equation is dropped and its magnitude stops being an
30//! unknown, which is exactly a `PV` bus, and the Jacobian stays non-singular
31//! all the way through the nose. A P-V curve needs a predictor–corrector
32//! precisely because it does *not* fix a magnitude — λ is a free parameter and
33//! the Jacobian goes singular at the fold. Here the fold is in `Q`, which is an
34//! output rather than an unknown, so nothing goes singular and a sweep is
35//! enough.
36//!
37//! That is a real difference in kind, not an implementation shortcut, and it is
38//! why this module reuses [`crate::run_power_flow`] rather than
39//! [`continuation`](crate::continuation)'s bordered corrector.
40//!
41//! # What the margin means
42//!
43//! The minimum of the curve is the most reactive power the bus can absorb
44//! before the relationship inverts. Reported as a positive number of MVAr: a
45//! larger margin is a stronger bus. A bus whose curve barely dips below zero is
46//! one small contingency away from having no reachable setpoint at all.
47//!
48//! # What this shares with the P-V side
49//!
50//! The answer, when both are asked. The bus a P-V trace names critical — the
51//! largest component of the tangent at the nose — should be the bus with the
52//! smallest Q-V margin, because both are measuring the same weakness by
53//! different means. `tests/qv_test.rs` asserts exactly that, and it is the
54//! strongest gate here: two independent methods agreeing is worth more than
55//! either agreeing with itself.
56
57use crate::PowerFlowOptions;
58use crate::network::{ShuntAdm, build_ybus, effective_injection, power_injections, stamp_shunts};
59use crate::solver::SolveStatus;
60use crate::types::{Bus, BusType, Line, Transformer};
61
62/// Everything a Q-V sweep needs to know.
63#[derive(Clone, Debug)]
64pub struct QvOptions {
65    /// The solve at every point. `method` must be `NewtonRaphson`.
66    pub pf: PowerFlowOptions,
67    /// Highest setpoint to try, per-unit.
68    pub v_max: f64,
69    /// Lowest. The nose is typically well above 0.4 on a healthy bus; a bus
70    /// whose curve is still falling at `v_min` reports `NoseNotReached` rather
71    /// than pretending the last sample is a minimum.
72    pub v_min: f64,
73    /// Setpoint decrement, per-unit.
74    pub step: f64,
75    /// Refine the minimum by fitting a parabola through the three samples
76    /// around it. Exact for a locally-quadratic curve, which this is near a
77    /// smooth minimum, and free — no extra solves.
78    pub refine: bool,
79}
80
81impl Default for QvOptions {
82    fn default() -> Self {
83        Self {
84            pf: PowerFlowOptions {
85                max_iter: 60,
86                ..Default::default()
87            },
88            v_max: 1.10,
89            v_min: 0.40,
90            step: 0.01,
91            refine: true,
92        }
93    }
94}
95
96/// One setpoint and the reactive power holding it would take.
97#[derive(Clone, Debug, PartialEq)]
98pub struct QvPoint {
99    /// The setpoint, per-unit.
100    pub voltage: f64,
101    /// What the fictitious condenser must supply, per-unit. Positive means the
102    /// bus has to be *supported* to reach this voltage.
103    pub q: f64,
104    pub iterations: usize,
105}
106
107/// The bottom of the curve: the most reactive power this bus can absorb.
108#[derive(Clone, Debug, PartialEq)]
109pub struct QvNose {
110    pub voltage: f64,
111    pub q: f64,
112    /// `−q` at the nose, per-unit — positive, and larger is stronger.
113    pub margin_pu: f64,
114    /// True when the voltage and `q` were interpolated rather than sampled.
115    pub refined: bool,
116}
117
118impl QvNose {
119    /// The margin in MVAr on a stated system base.
120    pub fn margin_mvar(&self, base_mva: f64) -> f64 {
121        self.margin_pu * base_mva
122    }
123}
124
125/// How a sweep ended.
126#[derive(Clone, Debug, PartialEq)]
127pub enum QvStatus {
128    /// The curve turned: a minimum was bracketed inside the swept range.
129    NoseFound,
130    /// Still falling at `v_min`. The lowest sample is a *lower bound* on the
131    /// margin, not the margin.
132    NoseNotReached,
133    /// Too few points converged to say anything.
134    Failed,
135    /// The request was not attempted.
136    Rejected(QvError),
137}
138
139/// A request a sweep will not attempt, stated rather than approximated.
140#[derive(Clone, Debug, PartialEq)]
141pub enum QvError {
142    /// A slack bus already fixes its own magnitude and already has a free
143    /// reactive injection, so there is no condenser to add and no curve to
144    /// trace.
145    SlackBus(usize),
146    BusOutOfRange {
147        bus: usize,
148        n: usize,
149    },
150    /// `v_min >= v_max`, or a non-positive step.
151    EmptyRange,
152    MethodUnsupported(crate::PowerFlowMethod),
153}
154
155/// A bus's Q-V curve.
156#[derive(Clone, Debug)]
157pub struct QvCurve {
158    pub bus: usize,
159    /// Converged samples, in sweep order (highest setpoint first).
160    pub points: Vec<QvPoint>,
161    pub nose: Option<QvNose>,
162    pub status: QvStatus,
163    /// The bus's voltage in the base case, for context: a curve is read
164    /// relative to where the bus actually sits.
165    pub base_voltage: f64,
166    /// Setpoints that did not converge. Worth seeing — a gap in the middle of a
167    /// curve is a different thing from a run of failures at the bottom.
168    pub failed: Vec<f64>,
169    /// The bus was already voltage-controlled, so the curve describes moving an
170    /// existing machine's setpoint rather than adding a condenser, and `q`
171    /// includes what that machine was already producing.
172    pub already_controlled: bool,
173}
174
175impl QvCurve {
176    fn rejected(bus: usize, why: QvError) -> Self {
177        Self {
178            bus,
179            points: Vec::new(),
180            nose: None,
181            status: QvStatus::Rejected(why),
182            base_voltage: f64::NAN,
183            failed: Vec::new(),
184            already_controlled: false,
185        }
186    }
187
188    /// The reactive margin in per-unit, when a nose was found.
189    pub fn margin_pu(&self) -> Option<f64> {
190        self.nose.as_ref().map(|n| n.margin_pu)
191    }
192}
193
194/// Traces one bus's Q-V curve.
195///
196/// See the module docs for what the curve means and why it needs no
197/// continuation.
198pub fn qv_curve(
199    buses: &[Bus],
200    lines: &[Line],
201    transformers: &[Transformer],
202    shunts: &[ShuntAdm],
203    bus: usize,
204    opts: QvOptions,
205) -> QvCurve {
206    if bus >= buses.len() {
207        return QvCurve::rejected(
208            bus,
209            QvError::BusOutOfRange {
210                bus,
211                n: buses.len(),
212            },
213        );
214    }
215    if buses[bus].bus_type == BusType::Slack {
216        return QvCurve::rejected(bus, QvError::SlackBus(bus));
217    }
218    if opts.pf.method != crate::PowerFlowMethod::NewtonRaphson {
219        return QvCurve::rejected(bus, QvError::MethodUnsupported(opts.pf.method));
220    }
221    // Every case spelled out, so a NaN bound is visibly refused rather than
222    // incidentally caught by a negated comparison.
223    let range_ok = opts.v_min.is_finite() && opts.v_max.is_finite() && opts.v_min < opts.v_max;
224    if !opts.step.is_finite() || opts.step <= 0.0 || !range_ok {
225        return QvCurve::rejected(bus, QvError::EmptyRange);
226    }
227
228    let mut y = build_ybus(buses.len(), lines, transformers);
229    stamp_shunts(&mut y, shunts);
230    let ybus = y.finish();
231
232    // Where the bus actually sits, for context.
233    let base = crate::run_power_flow(
234        buses.to_vec(),
235        lines,
236        transformers,
237        shunts,
238        crate::TapData::none(),
239        opts.pf.clone(),
240    );
241    let base_voltage = if base.stats.status == SolveStatus::Converged {
242        base.buses[bus].voltage_mag
243    } else {
244        f64::NAN
245    };
246
247    let already_controlled = buses[bus].bus_type == BusType::PV;
248    let mut points: Vec<QvPoint> = Vec::new();
249    let mut failed: Vec<f64> = Vec::new();
250
251    let steps = ((opts.v_max - opts.v_min) / opts.step).floor() as usize;
252    for k in 0..=steps {
253        let v = opts.v_max - opts.step * k as f64;
254
255        let mut trial = buses.to_vec();
256        // The fictitious condenser. Retyping to `PV` is the whole of it: the
257        // bus's reactive equation is dropped, so `Q` becomes free, and its
258        // magnitude stops being an unknown.
259        trial[bus].bus_type = BusType::PV;
260        trial[bus].voltage_mag = v;
261        // An unlimited machine, deliberately: the curve measures how much
262        // reactive power holding this voltage *would* take, which a limit would
263        // truncate rather than answer. Other buses keep their own limits.
264        trial[bus].q_min = f64::NEG_INFINITY;
265        trial[bus].q_max = f64::INFINITY;
266
267        let report = crate::run_power_flow(
268            trial,
269            lines,
270            transformers,
271            shunts,
272            crate::TapData::none(),
273            opts.pf.clone(),
274        );
275        if report.stats.status != SolveStatus::Converged {
276            failed.push(v);
277            continue;
278        }
279
280        let (_, q_calc) = power_injections(&report.buses, &ybus);
281        // What the condenser supplies is the bus's net injection less whatever
282        // the bus injects on its own account. Evaluated through
283        // `effective_injection` at the *solved* voltage so a voltage-dependent
284        // load is subtracted at the voltage it actually sees, rather than at
285        // its nominal.
286        let own = Bus {
287            voltage_mag: report.buses[bus].voltage_mag,
288            ..buses[bus].clone()
289        };
290        let (_, q_own) = effective_injection(&own);
291        points.push(QvPoint {
292            voltage: report.buses[bus].voltage_mag,
293            q: q_calc[bus] - q_own,
294            iterations: report.stats.iterations(),
295        });
296    }
297
298    let (nose, status) = locate_nose(&points, opts.refine);
299    QvCurve {
300        bus,
301        points,
302        nose,
303        status,
304        base_voltage,
305        failed,
306        already_controlled,
307    }
308}
309
310/// The minimum of a sampled curve, and whether it is genuinely inside the
311/// swept range.
312///
313/// A minimum at either end is not a minimum — it means the sweep stopped before
314/// the curve turned, and the sample is a bound rather than an answer. Saying so
315/// matters more here than usual: the margin is the headline number, and
316/// reporting the last sample of a still-falling curve would understate a bus's
317/// weakness in exactly the direction that misleads.
318fn locate_nose(points: &[QvPoint], refine: bool) -> (Option<QvNose>, QvStatus) {
319    if points.len() < 3 {
320        return (None, QvStatus::Failed);
321    }
322    let mut lowest = 0usize;
323    for (i, p) in points.iter().enumerate() {
324        if p.q < points[lowest].q {
325            lowest = i;
326        }
327    }
328    if lowest == 0 || lowest == points.len() - 1 {
329        return (
330            Some(QvNose {
331                voltage: points[lowest].voltage,
332                q: points[lowest].q,
333                margin_pu: -points[lowest].q,
334                refined: false,
335            }),
336            QvStatus::NoseNotReached,
337        );
338    }
339
340    let (a, b, c) = (&points[lowest - 1], &points[lowest], &points[lowest + 1]);
341    if !refine {
342        return (
343            Some(QvNose {
344                voltage: b.voltage,
345                q: b.q,
346                margin_pu: -b.q,
347                refined: false,
348            }),
349            QvStatus::NoseFound,
350        );
351    }
352
353    // Parabola through the three samples, minimized. Exact where the curve is
354    // locally quadratic, which it is near a smooth minimum.
355    let (x1, y1) = (a.voltage, a.q);
356    let (x2, y2) = (b.voltage, b.q);
357    let (x3, y3) = (c.voltage, c.q);
358    let denom = (x1 - x2) * (y2 - y3) - (x2 - x3) * (y1 - y2);
359    let nose = if denom.abs() < 1e-15 {
360        QvNose {
361            voltage: x2,
362            q: y2,
363            margin_pu: -y2,
364            refined: false,
365        }
366    } else {
367        let vertex = x2
368            - 0.5 * ((x2 - x1).powi(2) * (y2 - y3) - (x2 - x3).powi(2) * (y2 - y1))
369                / ((x2 - x1) * (y2 - y3) - (x2 - x3) * (y2 - y1));
370        // Refuse a vertex outside the bracketing samples — that means the three
371        // points do not describe a minimum and the parabola is extrapolating.
372        if vertex > x1.max(x3) || vertex < x1.min(x3) || !vertex.is_finite() {
373            QvNose {
374                voltage: x2,
375                q: y2,
376                margin_pu: -y2,
377                refined: false,
378            }
379        } else {
380            // Lagrange interpolation at the vertex.
381            let q = y1 * (vertex - x2) * (vertex - x3) / ((x1 - x2) * (x1 - x3))
382                + y2 * (vertex - x1) * (vertex - x3) / ((x2 - x1) * (x2 - x3))
383                + y3 * (vertex - x1) * (vertex - x2) / ((x3 - x1) * (x3 - x2));
384            QvNose {
385                voltage: vertex,
386                q,
387                margin_pu: -q,
388                refined: true,
389            }
390        }
391    };
392    (Some(nose), QvStatus::NoseFound)
393}