gridoxide_core/continuation/events.rs
1//! Discrete events along the curve, and locating them exactly.
2//!
3//! # Why the outer-loop driver is not reused
4//!
5//! [`outerloop::solve_with_loops`](crate::outerloop::solve_with_loops) drives
6//! `PersistentSolver::solve`, an ordinary `n × n` Newton at fixed injections; it
7//! cannot solve the bordered system at all. More to the point, its fixed-point
8//! driver flips a bus the moment a step overshoots a limit — which is precisely
9//! the imprecision event location exists to remove, and would make this module
10//! dead code.
11//!
12//! So the corrector runs with bus types **frozen**, and this module finds the λ
13//! at which a machine actually saturates. What it does *not* do is re-implement
14//! the switching rule:
15//! [`ReactiveLimits::check`](crate::outerloop::ReactiveLimits) remains the only
16//! code in the crate that flips a bus type and writes `q_spec = limit`, and it
17//! is called once, at the located crossing. There is no duplicated rule to drift
18//! and no double counting, because it is called nowhere else during a step.
19//!
20//! The two dispatch loops are handled differently again — see
21//! [`LoadingDirection::scale_loads_with_pickup`](super::direction::LoadingDirection::scale_loads_with_pickup)
22//! for why they are folded into the direction analytically instead.
23
24use crate::types::{Bus, BusType};
25
26// Moved to `roots` so dynamics can locate its events without depending on
27// continuation; re-exported so this path keeps working.
28pub use crate::roots::Illinois;
29
30/// Which limit a generator ran into.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum QLimitKind {
33 Max,
34 Min,
35}
36
37/// Something discrete that happened at a particular λ.
38#[derive(Clone, Debug, PartialEq)]
39pub enum ContinuationEvent {
40 /// A `PV` bus reached a reactive limit and was switched to `PQ`, pinned at
41 /// that limit. `lambda` is the located crossing, not the step that
42 /// straddled it.
43 QLimit {
44 bus: usize,
45 limit: QLimitKind,
46 lambda: f64,
47 iterations: usize,
48 },
49 /// The safety net fired: an accepted point violated a limit the locator did
50 /// not bracket. The switch is applied anyway, so the curve stays
51 /// outer-loop-consistent, but the λ is the step's, not the true crossing.
52 MissedQLimit { bus: usize, lambda: f64 },
53 /// A corrector failed and the step was halved.
54 StepRejected { lambda: f64, step: f64 },
55}
56
57/// The reactive-limit event function at every bus that is still `PV`.
58///
59/// Positive means violated, so the earliest crossing of the running maximum is
60/// the first machine to saturate. Buses with infinite limits — which is what
61/// every importer writes when the source document states none — never
62/// contribute.
63pub(crate) fn q_violations(buses: &[Bus], q_calc: &[f64]) -> Vec<(usize, f64)> {
64 buses
65 .iter()
66 .filter(|b| b.bus_type == BusType::PV)
67 .filter_map(|b| {
68 let q = q_calc[b.idx];
69 let over = q - b.q_max;
70 let under = b.q_min - q;
71 let e = over.max(under);
72 e.is_finite().then_some((b.idx, e))
73 })
74 .collect()
75}
76
77/// The largest event value over every still-`PV` bus: the scalar the locator
78/// brackets, so the *earliest* crossing along the step is the one found.
79pub(crate) fn worst_violation(buses: &[Bus], q_calc: &[f64]) -> f64 {
80 q_violations(buses, q_calc)
81 .into_iter()
82 .map(|(_, e)| e)
83 .fold(f64::NEG_INFINITY, f64::max)
84}