Skip to main content

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}