Skip to main content

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}