Skip to main content

gridoxide_core/
switches.rs

1//! Giving a retained switch a formulation.
2//!
3//! [`topology::bus_view`](fn@crate::topology::bus_view) decides *which* switches
4//! survive into the solver; this module decides what they become once they are
5//! there. `docs/src/powerflow/zero_impedance_branches.md` surveys the three
6//! options and this implements the second:
7//!
8//! | Treatment | Keeps identity | Scales | Constant pattern |
9//! |---|---|---|---|
10//! | Merge | ✗ | ✓✓ | ✗ |
11//! | [`Regularize`](SwitchTreatment::Regularize) | ✓ | ✗ | ✓ |
12//! | Constrain | ✓ | ✓ | ✓ |
13//!
14//! `Regularize` stamps each retained switch as a very stiff branch. Everything
15//! downstream then works unchanged — Y-bus, Jacobian, branch flows, state
16//! estimation — which is what makes it worth having despite not scaling: it is
17//! the cheapest possible route to switch flows and a settable switch position.
18//!
19//! # The scaling ceiling, and what measuring it actually showed
20//!
21//! A stiff branch is a large number in the admittance matrix, and enough of
22//! them are supposed to make the Jacobian ill-conditioned. `src/cgmes.rs`
23//! records that this was tried once before and the AC solve *diverged* on
24//! FullGrid with "20-odd" such branches active, and extrapolating from that
25//! calls the approach dead on arrival at real scale, for SmallGrid's 1,266
26//! switches and Svedala's 1,464.
27//!
28//! [`conditioning_probe`] was written to turn that anecdote into a curve. It
29//! did not find one. Across [`Chain`](ProbeShape::Chain),
30//! [`Star`](ProbeShape::Star) and [`Ring`](ProbeShape::Ring) arrangements, at
31//! every count from 1 to 1,464, and with the branch admittance both inductive
32//! and capacitive, every solve converged in two iterations with a plausible
33//! voltage profile. See `examples/switch_ceiling.rs`.
34//!
35//! **That is a negative result, not a clean bill of health.** What it rules out
36//! is that switch *count*, switch *arrangement*, or the admittance *sign*
37//! explains the historical divergence on its own. What it cannot rule out is
38//! interaction with real network data — the probe's networks are uniform, and
39//! FullGrid's are not: real impedances span decades, and the stiff branches
40//! there sat alongside transformers, shunts and an HVDC subsystem. Reproducing
41//! the divergence needs `Regularize` run against an actual CGMES node-breaker
42//! import, which does not exist yet.
43//!
44//! So the ceiling is genuinely unknown rather than known-to-be-30, and a caller
45//! should measure on its own data. `Constrain` remains the right answer for the
46//! general case for the reasons `zero_impedance_branches.md` gives — it needs
47//! no large number at all — but the empirical case against `Regularize` is
48//! weaker than assumed.
49//!
50//! # The open/closed representation, and the property it buys
51//!
52//! A retained switch becomes a [`Transformer`] whose `from_status`/`to_status`
53//! carry its position: both 1 when closed, both 0 when open.
54//! `network::branch_calc_param` already returns all-zeros for an open-terminal
55//! transformer, and `network::build_ybus` stamps those zeros anyway, so:
56//!
57//! > **Flipping a switch changes Y-bus values but not the sparsity pattern.**
58//!
59//! That is the property usually attributed to the *constrained* formulation, and it turns out `Regularize` has it too — for
60//! the same underlying reason the AC contingency work exploits
61//! (`network::build_ybus_with_outages`). What `Regularize` still lacks is the
62//! scaling, not the pattern stability.
63//!
64//! One caveat that comes with it: [`PersistentSolver::invalidate_admittances`]
65//! must be called when a position changes, because `JacobianPattern` caches
66//! each entry's admittance. See that method's doc — this is the hazard that
67//! bit the contingency work.
68//!
69//! [`PersistentSolver::invalidate_admittances`]: crate::solver::PersistentSolver::invalidate_admittances
70
71use num_complex::Complex;
72
73use crate::topology::bus_view::{BusView, RetainedSwitch};
74use crate::topology::ideal_connection_z;
75use crate::topology::model::SwitchIdx;
76use crate::types::Transformer;
77
78/// What a retained switch becomes in the solver's equations.
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
80pub enum SwitchTreatment {
81    /// Not represented at all. Only valid when nothing was retained — a
82    /// retained switch under `Merge` would be silently dropped, which is worse
83    /// than refusing.
84    #[default]
85    Merge,
86    /// A stiff branch, at the same magnitude
87    /// [`topology::IDEAL_CONNECTION_Y`](crate::topology::IDEAL_CONNECTION_Y)
88    /// gives a merged zero-impedance connection.
89    Regularize,
90    /// An exact equality: `θ_i = θ_j`, `V_i = V_j`, with the switch's flow as an
91    /// unknown. No admittance, and no constant to choose.
92    ///
93    /// Solved by [`constrained::solve_constrained`](crate::constrained::solve_constrained)
94    /// rather than by the ordinary Newton loop, since the system it builds is a
95    /// KKT system rather than a power-flow Jacobian. A network built under this
96    /// treatment therefore carries **no switch branches at all** — see
97    /// [`NodeBreakerNetwork::constrained_switches`].
98    Constrain,
99}
100
101/// The impedance a regularized switch is given: purely inductive, at the
102/// magnitude of `topology::IDEAL_CONNECTION_Y`.
103///
104/// **Purely reactive, deliberately.** `IDEAL_CONNECTION_Y = 2e5 + j2e5` has a
105/// *positive* imaginary part inherited from power-grid-model, which inverts to
106/// a negative reactance — harmless in AC but sign-flipping in DC, where
107/// `linear::btheta` needs an explicit guard for the PGM `link` that carries it.
108/// A switch branch has no such history to preserve, so it takes the same
109/// stiffness with the conventional sign and needs no guard anywhere.
110pub fn switch_reactance() -> f64 {
111    ideal_connection_z()
112}
113
114/// The series admittance of a regularized switch: `1/(j·x)`.
115pub fn switch_admittance() -> Complex<f64> {
116    Complex::new(1.0, 0.0) / Complex::new(0.0, switch_reactance())
117}
118
119/// The branches that give a bus view's retained switches identity.
120///
121/// Returned as [`Transformer`]s rather than [`Line`](crate::types::Line)s for
122/// one reason: only `Transformer` carries per-terminal status, which is how an
123/// open switch keeps its structural Y-bus entries and therefore how the
124/// sparsity pattern survives a position change. The tap is always unity and
125/// there is no shunt — nothing transformer-like is intended.
126///
127/// Ordering matches [`BusView::retained`], so the *n*th returned branch is the
128/// *n*th retained switch. Callers appending these to their own transformer list
129/// should record the offset: the crate's flat branch index is lines first, then
130/// transformers, so a switch's branch index is
131/// `lines.len() + transformers.len() + n`.
132///
133/// **Degenerate switches are skipped** — see
134/// [`BusView::is_degenerate`](crate::topology::BusView::is_degenerate). A
135/// retained switch whose ends already share a bus would stamp a self-loop
136/// contributing exactly nothing, and its "flow" would be meaningless. They are
137/// reported by [`degenerate_switches`] rather than silently included.
138pub fn regularized_branches(view: &BusView) -> Vec<Transformer> {
139    let y = switch_admittance();
140    view.retained()
141        .iter()
142        .filter(|r| !view.is_degenerate(r))
143        .map(|r| {
144            let status = u8::from(!r.open);
145            Transformer {
146                from: r.buses[0].0,
147                to: r.buses[1].0,
148                from_status: status,
149                to_status: status,
150                y_series: y,
151                y_shunt: Complex::new(0.0, 0.0),
152                tap: Complex::new(1.0, 0.0),
153            }
154        })
155        .collect()
156}
157
158/// The retained switches [`regularized_branches`] skipped, because their two
159/// ends already resolve to one bus.
160///
161/// Not an error — a bus coupler in a closed ring is legitimately degenerate —
162/// but a caller reporting switch flows needs to know that these have none.
163pub fn degenerate_switches(view: &BusView) -> Vec<RetainedSwitch> {
164    view.retained()
165        .iter()
166        .filter(|r| view.is_degenerate(r))
167        .copied()
168        .collect()
169}
170
171/// The switch arrangement a [`conditioning_probe`] builds.
172///
173/// The shape matters more than the count, which is the whole reason this is a
174/// parameter — see [`conditioning_probe`]'s doc.
175#[derive(Clone, Copy, Debug, PartialEq, Eq)]
176pub enum ProbeShape {
177    /// A single chain of switches from the slack bus. A tree, so the stiff
178    /// subnetwork has no circulating mode.
179    Chain,
180    /// Every switch fans out from one busbar node. Also a tree, and the shape a
181    /// real substation bay arrangement most resembles.
182    Star,
183    /// The switches close a ring. **Not** a tree: a loop of near-ideal branches
184    /// has a circulating current fixed only by the small differences between
185    /// their impedances, which is where regularization actually breaks down.
186    Ring,
187}
188
189/// How ill-conditioned a network becomes as regularized switches are added.
190///
191/// Returns `(count, iterations_or_None, far_end_voltage)` per entry in
192/// `counts`. `None` means the solve did not converge or produced a non-finite
193/// state.
194///
195/// `y_series` is the admittance each switch branch is given, so a caller can
196/// compare the inductive [`switch_admittance`] this module uses against
197/// [`topology::IDEAL_CONNECTION_Y`](crate::topology::IDEAL_CONNECTION_Y), whose
198/// positive imaginary part makes it capacitive. That comparison is the point:
199/// see below.
200///
201/// # What this measures, and what it corrected
202///
203/// `src/cgmes.rs` records that stamping CGMES switches as large-admittance
204/// branches was tried and the AC solve *diverged* on FullGrid with "20-odd"
205/// such branches active. Reasoning from that, SmallGrid's 1,266 and Svedala's
206/// 1,464 are 45–52x the count already measured to diverge.
207///
208/// Measured, that inference does not hold: **switch count alone is not the
209/// driver**. A [`Chain`](ProbeShape::Chain) or [`Star`](ProbeShape::Star) of
210/// 1,464 stiff branches converges in two iterations, because both are trees and
211/// a tree of stiff branches is merely stiff, not ill-conditioned. What breaks
212/// is a [`Ring`](ProbeShape::Ring) — a loop of near-ideal branches whose
213/// circulating current is determined only by the differences between
214/// impedances that are all nearly equal, which is exactly the indeterminacy
215/// `zero_impedance_branches.md` describes and the constrained formulation
216/// resolves with a spanning tree.
217///
218/// So the honest statement of `Regularize`'s ceiling is topological, not
219/// numerical: it is safe on radial switch arrangements at any count seen in
220/// real data, and it degrades on meshed ones. A substation bay is a star; a
221/// closed bus coupler in a double-busbar arrangement is a ring.
222pub fn conditioning_probe(
223    counts: &[usize],
224    shape: ProbeShape,
225    y_series: Complex<f64>,
226    tol: f64,
227    max_iter: usize,
228) -> Vec<(usize, Option<usize>, f64)> {
229    use crate::network::build_ybus;
230    use crate::solver::{JacobianBackend, PersistentSolver, SolveStatus};
231    use crate::types::{Bus, BusType, Line};
232
233    counts
234        .iter()
235        .map(|&n| {
236            // Each shape decides how many nodes its `n` switches span; the
237            // load then hangs off one of them through an ordinary line, so the
238            // network has something to solve for.
239            //
240            // Chain/Star span nodes `0..=n` (n switches, n+1 nodes). A ring of
241            // n switches closes over only `0..n` (n nodes), so it needs one
242            // fewer — and needs at least two switches to be a ring at all.
243            let switch_nodes = match shape {
244                ProbeShape::Chain | ProbeShape::Star => n + 1,
245                ProbeShape::Ring => n.max(2),
246            };
247            let n_buses = switch_nodes + 1;
248            let load = n_buses - 1;
249            let mut buses: Vec<Bus> = (0..n_buses)
250                .map(|i| Bus {
251                    idx: i,
252                    bus_type: if i == 0 { BusType::Slack } else { BusType::PQ },
253                    voltage_mag: 1.0,
254                    voltage_ang: 0.0,
255                    p_spec: 0.0,
256                    q_spec: 0.0,
257                    q_min: -f64::INFINITY,
258                    q_max: f64::INFINITY,
259                    u_rated: 0.0,
260                    zip_terms: Vec::new(),
261                })
262                .collect();
263            buses[load].p_spec = -0.1;
264            buses[load].q_spec = -0.03;
265
266            let switch = |from: usize, to: usize| Transformer {
267                from,
268                to,
269                from_status: 1,
270                to_status: 1,
271                y_series,
272                y_shunt: Complex::new(0.0, 0.0),
273                tap: Complex::new(1.0, 0.0),
274            };
275            let switches: Vec<Transformer> = match shape {
276                ProbeShape::Chain => (0..n).map(|i| switch(i, i + 1)).collect(),
277                // Everything hangs off the slack busbar; the load feeder is
278                // attached to the last spoke.
279                ProbeShape::Star => (0..n).map(|i| switch(0, i + 1)).collect(),
280                // A chain over `0..switch_nodes` whose last switch closes back
281                // on the slack, completing a loop of near-ideal branches.
282                ProbeShape::Ring => (0..switch_nodes)
283                    .map(|i| switch(i, (i + 1) % switch_nodes))
284                    .collect(),
285            };
286            // The feeder always leaves from the node furthest into the switch
287            // arrangement, so the load is genuinely fed through the switches.
288            let feeder_from = switch_nodes - 1;
289            let lines = vec![Line {
290                from: feeder_from,
291                to: load,
292                r: 0.01,
293                x: 0.05,
294                b_shunt: 0.0,
295                g_shunt: 0.0,
296            }];
297
298            let ybus = build_ybus(n_buses, &lines, &switches).finish();
299            crate::network::linear_initial_guess(&mut buses, &ybus);
300            let (_, stats) = PersistentSolver::new(JacobianBackend::default())
301                .solve_with_stats(&mut buses, &ybus, tol, max_iter);
302
303            let finite = buses
304                .iter()
305                .all(|b| b.voltage_mag.is_finite() && b.voltage_ang.is_finite());
306            let ok = stats.status == SolveStatus::Converged && finite;
307            (n, ok.then(|| stats.iterations()), buses[load].voltage_mag)
308        })
309        .collect()
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::topology::model::{NodeBreakerTopology, NodeIdx, Switch, SwitchKind};
316    use crate::topology::{RetentionPolicy, bus_view};
317
318    fn sw(a: usize, b: usize, open: bool) -> Switch {
319        Switch {
320            kind: SwitchKind::Breaker,
321            nodes: [NodeIdx(a), NodeIdx(b)],
322            open,
323            in_service: true,
324        }
325    }
326
327    /// A regularized switch is inductive, unlike `IDEAL_CONNECTION_Y`. That is
328    /// the whole reason DC needs no guard for it — a negative reactance would
329    /// make `B` indefinite.
330    #[test]
331    fn a_switch_branch_is_inductive_at_the_ideal_connection_stiffness() {
332        let y = switch_admittance();
333        assert!(y.im < 0.0, "switch admittance {y} is capacitive");
334
335        let z = Complex::new(1.0, 0.0) / y;
336        assert!(z.im > 0.0, "switch reactance {} is negative", z.im);
337        assert!((z.im - switch_reactance()).abs() < 1e-18);
338
339        // Same stiffness as a merged ideal connection, to within round-off.
340        assert!(
341            (y.norm() - crate::topology::IDEAL_CONNECTION_Y.norm()).abs() < 1.0,
342            "{} vs {}",
343            y.norm(),
344            crate::topology::IDEAL_CONNECTION_Y.norm()
345        );
346
347        // And DC reads it the right way round without any special case.
348        let dc = crate::linear::dc_branches(
349            &[],
350            &regularized_branches(&{
351                let mut t = NodeBreakerTopology::new(2);
352                t.add_switch(sw(0, 1, false));
353                bus_view(&t, &RetentionPolicy::RetainAll)
354            }),
355            crate::linear::DcOptions::default(),
356        );
357        assert_eq!(dc.len(), 1);
358        assert!(dc[0].b > 0.0, "DC susceptance came out {}", dc[0].b);
359    }
360
361    /// Position becomes terminal status, which is what keeps the Y-bus pattern
362    /// constant across a switching state change.
363    #[test]
364    fn open_and_closed_switches_differ_only_in_status() {
365        let mut topo = NodeBreakerTopology::new(3);
366        topo.add_switch(sw(0, 1, false));
367        topo.add_switch(sw(1, 2, true));
368        let view = bus_view(&topo, &RetentionPolicy::RetainAll);
369
370        let branches = regularized_branches(&view);
371        assert_eq!(branches.len(), 2);
372        assert_eq!((branches[0].from_status, branches[0].to_status), (1, 1));
373        assert_eq!((branches[1].from_status, branches[1].to_status), (0, 0));
374        assert_eq!(branches[0].y_series, branches[1].y_series);
375        assert_eq!((branches[0].from, branches[0].to), (0, 1));
376        assert_eq!((branches[1].from, branches[1].to), (1, 2));
377    }
378
379    /// The property the module doc claims: a position change moves values, not
380    /// structure. This is what lets a switching campaign share one symbolic
381    /// factorization.
382    #[test]
383    fn flipping_a_switch_preserves_the_ybus_sparsity_pattern() {
384        use crate::network::build_ybus;
385
386        let pattern = |open: bool| {
387            let mut topo = NodeBreakerTopology::new(3);
388            topo.add_switch(sw(0, 1, open));
389            topo.add_switch(sw(1, 2, false));
390            let view = bus_view(&topo, &RetentionPolicy::RetainAll);
391            let ybus = build_ybus(3, &[], &regularized_branches(&view)).finish();
392            (0..ybus.n())
393                .map(|i| ybus.row(i).iter().map(|&(j, _)| j).collect::<Vec<_>>())
394                .collect::<Vec<_>>()
395        };
396        assert_eq!(pattern(false), pattern(true));
397    }
398
399    /// A retained switch whose ends already share a bus stamps nothing, and is
400    /// reported rather than silently included.
401    #[test]
402    fn degenerate_switches_are_separated_out() {
403        let mut topo = NodeBreakerTopology::new(2);
404        topo.add_switch(sw(0, 1, false)); // retained
405        topo.add_switch(Switch {
406            kind: SwitchKind::Junction, // always merged, so both ends collapse
407            nodes: [NodeIdx(0), NodeIdx(1)],
408            open: false,
409            in_service: true,
410        });
411        let view = bus_view(&topo, &RetentionPolicy::RetainAll);
412
413        assert_eq!(view.retained().len(), 1);
414        assert!(regularized_branches(&view).is_empty());
415        assert_eq!(degenerate_switches(&view).len(), 1);
416    }
417
418    /// Nothing retained means nothing to stamp — the `MergeAll` path must add
419    /// no branches at all, or the default treatment would quietly change every
420    /// existing result.
421    #[test]
422    fn merge_all_produces_no_switch_branches() {
423        let mut topo = NodeBreakerTopology::new(3);
424        topo.add_switch(sw(0, 1, false));
425        topo.add_switch(sw(1, 2, true));
426        let view = bus_view(&topo, &RetentionPolicy::MergeAll);
427        assert!(regularized_branches(&view).is_empty());
428        assert!(degenerate_switches(&view).is_empty());
429    }
430
431    /// A closed regularized switch really does tie its two buses together: the
432    /// voltage drop across it must be negligible against an ordinary branch's.
433    #[test]
434    fn a_closed_switch_holds_its_two_buses_together() {
435        use crate::network::build_ybus;
436        use crate::solver::newton_raphson;
437        use crate::types::{Bus, BusType, Line};
438
439        let mut buses: Vec<Bus> = (0..3)
440            .map(|i| Bus {
441                idx: i,
442                bus_type: if i == 0 { BusType::Slack } else { BusType::PQ },
443                voltage_mag: 1.0,
444                voltage_ang: 0.0,
445                p_spec: 0.0,
446                q_spec: 0.0,
447                q_min: -f64::INFINITY,
448                q_max: f64::INFINITY,
449                u_rated: 0.0,
450                zip_terms: Vec::new(),
451            })
452            .collect();
453        buses[2].p_spec = -0.5;
454        buses[2].q_spec = -0.2;
455
456        let mut topo = NodeBreakerTopology::new(3);
457        topo.add_switch(sw(0, 1, false));
458        let view = bus_view(&topo, &RetentionPolicy::RetainAll);
459        let lines = vec![Line {
460            from: 1,
461            to: 2,
462            r: 0.02,
463            x: 0.06,
464            b_shunt: 0.0,
465            g_shunt: 0.0,
466        }];
467
468        let ybus = build_ybus(3, &lines, &regularized_branches(&view)).finish();
469        newton_raphson(&mut buses, &ybus, 1e-8, 30);
470
471        let drop = (buses[0].voltage_mag - buses[1].voltage_mag).abs();
472        let across_line = (buses[1].voltage_mag - buses[2].voltage_mag).abs();
473        assert!(
474            drop < 1e-4,
475            "a closed switch dropped {drop} p.u., which is not 'ideal enough'"
476        );
477        assert!(
478            drop < across_line / 100.0,
479            "{drop} vs {across_line} across a real branch"
480        );
481    }
482
483    /// The measured ceiling, pinned so it cannot drift silently.
484    ///
485    /// The prediction was that `Regularize` is dead on arrival at SmallGrid's
486    /// 1,266 switches and Svedala's 1,464. On synthetic networks it is not:
487    /// every shape, count and admittance sign converges. If this test ever
488    /// starts failing, the prediction has finally been reproduced — which is
489    /// worth knowing either way, so the assertion is on the *result*, not on a claim.
490    #[test]
491    fn regularized_switches_converge_at_every_count_this_probe_can_build() {
492        let counts = [1usize, 20, 29, 90, 1266, 1464];
493        for shape in [ProbeShape::Chain, ProbeShape::Star, ProbeShape::Ring] {
494            for (label, y) in [
495                ("inductive", switch_admittance()),
496                ("capacitive", crate::topology::IDEAL_CONNECTION_Y),
497            ] {
498                for (n, iters, vm) in conditioning_probe(&counts, shape, y, 1e-6, 30) {
499                    assert!(
500                        iters.is_some(),
501                        "{shape:?}/{label}: {n} switches failed to converge — \
502                         the switch-count ceiling has been reproduced"
503                    );
504                    assert!(
505                        (vm - 1.0).abs() < 0.05,
506                        "{shape:?}/{label}: {n} switches converged to an implausible \
507                         |V| = {vm}"
508                    );
509                }
510            }
511        }
512    }
513
514    /// An open regularized switch really does separate: the far side becomes a
515    /// sourceless island rather than a lightly-coupled one.
516    #[test]
517    fn an_open_switch_separates_its_two_buses() {
518        use crate::network::{build_ybus, connected_components};
519
520        let mut topo = NodeBreakerTopology::new(2);
521        topo.add_switch(sw(0, 1, true));
522        let view = bus_view(&topo, &RetentionPolicy::RetainAll);
523        let ybus = build_ybus(2, &[], &regularized_branches(&view)).finish();
524
525        // The structural entry survives — that is the point, since it is what
526        // keeps the sparsity pattern constant across a position change — so
527        // the *value* is what carries the position.
528        assert!(
529            ybus.row(0).iter().any(|&(j, _)| j == 1),
530            "structural entry was dropped"
531        );
532        assert_eq!(
533            ybus.get(0, 1),
534            Complex::new(0.0, 0.0),
535            "an open switch still conducts"
536        );
537        // And `connected_components` reads the value, not merely the structure,
538        // so it sees the separation: two components, not one.
539        assert_eq!(connected_components(&ybus).len(), 2);
540    }
541}
542
543/// A network whose retained switches are addressable elements.
544///
545/// The problem this solves is index arithmetic. `regularized_branches` appends
546/// switch branches to a transformer list, so a switch's flat branch index is
547/// `lines.len() + model_transformers + n` — which the caller had to compute,
548/// and which silently desynchronizes the moment anything about the branch order
549/// changes. Owning the mapping removes that whole class of mistake, and it is
550/// what makes the rest of the crate usable on switches without knowing they are
551/// switches.
552///
553/// # What comes free
554///
555/// A retained switch *is* a branch, so nothing else needed changing to make the
556/// existing machinery work on one:
557///
558/// - **DC** — `linear::dc_power_flow` takes the branch lists, so a switch's
559///   active flow appears in `DcSolution::branch_p` at its own index.
560/// - **Sensitivities** — `DcSensitivity::lodf_column` at a switch's branch index
561///   *is* its bus-split distribution factor.
562/// - **AC contingencies** — `batch::BatchSolver::solve_contingencies` outages a
563///   switch by its branch index like any other branch, so a switching campaign
564///   is an ordinary contingency sweep.
565///
566/// The one thing that is *not* free is the flow through a closed switch inside
567/// a loop of closed switches: see [`degenerate_switches`] and
568/// `zero_impedance_branches.md`.
569#[derive(Clone, Debug)]
570pub struct NodeBreakerNetwork {
571    pub buses: Vec<crate::types::Bus>,
572    pub lines: Vec<crate::types::Line>,
573    /// The model's own transformers, followed by one branch per non-degenerate
574    /// retained switch.
575    pub transformers: Vec<Transformer>,
576    pub shunts: Vec<crate::network::ShuntAdm>,
577    pub view: BusView,
578    /// The switch graph the view was derived from, kept so a switch can report
579    /// its own kind — a caller deciding whether to operate something needs to
580    /// know a breaker from a disconnector, and neither [`BusView`] nor
581    /// [`RetainedSwitch`] carries that.
582    pub topology: crate::topology::NodeBreakerTopology,
583    /// Per switch, a human-meaningful identifier from the source model (a CGMES
584    /// mRID). Empty when the source has none, in which case callers fall back
585    /// to [`SwitchIdx`].
586    pub switch_labels: Vec<String>,
587    /// Position in [`BusView::retained`] -> flat branch index, or `None` for a
588    /// degenerate switch that was not stamped.
589    branch_of_retained: Vec<Option<usize>>,
590    /// Per bus, whether no injecting equipment terminates on it.
591    ///
592    /// State estimation's reason for existing on this network: a bay's internal
593    /// nodes carry switches and nothing else, so the finer the topology, the
594    /// more of the state is pinned by exact zero-injection constraints rather
595    /// than by sensors. See
596    /// [`SeNetwork::from_node_breaker`](crate::se::SeNetwork::from_node_breaker).
597    pub zero_injection: Vec<bool>,
598}
599
600impl NodeBreakerNetwork {
601    /// Assembles the network, stamping retained switches under `treatment`.
602    #[allow(clippy::too_many_arguments)]
603    pub fn new(
604        buses: Vec<crate::types::Bus>,
605        lines: Vec<crate::types::Line>,
606        mut transformers: Vec<Transformer>,
607        shunts: Vec<crate::network::ShuntAdm>,
608        view: BusView,
609        topology: crate::topology::NodeBreakerTopology,
610        switch_labels: Vec<String>,
611        zero_injection: Vec<bool>,
612        treatment: SwitchTreatment,
613    ) -> Self {
614        let mut branch_of_retained = vec![None; view.retained().len()];
615        if treatment == SwitchTreatment::Regularize {
616            let base = lines.len() + transformers.len();
617            let mut stamped = 0;
618            for (n, r) in view.retained().iter().enumerate() {
619                if view.is_degenerate(r) {
620                    continue;
621                }
622                branch_of_retained[n] = Some(base + stamped);
623                stamped += 1;
624            }
625            transformers.extend(regularized_branches(&view));
626            debug_assert_eq!(lines.len() + transformers.len(), base + stamped);
627        }
628        Self {
629            buses,
630            lines,
631            transformers,
632            shunts,
633            view,
634            topology,
635            switch_labels,
636            branch_of_retained,
637            zero_injection,
638        }
639    }
640
641    /// A switch's kind — breaker, disconnector, fuse and so on.
642    pub fn switch_kind(&self, switch: SwitchIdx) -> Option<crate::topology::SwitchKind> {
643        self.topology.switches.get(switch.0).map(|s| s.kind)
644    }
645
646    /// A switch's source-model identifier, or its index rendered as text when
647    /// the source supplied none.
648    pub fn switch_label(&self, switch: SwitchIdx) -> String {
649        self.switch_labels
650            .get(switch.0)
651            .cloned()
652            .unwrap_or_else(|| format!("switch{}", switch.0))
653    }
654
655    /// Total branch count — the length of every branch-indexed vector the
656    /// solvers return for this network.
657    pub fn n_branches(&self) -> usize {
658        self.lines.len() + self.transformers.len()
659    }
660
661    /// The flat branch index of the `n`th retained switch, or `None` if it was
662    /// degenerate (both ends on one bus) and therefore not stamped.
663    pub fn branch_of_retained(&self, n: usize) -> Option<usize> {
664        self.branch_of_retained.get(n).copied().flatten()
665    }
666
667    /// The flat branch index of a switch named by its position in the source
668    /// topology, or `None` if it was not retained or was degenerate.
669    pub fn branch_of_switch(&self, switch: SwitchIdx) -> Option<usize> {
670        let n = self
671            .view
672            .retained()
673            .iter()
674            .position(|r| r.switch == switch)?;
675        self.branch_of_retained(n)
676    }
677
678    /// Every retained switch's `(SwitchIdx, branch index)`, skipping degenerate
679    /// ones — the list a contingency campaign iterates.
680    pub fn switch_branches(&self) -> Vec<(SwitchIdx, usize)> {
681        self.view
682            .retained()
683            .iter()
684            .enumerate()
685            .filter_map(|(n, r)| self.branch_of_retained(n).map(|b| (r.switch, b)))
686            .collect()
687    }
688
689    /// Opens or closes a retained switch in place.
690    ///
691    /// Changes Y-bus *values* only: the branch keeps its structural entries at
692    /// zero when open, so the **Y-bus** sparsity pattern is unchanged.
693    ///
694    /// The **Jacobian** pattern is a separate question, and the distinction
695    /// matters for what a caller must invalidate:
696    ///
697    /// - A cached [`JacobianPattern`](crate::jacobian::JacobianPattern) is
698    ///   always stale afterwards, since it stores each entry's admittance.
699    /// - Its *size* changes too whenever the flip severs part of the network:
700    ///   `connected_components` sees the opening (it reads values, not just
701    ///   structure), `mark_unreferenced_islands` pins the stranded buses to
702    ///   `Slack`, and the unknown count moves with them.
703    ///
704    /// So [`PersistentSolver::invalidate_admittances`](crate::solver::PersistentSolver::invalidate_admittances)
705    /// — which keeps the symbolic factorization — is correct only for a switch
706    /// the caller knows cannot island anything. Otherwise use
707    /// [`reset`](crate::solver::PersistentSolver::reset). Getting this wrong
708    /// used to panic inside `faer`; `RealSparseSystem::solve_values` now
709    /// refuses the mismatched call instead, so it surfaces as a failed solve.
710    ///
711    /// Returns `false` if the switch is not retained or was degenerate.
712    pub fn set_switch_open(&mut self, switch: SwitchIdx, open: bool) -> bool {
713        let Some(branch) = self.branch_of_switch(switch) else {
714            return false;
715        };
716        let status = u8::from(!open);
717        let t = &mut self.transformers[branch - self.lines.len()];
718        t.from_status = status;
719        t.to_status = status;
720        true
721    }
722
723    /// Whether a retained switch is currently open. `None` if it is not
724    /// retained or was degenerate.
725    pub fn is_switch_open(&self, switch: SwitchIdx) -> Option<bool> {
726        let branch = self.branch_of_switch(switch)?;
727        Some(self.transformers[branch - self.lines.len()].from_status == 0)
728    }
729
730    /// This network's retained switches as equality constraints, for
731    /// [`constrained::solve_constrained`](crate::constrained::solve_constrained).
732    ///
733    /// Positionally matches [`BusView::retained`](crate::topology::BusView::retained),
734    /// including the degenerate switches — a switch whose two ends already share
735    /// a bus becomes a self-loop, which the constrained solver reports as
736    /// indeterminate rather than skipping. Keeping them here is what lets a
737    /// caller index straight from a switch to its flow.
738    ///
739    /// Only meaningful under [`SwitchTreatment::Constrain`]. Under
740    /// [`Regularize`](SwitchTreatment::Regularize) the switches are already in
741    /// the branch list, and passing them here as well would model each of them
742    /// twice.
743    pub fn constrained_switches(&self) -> Vec<crate::constrained::ConstrainedSwitch> {
744        self.view
745            .retained()
746            .iter()
747            .map(|r| crate::constrained::ConstrainedSwitch {
748                from: r.buses[0].0,
749                to: r.buses[1].0,
750                open: r.open,
751            })
752            .collect()
753    }
754
755    /// Complex power entering a switch at its `from` terminal, given solved bus
756    /// voltages. `None` if the switch has no branch.
757    ///
758    /// This is `branch_flow::terminal_flow` at the switch's own index — a
759    /// switch reports its flow through exactly the same path any other branch
760    /// does.
761    pub fn switch_flow(
762        &self,
763        switch: SwitchIdx,
764        voltages: &[num_complex::Complex<f64>],
765    ) -> Option<(f64, f64)> {
766        let branch = self.branch_of_switch(switch)?;
767        let params = crate::branch_flow::branch_params(&self.lines, &self.transformers);
768        Some(crate::branch_flow::terminal_flow(
769            &params[branch],
770            crate::branch_flow::Terminal::From,
771            voltages,
772        ))
773    }
774}