Skip to main content

powerio/
network.rs

1//! Format-neutral network model: the hub every converter meets at.
2//!
3//! Readers map their format into a [`Network`]; writers map a `Network` back out.
4//! It is the one canonical data model: format-neutral tables with loads and
5//! shunts first-class, so a format that carries several loads per bus (PSS/E,
6//! PowerModels) maps without losing them, while MATPOWER (which folds demand and
7//! shunts onto the bus row) splits them out on read. The dense-indexed analysis
8//! view the matrix builders consume is [`IndexedNetwork`](crate::IndexedNetwork),
9//! derived from a `Network`. Two things make conversion honest:
10//!
11//! - **Retained source.** A `Network` keeps the raw text it was read from plus
12//!   its [`SourceFormat`], so writing back to the *same* format echoes it
13//!   byte-for-byte (no round-trip drift).
14//! - **Extras passthrough.** Every element carries an [`Extras`] map of
15//!   source-format fields the neutral model doesn't name, so X→`Network`→X keeps
16//!   them and a cross-format writer can pass through what its target understands.
17//!
18//! Fully lossless any-to-any isn't possible (formats model different things);
19//! the guarantee is byte-exact same-format and maximal-fidelity cross-format with
20//! the writer reporting whatever it can't represent.
21
22use std::collections::BTreeMap;
23use std::sync::Arc;
24
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27
28use crate::Error;
29
30/// Source-format fields the neutral model doesn't name, kept for round-trip and
31/// cross-format passthrough. Keys are the field names; values are JSON scalars.
32pub type Extras = BTreeMap<String, Value>;
33
34/// System base frequency in hertz when a format records none. Power networks run
35/// at 50 or 60 Hz; 60 is the default for the formats (MATPOWER, PowerModels,
36/// egret) that carry no frequency field.
37pub const DEFAULT_BASE_FREQUENCY: f64 = 60.0;
38
39/// serde default for [`Network::base_frequency`], so JSON written before the
40/// field existed still deserializes (the C ABI and Julia bridge ride on the JSON
41/// transport).
42fn default_base_frequency() -> f64 {
43    DEFAULT_BASE_FREQUENCY
44}
45
46/// A bus identifier as it appears in the source file: the external, stable id
47/// (1-based in MATPOWER, and possibly sparse; pegase has gaps in its ids).
48/// Distinct from the dense `[0, n)` analysis index, which only
49/// [`IndexedNetwork`](crate::IndexedNetwork) produces, via
50/// [`bus_index`](crate::IndexedNetwork::bus_index). The two are both integers
51/// and trivially confused; making the id its own type stops one being used where
52/// the other is meant (using a 1-based id to index a matrix is off-by-one on a
53/// contiguous case and pure garbage on a sparse one).
54///
55/// `#[serde(transparent)]` so the JSON transport carries a bare integer, not a
56/// wrapper object; the wire format is unchanged.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
58#[serde(transparent)]
59pub struct BusId(pub usize);
60
61impl BusId {
62    #[must_use]
63    pub const fn new(id: usize) -> Self {
64        Self(id)
65    }
66}
67
68impl std::fmt::Display for BusId {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        self.0.fmt(f)
71    }
72}
73
74/// Bus type per MATPOWER convention: 1=PQ, 2=PV, 3=ref/slack, 4=isolated.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "UPPERCASE")]
77#[repr(u8)]
78#[non_exhaustive]
79pub enum BusType {
80    Pq = 1,
81    Pv = 2,
82    Ref = 3,
83    Isolated = 4,
84}
85
86impl BusType {
87    /// Map a MATPOWER bus-type code to the enum; unknown codes fall back to PQ.
88    pub(crate) fn from_f64(v: f64) -> Self {
89        match v as i32 {
90            2 => Self::Pv,
91            3 => Self::Ref,
92            4 => Self::Isolated,
93            _ => Self::Pq,
94        }
95    }
96
97    /// The canonical short name (`"PQ"`, `"PV"`, `"REF"`, `"ISOLATED"`), shared
98    /// by the bindings so their bus-type strings can't drift.
99    #[must_use]
100    pub fn as_str(self) -> &'static str {
101        match self {
102            Self::Pq => "PQ",
103            Self::Pv => "PV",
104            Self::Ref => "REF",
105            Self::Isolated => "ISOLATED",
106        }
107    }
108}
109
110/// A generator cost curve (`mpc.gencost` row).
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[non_exhaustive]
113pub struct GenCost {
114    /// 1 = piecewise linear, 2 = polynomial.
115    pub model: u8,
116    pub startup: f64,
117    pub shutdown: f64,
118    /// Number of cost coefficients (polynomial) or breakpoints (piecewise).
119    pub ncost: usize,
120    /// Raw coefficients, highest order first for the polynomial model:
121    /// `[c_{k-1}, …, c1, c0]`.
122    pub coeffs: Vec<f64>,
123}
124
125impl GenCost {
126    /// Build a cost row from the values carried after `ncost`.
127    ///
128    /// Polynomial rows (`model == 2`) store `ncost` coefficients. Piecewise
129    /// linear rows (`model == 1`) store flattened `(x, y)` breakpoint pairs, so
130    /// `ncost` is half the coefficient count. Use [`GenCost::with_ncost`] for
131    /// malformed source rows or callers that need to preserve an explicit
132    /// `ncost`.
133    #[must_use]
134    pub fn new(model: u8, startup: f64, shutdown: f64, coeffs: Vec<f64>) -> Self {
135        let ncost = if model == 1 {
136            coeffs.len() / 2
137        } else {
138            coeffs.len()
139        };
140        Self {
141            model,
142            startup,
143            shutdown,
144            ncost,
145            coeffs,
146        }
147    }
148
149    #[must_use]
150    pub fn with_ncost(
151        model: u8,
152        startup: f64,
153        shutdown: f64,
154        ncost: usize,
155        coeffs: Vec<f64>,
156    ) -> Self {
157        Self {
158            model,
159            startup,
160            shutdown,
161            ncost,
162            coeffs,
163        }
164    }
165
166    /// `(q, c)` for the quadratic cost `½ q p² + c p` from a polynomial
167    /// (model 2) row. MATPOWER stores `c2 p² + c1 p + c0`, so `q = 2·c2` and
168    /// `c = c1`. Linear rows (`ncost == 2`) give `q = 0`. Piecewise (model 1)
169    /// or cubic and higher return `None`.
170    pub fn quadratic(&self) -> Option<(f64, f64)> {
171        if self.model != 2 {
172            return None;
173        }
174        // Reject a row whose coefficient slice is shorter than `ncost` claims,
175        // rather than reading the wrong powers by position.
176        if self.coeffs.len() < self.ncost {
177            return None;
178        }
179        match self.ncost {
180            3 => Some((2.0 * self.coeffs[0], self.coeffs[1])),
181            2 => Some((0.0, self.coeffs[0])),
182            1 => Some((0.0, 0.0)),
183            _ => None,
184        }
185    }
186}
187
188/// Which format a [`Network`] was read from. Drives the same format byte exact
189/// echo on write.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[non_exhaustive]
192pub enum SourceFormat {
193    Matpower,
194    PowerModelsJson,
195    EgretJson,
196    Psse,
197    PowerWorld,
198    PandapowerJson,
199    /// Read from a GE PSLF `.epc` case. Same source text is retained, so a
200    /// same-format write echoes it byte-for-byte; a cross-format or
201    /// source-dropped write goes through the `.epc` serializer
202    /// ([`write_pslf`](crate::write_pslf)).
203    Pslf,
204    /// Read from a PowerWorld `.pwb` binary case. Read only: there is no
205    /// `.pwb` writer and no retained source text, so writing goes through
206    /// another format's writer.
207    PowerWorldBinary,
208    /// Built in memory, for example from synth or an edited case; no source text.
209    InMemory,
210    /// A normalized derived form ([`Network::to_normalized`]): per unit, radians,
211    /// filtered, source bus ids preserved. Distinct from
212    /// [`InMemory`](SourceFormat::InMemory) so consumers can tell a per unit
213    /// product from a raw in memory network; it has no source text and a different
214    /// unit basis than a parsed network.
215    Normalized,
216    /// Read back from a gridfm-datakit Parquet dataset (the ML→classical bridge,
217    /// `powerio-matrix`'s `read_gridfm_dataset`). A lossy, power flow complete
218    /// reconstruction with no retained source text: original bus ids are
219    /// synthesized `1..n`, per element load/shunt granularity is folded to one
220    /// synthetic element per bus, and HVDC/storage/piecewise costs are absent.
221    Gridfm,
222    /// Read from a PyPSA CSV folder. This is a folder format rather than a
223    /// single retained text document, so same-format writes are canonicalized.
224    PypsaCsv,
225    /// Read from an ARPA-E GO Challenge 3 JSON input document. The source is a
226    /// unit commitment data set; the neutral transmission model keeps a static
227    /// first interval network and retains the source text for the full data.
228    Goc3Json,
229    /// Read from a Surge native JSON document.
230    SurgeJson,
231}
232
233impl SourceFormat {
234    /// Stable lowercase token for provenance and reporting (package origin,
235    /// CLI summaries, Python bindings). The match is exhaustive here so a new
236    /// variant fails compilation at the one mapping instead of silently
237    /// reporting "unknown" from a downstream wildcard copy.
238    #[must_use]
239    pub fn name(self) -> &'static str {
240        match self {
241            SourceFormat::Matpower => "matpower",
242            SourceFormat::PowerModelsJson => "powermodels-json",
243            SourceFormat::EgretJson => "egret-json",
244            SourceFormat::Psse => "psse",
245            SourceFormat::PowerWorld => "powerworld",
246            SourceFormat::PandapowerJson => "pandapower-json",
247            SourceFormat::Pslf => "pslf",
248            SourceFormat::PowerWorldBinary => "powerworld-pwb",
249            SourceFormat::InMemory => "in-memory",
250            SourceFormat::Normalized => "normalized",
251            SourceFormat::Gridfm => "gridfm",
252            SourceFormat::PypsaCsv => "pypsa-csv",
253            SourceFormat::Goc3Json => "goc3-json",
254            SourceFormat::SurgeJson => "surge-json",
255        }
256    }
257}
258
259/// A format-neutral power network.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[non_exhaustive]
262pub struct Network {
263    pub name: String,
264    pub base_mva: f64,
265    /// System base frequency in hertz (50 or 60). Threaded through the formats
266    /// that record it (PSS/E `BASFRQ`, pandapower `f_hz`) and defaulted to
267    /// [`DEFAULT_BASE_FREQUENCY`] for the rest. Load-bearing for any
268    /// reactance↔henry conversion (pandapower line charging) and reported as a
269    /// fidelity loss when a non-default value writes to a format with no
270    /// frequency field.
271    #[serde(default = "default_base_frequency")]
272    pub base_frequency: f64,
273    pub buses: Vec<Bus>,
274    pub loads: Vec<Load>,
275    pub shunts: Vec<Shunt>,
276    pub branches: Vec<Branch>,
277    #[serde(default)]
278    pub switches: Vec<Switch>,
279    pub generators: Vec<Generator>,
280    pub storage: Vec<Storage>,
281    pub hvdc: Vec<Hvdc>,
282    /// Three-winding transformers, kept as typed records rather than folded into
283    /// `branches`, so a star point and the per-winding data survive a round trip.
284    /// `#[serde(default)]` so JSON written before the field existed still
285    /// deserializes. [`IndexedNetwork`](crate::IndexedNetwork) lowers each
286    /// in-service record into a star bus plus three branches (via
287    /// [`Transformer3W::star_expansion`]) before building any matrix, so a
288    /// 3-winding transformer does appear in `Y_bus`/connectivity; the canonical
289    /// model keeps the typed record for round-trip fidelity.
290    #[serde(default)]
291    pub transformers_3w: Vec<Transformer3W>,
292    /// Area records: scheduled interchange and per-area swing bus. Distinct from
293    /// the bare `area` number on each [`Bus`]; this is the area's metadata, which
294    /// every conversion dropped before. `#[serde(default)]` so older JSON still
295    /// deserializes.
296    #[serde(default)]
297    pub areas: Vec<Area>,
298    /// Solver / solution-control metadata when the source carries it, else `None`.
299    /// `#[serde(default)]` so older JSON still deserializes.
300    #[serde(default)]
301    pub solver: Option<SolverParams>,
302    pub source_format: SourceFormat,
303    /// Raw source text, when read from a textual format; enables a byte-exact
304    /// same-format round-trip. `Arc<String>` (not `Arc<str>`) is deliberate: a
305    /// reader that already owns the buffer (the MATPOWER file path) moves it in
306    /// with no second copy of the whole file. The trade is one extra indirection
307    /// per access; don't "simplify" it back to `Arc<str>`, which would reintroduce
308    /// the copy this avoids.
309    ///
310    /// Skipped in JSON: the structured tables are the transport, not the raw
311    /// echo, and skipping also keeps serde's `rc` feature out of the build. A
312    /// `from_json` round-trip returns this as `None`.
313    #[serde(skip)]
314    pub source: Option<Arc<String>>,
315}
316
317/// v1-facing name for the canonical scalar positive sequence model.
318pub type BalancedNetwork = Network;
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
321#[non_exhaustive]
322pub struct Bus {
323    /// Stable bus id (1-based in MATPOWER; preserved verbatim).
324    pub id: BusId,
325    pub kind: BusType,
326    /// Voltage magnitude (p.u.).
327    pub vm: f64,
328    /// Voltage angle (degrees).
329    pub va: f64,
330    pub base_kv: f64,
331    pub vmax: f64,
332    pub vmin: f64,
333    /// Emergency (short-term) voltage band, set only when the source states one
334    /// distinct from the normal [`vmax`](Bus::vmax)/[`vmin`](Bus::vmin) band (PSS/E
335    /// `EVHI`/`EVLO`). `None` means the emergency band equals the normal band, so
336    /// read `evhi.unwrap_or(vmax)` / `evlo.unwrap_or(vmin)`. `#[serde(default)]` so
337    /// JSON written before the fields existed still deserializes.
338    #[serde(default)]
339    pub evhi: Option<f64>,
340    #[serde(default)]
341    pub evlo: Option<f64>,
342    pub area: usize,
343    pub zone: usize,
344    pub name: Option<String>,
345    /// Stable row identity for `.pio.json` payloads and operating point updates:
346    /// the source record uid where the format defines one (GOC3), synthesized at
347    /// package build otherwise. `#[serde(default)]` so JSON written before the
348    /// field existed still deserializes.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub uid: Option<String>,
351    pub extras: Extras,
352}
353
354impl Bus {
355    #[must_use]
356    pub fn new(id: BusId, kind: BusType, base_kv: f64) -> Self {
357        Self {
358            id,
359            kind,
360            vm: 1.0,
361            va: 0.0,
362            base_kv,
363            vmax: 1.1,
364            vmin: 0.9,
365            evhi: None,
366            evlo: None,
367            area: 1,
368            zone: 1,
369            name: None,
370            uid: None,
371            extras: Extras::new(),
372        }
373    }
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize)]
377#[non_exhaustive]
378pub struct Load {
379    pub bus: BusId,
380    /// Active demand (MW).
381    pub p: f64,
382    /// Reactive demand (MVAr).
383    pub q: f64,
384    /// Voltage dependence, when the source states one. `None` is constant power.
385    #[serde(default)]
386    pub voltage_model: Option<LoadVoltageModel>,
387    pub in_service: bool,
388    /// Stable row identity; see [`Bus::uid`].
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub uid: Option<String>,
391    pub extras: Extras,
392}
393
394impl Load {
395    #[must_use]
396    pub fn new(bus: BusId, p: f64, q: f64) -> Self {
397        Self {
398            bus,
399            p,
400            q,
401            voltage_model: None,
402            in_service: true,
403            uid: None,
404            extras: Extras::new(),
405        }
406    }
407}
408
409/// Voltage dependence for a transmission load.
410#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
411#[serde(tag = "kind", rename_all = "snake_case")]
412#[non_exhaustive]
413pub enum LoadVoltageModel {
414    /// Explicit constant power marker.
415    ConstantPower,
416    /// ZIP load split in source units. The three active parts sum to
417    /// [`Load::p`], and the three reactive parts sum to [`Load::q`].
418    Zip {
419        p_constant_power: f64,
420        q_constant_power: f64,
421        p_constant_current: f64,
422        q_constant_current: f64,
423        p_constant_impedance: f64,
424        q_constant_impedance: f64,
425        #[serde(default)]
426        v_nom: Option<f64>,
427        /// Source load type code, when a format has one (PSS/E `ID`/`LOADTYPE`
428        /// style metadata).
429        #[serde(default)]
430        load_type: Option<i32>,
431        /// Source scaling factor, when a format has one.
432        #[serde(default)]
433        scaling: Option<f64>,
434    },
435    /// Exponential voltage model: `P = p * (V / v_nom)^gamma_p`,
436    /// `Q = q * (V / v_nom)^gamma_q`.
437    Exponential {
438        p: f64,
439        q: f64,
440        #[serde(default)]
441        v_nom: Option<f64>,
442        gamma_p: f64,
443        gamma_q: f64,
444    },
445}
446
447impl LoadVoltageModel {
448    #[must_use]
449    pub fn has_non_matpower_fields(&self) -> bool {
450        match self {
451            Self::ConstantPower => false,
452            Self::Zip {
453                p_constant_current,
454                q_constant_current,
455                p_constant_impedance,
456                q_constant_impedance,
457                v_nom,
458                load_type,
459                scaling,
460                ..
461            } => {
462                *p_constant_current != 0.0
463                    || *q_constant_current != 0.0
464                    || *p_constant_impedance != 0.0
465                    || *q_constant_impedance != 0.0
466                    || v_nom.is_some()
467                    || load_type.is_some()
468                    || scaling.is_some()
469            }
470            Self::Exponential { .. } => true,
471        }
472    }
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
476#[non_exhaustive]
477pub struct Shunt {
478    pub bus: BusId,
479    /// Shunt conductance (MW at V = 1 p.u.).
480    pub g: f64,
481    /// Shunt susceptance (MVAr at V = 1 p.u.). For a switched shunt this is the
482    /// initial (steady-state) value within the [`control`](Shunt::control) blocks.
483    pub b: f64,
484    pub in_service: bool,
485    /// Switching-control data when this is a switched (adjustable) shunt; `None`
486    /// for a fixed shunt. `#[serde(default)]` so JSON written before the field
487    /// existed still deserializes.
488    #[serde(default)]
489    pub control: Option<SwitchedShuntControl>,
490    /// Stable row identity; see [`Bus::uid`].
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub uid: Option<String>,
493    pub extras: Extras,
494}
495
496impl Shunt {
497    #[must_use]
498    pub fn new(bus: BusId, g: f64, b: f64) -> Self {
499        Self {
500            bus,
501            g,
502            b,
503            in_service: true,
504            control: None,
505            uid: None,
506            extras: Extras::new(),
507        }
508    }
509}
510
511/// How a switched shunt adjusts its susceptance. Maps to the PSS/E `MODSW` code.
512#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
513#[serde(rename_all = "snake_case")]
514#[non_exhaustive]
515pub enum SwitchedShuntMode {
516    /// Fixed at its initial susceptance, no automatic switching (`MODSW` 0).
517    Locked,
518    /// Continuous adjustment within the block range (`MODSW` 1).
519    Continuous,
520    /// Discrete adjustment in fixed steps (`MODSW` 2 and up).
521    Discrete,
522}
523
524/// One block of a switched shunt: `steps` equal increments of susceptance `b`.
525#[derive(Debug, Clone, Serialize, Deserialize)]
526#[non_exhaustive]
527pub struct ShuntBlock {
528    pub steps: u32,
529    /// Susceptance increment per step (MVAr at V = 1 p.u.).
530    pub b: f64,
531}
532
533impl ShuntBlock {
534    #[must_use]
535    pub const fn new(steps: u32, b: f64) -> Self {
536        Self { steps, b }
537    }
538}
539
540/// Switching-control data for a switched shunt ([`Shunt::control`]): the mode,
541/// the regulated voltage band and bus, the reactive-range percentage, and the
542/// adjustable susceptance blocks. The shunt's [`b`](Shunt::b) is the initial
543/// value within the blocks' total range.
544#[derive(Debug, Clone, Serialize, Deserialize)]
545#[non_exhaustive]
546pub struct SwitchedShuntControl {
547    pub mode: SwitchedShuntMode,
548    /// Regulated voltage band (per unit).
549    pub vhigh: f64,
550    pub vlow: f64,
551    /// The regulated bus; `None` means the shunt regulates its own bus.
552    pub control_bus: Option<BusId>,
553    /// Percent of the controlled device's reactive range to apply (PSS/E `RMPCT`).
554    pub rmpct: f64,
555    pub blocks: Vec<ShuntBlock>,
556}
557
558impl SwitchedShuntControl {
559    #[must_use]
560    pub fn new(mode: SwitchedShuntMode, vhigh: f64, vlow: f64, blocks: Vec<ShuntBlock>) -> Self {
561        Self {
562            mode,
563            vhigh,
564            vlow,
565            control_bus: None,
566            rmpct: 100.0,
567            blocks,
568        }
569    }
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize)]
573#[non_exhaustive]
574pub struct Branch {
575    pub from: BusId,
576    pub to: BusId,
577    /// Series resistance (p.u.).
578    pub r: f64,
579    /// Series reactance (p.u.).
580    pub x: f64,
581    /// MATPOWER compatible total line charging susceptance (p.u.). This is the
582    /// legacy total projection; when [`charging`](Branch::charging) is present,
583    /// per terminal admittance is canonical and this field is compatibility data.
584    pub b: f64,
585    /// Per terminal shunt admittance (p.u.). If absent, derive symmetric
586    /// susceptance from [`b`](Branch::b).
587    #[serde(default)]
588    pub charging: Option<BranchCharging>,
589    pub rate_a: f64,
590    pub rate_b: f64,
591    pub rate_c: f64,
592    /// Additional MVA rating sets beyond A/B/C. Matrix builders continue to use
593    /// `rate_a` unless they opt into one of these named sets.
594    #[serde(default)]
595    pub rating_sets: Vec<BranchRatingSet>,
596    /// Current ratings, when the source distinguishes them from MVA ratings.
597    #[serde(default)]
598    pub current_ratings: Option<BranchCurrentRatings>,
599    /// Tap ratio, MATPOWER convention: 0 means "no tap" (a line), treated as 1.
600    pub tap: f64,
601    /// Phase shift (degrees).
602    pub shift: f64,
603    pub in_service: bool,
604    pub angmin: f64,
605    pub angmax: f64,
606    /// Regulating-transformer control data, when this branch is a transformer
607    /// under automatic tap or phase control. `None` for lines and for fixed-ratio
608    /// transformers. `#[serde(default)]` so JSON written before the field existed
609    /// still deserializes.
610    #[serde(default)]
611    pub control: Option<TransformerControl>,
612    /// Solved branch flow values, when present in a case snapshot.
613    #[serde(default)]
614    pub solution: Option<BranchSolution>,
615    /// Stable row identity; see [`Bus::uid`].
616    #[serde(default, skip_serializing_if = "Option::is_none")]
617    pub uid: Option<String>,
618    pub extras: Extras,
619}
620
621/// Extra branch MVA rating set beyond the canonical A/B/C columns.
622#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
623#[non_exhaustive]
624pub struct BranchRatingSet {
625    pub name: String,
626    pub rate_mva: f64,
627}
628
629impl BranchRatingSet {
630    #[must_use]
631    pub fn new(name: impl Into<String>, rate_mva: f64) -> Self {
632        Self {
633            name: name.into(),
634            rate_mva,
635        }
636    }
637}
638
639/// Per terminal branch shunt admittance in p.u. This is the canonical
640/// physical branch shunt model when present.
641#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
642#[non_exhaustive]
643pub struct BranchCharging {
644    pub g_fr: f64,
645    pub b_fr: f64,
646    pub g_to: f64,
647    pub b_to: f64,
648}
649
650impl BranchCharging {
651    #[must_use]
652    pub const fn new(g_fr: f64, b_fr: f64, g_to: f64, b_to: f64) -> Self {
653        Self {
654            g_fr,
655            b_fr,
656            g_to,
657            b_to,
658        }
659    }
660
661    #[must_use]
662    pub fn from_total_b(b: f64) -> Self {
663        Self {
664            g_fr: 0.0,
665            b_fr: b / 2.0,
666            g_to: 0.0,
667            b_to: b / 2.0,
668        }
669    }
670
671    #[must_use]
672    pub fn total_b(self) -> f64 {
673        self.b_fr + self.b_to
674    }
675
676    #[must_use]
677    pub fn total_g(self) -> f64 {
678        self.g_fr + self.g_to
679    }
680
681    #[must_use]
682    pub fn is_matpower_symmetric(self) -> bool {
683        self.g_fr.abs() <= f64::EPSILON
684            && self.g_to.abs() <= f64::EPSILON
685            && (self.b_fr - self.b_to).abs() <= f64::EPSILON
686    }
687}
688
689/// Current limits for a branch, in source units.
690#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
691#[non_exhaustive]
692pub struct BranchCurrentRatings {
693    pub c_rating_a: f64,
694    pub c_rating_b: f64,
695    pub c_rating_c: f64,
696}
697
698impl BranchCurrentRatings {
699    #[must_use]
700    pub const fn new(c_rating_a: f64, c_rating_b: f64, c_rating_c: f64) -> Self {
701        Self {
702            c_rating_a,
703            c_rating_b,
704            c_rating_c,
705        }
706    }
707}
708
709/// Solved branch terminal flows in MW/MVAr.
710#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
711#[non_exhaustive]
712pub struct BranchSolution {
713    pub pf: f64,
714    pub qf: f64,
715    pub pt: f64,
716    pub qt: f64,
717}
718
719impl BranchSolution {
720    #[must_use]
721    pub const fn new(pf: f64, qf: f64, pt: f64, qt: f64) -> Self {
722        Self { pf, qf, pt, qt }
723    }
724}
725
726impl Branch {
727    #[must_use]
728    pub fn new(from: BusId, to: BusId, r: f64, x: f64) -> Self {
729        Self {
730            from,
731            to,
732            r,
733            x,
734            b: 0.0,
735            charging: None,
736            rate_a: 0.0,
737            rate_b: 0.0,
738            rate_c: 0.0,
739            rating_sets: Vec::new(),
740            current_ratings: None,
741            tap: 0.0,
742            shift: 0.0,
743            in_service: true,
744            angmin: -360.0,
745            angmax: 360.0,
746            control: None,
747            solution: None,
748            uid: None,
749            extras: Extras::new(),
750        }
751    }
752
753    /// Effective tap ratio (0 ⇒ 1).
754    #[must_use]
755    pub fn effective_tap(&self) -> f64 {
756        if self.tap == 0.0 { 1.0 } else { self.tap }
757    }
758
759    /// Per terminal shunt admittance, deriving the legacy symmetric MATPOWER
760    /// charging model when the richer field is absent.
761    #[must_use]
762    pub fn terminal_charging(&self) -> BranchCharging {
763        self.charging
764            .unwrap_or_else(|| BranchCharging::from_total_b(self.b))
765    }
766
767    /// Total susceptance projection for MATPOWER shaped formats that only carry
768    /// one line charging value.
769    #[must_use]
770    pub fn legacy_total_charging_b(&self) -> f64 {
771        self.terminal_charging().total_b()
772    }
773
774    /// Whether this branch has charging that a MATPOWER branch row cannot carry.
775    #[must_use]
776    pub fn has_non_matpower_charging(&self) -> bool {
777        self.charging
778            .is_some_and(|charging| !charging.is_matpower_symmetric())
779    }
780
781    /// A transformer iff the raw tap field is nonzero (an explicit `1` counts) or
782    /// there is a phase shift.
783    #[must_use]
784    pub fn is_transformer(&self) -> bool {
785        self.tap != 0.0 || self.shift != 0.0
786    }
787
788    /// True when the branch constrains its angle difference, i.e. the limits
789    /// deviate from the ±360° "unconstrained" default. Formats without angle
790    /// limit fields (PSS/E, PowerWorld) use this to warn on what they drop.
791    #[must_use]
792    pub fn has_angle_limits(&self) -> bool {
793        self.angmin > -360.0 || self.angmax < 360.0
794    }
795}
796
797/// A transmission switch. Closed switches are preserved as data; matrix builders
798/// do not lower them into zero impedance branches.
799#[derive(Debug, Clone, Serialize, Deserialize)]
800#[non_exhaustive]
801pub struct Switch {
802    pub from: BusId,
803    pub to: BusId,
804    pub closed: bool,
805    #[serde(default)]
806    pub thermal_rating: Option<f64>,
807    #[serde(default)]
808    pub current_rating: Option<f64>,
809    #[serde(default)]
810    pub pf: Option<f64>,
811    #[serde(default)]
812    pub qf: Option<f64>,
813    #[serde(default)]
814    pub pt: Option<f64>,
815    #[serde(default)]
816    pub qt: Option<f64>,
817    /// Stable row identity; see [`Bus::uid`].
818    #[serde(default, skip_serializing_if = "Option::is_none")]
819    pub uid: Option<String>,
820    pub extras: Extras,
821}
822
823impl Switch {
824    #[must_use]
825    pub fn new(from: BusId, to: BusId, closed: bool) -> Self {
826        Self {
827            from,
828            to,
829            closed,
830            thermal_rating: None,
831            current_rating: None,
832            pf: None,
833            qf: None,
834            pt: None,
835            qt: None,
836            uid: None,
837            extras: Extras::new(),
838        }
839    }
840}
841
842/// What a regulating transformer's tap (or phase shift) automatically controls.
843/// Maps to the PSS/E control code `COD` and the PSLF transformer `type`.
844#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
845#[serde(rename_all = "snake_case")]
846#[non_exhaustive]
847pub enum TransformerControlMode {
848    /// Fixed ratio, no automatic adjustment (PSS/E `COD` 0/±4, PSLF type 1).
849    Fixed,
850    /// Bus voltage control via tap (LTC; PSS/E `COD` ±1, PSLF type 2).
851    Voltage,
852    /// Reactive power flow control via tap (PSS/E `COD` ±2).
853    ReactiveFlow,
854    /// Active power flow control via phase shift (PSS/E `COD` ±3, PSLF type 4).
855    ActiveFlow,
856}
857
858/// Automatic-control data for a regulating transformer ([`Branch::control`]).
859///
860/// The limits carry whatever the [`mode`](TransformerControl::mode) regulates:
861/// `tap_min`/`tap_max` bound the tap ratio (or the phase angle, for
862/// [`ActiveFlow`](TransformerControlMode::ActiveFlow)), and `band_min`/`band_max`
863/// bound the controlled quantity (the regulated voltage band, or the
864/// scheduled MW/MVAr). `ntp` is the number of discrete tap positions and
865/// `controlled_bus` is the regulated bus (`None` = the transformer's own
866/// terminal). `mva_base` is the winding MVA base the impedance is referred to.
867#[derive(Debug, Clone, Serialize, Deserialize)]
868#[non_exhaustive]
869pub struct TransformerControl {
870    pub mode: TransformerControlMode,
871    pub controlled_bus: Option<BusId>,
872    pub tap_min: f64,
873    pub tap_max: f64,
874    pub band_min: f64,
875    pub band_max: f64,
876    pub ntp: u32,
877    pub mva_base: f64,
878}
879
880impl Default for TransformerControl {
881    fn default() -> Self {
882        // PSS/E's documented defaults for an unset winding-control block.
883        TransformerControl {
884            mode: TransformerControlMode::Fixed,
885            controlled_bus: None,
886            tap_min: 0.9,
887            tap_max: 1.1,
888            band_min: 0.9,
889            band_max: 1.1,
890            ntp: 33,
891            mva_base: 0.0,
892        }
893    }
894}
895
896impl TransformerControl {
897    #[must_use]
898    pub fn new(mode: TransformerControlMode) -> Self {
899        Self {
900            mode,
901            ..Self::default()
902        }
903    }
904}
905
906#[derive(Debug, Clone, Serialize, Deserialize)]
907#[non_exhaustive]
908pub struct Generator {
909    pub bus: BusId,
910    /// Real power set point (MW).
911    pub pg: f64,
912    /// Reactive power set point (MVAr).
913    pub qg: f64,
914    pub pmax: f64,
915    pub pmin: f64,
916    pub qmax: f64,
917    pub qmin: f64,
918    /// Voltage set point (p.u.).
919    pub vg: f64,
920    pub mbase: f64,
921    pub in_service: bool,
922    pub cost: Option<GenCost>,
923    /// The MATPOWER gen capability / ramp columns past `PMIN`, aligned to
924    /// `GEN_EXTRA_KEYS` by index (`None` for a column the source omitted).
925    /// A fixed array, not an [`Extras`] map: a string-keyed map per generator
926    /// costs 11 heap allocations each, which dominates the parse of a large
927    /// generator-heavy case. Surfaced into formats that name them (PowerModels).
928    /// On the JSON snapshot it is a name-keyed object (see `caps_serde`) so the
929    /// schema stays additive when `GEN_EXTRA_KEYS` grows; `#[serde(default)]` so a
930    /// snapshot that omits it deserializes to the empty set.
931    #[serde(default = "default_caps", with = "caps_serde")]
932    pub caps: GenCaps,
933    /// The remote bus whose voltage this generator regulates, when that is not its
934    /// own terminal bus (PSS/E `IREG`). `None` means it regulates its own bus.
935    /// Part of the cross-element voltage-control graph: a format that names a
936    /// remote regulated bus (PSS/E) keeps it across a round trip instead of
937    /// collapsing every generator onto its own terminal. `#[serde(default)]` so
938    /// JSON written before the field existed still deserializes.
939    #[serde(default)]
940    pub regulated_bus: Option<BusId>,
941    /// Stable row identity; see [`Bus::uid`].
942    #[serde(default, skip_serializing_if = "Option::is_none")]
943    pub uid: Option<String>,
944}
945
946impl Generator {
947    #[must_use]
948    pub fn new(bus: BusId) -> Self {
949        Self {
950            bus,
951            pg: 0.0,
952            qg: 0.0,
953            pmax: 0.0,
954            pmin: 0.0,
955            qmax: 0.0,
956            qmin: 0.0,
957            vg: 1.0,
958            mbase: 0.0,
959            in_service: true,
960            cost: None,
961            caps: default_caps(),
962            regulated_bus: None,
963            uid: None,
964        }
965    }
966
967    /// True when any capability / ramp column is present. Formats without those
968    /// fields (PSS/E, PowerWorld) use this to warn on what they drop.
969    #[must_use]
970    pub fn has_caps(&self) -> bool {
971        self.caps.iter().any(Option::is_some)
972    }
973}
974
975/// A generator's capability / ramp columns, one slot per `GEN_EXTRA_KEYS` name.
976pub type GenCaps = [Option<f64>; GEN_EXTRA_KEYS.len()];
977
978/// The empty capability set, for a JSON snapshot that omits the field entirely.
979fn default_caps() -> GenCaps {
980    [None; GEN_EXTRA_KEYS.len()]
981}
982
983/// Serialize [`GenCaps`] as a name-keyed object (`{"ramp_30": 1.2, ...}`) keyed by
984/// [`GEN_EXTRA_KEYS`], emitting only the present slots, instead of a length-exact
985/// array. A fixed-length array round-trips through serde only at exactly its
986/// current length: the day `GEN_EXTRA_KEYS` grows a column, every old snapshot
987/// fails to deserialize and every new one fails on an old build, and the C ABI
988/// ties the JSON snapshot schema to its version, so that is a forced ABI break.
989/// The named map makes a new key purely additive: an old document simply lacks it
990/// (deserializes to `None`), and an unknown key from a newer document is ignored.
991/// In memory `caps` stays a fixed array, so the per-generator allocation cost the
992/// array avoids is unchanged; only the wire form is named.
993mod caps_serde {
994    use super::{GEN_EXTRA_KEYS, GenCaps};
995    use serde::de::{Deserialize, Deserializer};
996    use serde::ser::{SerializeMap, Serializer};
997    use std::collections::BTreeMap;
998
999    pub(super) fn serialize<S: Serializer>(caps: &GenCaps, s: S) -> Result<S::Ok, S::Error> {
1000        let present = caps.iter().filter(|v| v.is_some()).count();
1001        let mut map = s.serialize_map(Some(present))?;
1002        for (key, slot) in GEN_EXTRA_KEYS.iter().zip(caps.iter()) {
1003            if let Some(value) = slot {
1004                map.serialize_entry(key, value)?;
1005            }
1006        }
1007        map.end()
1008    }
1009
1010    pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<GenCaps, D::Error> {
1011        // Accept an explicit `null` as the empty set (treated like an omitted
1012        // field), so a producer that encodes "no caps" as `null` round-trips the
1013        // same way `cost: Option<_>` does. `#[serde(default)]` only covers an
1014        // absent key, not a present `null`.
1015        let named = Option::<BTreeMap<String, f64>>::deserialize(d)?.unwrap_or_default();
1016        let mut caps: GenCaps = [None; GEN_EXTRA_KEYS.len()];
1017        for (slot, key) in caps.iter_mut().zip(GEN_EXTRA_KEYS.iter()) {
1018            *slot = named.get(*key).copied();
1019        }
1020        Ok(caps)
1021    }
1022}
1023
1024#[derive(Debug, Clone, Serialize, Deserialize)]
1025#[non_exhaustive]
1026pub struct Storage {
1027    pub bus: BusId,
1028    pub ps: f64,
1029    pub qs: f64,
1030    pub energy: f64,
1031    pub energy_rating: f64,
1032    pub charge_rating: f64,
1033    pub discharge_rating: f64,
1034    pub charge_efficiency: f64,
1035    pub discharge_efficiency: f64,
1036    pub thermal_rating: f64,
1037    #[serde(default)]
1038    pub current_rating: Option<f64>,
1039    pub qmin: f64,
1040    pub qmax: f64,
1041    pub r: f64,
1042    pub x: f64,
1043    pub p_loss: f64,
1044    pub q_loss: f64,
1045    pub in_service: bool,
1046    /// Stable row identity; see [`Bus::uid`].
1047    #[serde(default, skip_serializing_if = "Option::is_none")]
1048    pub uid: Option<String>,
1049    pub extras: Extras,
1050}
1051
1052impl Storage {
1053    #[must_use]
1054    pub fn new(bus: BusId) -> Self {
1055        Self {
1056            bus,
1057            ps: 0.0,
1058            qs: 0.0,
1059            energy: 0.0,
1060            energy_rating: 0.0,
1061            charge_rating: 0.0,
1062            discharge_rating: 0.0,
1063            charge_efficiency: 1.0,
1064            discharge_efficiency: 1.0,
1065            thermal_rating: 0.0,
1066            current_rating: None,
1067            qmin: 0.0,
1068            qmax: 0.0,
1069            r: 0.0,
1070            x: 0.0,
1071            p_loss: 0.0,
1072            q_loss: 0.0,
1073            in_service: true,
1074            uid: None,
1075            extras: Extras::new(),
1076        }
1077    }
1078}
1079
1080/// A two-terminal HVDC line (MATPOWER `dcline`).
1081///
1082/// `pf`/`pt`/`qf`/`qt` are stored in MATPOWER's sign convention regardless of
1083/// source: the PowerModels reader un-flips `pt`/`qf`/`qt` on the way in, and the
1084/// PowerModels writer re-flips them on the way out (PowerModels.jl uses the
1085/// opposite sign). The flip is a format-boundary translation, so a derived view
1086/// like `to_normalized` keeps the MATPOWER convention and only scales to per unit.
1087#[derive(Debug, Clone, Serialize, Deserialize)]
1088#[non_exhaustive]
1089pub struct Hvdc {
1090    pub from: BusId,
1091    pub to: BusId,
1092    pub in_service: bool,
1093    pub pf: f64,
1094    pub pt: f64,
1095    pub qf: f64,
1096    pub qt: f64,
1097    pub vf: f64,
1098    pub vt: f64,
1099    pub pmin: f64,
1100    pub pmax: f64,
1101    pub qminf: f64,
1102    pub qmaxf: f64,
1103    pub qmint: f64,
1104    pub qmaxt: f64,
1105    pub loss0: f64,
1106    pub loss1: f64,
1107    #[serde(default)]
1108    pub cost: Option<GenCost>,
1109    /// Stable row identity; see [`Bus::uid`].
1110    #[serde(default, skip_serializing_if = "Option::is_none")]
1111    pub uid: Option<String>,
1112    pub extras: Extras,
1113}
1114
1115impl Hvdc {
1116    #[must_use]
1117    pub fn new(from: BusId, to: BusId) -> Self {
1118        Self {
1119            from,
1120            to,
1121            in_service: true,
1122            pf: 0.0,
1123            pt: 0.0,
1124            qf: 0.0,
1125            qt: 0.0,
1126            vf: 1.0,
1127            vt: 1.0,
1128            pmin: 0.0,
1129            pmax: 0.0,
1130            qminf: 0.0,
1131            qmaxf: 0.0,
1132            qmint: 0.0,
1133            qmaxt: 0.0,
1134            loss0: 0.0,
1135            loss1: 0.0,
1136            cost: None,
1137            uid: None,
1138            extras: Extras::new(),
1139        }
1140    }
1141}
1142
1143/// An area record: the area's scheduled net interchange and its swing bus.
1144///
1145/// The [`number`](Area::number) matches the `area` field carried on each
1146/// [`Bus`]; this table holds the per-area metadata (the interchange target and
1147/// the area slack) that the bus number alone can't. Maps to the PSS/E area record
1148/// (`I, ISW, PDES, PTOL, ARNAME`).
1149#[derive(Debug, Clone, Serialize, Deserialize)]
1150#[non_exhaustive]
1151pub struct Area {
1152    pub number: usize,
1153    /// The area swing (slack) bus, or `None` when unset.
1154    pub slack_bus: Option<BusId>,
1155    /// Scheduled net interchange (MW); positive is export out of the area.
1156    pub net_interchange: f64,
1157    /// Interchange tolerance bandwidth (MW).
1158    pub tolerance: f64,
1159    pub name: Option<String>,
1160}
1161
1162impl Area {
1163    #[must_use]
1164    pub fn new(number: usize) -> Self {
1165        Self {
1166            number,
1167            slack_bus: None,
1168            net_interchange: 0.0,
1169            tolerance: 0.0,
1170            name: None,
1171        }
1172    }
1173}
1174
1175/// Solver / solution-control metadata: the Newton tolerance and iteration cap,
1176/// the zero-impedance threshold, and the per-quantity adjustment-enable flags.
1177///
1178/// Each field is optional because a source states only the ones it carries. No
1179/// power flow physics, but it determines whether a downstream solver reproduces
1180/// the source tool's converged answer. Maps to the PSS/E v34+ system-wide block
1181/// (`GENERAL THRSHZ`, `NEWTON TOLN`/`ITMXN`, `SOLVER ACTAPS`/`AREAIN`/`PHSHFT`/
1182/// `DCTAPS`/`SWSHNT`).
1183#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1184#[non_exhaustive]
1185pub struct SolverParams {
1186    /// Newton power flow mismatch tolerance (`NEWTON TOLN`).
1187    pub newton_tolerance: Option<f64>,
1188    /// Newton iteration cap (`NEWTON ITMXN`).
1189    pub max_iterations: Option<u32>,
1190    /// Branches with `|x|` below this are treated as zero impedance (`GENERAL THRSHZ`).
1191    pub zero_impedance_threshold: Option<f64>,
1192    /// Whether the solver adjusts transformer taps (`SOLVER ACTAPS`).
1193    pub adjust_taps: Option<bool>,
1194    /// Whether the solver adjusts area interchange (`SOLVER AREAIN`).
1195    pub adjust_area_interchange: Option<bool>,
1196    /// Whether the solver adjusts phase-shift angles (`SOLVER PHSHFT`).
1197    pub adjust_phase_shift: Option<bool>,
1198    /// Whether the solver adjusts DC line taps (`SOLVER DCTAPS`).
1199    pub adjust_dc_taps: Option<bool>,
1200    /// Whether the solver adjusts switched shunts (`SOLVER SWSHNT`).
1201    pub adjust_switched_shunt: Option<bool>,
1202}
1203
1204impl SolverParams {
1205    #[must_use]
1206    pub fn new() -> Self {
1207        Self::default()
1208    }
1209
1210    /// True when no field is set (so readers can avoid attaching an empty record).
1211    #[must_use]
1212    pub fn is_empty(&self) -> bool {
1213        *self == SolverParams::default()
1214    }
1215}
1216
1217/// A series impedance with the MVA base it is expressed on. Used pairwise by
1218/// [`Transformer3W`]; a self-contained unit so the base travels with the value
1219/// instead of being implied by position.
1220///
1221/// `r`/`x` are per unit on the *system* base (the same `CZ = 1` convention as
1222/// [`Branch::r`]/[`Branch::x`], so the matrix math needs no rebasing); `base_mva`
1223/// records the winding-pair MVA base the source file declared (PSS/E `SBASE1-2`
1224/// and friends), kept so a write-back reproduces it and so a future `CZ = 2`
1225/// reader has somewhere to put the winding base it must rebase from. Room to grow
1226/// (winding voltage base, turns-ratio units) as the transformer control work
1227/// lands without reshaping the [`Transformer3W::z`] array.
1228#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1229#[non_exhaustive]
1230pub struct Impedance {
1231    pub r: f64,
1232    pub x: f64,
1233    pub base_mva: f64,
1234}
1235
1236impl Impedance {
1237    #[must_use]
1238    pub const fn new(r: f64, x: f64, base_mva: f64) -> Self {
1239        Self { r, x, base_mva }
1240    }
1241}
1242
1243/// One winding of a [`Transformer3W`]: its terminal bus, off-nominal ratio, phase
1244/// shift, nominal voltage, and thermal ratings.
1245#[derive(Debug, Clone, Serialize, Deserialize)]
1246#[non_exhaustive]
1247pub struct Winding {
1248    pub bus: BusId,
1249    /// Off-nominal turns ratio (1.0 = nominal); the PSS/E `WINDV`, `CW = 1`.
1250    pub tap: f64,
1251    /// Phase shift (degrees).
1252    pub shift: f64,
1253    /// Winding nominal voltage (kV); 0 defers to the terminal bus base kV.
1254    pub nominal_kv: f64,
1255    pub rate_a: f64,
1256    pub rate_b: f64,
1257    pub rate_c: f64,
1258}
1259
1260impl Winding {
1261    #[must_use]
1262    pub fn new(bus: BusId) -> Self {
1263        Self {
1264            bus,
1265            tap: 1.0,
1266            shift: 0.0,
1267            nominal_kv: 0.0,
1268            rate_a: 0.0,
1269            rate_b: 0.0,
1270            rate_c: 0.0,
1271        }
1272    }
1273}
1274
1275/// A three-winding transformer: three terminal buses joined at a common star
1276/// point, with the series impedance given pairwise (winding 1-2, 2-3, 3-1).
1277///
1278/// Kept as a typed record (not three [`Branch`]es) so the star-point voltage and
1279/// the per-winding control data survive a same-format round trip. Both the PSS/E
1280/// 3-winding record and the PSLF tertiary-winding record map onto it.
1281/// [`star_expansion`](Transformer3W::star_expansion) turns it into the synthetic
1282/// star bus plus three branches for a consumer that works in the bus-branch model;
1283/// [`IndexedNetwork`](crate::IndexedNetwork) applies it before building any matrix,
1284/// so a 3-winding transformer contributes to `Y_bus` and connectivity.
1285#[derive(Debug, Clone, Serialize, Deserialize)]
1286#[non_exhaustive]
1287pub struct Transformer3W {
1288    /// The three windings, in order (primary, secondary, tertiary).
1289    pub windings: [Winding; 3],
1290    /// Pairwise series impedance `[z12, z23, z31]` (primary-secondary,
1291    /// secondary-tertiary, tertiary-primary), each per unit on the system base
1292    /// with its declared MVA base.
1293    pub z: [Impedance; 3],
1294    /// Star-point voltage magnitude (p.u.) and angle (degrees), as solved.
1295    pub star_vm: f64,
1296    pub star_va: f64,
1297    /// Magnetizing shunt referred to the star point (p.u. on the system base).
1298    pub mag_g: f64,
1299    pub mag_b: f64,
1300    pub in_service: bool,
1301    pub name: Option<String>,
1302    /// Stable row identity; see [`Bus::uid`].
1303    #[serde(default, skip_serializing_if = "Option::is_none")]
1304    pub uid: Option<String>,
1305    pub extras: Extras,
1306}
1307
1308impl Transformer3W {
1309    #[must_use]
1310    pub fn new(windings: [Winding; 3], z: [Impedance; 3]) -> Self {
1311        Self {
1312            windings,
1313            z,
1314            star_vm: 1.0,
1315            star_va: 0.0,
1316            mag_g: 0.0,
1317            mag_b: 0.0,
1318            in_service: true,
1319            name: None,
1320            uid: None,
1321            extras: Extras::new(),
1322        }
1323    }
1324
1325    /// The per-winding star impedances `(r, x)` — winding *k* to the star point —
1326    /// from the pairwise values, per unit on the system base.
1327    ///
1328    /// Standard pairwise→star conversion: `z1 = (z12 + z31 - z23) / 2`, and so on.
1329    /// Because the impedances are already on a common base, the split is linear in
1330    /// `r` and `x` separately.
1331    #[must_use]
1332    pub fn star_impedances(&self) -> [(f64, f64); 3] {
1333        let [z12, z23, z31] = self.z;
1334        let half = |a: f64, b: f64, c: f64| (a + b - c) / 2.0;
1335        [
1336            (half(z12.r, z31.r, z23.r), half(z12.x, z31.x, z23.x)),
1337            (half(z12.r, z23.r, z31.r), half(z12.x, z23.x, z31.x)),
1338            (half(z23.r, z31.r, z12.r), half(z23.x, z31.x, z12.x)),
1339        ]
1340    }
1341
1342    /// Expand into a synthetic star [`Bus`] (id `star_id`) plus three [`Branch`]es,
1343    /// one per winding, for a consumer that works in the bus-branch model.
1344    /// [`IndexedNetwork`](crate::IndexedNetwork) calls this via
1345    /// `Network::expand_transformers_3w` when assembling matrix inputs. The star
1346    /// bus carries the stored star voltage and the magnetizing shunt is left to the
1347    /// caller; each branch takes its winding's tap, phase shift, and ratings.
1348    #[must_use]
1349    pub fn star_expansion(&self, star_id: BusId) -> (Bus, [Branch; 3]) {
1350        let star = Bus {
1351            id: star_id,
1352            kind: BusType::Pq,
1353            vm: self.star_vm,
1354            va: self.star_va,
1355            base_kv: self.windings[0].nominal_kv,
1356            vmax: 1.1,
1357            vmin: 0.9,
1358            evhi: None,
1359            evlo: None,
1360            area: 0,
1361            zone: 0,
1362            name: self.name.clone(),
1363            uid: self.uid.clone(),
1364            extras: Extras::new(),
1365        };
1366        let zs = self.star_impedances();
1367        let branch = |w: &Winding, (r, x): (f64, f64)| Branch {
1368            from: w.bus,
1369            to: star_id,
1370            r,
1371            x,
1372            b: 0.0,
1373            charging: None,
1374            rate_a: w.rate_a,
1375            rate_b: w.rate_b,
1376            rate_c: w.rate_c,
1377            rating_sets: Vec::new(),
1378            current_ratings: None,
1379            tap: w.tap,
1380            shift: w.shift,
1381            in_service: self.in_service,
1382            angmin: -360.0,
1383            angmax: 360.0,
1384            control: None,
1385            solution: None,
1386            uid: None,
1387            extras: Extras::new(),
1388        };
1389        let branches = [
1390            branch(&self.windings[0], zs[0]),
1391            branch(&self.windings[1], zs[1]),
1392            branch(&self.windings[2], zs[2]),
1393        ];
1394        (star, branches)
1395    }
1396}
1397
1398/// The MATPOWER gen capability / ramp columns past `PMIN`, in order. The index
1399/// into this array is the slot index into a [`GenCaps`].
1400pub(crate) const GEN_EXTRA_KEYS: [&str; 11] = [
1401    "pc1", "pc2", "qc1min", "qc1max", "qc2min", "qc2max", "ramp_agc", "ramp_10", "ramp_30",
1402    "ramp_q", "apf",
1403];
1404
1405/// A value-domain finding from [`Network::validate_values`]: an element field
1406/// whose value falls outside its physical range, paired with the value
1407/// [`repair`](Network::repair) would set in its place.
1408///
1409/// `#[non_exhaustive]`: a returns-only record, so downstream code reads it but
1410/// never constructs it, leaving room to add locator fields without a break.
1411#[derive(Debug, Clone, PartialEq)]
1412#[non_exhaustive]
1413pub struct Diagnostic {
1414    /// Human-readable element locator, e.g. `"bus 3"` or `"generator at bus 5"`.
1415    pub element: String,
1416    pub field: &'static str,
1417    pub old: f64,
1418    pub new: f64,
1419    pub reason: &'static str,
1420}
1421
1422/// Voltage magnitude (p.u.) repair: non-positive or above 2 (or non-finite) → 1.0.
1423/// A zero magnitude is treated as out of domain (a de-energized placeholder), not
1424/// a valid 0 p.u.
1425fn repair_vm(vm: f64) -> Option<f64> {
1426    (!vm.is_finite() || vm <= 0.0 || vm > 2.0).then_some(1.0)
1427}
1428
1429/// Voltage angle (degrees) repair: `|va| > 2000` (or non-finite) → 0.0.
1430fn repair_va(va: f64) -> Option<f64> {
1431    (!va.is_finite() || va.abs() > 2000.0).then_some(0.0)
1432}
1433
1434/// Generator MVA base repair: non-positive (or non-finite) → the system base.
1435fn repair_mbase(mbase: f64, sbase: f64) -> Option<f64> {
1436    (!mbase.is_finite() || mbase <= 0.0).then_some(sbase)
1437}
1438
1439/// Generator voltage setpoint (p.u.) repair: non-positive (or non-finite) → 1.0.
1440fn repair_vg(vg: f64) -> Option<f64> {
1441    (!vg.is_finite() || vg <= 0.0).then_some(1.0)
1442}
1443
1444impl Network {
1445    #[must_use]
1446    pub fn new(name: impl Into<String>, base_mva: f64) -> Network {
1447        Network {
1448            name: name.into(),
1449            base_mva,
1450            base_frequency: DEFAULT_BASE_FREQUENCY,
1451            buses: Vec::new(),
1452            loads: Vec::new(),
1453            shunts: Vec::new(),
1454            branches: Vec::new(),
1455            switches: Vec::new(),
1456            generators: Vec::new(),
1457            storage: Vec::new(),
1458            hvdc: Vec::new(),
1459            transformers_3w: Vec::new(),
1460            areas: Vec::new(),
1461            solver: None,
1462            source_format: SourceFormat::InMemory,
1463            source: None,
1464        }
1465    }
1466
1467    /// A network assembled in memory from buses and branches, with no loads,
1468    /// shunts, generators, storage, HVDC, or retained source document. Synthetic
1469    /// topology generators and tests use it instead of repeating the struct
1470    /// literal. The caller owns reference integrity (run `check_references` if
1471    /// the ids might be inconsistent).
1472    #[must_use]
1473    pub fn in_memory(
1474        name: impl Into<String>,
1475        base_mva: f64,
1476        buses: Vec<Bus>,
1477        branches: Vec<Branch>,
1478    ) -> Network {
1479        let mut net = Self::new(name, base_mva);
1480        net.buses = buses;
1481        net.branches = branches;
1482        net
1483    }
1484
1485    /// Serialize the structured tables to JSON: the transport the C ABI
1486    /// (the `powerio-json` format) and the Julia bridge consume. The retained `source` text
1487    /// is excluded (see the field's `#[serde(skip)]`), so the byte-exact echo
1488    /// stays on the same-format write path; a [`from_json`](Network::from_json)
1489    /// round-trip reproduces every field except `source`, which returns `None`.
1490    ///
1491    /// JSON has no `Inf`/`NaN`: `serde_json` writes a non-finite field as
1492    /// `null`, which [`from_json`](Network::from_json) rejects on the way back
1493    /// (`null` is not an `f64`). The write stays total, the bindings
1494    /// materialize every parsed network through this transport, and readers
1495    /// legitimately produce `Inf` limits, but such a snapshot does not round
1496    /// trip; [`write_as`](crate::write_as) reports the degradation as a
1497    /// fidelity warning naming the field.
1498    ///
1499    /// # Errors
1500    /// A `serde_json` serialization failure (none arise from this model today).
1501    pub fn to_json(&self) -> crate::Result<String> {
1502        serde_json::to_string(self).map_err(|e| Error::FormatRead {
1503            format: "JSON",
1504            message: e.to_string(),
1505        })
1506    }
1507
1508    /// The paths of every non-finite numeric field, empty when all values are
1509    /// finite. Drives the snapshot writer's degradation warning (see
1510    /// [`to_json`](Network::to_json)): serde writes EVERY non-finite `f64` as
1511    /// `null`, so the warning must name them all, not just the first, or the
1512    /// caller fixes one field and the snapshot still fails to read back.
1513    /// `extras` maps hold `serde_json::Value`, which cannot carry a non-finite
1514    /// number, so only the typed `f64` fields need scanning. Every struct is
1515    /// destructured exhaustively: adding an `f64` field without classifying it
1516    /// here is a compile error, not a silently unguarded value.
1517    // The length IS the exhaustive field walk; splitting it would only scatter
1518    // the per-struct lists the compile-time check exists to keep in one place.
1519    #[allow(clippy::too_many_lines)]
1520    pub(crate) fn non_finite_fields(&self) -> Vec<String> {
1521        fn bad<'a>(
1522            fields: impl IntoIterator<Item = (&'a str, f64)>,
1523        ) -> impl Iterator<Item = &'a str> {
1524            fields
1525                .into_iter()
1526                .filter_map(|(name, v)| (!v.is_finite()).then_some(name))
1527        }
1528        let mut out = Vec::new();
1529        if !self.base_mva.is_finite() {
1530            out.push("base_mva".into());
1531        }
1532        if !self.base_frequency.is_finite() {
1533            out.push("base_frequency".into());
1534        }
1535        for (i, b) in self.buses.iter().enumerate() {
1536            #[rustfmt::skip]
1537            let Bus { id: _, kind: _, vm, va, base_kv, vmax, vmin, evhi: _, evlo: _, area: _, zone: _, name: _, uid: _, extras: _ } = b;
1538            let fields = [
1539                ("vm", *vm),
1540                ("va", *va),
1541                ("base_kv", *base_kv),
1542                ("vmax", *vmax),
1543                ("vmin", *vmin),
1544            ];
1545            out.extend(bad(fields).map(|f| format!("buses[{i}].{f}")));
1546        }
1547        for (i, l) in self.loads.iter().enumerate() {
1548            let Load {
1549                bus: _,
1550                p,
1551                q,
1552                voltage_model,
1553                in_service: _,
1554                uid: _,
1555                extras: _,
1556            } = l;
1557            out.extend(bad([("p", *p), ("q", *q)]).map(|f| format!("loads[{i}].{f}")));
1558            if let Some(model) = voltage_model {
1559                match model {
1560                    LoadVoltageModel::ConstantPower => {}
1561                    LoadVoltageModel::Zip {
1562                        p_constant_power,
1563                        q_constant_power,
1564                        p_constant_current,
1565                        q_constant_current,
1566                        p_constant_impedance,
1567                        q_constant_impedance,
1568                        v_nom,
1569                        load_type: _,
1570                        scaling,
1571                    } => {
1572                        let fields = [
1573                            ("p_constant_power", *p_constant_power),
1574                            ("q_constant_power", *q_constant_power),
1575                            ("p_constant_current", *p_constant_current),
1576                            ("q_constant_current", *q_constant_current),
1577                            ("p_constant_impedance", *p_constant_impedance),
1578                            ("q_constant_impedance", *q_constant_impedance),
1579                        ];
1580                        out.extend(bad(fields).map(|f| format!("loads[{i}].voltage_model.{f}")));
1581                        if matches!(v_nom, Some(v) if !v.is_finite()) {
1582                            out.push(format!("loads[{i}].voltage_model.v_nom"));
1583                        }
1584                        if matches!(scaling, Some(v) if !v.is_finite()) {
1585                            out.push(format!("loads[{i}].voltage_model.scaling"));
1586                        }
1587                    }
1588                    LoadVoltageModel::Exponential {
1589                        p,
1590                        q,
1591                        v_nom,
1592                        gamma_p,
1593                        gamma_q,
1594                    } => {
1595                        out.extend(
1596                            bad([
1597                                ("p", *p),
1598                                ("q", *q),
1599                                ("gamma_p", *gamma_p),
1600                                ("gamma_q", *gamma_q),
1601                            ])
1602                            .map(|f| format!("loads[{i}].voltage_model.{f}")),
1603                        );
1604                        if matches!(v_nom, Some(v) if !v.is_finite()) {
1605                            out.push(format!("loads[{i}].voltage_model.v_nom"));
1606                        }
1607                    }
1608                }
1609            }
1610        }
1611        for (i, s) in self.shunts.iter().enumerate() {
1612            let Shunt {
1613                bus: _,
1614                g,
1615                b,
1616                in_service: _,
1617                control: _,
1618                uid: _,
1619                extras: _,
1620            } = s;
1621            out.extend(bad([("g", *g), ("b", *b)]).map(|f| format!("shunts[{i}].{f}")));
1622        }
1623        for (i, br) in self.branches.iter().enumerate() {
1624            #[rustfmt::skip]
1625            let Branch { from: _, to: _, r, x, b, charging, rate_a, rate_b, rate_c, rating_sets, current_ratings, tap, shift, in_service: _, angmin, angmax, control: _, solution, uid: _, extras: _ } = br;
1626            let fields = [
1627                ("r", *r),
1628                ("x", *x),
1629                ("b", *b),
1630                ("rate_a", *rate_a),
1631                ("rate_b", *rate_b),
1632                ("rate_c", *rate_c),
1633                ("tap", *tap),
1634                ("shift", *shift),
1635                ("angmin", *angmin),
1636                ("angmax", *angmax),
1637            ];
1638            out.extend(bad(fields).map(|f| format!("branches[{i}].{f}")));
1639            out.extend(
1640                rating_sets
1641                    .iter()
1642                    .enumerate()
1643                    .filter(|(_, r)| !r.rate_mva.is_finite())
1644                    .map(|(j, _)| format!("branches[{i}].rating_sets[{j}].rate_mva")),
1645            );
1646            if let Some(charging) = charging {
1647                let BranchCharging {
1648                    g_fr,
1649                    b_fr,
1650                    g_to,
1651                    b_to,
1652                } = charging;
1653                let fields = [
1654                    ("g_fr", *g_fr),
1655                    ("b_fr", *b_fr),
1656                    ("g_to", *g_to),
1657                    ("b_to", *b_to),
1658                ];
1659                out.extend(bad(fields).map(|f| format!("branches[{i}].charging.{f}")));
1660            }
1661            if let Some(current) = current_ratings {
1662                let BranchCurrentRatings {
1663                    c_rating_a,
1664                    c_rating_b,
1665                    c_rating_c,
1666                } = current;
1667                let fields = [
1668                    ("c_rating_a", *c_rating_a),
1669                    ("c_rating_b", *c_rating_b),
1670                    ("c_rating_c", *c_rating_c),
1671                ];
1672                out.extend(bad(fields).map(|f| format!("branches[{i}].current_ratings.{f}")));
1673            }
1674            if let Some(solution) = solution {
1675                let BranchSolution { pf, qf, pt, qt } = solution;
1676                out.extend(
1677                    bad([("pf", *pf), ("qf", *qf), ("pt", *pt), ("qt", *qt)])
1678                        .map(|f| format!("branches[{i}].solution.{f}")),
1679                );
1680            }
1681        }
1682        for (i, sw) in self.switches.iter().enumerate() {
1683            let Switch {
1684                from: _,
1685                to: _,
1686                closed: _,
1687                thermal_rating,
1688                current_rating,
1689                pf,
1690                qf,
1691                pt,
1692                qt,
1693                uid: _,
1694                extras: _,
1695            } = sw;
1696            for (field, value) in [
1697                ("thermal_rating", *thermal_rating),
1698                ("current_rating", *current_rating),
1699                ("pf", *pf),
1700                ("qf", *qf),
1701                ("pt", *pt),
1702                ("qt", *qt),
1703            ] {
1704                if matches!(value, Some(v) if !v.is_finite()) {
1705                    out.push(format!("switches[{i}].{field}"));
1706                }
1707            }
1708        }
1709        for (i, g) in self.generators.iter().enumerate() {
1710            #[rustfmt::skip]
1711            let Generator { bus: _, pg, qg, pmax, pmin, qmax, qmin, vg, mbase, in_service: _, cost, caps, regulated_bus: _, uid: _ } = g;
1712            let fields = [
1713                ("pg", *pg),
1714                ("qg", *qg),
1715                ("pmax", *pmax),
1716                ("pmin", *pmin),
1717                ("qmax", *qmax),
1718                ("qmin", *qmin),
1719                ("vg", *vg),
1720                ("mbase", *mbase),
1721            ];
1722            out.extend(bad(fields).map(|f| format!("generators[{i}].{f}")));
1723            if let Some(GenCost {
1724                model: _,
1725                startup,
1726                shutdown,
1727                ncost: _,
1728                coeffs,
1729            }) = cost
1730            {
1731                out.extend(
1732                    bad([("startup", *startup), ("shutdown", *shutdown)])
1733                        .map(|f| format!("generators[{i}].cost.{f}")),
1734                );
1735                if coeffs.iter().any(|c| !c.is_finite()) {
1736                    out.push(format!("generators[{i}].cost.coeffs"));
1737                }
1738            }
1739            // Name the exact cap key (caps serializes as a name-keyed object, so
1740            // the null lands at generators[i].caps.<key>, e.g. ramp_30), matching
1741            // the key-level precision of every other field.
1742            for (key, slot) in GEN_EXTRA_KEYS.iter().zip(caps.iter()) {
1743                if matches!(slot, Some(v) if !v.is_finite()) {
1744                    out.push(format!("generators[{i}].caps.{key}"));
1745                }
1746            }
1747        }
1748        for (i, s) in self.storage.iter().enumerate() {
1749            #[rustfmt::skip]
1750            let Storage { bus: _, ps, qs, energy, energy_rating, charge_rating, discharge_rating, charge_efficiency, discharge_efficiency, thermal_rating, current_rating, qmin, qmax, r, x, p_loss, q_loss, in_service: _, uid: _, extras: _ } = s;
1751            let fields = [
1752                ("ps", *ps),
1753                ("qs", *qs),
1754                ("energy", *energy),
1755                ("energy_rating", *energy_rating),
1756                ("charge_rating", *charge_rating),
1757                ("discharge_rating", *discharge_rating),
1758                ("charge_efficiency", *charge_efficiency),
1759                ("discharge_efficiency", *discharge_efficiency),
1760                ("thermal_rating", *thermal_rating),
1761                ("qmin", *qmin),
1762                ("qmax", *qmax),
1763                ("r", *r),
1764                ("x", *x),
1765                ("p_loss", *p_loss),
1766                ("q_loss", *q_loss),
1767            ];
1768            out.extend(bad(fields).map(|f| format!("storage[{i}].{f}")));
1769            if matches!(current_rating, Some(v) if !v.is_finite()) {
1770                out.push(format!("storage[{i}].current_rating"));
1771            }
1772        }
1773        for (i, h) in self.hvdc.iter().enumerate() {
1774            #[rustfmt::skip]
1775            let Hvdc { from: _, to: _, in_service: _, pf, pt, qf, qt, vf, vt, pmin, pmax, qminf, qmaxf, qmint, qmaxt, loss0, loss1, cost, uid: _, extras: _ } = h;
1776            let fields = [
1777                ("pf", *pf),
1778                ("pt", *pt),
1779                ("qf", *qf),
1780                ("qt", *qt),
1781                ("vf", *vf),
1782                ("vt", *vt),
1783                ("pmin", *pmin),
1784                ("pmax", *pmax),
1785                ("qminf", *qminf),
1786                ("qmaxf", *qmaxf),
1787                ("qmint", *qmint),
1788                ("qmaxt", *qmaxt),
1789                ("loss0", *loss0),
1790                ("loss1", *loss1),
1791            ];
1792            out.extend(bad(fields).map(|f| format!("hvdc[{i}].{f}")));
1793            if let Some(GenCost {
1794                model: _,
1795                startup,
1796                shutdown,
1797                ncost: _,
1798                coeffs,
1799            }) = cost
1800            {
1801                out.extend(
1802                    bad([("startup", *startup), ("shutdown", *shutdown)])
1803                        .map(|f| format!("hvdc[{i}].cost.{f}")),
1804                );
1805                if coeffs.iter().any(|c| !c.is_finite()) {
1806                    out.push(format!("hvdc[{i}].cost.coeffs"));
1807                }
1808            }
1809        }
1810        out
1811    }
1812
1813    /// Serialize this network to `format`, preserving the retained source text
1814    /// on same-format writes and reporting any target-format fidelity warnings.
1815    ///
1816    /// # Errors
1817    /// As [`write_as`](crate::write_as): only a `PowerioJson` serialization
1818    /// failure.
1819    pub fn to_format(&self, format: crate::TargetFormat) -> crate::Result<crate::Conversion> {
1820        crate::write_as(self, format)
1821    }
1822
1823    /// Serialize this network with write-time cost policies.
1824    ///
1825    /// The network itself is not mutated. Default options preserve
1826    /// [`to_format`](Self::to_format) behavior.
1827    pub fn to_format_with_options(
1828        &self,
1829        format: crate::TargetFormat,
1830        options: &crate::WriteOptions,
1831    ) -> crate::Result<crate::Conversion> {
1832        crate::write_as_with_options(self, format, options)
1833    }
1834
1835    /// Serialize this network to MATPOWER `.m` text.
1836    ///
1837    /// This is byte-exact when the network was parsed from MATPOWER and still
1838    /// carries its retained source text.
1839    #[must_use]
1840    pub fn to_matpower(&self) -> String {
1841        crate::write_matpower(self)
1842    }
1843
1844    /// Rebuild a `Network` from JSON produced by [`to_json`](Network::to_json).
1845    ///
1846    /// Validates the result (no buses, unique bus ids, no dangling references)
1847    /// before returning, so the JSON transport (the C ABI and Julia bridge ride
1848    /// on it) can't hand back a network the file readers would have rejected
1849    /// (the same no-buses guard `read_source` applies to every parse path).
1850    pub fn from_json(text: &str) -> crate::Result<Network> {
1851        let net: Network = serde_json::from_str(text).map_err(|e| Error::FormatRead {
1852            format: "JSON",
1853            message: e.to_string(),
1854        })?;
1855        net.check_references("JSON")?;
1856        if net.buses.is_empty() {
1857            return Err(Error::FormatRead {
1858                format: "JSON",
1859                message: "case has no buses".into(),
1860            });
1861        }
1862        Ok(net)
1863    }
1864
1865    /// Whether this is a normalized (per-unit, radian, filtered)
1866    /// derived product from [`to_normalized`](Network::to_normalized), rather
1867    /// than a raw network at the file's unit basis. Unit-sensitive code that
1868    /// takes a `&Network` can check this instead of silently assuming MW.
1869    #[must_use]
1870    pub fn is_normalized(&self) -> bool {
1871        self.source_format == SourceFormat::Normalized
1872    }
1873
1874    /// Error unless `base_mva` is a positive, finite number. It is every
1875    /// per-unit divisor, so a malformed base would otherwise silently poison
1876    /// downstream values with `NaN`/`Inf` or flipped signs. The per-unit
1877    /// consumers ([`to_normalized`](Network::to_normalized), the gridfm
1878    /// export) call this; any other unit-sensitive consumer should too.
1879    pub fn check_base_mva(&self) -> crate::Result<()> {
1880        if self.base_mva.is_finite() && self.base_mva > 0.0 {
1881            Ok(())
1882        } else {
1883            Err(crate::Error::InvalidBaseMva {
1884                base: self.base_mva,
1885            })
1886        }
1887    }
1888
1889    /// Report element fields whose values fall outside their physical domain,
1890    /// without changing anything. Each [`Diagnostic`] names the element, the
1891    /// field, the current value, the value [`repair`](Network::repair) would set,
1892    /// and why.
1893    ///
1894    /// This generalizes the per-reader value clamps (a bus voltage magnitude
1895    /// outside `[0, 2]`, an angle past `±2000°`, a zero generator MVA base or
1896    /// voltage setpoint) into one pass any consumer can run, separate from the
1897    /// structural [`validate`](Network::validate) (which only checks ids and
1898    /// references). It is non-mutating; call [`repair`](Network::repair) to apply
1899    /// the fixes.
1900    #[must_use]
1901    pub fn validate_values(&self) -> Vec<Diagnostic> {
1902        let mut out = Vec::new();
1903        for b in &self.buses {
1904            if let Some(new) = repair_vm(b.vm) {
1905                out.push(Diagnostic {
1906                    element: format!("bus {}", b.id),
1907                    field: "vm",
1908                    old: b.vm,
1909                    new,
1910                    reason: "voltage magnitude outside [0, 2] p.u.",
1911                });
1912            }
1913            if let Some(new) = repair_va(b.va) {
1914                out.push(Diagnostic {
1915                    element: format!("bus {}", b.id),
1916                    field: "va",
1917                    old: b.va,
1918                    new,
1919                    reason: "voltage angle outside ±2000°",
1920                });
1921            }
1922        }
1923        for g in &self.generators {
1924            if let Some(new) = repair_mbase(g.mbase, self.base_mva) {
1925                out.push(Diagnostic {
1926                    element: format!("generator at bus {}", g.bus),
1927                    field: "mbase",
1928                    old: g.mbase,
1929                    new,
1930                    reason: "non-positive generator MVA base",
1931                });
1932            }
1933            if let Some(new) = repair_vg(g.vg) {
1934                out.push(Diagnostic {
1935                    element: format!("generator at bus {}", g.bus),
1936                    field: "vg",
1937                    old: g.vg,
1938                    new,
1939                    reason: "non-positive voltage setpoint",
1940                });
1941            }
1942        }
1943        out
1944    }
1945
1946    /// Drop the retained source text after an in-place mutation, so a later
1947    /// [`write_as`](crate::write_as) to the source format re-serializes the
1948    /// modified model instead of echoing the now-stale original bytes. A no-op
1949    /// operation leaves the source intact, keeping the byte-exact echo for an
1950    /// unmodified round trip.
1951    pub(crate) fn invalidate_source(&mut self) {
1952        self.source = None;
1953    }
1954
1955    /// Clamp every out-of-domain value to its repaired value (the same rules
1956    /// [`validate_values`](Network::validate_values) reports), returning the list
1957    /// of changes made. A second call returns an empty list (the values are now
1958    /// in domain).
1959    pub fn repair(&mut self) -> Vec<Diagnostic> {
1960        let findings = self.validate_values();
1961        let sbase = self.base_mva;
1962        for b in &mut self.buses {
1963            if let Some(new) = repair_vm(b.vm) {
1964                b.vm = new;
1965            }
1966            if let Some(new) = repair_va(b.va) {
1967                b.va = new;
1968            }
1969        }
1970        for g in &mut self.generators {
1971            if let Some(new) = repair_mbase(g.mbase, sbase) {
1972                g.mbase = new;
1973            }
1974            if let Some(new) = repair_vg(g.vg) {
1975                g.vg = new;
1976            }
1977        }
1978        // The repairs changed the model, so the retained source no longer matches.
1979        if !findings.is_empty() {
1980            self.invalidate_source();
1981        }
1982        findings
1983    }
1984
1985    /// A bus-branch lowering of the network for analysis: each in-service
1986    /// 3-winding transformer becomes a synthetic star bus, its three winding
1987    /// branches, and (when present) its magnetizing shunt, so the matrix builders
1988    /// and connectivity see it. Returns the network unchanged (borrowed) when
1989    /// there are no 3-winding transformers, so the common case allocates nothing.
1990    ///
1991    /// The canonical `Network` keeps the typed [`Transformer3W`] records; this is
1992    /// the derived analysis form that [`IndexedNetwork`](crate::IndexedNetwork)
1993    /// builds behind the scenes, so callers never see the synthetic buses in the
1994    /// model they read or write.
1995    pub(crate) fn expand_transformers_3w(&self) -> std::borrow::Cow<'_, Network> {
1996        if self.transformers_3w.is_empty() {
1997            return std::borrow::Cow::Borrowed(self);
1998        }
1999        let mut net = self.clone();
2000        // The star branches carry per-unit impedance (CZ = 1), the same convention
2001        // the matrix builders read straight off a branch, so no rebasing. The
2002        // magnetizing shunt is an admittance, so it scales like every other shunt:
2003        // by the per-unit base for a raw network, by 1 for a normalized one.
2004        let scale = if net.is_normalized() {
2005            1.0
2006        } else {
2007            net.base_mva
2008        };
2009        let base_id = net.buses.iter().map(|b| b.id.0).max().unwrap_or(0) + 1;
2010        for (k, t) in self
2011            .transformers_3w
2012            .iter()
2013            .filter(|t| t.in_service)
2014            .enumerate()
2015        {
2016            let star_id = BusId(base_id + k);
2017            let (star, branches) = t.star_expansion(star_id);
2018            net.buses.push(star);
2019            net.branches.extend(branches);
2020            if t.mag_g != 0.0 || t.mag_b != 0.0 {
2021                net.shunts.push(Shunt {
2022                    bus: star_id,
2023                    g: t.mag_g * scale,
2024                    b: t.mag_b * scale,
2025                    in_service: true,
2026                    control: None,
2027                    uid: None,
2028                    extras: Extras::new(),
2029                });
2030            }
2031        }
2032        net.transformers_3w.clear();
2033        std::borrow::Cow::Owned(net)
2034    }
2035
2036    /// Check structural integrity: bus ids are unique and every element
2037    /// references an existing bus. The file readers and [`from_json`](Network::from_json)
2038    /// run this; a `Network` built by hand (or mutated, e.g. by a scenario
2039    /// generator) should call it before handing the network to
2040    /// [`IndexedNetwork`](crate::IndexedNetwork), whose dense indexing assumes it.
2041    pub fn validate(&self) -> crate::Result<()> {
2042        self.check_references("network")
2043    }
2044
2045    /// Error if two buses share an id, or if any element references a bus that
2046    /// doesn't exist. Readers call this after parsing so a missing/garbled id
2047    /// (which would otherwise default to a placeholder and silently re-wire the
2048    /// network) fails loudly instead.
2049    pub(crate) fn check_references(&self, format: &'static str) -> crate::Result<()> {
2050        // HashSet, not BTreeSet: building the id set and probing it once per branch
2051        // endpoint / load / shunt / gen is the dominant cost of a large parse, and
2052        // a BTreeSet pays a log-n pointer-chasing probe each time. Pre-size to skip
2053        // rehashing.
2054        let mut ids = std::collections::HashSet::with_capacity(self.buses.len());
2055        for b in &self.buses {
2056            if !ids.insert(b.id) {
2057                return Err(Error::FormatRead {
2058                    format,
2059                    message: format!("duplicate bus id {}", b.id),
2060                });
2061            }
2062        }
2063        let check = |bus: BusId, what: &str| -> crate::Result<()> {
2064            if ids.contains(&bus) {
2065                Ok(())
2066            } else {
2067                Err(Error::FormatRead {
2068                    format,
2069                    message: format!("{what} references unknown bus {bus}"),
2070                })
2071            }
2072        };
2073        // Format the context only on the error path, not once per branch.
2074        for (i, br) in self.branches.iter().enumerate() {
2075            for bus in [br.from, br.to] {
2076                if !ids.contains(&bus) {
2077                    return Err(Error::FormatRead {
2078                        format,
2079                        message: format!("branch {i} references unknown bus {bus}"),
2080                    });
2081                }
2082            }
2083            if let Some(bus) = br.control.as_ref().and_then(|c| c.controlled_bus) {
2084                check(bus, "transformer control")?;
2085            }
2086        }
2087        for (i, sw) in self.switches.iter().enumerate() {
2088            for bus in [sw.from, sw.to] {
2089                if !ids.contains(&bus) {
2090                    return Err(Error::FormatRead {
2091                        format,
2092                        message: format!("switch {i} references unknown bus {bus}"),
2093                    });
2094                }
2095            }
2096        }
2097        for l in &self.loads {
2098            check(l.bus, "load")?;
2099        }
2100        for s in &self.shunts {
2101            check(s.bus, "shunt")?;
2102            if let Some(bus) = s.control.as_ref().and_then(|c| c.control_bus) {
2103                check(bus, "switched-shunt control")?;
2104            }
2105        }
2106        for g in &self.generators {
2107            check(g.bus, "generator")?;
2108            if let Some(bus) = g.regulated_bus {
2109                check(bus, "generator voltage control")?;
2110            }
2111        }
2112        for d in &self.hvdc {
2113            check(d.from, "dcline")?;
2114            check(d.to, "dcline")?;
2115        }
2116        for s in &self.storage {
2117            check(s.bus, "storage")?;
2118        }
2119        for a in &self.areas {
2120            if let Some(slack) = a.slack_bus {
2121                check(slack, "area swing")?;
2122            }
2123        }
2124        for t in &self.transformers_3w {
2125            for w in &t.windings {
2126                check(w.bus, "3-winding transformer")?;
2127            }
2128        }
2129        Ok(())
2130    }
2131}
2132
2133#[cfg(test)]
2134mod tests {
2135    use super::*;
2136
2137    fn close(actual: f64, expected: f64) {
2138        assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
2139    }
2140
2141    fn bus(id: usize) -> Bus {
2142        Bus {
2143            id: BusId(id),
2144            kind: BusType::Pq,
2145            vm: 1.0,
2146            va: 0.0,
2147            base_kv: 230.0,
2148            vmax: 1.1,
2149            vmin: 0.9,
2150            evhi: None,
2151            evlo: None,
2152            area: 1,
2153            zone: 1,
2154            name: None,
2155            uid: None,
2156            extras: Extras::new(),
2157        }
2158    }
2159
2160    fn winding(b: usize) -> Winding {
2161        Winding {
2162            bus: BusId(b),
2163            tap: 1.0,
2164            shift: 0.0,
2165            nominal_kv: 230.0,
2166            rate_a: 100.0,
2167            rate_b: 0.0,
2168            rate_c: 0.0,
2169        }
2170    }
2171
2172    fn transformer_3w() -> Transformer3W {
2173        let z = |r, x| Impedance {
2174            r,
2175            x,
2176            base_mva: 100.0,
2177        };
2178        Transformer3W {
2179            windings: [winding(1), winding(2), winding(3)],
2180            z: [z(0.01, 0.10), z(0.02, 0.20), z(0.03, 0.30)],
2181            star_vm: 0.98,
2182            star_va: -1.5,
2183            mag_g: 0.0,
2184            mag_b: 0.0,
2185            in_service: true,
2186            name: Some("T1".into()),
2187            uid: None,
2188            extras: Extras::new(),
2189        }
2190    }
2191
2192    #[test]
2193    fn star_impedances_split_the_pairwise_values() {
2194        // z1 = (z12 + z31 - z23)/2, z2 = (z12 + z23 - z31)/2, z3 = (z23 + z31 - z12)/2.
2195        let [(r1, x1), (r2, x2), (r3, x3)] = transformer_3w().star_impedances();
2196        close(r1, 0.01);
2197        close(x1, 0.10);
2198        close(r2, 0.0);
2199        close(x2, 0.0);
2200        close(r3, 0.02);
2201        close(x3, 0.20);
2202    }
2203
2204    #[test]
2205    fn star_expansion_builds_a_star_bus_and_three_branches() {
2206        let t = transformer_3w();
2207        let (star, branches) = t.star_expansion(BusId(99));
2208
2209        assert_eq!(star.id, BusId(99));
2210        close(star.vm, 0.98);
2211        close(star.va, -1.5);
2212        // Each branch runs from its winding bus to the star, carrying the
2213        // winding tap and ratings and the split impedance.
2214        for (i, br) in branches.iter().enumerate() {
2215            assert_eq!(br.from, t.windings[i].bus);
2216            assert_eq!(br.to, BusId(99));
2217            close(br.tap, 1.0);
2218            close(br.rate_a, 100.0);
2219        }
2220        close(branches[2].r, 0.02);
2221        close(branches[2].x, 0.20);
2222    }
2223
2224    #[test]
2225    fn three_winding_transformer_survives_json_transport() {
2226        let mut net = Network::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
2227        net.transformers_3w.push(transformer_3w());
2228        net.validate().unwrap();
2229
2230        let back = Network::from_json(&net.to_json().unwrap()).unwrap();
2231        assert_eq!(back.transformers_3w.len(), 1);
2232        close(back.transformers_3w[0].z[1].x, 0.20);
2233        assert_eq!(back.transformers_3w[0].windings[2].bus, BusId(3));
2234    }
2235
2236    #[test]
2237    fn check_references_rejects_a_dangling_winding_bus() {
2238        let mut net = Network::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
2239        net.transformers_3w.push(transformer_3w()); // winding 3 references bus 3
2240        let err = net.validate().unwrap_err().to_string();
2241        assert!(
2242            err.contains("3-winding transformer references unknown bus 3"),
2243            "got {err}"
2244        );
2245    }
2246
2247    /// A regulating transformer (bus 1→2) controlling the voltage at bus `reg`.
2248    fn regulating_branch(reg: usize) -> Branch {
2249        Branch {
2250            from: BusId(1),
2251            to: BusId(2),
2252            r: 0.0,
2253            x: 0.1,
2254            b: 0.0,
2255            charging: None,
2256            rate_a: 0.0,
2257            rate_b: 0.0,
2258            rate_c: 0.0,
2259            rating_sets: Vec::new(),
2260            current_ratings: None,
2261            tap: 1.0,
2262            shift: 0.0,
2263            in_service: true,
2264            angmin: -360.0,
2265            angmax: 360.0,
2266            control: Some(TransformerControl {
2267                mode: TransformerControlMode::Voltage,
2268                controlled_bus: Some(BusId(reg)),
2269                tap_min: 0.95,
2270                tap_max: 1.05,
2271                band_min: 1.0,
2272                band_max: 1.02,
2273                ntp: 17,
2274                mva_base: 100.0,
2275            }),
2276            solution: None,
2277            uid: None,
2278            extras: Extras::new(),
2279        }
2280    }
2281
2282    #[test]
2283    fn transformer_control_survives_json_transport() {
2284        let mut net = Network::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
2285        net.branches.push(regulating_branch(3));
2286        net.validate().unwrap();
2287
2288        let back = Network::from_json(&net.to_json().unwrap()).unwrap();
2289        let c = back.branches[0].control.as_ref().unwrap();
2290        assert_eq!(c.mode, TransformerControlMode::Voltage);
2291        assert_eq!(c.controlled_bus, Some(BusId(3)));
2292        close(c.tap_max, 1.05);
2293        assert_eq!(c.ntp, 17);
2294    }
2295
2296    #[test]
2297    fn gen_caps_serialize_as_a_named_map_that_grows_additively() {
2298        let mut caps: GenCaps = [None; GEN_EXTRA_KEYS.len()];
2299        caps[8] = Some(1.5); // ramp_30
2300        caps[10] = Some(0.5); // apf
2301        let g = Generator {
2302            bus: BusId(1),
2303            pg: 10.0,
2304            qg: 0.0,
2305            pmax: 100.0,
2306            pmin: 0.0,
2307            qmax: 50.0,
2308            qmin: -50.0,
2309            vg: 1.0,
2310            mbase: 100.0,
2311            in_service: true,
2312            cost: None,
2313            caps,
2314            regulated_bus: None,
2315            uid: None,
2316        };
2317
2318        // caps is a name-keyed object emitting only the present slots, not a
2319        // length-exact array.
2320        let json = serde_json::to_string(&g).unwrap();
2321        assert!(json.contains(r#""caps":{"#), "caps is an object: {json}");
2322        assert!(json.contains(r#""ramp_30":1.5"#) && json.contains(r#""apf":0.5"#));
2323        let back: Generator = serde_json::from_str(&json).unwrap();
2324        assert_eq!(back.caps, g.caps);
2325
2326        // Growing GEN_EXTRA_KEYS stays additive: an unknown future key is ignored,
2327        // a missing key reads as None, and an omitted field is the empty set.
2328        let with_future = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
2329            "vg":1,"mbase":100,"in_service":true,"cost":null,
2330            "caps":{"ramp_30":1.5,"future_ramp":9.9}}"#;
2331        let g2: Generator = serde_json::from_str(with_future).unwrap();
2332        assert_eq!(g2.caps[8], Some(1.5));
2333        assert_eq!(g2.caps.iter().filter(|v| v.is_some()).count(), 1);
2334        let no_caps = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
2335            "vg":1,"mbase":100,"in_service":true,"cost":null}"#;
2336        let g3: Generator = serde_json::from_str(no_caps).unwrap();
2337        assert!(!g3.has_caps());
2338
2339        // An explicit `"caps":null` is the empty set too, the same as omitting it.
2340        let null_caps = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
2341            "vg":1,"mbase":100,"in_service":true,"cost":null,"caps":null}"#;
2342        let g4: Generator = serde_json::from_str(null_caps).unwrap();
2343        assert!(!g4.has_caps());
2344    }
2345
2346    #[test]
2347    fn non_finite_fields_lists_every_offender_not_just_the_first() {
2348        let bus = |id, vm| Bus {
2349            id: BusId(id),
2350            kind: BusType::Pq,
2351            vm,
2352            va: 0.0,
2353            base_kv: 230.0,
2354            vmax: 1.1,
2355            vmin: 0.9,
2356            evhi: None,
2357            evlo: None,
2358            area: 1,
2359            zone: 1,
2360            name: None,
2361            uid: None,
2362            extras: Extras::new(),
2363        };
2364        let branch = Branch {
2365            from: BusId(1),
2366            to: BusId(2),
2367            r: 0.0,
2368            x: f64::INFINITY,
2369            b: 0.0,
2370            charging: None,
2371            rate_a: 0.0,
2372            rate_b: 0.0,
2373            rate_c: 0.0,
2374            rating_sets: Vec::new(),
2375            current_ratings: None,
2376            tap: 0.0,
2377            shift: 0.0,
2378            in_service: true,
2379            angmin: -360.0,
2380            angmax: 360.0,
2381            control: None,
2382            solution: None,
2383            uid: None,
2384            extras: Extras::new(),
2385        };
2386        // A non-finite generator capability reports at its exact key path
2387        // (caps serializes as a name-keyed object), not the parent `caps`.
2388        let mut g = Generator {
2389            bus: BusId(1),
2390            pg: 0.0,
2391            qg: 0.0,
2392            pmax: 0.0,
2393            pmin: 0.0,
2394            qmax: 0.0,
2395            qmin: 0.0,
2396            vg: 1.0,
2397            mbase: 100.0,
2398            in_service: true,
2399            cost: None,
2400            caps: GenCaps::default(),
2401            regulated_bus: None,
2402            uid: None,
2403        };
2404        g.caps[8] = Some(f64::INFINITY); // ramp_30
2405        // Three distinct non-finite fields: a bus vm (NaN), a branch x (Inf), and
2406        // a generator ramp_30 cap (Inf).
2407        let mut net = Network::in_memory(
2408            "nf",
2409            100.0,
2410            vec![bus(1, f64::NAN), bus(2, 1.0)],
2411            vec![branch],
2412        );
2413        net.generators.push(g);
2414        let fields = net.non_finite_fields();
2415        assert!(fields.contains(&"buses[0].vm".to_string()), "{fields:?}");
2416        assert!(fields.contains(&"branches[0].x".to_string()), "{fields:?}");
2417        assert!(
2418            fields.contains(&"generators[0].caps.ramp_30".to_string()),
2419            "caps reported at key precision: {fields:?}"
2420        );
2421        assert_eq!(
2422            fields.len(),
2423            3,
2424            "exactly the three offenders, no more: {fields:?}"
2425        );
2426    }
2427
2428    #[test]
2429    fn check_references_rejects_a_dangling_controlled_bus() {
2430        let mut net = Network::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
2431        net.branches.push(regulating_branch(9)); // controls a bus that doesn't exist
2432        let err = net.validate().unwrap_err().to_string();
2433        assert!(
2434            err.contains("transformer control references unknown bus 9"),
2435            "got {err}"
2436        );
2437    }
2438
2439    /// A discrete switched shunt on bus 1 regulating the voltage at bus `reg`.
2440    fn switched_shunt(reg: usize) -> Shunt {
2441        Shunt {
2442            bus: BusId(1),
2443            g: 0.0,
2444            b: 19.0,
2445            in_service: true,
2446            control: Some(SwitchedShuntControl {
2447                mode: SwitchedShuntMode::Discrete,
2448                vhigh: 1.05,
2449                vlow: 0.95,
2450                control_bus: Some(BusId(reg)),
2451                rmpct: 100.0,
2452                blocks: vec![
2453                    ShuntBlock { steps: 2, b: 25.0 },
2454                    ShuntBlock { steps: 1, b: 50.0 },
2455                ],
2456            }),
2457            uid: None,
2458            extras: Extras::new(),
2459        }
2460    }
2461
2462    #[test]
2463    fn switched_shunt_control_survives_json_transport() {
2464        let mut net = Network::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
2465        net.shunts.push(switched_shunt(3));
2466        net.validate().unwrap();
2467
2468        let back = Network::from_json(&net.to_json().unwrap()).unwrap();
2469        let c = back.shunts[0].control.as_ref().unwrap();
2470        assert_eq!(c.mode, SwitchedShuntMode::Discrete);
2471        assert_eq!(c.control_bus, Some(BusId(3)));
2472        assert_eq!(c.blocks.len(), 2);
2473        close(c.blocks[1].b, 50.0);
2474    }
2475
2476    #[test]
2477    fn check_references_rejects_a_dangling_switched_shunt_control_bus() {
2478        let mut net = Network::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
2479        net.shunts.push(switched_shunt(9)); // controls a bus that doesn't exist
2480        let err = net.validate().unwrap_err().to_string();
2481        assert!(
2482            err.contains("switched-shunt control references unknown bus 9"),
2483            "got {err}"
2484        );
2485    }
2486
2487    #[test]
2488    fn validate_values_flags_and_repair_clamps_out_of_domain_values() {
2489        let mut net = Network::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
2490        net.buses[0].vm = 0.0; // outside [0, 2]
2491        net.buses[1].va = 9000.0; // past ±2000°
2492        net.generators.push(Generator {
2493            bus: BusId(1),
2494            pg: 10.0,
2495            qg: 0.0,
2496            pmax: 100.0,
2497            pmin: 0.0,
2498            qmax: 50.0,
2499            qmin: -50.0,
2500            vg: 0.0,    // non-positive setpoint
2501            mbase: 0.0, // non-positive base
2502            in_service: true,
2503            cost: None,
2504            caps: Default::default(),
2505            regulated_bus: None,
2506            uid: None,
2507        });
2508
2509        let diags = net.validate_values();
2510        let fields: std::collections::BTreeSet<_> = diags.iter().map(|d| d.field).collect();
2511        assert_eq!(
2512            fields,
2513            ["mbase", "va", "vg", "vm"].into_iter().collect(),
2514            "all four out-of-domain fields reported"
2515        );
2516        // Non-mutating: the network still holds the bad values.
2517        close(net.buses[0].vm, 0.0);
2518
2519        let applied = net.repair();
2520        assert_eq!(applied.len(), diags.len());
2521        close(net.buses[0].vm, 1.0);
2522        close(net.buses[1].va, 0.0);
2523        close(net.generators[0].mbase, 100.0); // → base_mva
2524        close(net.generators[0].vg, 1.0);
2525        // Idempotent: nothing left to repair.
2526        assert!(net.validate_values().is_empty());
2527    }
2528
2529    #[test]
2530    fn validate_values_is_empty_for_a_clean_network() {
2531        let net = Network::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
2532        assert!(net.validate_values().is_empty());
2533    }
2534}