gridoxide_core/lib.rs
1//! gridoxide's solvers, without any file format.
2//!
3//! - The network model: [`types`], [`network`] (the Y-bus and island
4//! classification), [`topology`], [`branch_flow`], [`jacobian`],
5//! [`ratings`] and [`json`], gridoxide's own network document.
6//! - Power flow: [`run_power_flow`], Newton-Raphson ([`solver`]) with its
7//! outer loops ([`outerloop`]) or one of the direct methods in [`linear`],
8//! and what is built directly on it — batches ([`batch`], [`bde`],
9//! [`scenario`]), sensitivities ([`ac_sensitivity`]), continuation and Q-V
10//! curves ([`continuation`], [`qv`]), HVDC ([`dc`]), node-breaker
11//! switching ([`switches`], [`constrained`]), reactive dispatch
12//! ([`dispatch`]) and the injection Hessian OPF needs
13//! ([`injection_hessian`]).
14//! - State estimation ([`se`]) and its [`measurement`] model.
15//! - Short circuit ([`shortcircuit`]).
16//!
17//! Every reader and writer of a file format lives in `gridoxide`, on top, as
18//! do OPF, remedial actions, dynamics and the C and Python bindings. The
19//! sparse LU is gridoxide-sparse's.
20
21pub mod ac_sensitivity;
22pub mod batch;
23pub mod bde;
24pub mod branch_flow;
25pub mod constrained;
26pub mod continuation;
27pub mod dc;
28pub mod dispatch;
29pub mod injection_hessian;
30pub mod jacobian;
31pub mod json;
32pub mod linear;
33pub mod measurement;
34pub mod network;
35pub mod outerloop;
36pub mod qv;
37pub mod ratings;
38pub mod roots;
39pub mod scenario;
40pub mod se;
41pub mod shortcircuit;
42pub mod solver;
43pub mod switches;
44pub mod topology;
45pub mod types;
46
47use json::NetworkData;
48use linear::{
49 btheta::{DcIslandStatus, DcSolution, dc_power_flow},
50 impedance::{LinearIslandStatus, LinearReport, linear_power_flow},
51};
52use network::{ShuntAdm, YBus, build_ybus, linear_initial_guess, stamp_shunts};
53use solver::{IslandReport, JacobianBackend, PersistentSolver, SolveStats, SolveStatus};
54use types::{Bus, Line, Transformer};
55
56/// Which method [`crate::run_power_flow`] uses.
57///
58/// The two linear methods are genuinely different algorithms, not two
59/// spellings of one — see `docs/src/powerflow/dc.md` and
60/// `linear_impedance.md`. Both are direct solves, so neither consults
61/// [`PowerFlowOptions::tol`] or `max_iter`.
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
63pub enum PowerFlowMethod {
64 /// The full nonlinear solve. Everything else in this crate's history.
65 #[default]
66 NewtonRaphson,
67 /// Real Bθ: active power only, `|V| = 1`, resistance discarded. One
68 /// factorization, no iterations. See
69 /// [`linear::btheta::dc_power_flow`].
70 Dc,
71 /// Complex constant-admittance: every load replaced by the admittance
72 /// drawing its rated power at `|V| = 1`. Mathematically
73 /// power-grid-model's `CalculationMethod.linear`. See
74 /// [`linear::impedance::linear_power_flow`].
75 LinearImpedance,
76}
77
78/// Where [`PowerFlowMethod::NewtonRaphson`] starts from. Ignored by the
79/// direct methods, which have no starting point.
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
81pub enum PowerFlowInit {
82 /// `|V| = 1`, `θ = 0`, untouched.
83 Flat,
84 /// One constant-admittance solve, via `network::linear_initial_guess`.
85 ///
86 /// The default because it is what this crate has always done — see
87 /// [`crate::run_power_flow_analysis_from_ybus`], which calls it
88 /// unconditionally — and because it is power-grid-model's only
89 /// robustness mechanism, needed where weak sources meet large transformer
90 /// phase shifts and flat-start Newton diverges.
91 #[default]
92 LinearImpedance,
93 /// One DC solve, seeding angles and **only** angles — bus types,
94 /// injections and magnitudes are all restored afterwards, since
95 /// `linear::btheta::dc_power_flow` is a solver rather than an
96 /// initializer and would otherwise pre-empt Newton's own classification
97 /// pass. DC has no magnitude information to offer in any case, beyond the
98 /// flat `|V| = 1` it assumes. This is powsybl's
99 /// `DcValueVoltageInitializer`.
100 ///
101 /// Opt-in rather than default only because `LinearImpedance` is the
102 /// long-standing behavior, not because it is weaker. Measured on the
103 /// committed PGM fixtures
104 /// (`tests/power_flow_methods_test.rs::both_warm_starts_rescue_a_case_flat_start_cannot_solve`):
105 /// on `distribution-case`, where a flat start does *not* converge inside
106 /// 20 iterations, DC init converges in 5 against `LinearImpedance`'s 4.
107 /// Both rescue the case; neither dominates.
108 ///
109 /// The one thing DC init cannot do is supply voltage magnitudes — it has
110 /// none to give beyond the flat 1 p.u. it assumes — so on networks whose
111 /// magnitudes stray far from nominal, `LinearImpedance` starts with
112 /// strictly more information.
113 Dc,
114}
115
116/// Everything [`crate::run_power_flow`] needs to know.
117///
118/// The pre-existing entry points — [`solver::newton_raphson`],
119/// [`solver::newton_raphson_with_backend`], [`solver::PersistentSolver::solve`],
120/// the outer-loop driver — keep their positional parameters
121/// and are unaffected by this type. It exists so that adding a fifth or sixth
122/// knob does not mean a fifth or sixth positional argument, following
123/// `se::nr::SeOptions`.
124#[derive(Clone, Debug)]
125pub struct PowerFlowOptions {
126 /// Convergence tolerance on the maximum P/Q mismatch. Newton only.
127 pub tol: f64,
128 /// Newton only.
129 pub max_iter: usize,
130 /// Which sparse-LU backend solves the Jacobian. Newton only: the direct
131 /// methods each own their factorization type.
132 pub backend: JacobianBackend,
133 pub method: PowerFlowMethod,
134 pub init: PowerFlowInit,
135 /// Adds [`crate::outerloop::ReactiveLimits`] to the outer-loop list.
136 pub enforce_q_limits: bool,
137 /// Adds [`crate::outerloop::DistributedSlack`] to the outer-loop list,
138 /// innermost, with these participation weights.
139 ///
140 /// Mutually exclusive with [`area_interchange`](Self::area_interchange),
141 /// which subsumes it — configuring both would move the same schedules
142 /// twice, and `run_power_flow` refuses rather than doing so.
143 pub distribute_slack: Option<crate::outerloop::SlackDistribution>,
144 /// Adds [`crate::outerloop::AreaInterchange`] in distributed slack's place,
145 /// holding each control area's net export at its scheduled value.
146 pub area_interchange: Option<crate::outerloop::AreaDefinition>,
147 /// Adds [`crate::outerloop::PhaseControl`] and
148 /// [`crate::outerloop::TransformerVoltageControl`], outermost. Needs an
149 /// importer that retained tap tables and regulating controls; with none,
150 /// both loops are stable on their first check and cost one call each.
151 pub control_taps: bool,
152 /// Adds [`crate::outerloop::RemoteVoltageControl`] to the loop list, for
153 /// machines that regulate a bus other than their own.
154 ///
155 /// Off by default, and the default is *wrong* rather than merely
156 /// conservative: without it a remote machine's reactive power appears at the
157 /// bus it holds instead of at the machine, so the reactive flow between them
158 /// is missing. It is opt-in because turning it on changes answers, and
159 /// because it needs network data only CGMES supplies — see
160 /// [`crate::RemoteControlData`].
161 pub control_remote_voltage: bool,
162 /// Positions a tap controller may move in one outer pass. The sensitivity
163 /// is exact only at the converged state, so a large step overshoots;
164 /// capping trades passes for stability.
165 pub tap_max_shift: i32,
166 /// Cap on total outer-loop re-solves across every configured loop.
167 pub max_outer_iter: usize,
168 /// Susceptance choice and transformer-ratio handling for
169 /// [`PowerFlowMethod::Dc`] and for [`PowerFlowInit::Dc`].
170 pub dc: crate::linear::DcOptions,
171}
172
173impl Default for PowerFlowOptions {
174 fn default() -> Self {
175 Self {
176 // The values `run_power_flow_analysis_from_ybus` has always
177 // hard-coded, so the default options reproduce today's behavior
178 // exactly.
179 tol: 1e-6,
180 max_iter: 20,
181 backend: JacobianBackend::default(),
182 method: PowerFlowMethod::default(),
183 init: PowerFlowInit::default(),
184 enforce_q_limits: false,
185 distribute_slack: None,
186 area_interchange: None,
187 control_taps: false,
188 control_remote_voltage: false,
189 tap_max_shift: 3,
190 max_outer_iter: 10,
191 dc: crate::linear::DcOptions::default(),
192 }
193 }
194}
195
196/// Result of a full power-flow analysis: the solved (or placeholder, for
197/// unreferenced islands) buses, plus a per-connected-component breakdown of
198/// how each one was resolved — see [`solver::IslandReport`]/
199/// [`solver::IslandStatus`].
200#[derive(Debug)]
201pub struct PowerFlowReport {
202 pub buses: Vec<Bus>,
203 /// Newton-Raphson's per-component breakdown. **Empty** for the two direct
204 /// methods, which report through [`dc`](Self::dc) and
205 /// [`linear`](Self::linear) instead.
206 ///
207 /// This is not laziness. `IslandStatus::MaxIterationsReached` cannot
208 /// happen without iterations, and `IslandStatus::AmbiguousReferenceBus`
209 /// means "over-determined, the answer may satisfy neither reference" —
210 /// which is true of AC and false of DC, where two references leave the
211 /// system perfectly well posed. Mapping onto this vocabulary would state
212 /// things about a DC solve that are not so.
213 pub islands: Vec<IslandReport>,
214 /// Iteration count and per-iteration convergence trace. The solver
215 /// itself prints nothing (see [`solver::SolveStats`]); `src/main.rs`
216 /// reconstructs the progress output from this.
217 ///
218 /// For the direct methods this carries only a status — `Converged` or
219 /// `Singular` — with an empty history, since they take no iterations.
220 pub stats: SolveStats,
221 /// Populated only by [`PowerFlowMethod::Dc`]: per-branch active flows,
222 /// per-island slack pickup, and the solve's residual.
223 pub dc: Option<DcSolution>,
224 /// Populated only by [`PowerFlowMethod::LinearImpedance`].
225 pub linear: Option<LinearReport>,
226 /// What the outer loops did, when any were configured. `None` for a plain
227 /// Newton solve and for the two direct methods.
228 pub outer: Option<OuterLoopOutcome>,
229}
230
231/// The tap tables and regulating controls a solve may act on.
232///
233/// Network data, so it travels in the signature rather than in
234/// [`PowerFlowOptions`]. A caller with no tap control passes
235/// [`TapData::none`], which is what makes
236/// [`PowerFlowOptions::control_taps`] a no-op rather than a lie.
237#[derive(Clone, Copy, Debug, Default)]
238pub struct TapData<'a> {
239 /// Parallel to the `transformers` slice.
240 pub changers: &'a [Option<types::TapChanger>],
241 pub regulation: &'a [outerloop::TapRegulation],
242}
243
244impl TapData<'_> {
245 /// No tap tables and no controls: what every importer that does not retain
246 /// them supplies, and what a caller uninterested in tap control passes.
247 pub fn none() -> Self {
248 Self::default()
249 }
250
251 pub fn is_empty(&self) -> bool {
252 self.changers.is_empty() || self.regulation.is_empty()
253 }
254}
255
256/// The regulating machines a solve may act on.
257///
258/// Network data, so it travels in the signature rather than in
259/// [`PowerFlowOptions`] — the same place and for the same reason as
260/// [`TapData`]. A caller with none passes [`RemoteControlData::none`], which is
261/// what makes [`PowerFlowOptions::control_remote_voltage`] a no-op
262/// rather than a lie.
263#[derive(Clone, Copy, Debug, Default)]
264pub struct RemoteControlData<'a> {
265 pub machines: &'a [types::RegulatingMachine],
266}
267
268impl RemoteControlData<'_> {
269 /// No machines: what every importer but CGMES supplies.
270 pub fn none() -> Self {
271 Self::default()
272 }
273
274 pub fn is_empty(&self) -> bool {
275 self.machines.iter().all(|m| m.at_bus == m.controls_bus)
276 }
277}
278
279/// The outer loops' side of a solve, gathered so a caller need not downcast
280/// [`outerloop::OuterLoop`] trait objects to read their reports.
281#[derive(Clone, Debug, Default)]
282pub struct OuterLoopOutcome {
283 /// Per-loop iteration counts and final statuses.
284 pub report: Option<outerloop::OuterLoopReport>,
285 /// Buses switched `PV → PQ`, in switch order. Empty unless
286 /// [`PowerFlowOptions::enforce_q_limits`].
287 pub q_limit_switches: Vec<usize>,
288 /// Populated when [`PowerFlowOptions::distribute_slack`] was set.
289 pub slack: Option<outerloop::SlackDistributionReport>,
290 /// Populated when [`PowerFlowOptions::area_interchange`] was set.
291 pub area: Option<outerloop::AreaInterchangeReport>,
292 /// Populated when [`PowerFlowOptions::control_taps`] was set:
293 /// the voltage controllers, then the phase controllers.
294 pub taps: Vec<outerloop::ControllerReport>,
295 /// Populated when [`PowerFlowOptions::control_remote_voltage`] was
296 /// set: what each remotely-regulating machine reached, and how.
297 pub remote: Vec<outerloop::RemoteControlReport>,
298 /// Buses this run re-typed to move a control onto the machine holding it,
299 /// as `(bus, from, to)`. Worth reporting: the network solved is not quite
300 /// the one handed over.
301 pub retyped: Vec<(usize, types::BusType, types::BusType)>,
302 /// The transformers as the loops left them — tap positions moved. Empty
303 /// unless a tap loop ran, since nothing else changes a transformer.
304 pub transformers: Vec<Transformer>,
305 /// The tap changers as the loops left them, parallel to `transformers`.
306 pub changers: Vec<Option<types::TapChanger>>,
307}
308
309impl PowerFlowReport {
310 /// The shape every method-specific constructor starts from.
311 fn empty(buses: Vec<Bus>, status: SolveStatus) -> Self {
312 Self {
313 buses,
314 islands: Vec::new(),
315 stats: SolveStats {
316 status,
317 mismatch_history: Vec::new(),
318 },
319 dc: None,
320 linear: None,
321 outer: None,
322 }
323 }
324}
325
326pub fn run_power_flow_analysis(network_data: NetworkData) -> PowerFlowReport {
327 let ybus = build_ybus(network_data.buses.len(), &network_data.lines, &[]);
328 run_power_flow_analysis_from_ybus(network_data.buses, ybus)
329}
330
331/// Runs a power flow by the method named in `opts`.
332///
333/// Takes branch lists rather than a finished Y-bus because
334/// [`PowerFlowMethod::Dc`] is formulated per branch — it needs each branch's
335/// own `x` and `tap`, which a Y-bus has already summed away — and never
336/// builds one at all. The other two methods assemble their Y-bus here, from
337/// these same lists plus `shunts`.
338///
339/// [`run_power_flow_analysis`] and [`run_power_flow_analysis_from_ybus`] are
340/// unchanged and remain the shortest path to an ordinary Newton solve;
341/// `PowerFlowOptions::default()` here reproduces exactly what they do.
342pub fn run_power_flow(
343 buses: Vec<Bus>,
344 lines: &[Line],
345 transformers: &[Transformer],
346 shunts: &[ShuntAdm],
347 taps: TapData<'_>,
348 opts: PowerFlowOptions,
349) -> PowerFlowReport {
350 run_power_flow_with_remote(
351 buses,
352 lines,
353 transformers,
354 shunts,
355 taps,
356 RemoteControlData::none(),
357 opts,
358 )
359}
360
361/// [`run_power_flow`] with the regulating machines a remote-voltage control
362/// needs. The two-argument form is the shorter path for every caller that has
363/// none, which is every importer but CGMES.
364pub fn run_power_flow_with_remote(
365 mut buses: Vec<Bus>,
366 lines: &[Line],
367 transformers: &[Transformer],
368 shunts: &[ShuntAdm],
369 taps: TapData<'_>,
370 remote: RemoteControlData<'_>,
371 opts: PowerFlowOptions,
372) -> PowerFlowReport {
373 match opts.method {
374 PowerFlowMethod::Dc => {
375 let solution = dc_power_flow(&mut buses, lines, transformers, opts.dc);
376 let status = if solution
377 .islands
378 .iter()
379 .any(|i| i.status == DcIslandStatus::Singular)
380 {
381 SolveStatus::Singular
382 } else {
383 SolveStatus::Converged
384 };
385 let mut report = PowerFlowReport::empty(buses, status);
386 report.dc = Some(solution);
387 report
388 }
389 PowerFlowMethod::LinearImpedance => {
390 let ybus = finished_ybus(buses.len(), lines, transformers, shunts);
391 let solution = linear_power_flow(&mut buses, &ybus);
392 let status = if solution
393 .islands
394 .iter()
395 .any(|i| i.status == LinearIslandStatus::Singular)
396 {
397 SolveStatus::Singular
398 } else {
399 SolveStatus::Converged
400 };
401 let mut report = PowerFlowReport::empty(buses, status);
402 report.linear = Some(solution);
403 report
404 }
405 PowerFlowMethod::NewtonRaphson => {
406 let ybus = finished_ybus(buses.len(), lines, transformers, shunts);
407 initialize_newton(&mut buses, lines, transformers, &ybus, opts.init, opts.dc);
408
409 newton_with_loops(buses, lines, transformers, shunts, taps, remote, ybus, opts)
410 }
411 }
412}
413
414/// Sets Newton's starting point in `buses`, as [`run_power_flow`] does
415/// before its first iteration.
416///
417/// Public so that a caller driving [`PersistentSolver`] itself — the Python
418/// bindings, which keep one solver per model — starts from the same point
419/// `run_power_flow` would for the same [`PowerFlowInit`]. `ybus` must be the
420/// network's finished admittance matrix (shunts stamped); `dc` is read only
421/// for [`PowerFlowInit::Dc`].
422pub fn initialize_newton(
423 buses: &mut [Bus],
424 lines: &[Line],
425 transformers: &[Transformer],
426 ybus: &network::YBusSparse,
427 init: PowerFlowInit,
428 dc: crate::linear::DcOptions,
429) {
430 match init {
431 PowerFlowInit::Flat => {}
432 PowerFlowInit::LinearImpedance => linear_initial_guess(buses, ybus),
433 // Seed angles and *only* angles.
434 //
435 // `dc_power_flow` is a solver, not an initializer: it also
436 // normalizes PQ magnitudes to its own |V| = 1 assumption, and
437 // it runs `network::mark_unreferenced_islands`, which rewrites
438 // a sourceless island's buses to `Slack` with zero injection.
439 // Both are right for a DC solve and wrong here — Newton has
440 // its own classification pass, and letting the initializer
441 // pre-empt it changes the island statuses that pass reports
442 // (a sourceless island comes back `AmbiguousReferenceBus`, or
443 // for a single bus, silently `Converged`). See
444 // `network::linear_initial_guess`, which states the same
445 // invariant for the other initializer.
446 //
447 // So everything but `voltage_ang` is restored afterwards.
448 PowerFlowInit::Dc => {
449 let saved: Vec<(types::BusType, f64, f64, f64)> = buses
450 .iter()
451 .map(|b| (b.bus_type, b.p_spec, b.q_spec, b.voltage_mag))
452 .collect();
453 dc_power_flow(buses, lines, transformers, dc);
454 for (bus, (bus_type, p_spec, q_spec, voltage_mag)) in buses.iter_mut().zip(saved) {
455 bus.bus_type = bus_type;
456 bus.p_spec = p_spec;
457 bus.q_spec = q_spec;
458 bus.voltage_mag = voltage_mag;
459 }
460 }
461 }
462}
463
464/// The Newton branch of [`run_power_flow`]: assemble the configured outer
465/// loops, run them to a fixed point, and gather their reports.
466///
467/// With no loop configured this is one ordinary
468/// [`PersistentSolver::solve_with_stats`] call and the report's `outer` field
469/// stays `None` — the path every existing caller takes, unchanged in what it
470/// computes.
471#[allow(clippy::too_many_arguments)]
472#[allow(clippy::too_many_arguments)]
473fn newton_with_loops(
474 mut buses: Vec<Bus>,
475 lines: &[Line],
476 transformers: &[Transformer],
477 shunts: &[ShuntAdm],
478 taps: TapData<'_>,
479 remote: RemoteControlData<'_>,
480 mut ybus: network::YBusSparse,
481 opts: PowerFlowOptions,
482) -> PowerFlowReport {
483 let wants_taps = opts.control_taps && !taps.is_empty();
484 let wants_remote = opts.control_remote_voltage && !remote.is_empty();
485 if !opts.enforce_q_limits
486 && opts.distribute_slack.is_none()
487 && opts.area_interchange.is_none()
488 && !wants_taps
489 && !wants_remote
490 {
491 let mut solver = PersistentSolver::new(opts.backend);
492 let (islands, stats) = solver.solve_with_stats(&mut buses, &ybus, opts.tol, opts.max_iter);
493 return PowerFlowReport {
494 buses,
495 islands,
496 stats,
497 dc: None,
498 linear: None,
499 outer: None,
500 };
501 }
502
503 // Both would move the same schedules, so this is a caller mistake rather
504 // than a combination to resolve. Refusing beats picking one silently.
505 assert!(
506 !(opts.distribute_slack.is_some() && opts.area_interchange.is_some()),
507 "distribute_slack and area_interchange cannot both be set: area interchange subsumes \
508 distributed slack (one area with a zero target *is* distributed slack), so running both \
509 would dispatch the same generators twice"
510 );
511
512 // Owned copies, because a tap loop mutates them and the caller handed over
513 // shared slices. Returned on the report so the moved positions are not
514 // lost — a tap position is an answer here, not an input.
515 let mut transformers = transformers.to_vec();
516 let mut changers = taps.changers.to_vec();
517
518 let mut slack = opts
519 .distribute_slack
520 .clone()
521 .map(outerloop::DistributedSlack::new);
522 let mut area = opts
523 .area_interchange
524 .clone()
525 .map(outerloop::AreaInterchange::new);
526 let mut qlim = opts.enforce_q_limits.then(outerloop::ReactiveLimits::new);
527 let mut remote_loop =
528 wants_remote.then(|| outerloop::RemoteVoltageControl::new(remote.machines));
529 let mut phase =
530 wants_taps.then(|| outerloop::PhaseControl::new().max_tap_shift(opts.tap_max_shift));
531 let mut voltage = wants_taps
532 .then(|| outerloop::TransformerVoltageControl::new().max_tap_shift(opts.tap_max_shift));
533
534 let (islands, report) = {
535 let mut ctx = outerloop::SolveContext::new(&mut buses, &mut ybus)
536 .with_branches(lines, &mut transformers, shunts)
537 .with_taps(&mut changers, taps.regulation);
538
539 // Innermost first: the active-power balance, then reactive limits,
540 // then the tap controls. Area interchange stands in distributed
541 // slack's place rather than beside it — it generalizes it, and running
542 // both would move the same schedules twice.
543 let mut list: Vec<&mut dyn outerloop::OuterLoop> = Vec::new();
544 // Innermost of all: a remote controller decides *where* the reactive
545 // power is produced, which every loop after it reads. Running it inside
546 // reactive limits also means a machine that saturates is seen by
547 // `ReactiveLimits` as the `PV` bus it now is, rather than as a bus it
548 // was never connected to.
549 if let Some(l) = remote_loop.as_mut() {
550 list.push(l);
551 }
552 if let Some(l) = area.as_mut() {
553 list.push(l);
554 }
555 if let Some(l) = slack.as_mut() {
556 list.push(l);
557 }
558 if let Some(l) = qlim.as_mut() {
559 list.push(l);
560 }
561 if let Some(l) = phase.as_mut() {
562 list.push(l);
563 }
564 if let Some(l) = voltage.as_mut() {
565 list.push(l);
566 }
567 outerloop::solve_with_loops(
568 &mut ctx,
569 opts.tol,
570 opts.max_iter,
571 opts.backend,
572 &mut list,
573 opts.max_outer_iter,
574 )
575 };
576
577 let mut outcome = OuterLoopOutcome {
578 report: Some(report),
579 q_limit_switches: qlim
580 .as_ref()
581 .map(|l| l.switches().to_vec())
582 .unwrap_or_default(),
583 slack: slack.map(outerloop::DistributedSlack::into_report),
584 area: area.map(outerloop::AreaInterchange::into_report),
585 taps: Vec::new(),
586 remote: remote_loop
587 .as_ref()
588 .map(|l| l.report_against(&buses))
589 .unwrap_or_default(),
590 retyped: remote_loop
591 .as_ref()
592 .map(|l| l.retyped().to_vec())
593 .unwrap_or_default(),
594 transformers: Vec::new(),
595 changers: Vec::new(),
596 };
597 if let Some(l) = voltage.as_ref() {
598 outcome
599 .taps
600 .extend(l.report(taps.regulation, &changers).controllers);
601 }
602 if let Some(l) = phase.as_ref() {
603 outcome
604 .taps
605 .extend(l.report(taps.regulation, &changers).controllers);
606 }
607 if wants_taps {
608 outcome.transformers = transformers;
609 outcome.changers = changers;
610 }
611
612 // The outer loops re-solve through `PersistentSolver::solve`, which does
613 // not hand back `SolveStats`; the status is reconstructed from the island
614 // reports, which is the same verdict by a different route.
615 let status = if islands.iter().all(|i| {
616 !matches!(
617 i.status,
618 solver::IslandStatus::Singular | solver::IslandStatus::MaxIterationsReached
619 )
620 }) {
621 SolveStatus::Converged
622 } else if islands
623 .iter()
624 .any(|i| i.status == solver::IslandStatus::Singular)
625 {
626 SolveStatus::Singular
627 } else {
628 SolveStatus::MaxIterationsReached
629 };
630
631 PowerFlowReport {
632 buses,
633 islands,
634 stats: SolveStats {
635 status,
636 mismatch_history: Vec::new(),
637 },
638 dc: None,
639 linear: None,
640 outer: Some(outcome),
641 }
642}
643
644fn finished_ybus(
645 n: usize,
646 lines: &[Line],
647 transformers: &[Transformer],
648 shunts: &[ShuntAdm],
649) -> network::YBusSparse {
650 let mut ybus = build_ybus(n, lines, transformers);
651 stamp_shunts(&mut ybus, shunts);
652 ybus.finish()
653}
654
655/// Runs a power flow analysis given a pre-built Y-bus matrix.
656/// Intended for the 3-phase case too, where `buses` is a 3N-element vector
657/// and `ybus` is the 3N×3N phase-domain admittance matrix from
658/// `build_ybus_3ph` — a physical bus's 3 phase-rows always share the same
659/// `BusType`, so the island partitioning `PersistentSolver::solve` does
660/// internally is safe for that case too (worst case, in a network with
661/// perfectly balanced zero/positive-sequence impedances, phases may
662/// partition into separate components rather than staying grouped by
663/// physical bus; each still carries its own valid reference, so this
664/// doesn't change correctness, only granularity).
665///
666/// Every disconnected component of `ybus` is solved in this same call (not
667/// just the largest one) — see [`solver::PersistentSolver::solve`], the one
668/// canonical solve entry point every public function in this crate
669/// (including this one) ultimately goes through. `PowerFlowReport::islands`
670/// gives the resulting per-component breakdown.
671pub fn run_power_flow_analysis_from_ybus(mut buses: Vec<Bus>, ybus: YBus) -> PowerFlowReport {
672 let ybus = ybus.finish();
673 linear_initial_guess(&mut buses, &ybus);
674 let mut solver = PersistentSolver::new(JacobianBackend::default());
675 let (islands, stats) = solver.solve_with_stats(&mut buses, &ybus, 1e-6, 20);
676 PowerFlowReport {
677 buses,
678 islands,
679 stats,
680 dc: None,
681 linear: None,
682 outer: None,
683 }
684}