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 ®ularized_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, &[], ®ularized_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, ®ularized_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, &[], ®ularized_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 ¶ms[branch],
770 crate::branch_flow::Terminal::From,
771 voltages,
772 ))
773 }
774}