Skip to main content

powerio_tx/
network.rs

1//! Format neutral balanced network model.
2//!
3//! Readers map source formats into a [`BalancedNetwork`], and writers map a network to
4//! target formats. Loads and shunts have separate tables, so formats can retain
5//! several elements at one bus. MATPOWER demand and shunt fields become those
6//! records during parsing. [`IndexedNetwork`](crate::IndexedNetwork) provides
7//! the dense analysis view used by matrix builders.
8//!
9//! A network can retain its source bytes and [`SourceFormat`] for same format
10//! writing. Each element also has an [`Extras`] map for source fields not named
11//! by the typed model.
12//!
13//! Formats represent different data. Cross format writers report unsupported
14//! fields rather than claiming an exact conversion.
15
16// `crate::nonfinite` is text, not a link: the adapters moved into
17// powerio-core's hidden implementation module, and schemars copies these doc
18// comments verbatim into the frozen 0.9 document schema, so the wording cannot
19// change to match.
20#![expect(
21    rustdoc::broken_intra_doc_links,
22    reason = "the frozen 0.9 schema records these doc strings byte for byte"
23)]
24
25use std::collections::BTreeMap;
26
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29
30use crate::geo::{GeoMeta, Location};
31use crate::{Error, Result};
32
33/// Source format fields the neutral model does not name, kept for round trips
34/// and cross format conversion. Keys are field names; values are JSON scalars.
35pub type Extras = BTreeMap<String, Value>;
36
37/// System base frequency in hertz when a format records none. Power networks run
38/// at 50 or 60 Hz; 60 is the default for the formats (MATPOWER, PowerModels,
39/// egret) that carry no frequency field.
40pub const DEFAULT_BASE_FREQUENCY: f64 = 60.0;
41
42/// serde default for [`BalancedNetwork::base_frequency`], so JSON written before the
43/// field existed still deserializes (the C ABI and Julia bridge ride on the JSON
44/// transport).
45fn default_base_frequency() -> f64 {
46    DEFAULT_BASE_FREQUENCY
47}
48
49/// A source bus ID, preserved from the input format.
50///
51/// MATPOWER IDs are 1-based and can contain gaps. They are distinct from the
52/// zero based dense indices produced by
53/// [`IndexedNetwork::bus_index`](crate::IndexedNetwork::bus_index). JSON stores
54/// this type as an integer.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
56#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
57#[serde(transparent)]
58pub struct BusId(pub usize);
59
60impl BusId {
61    /// The largest id a network may carry. The C ABI reports bus ids as int64,
62    /// so an id past this ceiling has no distinct value there;
63    /// [`BalancedNetwork::validate`] refuses one.
64    pub const MAX: Self = Self(i64::MAX as usize);
65
66    #[must_use]
67    pub const fn new(id: usize) -> Self {
68        Self(id)
69    }
70}
71
72impl std::fmt::Display for BusId {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        self.0.fmt(f)
75    }
76}
77
78/// Bus type per MATPOWER convention: 1=PQ, 2=PV, 3=ref/slack, 4=isolated.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81#[serde(rename_all = "UPPERCASE")]
82#[repr(u8)]
83#[non_exhaustive]
84pub enum BusType {
85    Pq = 1,
86    Pv = 2,
87    Ref = 3,
88    Isolated = 4,
89}
90
91impl BusType {
92    /// Map a MATPOWER bus-type code to the enum; unknown codes fall back to PQ.
93    pub(crate) fn from_f64(v: f64) -> Self {
94        match v as i32 {
95            2 => Self::Pv,
96            3 => Self::Ref,
97            4 => Self::Isolated,
98            _ => Self::Pq,
99        }
100    }
101
102    /// The canonical short name (`"PQ"`, `"PV"`, `"REF"`, `"ISOLATED"`), shared
103    /// by the bindings so their bus-type strings can't drift.
104    #[must_use]
105    pub fn as_str(self) -> &'static str {
106        match self {
107            Self::Pq => "PQ",
108            Self::Pv => "PV",
109            Self::Ref => "REF",
110            Self::Isolated => "ISOLATED",
111        }
112    }
113}
114
115/// A generator cost curve (`mpc.gencost` row).
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118#[non_exhaustive]
119pub struct GenCost {
120    /// 1 = piecewise linear, 2 = polynomial.
121    pub model: u8,
122    pub startup: f64,
123    pub shutdown: f64,
124    /// Number of cost coefficients (polynomial) or breakpoints (piecewise).
125    pub ncost: usize,
126    /// Raw coefficients, highest order first for the polynomial model:
127    /// `[c_{k-1}, …, c1, c0]`.
128    pub coeffs: Vec<f64>,
129}
130
131impl GenCost {
132    /// Build a cost row from the values carried after `ncost`.
133    ///
134    /// Polynomial rows (`model == 2`) store `ncost` coefficients. Piecewise
135    /// linear rows (`model == 1`) store flattened `(x, y)` breakpoint pairs, so
136    /// `ncost` is half the coefficient count. Use [`GenCost::with_ncost`] for
137    /// malformed source rows or callers that need to preserve an explicit
138    /// `ncost`.
139    #[must_use]
140    pub fn new(model: u8, startup: f64, shutdown: f64, coeffs: Vec<f64>) -> Self {
141        let ncost = if model == 1 {
142            coeffs.len() / 2
143        } else {
144            coeffs.len()
145        };
146        Self {
147            model,
148            startup,
149            shutdown,
150            ncost,
151            coeffs,
152        }
153    }
154
155    #[must_use]
156    pub fn with_ncost(
157        model: u8,
158        startup: f64,
159        shutdown: f64,
160        ncost: usize,
161        coeffs: Vec<f64>,
162    ) -> Self {
163        Self {
164            model,
165            startup,
166            shutdown,
167            ncost,
168            coeffs,
169        }
170    }
171
172    /// `(q, c)` for the quadratic cost `½ q p² + c p` from a polynomial
173    /// (model 2) row. MATPOWER stores `c2 p² + c1 p + c0`, so `q = 2·c2` and
174    /// `c = c1`. Linear rows (`ncost == 2`) give `q = 0`. Piecewise (model 1)
175    /// or cubic and higher return `None`.
176    pub fn quadratic(&self) -> Option<(f64, f64)> {
177        self.quadratic_with_constant().map(|(q, c, _)| (q, c))
178    }
179
180    /// `(q, c, c0)` for the quadratic cost `½ q p² + c p + c0` from a
181    /// polynomial (model 2) row, keeping the constant term that
182    /// [`quadratic`](Self::quadratic) drops. Linear rows (`ncost == 2`) give
183    /// `q = 0`; constant rows (`ncost == 1`) give `q = c = 0`. Piecewise
184    /// (model 1) or cubic and higher return `None`.
185    pub fn quadratic_with_constant(&self) -> Option<(f64, f64, f64)> {
186        if self.model != 2 {
187            return None;
188        }
189        // Reject a row whose coefficient slice is shorter than `ncost` claims,
190        // rather than reading the wrong powers by position.
191        if self.coeffs.len() < self.ncost {
192            return None;
193        }
194        // Matches on the stated arity, so a cubic row is refused even when its
195        // leading coefficient is zero. `quadratic_with_constant_tol` is the
196        // reader that lowers the order first.
197        match self.ncost {
198            3 => Some((2.0 * self.coeffs[0], self.coeffs[1], self.coeffs[2])),
199            2 => Some((0.0, self.coeffs[0], self.coeffs[1])),
200            1 => Some((0.0, 0.0, self.coeffs[0])),
201            _ => None,
202        }
203    }
204
205    /// Largest leading polynomial coefficient that
206    /// [`quadratic_with_constant_tol`](Self::quadratic_with_constant_tol)
207    /// reads as a rounding artifact of the source, not as a term of the curve.
208    pub const LEADING_COEFF_TOL: f64 = 1e-12;
209
210    /// `(q, c, c0)` as [`quadratic_with_constant`](Self::quadratic_with_constant)
211    /// gives it, after the leading coefficients at or below `tol` come off the
212    /// row.
213    ///
214    /// A model 2 row often carries a leading coefficient near `1e-17`, which
215    /// the source produced by rounding. Such a row states a linear curve and
216    /// reads as a quadratic one. Pass
217    /// [`LEADING_COEFF_TOL`](Self::LEADING_COEFF_TOL) to strip the artifact,
218    /// or `0.0` to strip an exact zero alone.
219    pub fn quadratic_with_constant_tol(&self, tol: f64) -> Option<(f64, f64, f64)> {
220        if self.model != 2 {
221            return None;
222        }
223        if self.coeffs.len() < self.ncost {
224            return None;
225        }
226        let row = &self.coeffs[..self.ncost];
227        let mut first = 0;
228        while first + 1 < row.len() && row[first].abs() <= tol {
229            first += 1;
230        }
231        match row.len() - first {
232            3 => Some((2.0 * row[first], row[first + 1], row[first + 2])),
233            2 => Some((0.0, row[first], row[first + 1])),
234            1 => Some((0.0, 0.0, row[first])),
235            _ => None,
236        }
237    }
238}
239
240/// Which format a [`BalancedNetwork`] was read from. Drives the same format byte exact
241/// echo on write.
242///
243/// Serializes as the same lowercase token [`name`](SourceFormat::name) reports
244/// and every string entry point accepts, so the value a document carries is
245/// valid input to `from`. Documents written before 0.9.0 spelled the bare
246/// variant name ("Matpower"); the read side still accepts those spellings.
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
249#[non_exhaustive]
250pub enum SourceFormat {
251    #[serde(rename = "matpower", alias = "Matpower")]
252    Matpower,
253    #[serde(rename = "powermodels-json", alias = "PowerModelsJson")]
254    PowerModelsJson,
255    #[serde(rename = "egret-json", alias = "EgretJson")]
256    EgretJson,
257    #[serde(rename = "psse", alias = "Psse")]
258    Psse,
259    #[serde(rename = "powerworld", alias = "PowerWorld")]
260    PowerWorld,
261    #[serde(rename = "pandapower-json", alias = "PandapowerJson")]
262    PandapowerJson,
263    /// Read from a GE PSLF `.epc` case. Same source text is retained, so a
264    /// same-format write echoes it byte-for-byte; a cross-format or
265    /// source-dropped write goes through the `.epc` serializer
266    /// ([`write_pslf`](crate::write_pslf)).
267    #[serde(rename = "pslf", alias = "Pslf")]
268    Pslf,
269    /// Read from a PowerWorld `.pwb` binary case. Read only: there is no
270    /// `.pwb` writer and no retained source text, so writing goes through
271    /// another format's writer.
272    #[serde(rename = "powerworld-pwb", alias = "PowerWorldBinary")]
273    PowerWorldBinary,
274    /// Built in memory, for example from synth or an edited case; no source text.
275    #[serde(rename = "in-memory", alias = "InMemory")]
276    InMemory,
277    /// A normalized derived form ([`BalancedNetwork::to_normalized`]): per unit, radians,
278    /// filtered, source bus ids preserved. Distinct from
279    /// [`InMemory`](SourceFormat::InMemory) so consumers can tell a per unit
280    /// product from a raw in memory network; it has no source text and a different
281    /// unit basis than a parsed network.
282    #[serde(rename = "normalized", alias = "Normalized")]
283    Normalized,
284    /// Read back from a gridfm-datakit Parquet dataset (the ML→classical bridge,
285    /// `powerio-matrix`'s `read_gridfm_dataset`). A lossy, power flow complete
286    /// reconstruction with no retained source text: original bus ids are
287    /// synthesized `1..n`, per element load/shunt granularity is folded to one
288    /// synthetic element per bus, and HVDC/storage/piecewise costs are absent.
289    #[serde(rename = "gridfm", alias = "Gridfm")]
290    Gridfm,
291    /// Read from a PyPSA CSV folder. This is a folder format rather than a
292    /// single retained text document, so same-format writes are canonicalized.
293    #[serde(rename = "pypsa-csv", alias = "PypsaCsv")]
294    PypsaCsv,
295    /// Read from a DOE GO Challenge 3 JSON input document. The source is a
296    /// unit commitment data set; the neutral transmission model keeps a static
297    /// first interval network and retains the source text for the full data.
298    #[serde(rename = "goc3-json", alias = "Goc3Json")]
299    Goc3Json,
300    /// Read from a Surge native JSON document.
301    #[serde(rename = "surge-json", alias = "SurgeJson")]
302    SurgeJson,
303    /// Read from one raw JSON document in a DeepMind OPFData release. The
304    /// source carries both solver initial values and a solution. The balanced
305    /// model represents the solved snapshot and retains the source for an
306    /// exact write back to the same format.
307    #[serde(rename = "opfdata-json", alias = "DeepMindOpfDataJson")]
308    DeepMindOpfDataJson,
309}
310
311impl SourceFormat {
312    /// Stable lowercase token for the source format in module records, CLI
313    /// summaries, and language bindings. The match is exhaustive here so a new
314    /// variant fails compilation at the one mapping instead of silently
315    /// reporting "unknown" from a downstream wildcard copy.
316    #[must_use]
317    pub fn name(self) -> &'static str {
318        match self {
319            SourceFormat::Matpower => "matpower",
320            SourceFormat::PowerModelsJson => "powermodels-json",
321            SourceFormat::EgretJson => "egret-json",
322            SourceFormat::Psse => "psse",
323            SourceFormat::PowerWorld => "powerworld",
324            SourceFormat::PandapowerJson => "pandapower-json",
325            SourceFormat::Pslf => "pslf",
326            SourceFormat::PowerWorldBinary => "powerworld-pwb",
327            SourceFormat::InMemory => "in-memory",
328            SourceFormat::Normalized => "normalized",
329            SourceFormat::Gridfm => "gridfm",
330            SourceFormat::PypsaCsv => "pypsa-csv",
331            SourceFormat::Goc3Json => "goc3-json",
332            SourceFormat::SurgeJson => "surge-json",
333            SourceFormat::DeepMindOpfDataJson => "opfdata-json",
334        }
335    }
336}
337
338/// A balanced network with stable source bus IDs and separate element tables:
339/// an immutable cheap to clone owning handle over private shared tables.
340///
341/// Cloning the handle bumps one reference count and clones no table
342/// allocation. Reads go through the per field accessors; the `*_mut`
343/// accessors copy the shared tables once on first write to a shared handle
344/// (copy on write), so no other handle ever observes a mutation. The choice
345/// of whole value sharing is private: clone stays zero allocation because
346/// the handle wraps its tables in one `Arc`.
347#[derive(Debug, Clone)]
348pub struct BalancedNetwork {
349    tables: std::sync::Arc<BalancedNetworkTables>,
350}
351
352impl BalancedNetwork {
353    pub(crate) fn from_tables(tables: BalancedNetworkTables) -> Self {
354        Self {
355            tables: std::sync::Arc::new(tables),
356        }
357    }
358
359    /// The one mutation door: copies the shared tables on first write to a
360    /// shared handle, so no other handle observes the change.
361    pub(crate) fn tables_mut(&mut self) -> &mut BalancedNetworkTables {
362        std::sync::Arc::make_mut(&mut self.tables)
363    }
364}
365
366/// A balanced network with stable source bus IDs and separate element tables.
367///
368/// `remote = "Self"` turns the derived serde impls into inherent functions;
369/// the trait impls beneath the struct route them through [`crate::nonfinite`],
370/// so a nonfinite float spells as a string on every JSON route.
371// The one owned table store behind the `BalancedNetwork` handle. The doc
372// string above is frozen into the generated 0.9 schema description, so the
373// handle split leaves it as it was; the schema also keeps the handle's name
374// through the schemars rename below.
375#[derive(Debug, Clone, Serialize, Deserialize)]
376#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
377#[cfg_attr(feature = "schema", schemars(rename = "BalancedNetwork"))]
378#[serde(remote = "Self")]
379pub(crate) struct BalancedNetworkTables {
380    pub name: String,
381    pub base_mva: f64,
382    /// System base frequency in hertz (50 or 60). Threaded through the formats
383    /// that record it (PSS/E `BASFRQ`, pandapower `f_hz`) and defaulted to
384    /// [`DEFAULT_BASE_FREQUENCY`] for the rest. Load-bearing for any
385    /// reactance↔henry conversion (pandapower line charging) and reported as a
386    /// fidelity loss when a non-default value writes to a format with no
387    /// frequency field.
388    #[serde(default = "default_base_frequency")]
389    pub base_frequency: f64,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub geo: Option<GeoMeta>,
392    pub buses: std::sync::Arc<Vec<Bus>>,
393    pub loads: std::sync::Arc<Vec<Load>>,
394    pub shunts: std::sync::Arc<Vec<Shunt>>,
395    pub branches: std::sync::Arc<Vec<Branch>>,
396    #[serde(default)]
397    pub switches: std::sync::Arc<Vec<Switch>>,
398    pub generators: std::sync::Arc<Vec<Generator>>,
399    pub storage: std::sync::Arc<Vec<Storage>>,
400    pub hvdc: std::sync::Arc<Vec<Hvdc>>,
401    /// Three-winding transformers, kept as typed records rather than folded into
402    /// `branches`, so a star point and the per-winding data survive a round trip.
403    /// `#[serde(default)]` so JSON written before the field existed still
404    /// deserializes. [`IndexedNetwork`](crate::IndexedNetwork) lowers each
405    /// in-service record into a star bus plus three branches (via
406    /// [`Transformer3W::star_expansion`]) before building any matrix, so a
407    /// 3-winding transformer does appear in `Y_bus`/connectivity; the canonical
408    /// model keeps the typed record for round-trip fidelity.
409    #[serde(default)]
410    pub transformers_3w: std::sync::Arc<Vec<Transformer3W>>,
411    /// Area records: scheduled interchange and per-area swing bus. Distinct from
412    /// the bare `area` number on each [`Bus`]; this is the area's metadata, which
413    /// every conversion dropped before. `#[serde(default)]` so older JSON still
414    /// deserializes.
415    #[serde(default)]
416    pub areas: std::sync::Arc<Vec<Area>>,
417    /// Solver / solution-control metadata when the source carries it, else `None`.
418    /// `#[serde(default)]` so older JSON still deserializes.
419    #[serde(default)]
420    pub solver: Option<SolverParams>,
421    pub source_format: SourceFormat,
422}
423
424impl Serialize for BalancedNetwork {
425    fn serialize<S: serde::Serializer>(
426        &self,
427        serializer: S,
428    ) -> std::result::Result<S::Ok, S::Error> {
429        BalancedNetworkTables::serialize(
430            &self.tables,
431            powerio_core::__implementation::nonfinite::NonFiniteSer(serializer),
432        )
433    }
434}
435
436impl<'de> Deserialize<'de> for BalancedNetwork {
437    fn deserialize<D: serde::Deserializer<'de>>(
438        deserializer: D,
439    ) -> std::result::Result<Self, D::Error> {
440        BalancedNetworkTables::deserialize(powerio_core::__implementation::nonfinite::NonFiniteDe(
441            deserializer,
442        ))
443        .map(BalancedNetwork::from_tables)
444    }
445}
446
447#[cfg(feature = "schema")]
448impl schemars::JsonSchema for BalancedNetwork {
449    fn schema_name() -> std::borrow::Cow<'static, str> {
450        <BalancedNetworkTables as schemars::JsonSchema>::schema_name()
451    }
452
453    fn schema_id() -> std::borrow::Cow<'static, str> {
454        <BalancedNetworkTables as schemars::JsonSchema>::schema_id()
455    }
456
457    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
458        <BalancedNetworkTables as schemars::JsonSchema>::json_schema(generator)
459    }
460}
461
462macro_rules! table_accessors {
463    ($($(#[$doc:meta])* $field:ident, $field_mut:ident: $ty:ty;)+) => {
464        impl BalancedNetwork {
465            $(
466                $(#[$doc])*
467                #[must_use]
468                pub fn $field(&self) -> &$ty {
469                    &self.tables.$field
470                }
471
472                /// Mutable access to the same table; a shared handle copies
473                /// its tables once here, so no other handle observes the
474                /// change.
475                #[must_use]
476                pub fn $field_mut(&mut self) -> &mut $ty {
477                    &mut self.tables_mut().$field
478                }
479            )+
480        }
481    };
482}
483
484table_accessors! {
485    /// The case name.
486    name, name_mut: String;
487    /// The geographic metadata when the source carries any.
488    geo, geo_mut: Option<GeoMeta>;
489    /// Solver / solution-control metadata when the source carries it.
490    solver, solver_mut: Option<SolverParams>;
491}
492
493/// The element tables sit behind their own shared allocation inside the
494/// shared table set, so a time series of networks that varies one table
495/// clones only that table per point while every untouched table stays one
496/// allocation across the whole series.
497macro_rules! shared_table_accessors {
498    ($($(#[$doc:meta])* $field:ident, $field_mut:ident: $ty:ty;)+) => {
499        impl BalancedNetwork {
500            $(
501                $(#[$doc])*
502                #[must_use]
503                pub fn $field(&self) -> &$ty {
504                    &self.tables.$field
505                }
506
507                /// Mutable access to the same table. A shared handle copies
508                /// the table set spine and this one table here, so no other
509                /// handle observes the change and untouched tables stay
510                /// shared.
511                #[must_use]
512                pub fn $field_mut(&mut self) -> &mut $ty {
513                    std::sync::Arc::make_mut(&mut self.tables_mut().$field)
514                }
515            )+
516        }
517    };
518}
519
520impl BalancedNetwork {
521    /// Replace each element table's allocation with `donor`'s wherever the
522    /// contents are equal, so equal tables across derived networks (the
523    /// scenarios of one dataset, the points of one series) are stored once.
524    /// No value changes; a table that differs anywhere keeps its own
525    /// allocation.
526    pub fn share_equal_tables(&mut self, donor: &Self) {
527        macro_rules! share {
528            ($($field:ident),+) => {
529                $(
530                    if !std::sync::Arc::ptr_eq(&self.tables.$field, &donor.tables.$field)
531                        && self.tables.$field == donor.tables.$field
532                    {
533                        self.tables_mut().$field = donor.tables.$field.clone();
534                    }
535                )+
536            };
537        }
538        share!(
539            buses,
540            loads,
541            shunts,
542            branches,
543            switches,
544            generators,
545            storage,
546            hvdc,
547            transformers_3w,
548            areas
549        );
550    }
551}
552
553shared_table_accessors! {
554    buses, buses_mut: Vec<Bus>;
555    loads, loads_mut: Vec<Load>;
556    shunts, shunts_mut: Vec<Shunt>;
557    branches, branches_mut: Vec<Branch>;
558    switches, switches_mut: Vec<Switch>;
559    generators, generators_mut: Vec<Generator>;
560    storage, storage_mut: Vec<Storage>;
561    hvdc, hvdc_mut: Vec<Hvdc>;
562    /// Three-winding transformers, kept as typed records.
563    transformers_3w, transformers_3w_mut: Vec<Transformer3W>;
564    /// Area records: scheduled interchange and per-area swing bus.
565    areas, areas_mut: Vec<Area>;
566}
567
568impl BalancedNetwork {
569    /// System MVA base.
570    #[must_use]
571    pub fn base_mva(&self) -> f64 {
572        self.tables.base_mva
573    }
574
575    #[must_use]
576    pub fn base_mva_mut(&mut self) -> &mut f64 {
577        &mut self.tables_mut().base_mva
578    }
579
580    /// System base frequency in hertz (50 or 60).
581    #[must_use]
582    pub fn base_frequency(&self) -> f64 {
583        self.tables.base_frequency
584    }
585
586    #[must_use]
587    pub fn base_frequency_mut(&mut self) -> &mut f64 {
588        &mut self.tables_mut().base_frequency
589    }
590
591    /// The format the case was parsed from.
592    #[must_use]
593    pub fn source_format(&self) -> SourceFormat {
594        self.tables.source_format
595    }
596
597    #[must_use]
598    pub fn source_format_mut(&mut self) -> &mut SourceFormat {
599        &mut self.tables_mut().source_format
600    }
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
604#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
605#[non_exhaustive]
606pub struct Bus {
607    /// Stable bus id (1-based in MATPOWER; preserved verbatim).
608    pub id: BusId,
609    pub kind: BusType,
610    /// Voltage magnitude (p.u.).
611    pub vm: f64,
612    /// Voltage angle (degrees).
613    pub va: f64,
614    pub base_kv: f64,
615    pub vmax: f64,
616    pub vmin: f64,
617    /// Emergency (short-term) voltage band, set only when the source states one
618    /// distinct from the normal [`vmax`](Bus::vmax)/[`vmin`](Bus::vmin) band (PSS/E
619    /// `EVHI`/`EVLO`). `None` means the emergency band equals the normal band, so
620    /// read `evhi.unwrap_or(vmax)` / `evlo.unwrap_or(vmin)`. `#[serde(default)]` so
621    /// JSON written before the fields existed still deserializes.
622    #[serde(default)]
623    pub evhi: Option<f64>,
624    #[serde(default)]
625    pub evlo: Option<f64>,
626    pub area: usize,
627    pub zone: usize,
628    pub name: Option<String>,
629    /// Stable row identity for `.pio.json` payloads and operating point updates:
630    /// the source record uid where the format defines one (GOC3), synthesized at
631    /// package build otherwise. `#[serde(default)]` so JSON written before the
632    /// field existed still deserializes.
633    #[serde(default, skip_serializing_if = "Option::is_none")]
634    pub uid: Option<String>,
635    /// Optional bus coordinates in the network coordinate space.
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub location: Option<Location>,
638    pub extras: Extras,
639}
640
641impl Bus {
642    #[must_use]
643    pub fn new(id: BusId, kind: BusType, base_kv: f64) -> Self {
644        Self {
645            id,
646            kind,
647            vm: 1.0,
648            va: 0.0,
649            base_kv,
650            vmax: 1.1,
651            vmin: 0.9,
652            evhi: None,
653            evlo: None,
654            area: 1,
655            zone: 1,
656            name: None,
657            uid: None,
658            location: None,
659            extras: Extras::new(),
660        }
661    }
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
665#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
666#[non_exhaustive]
667pub struct Load {
668    pub bus: BusId,
669    /// Active demand (MW).
670    pub p: f64,
671    /// Reactive demand (MVAr).
672    pub q: f64,
673    /// Voltage dependence, when the source states one. `None` is constant power.
674    #[serde(default)]
675    pub voltage_model: Option<LoadVoltageModel>,
676    pub in_service: bool,
677    /// Stable row identity; see [`Bus::uid`].
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub uid: Option<String>,
680    pub extras: Extras,
681}
682
683impl Load {
684    #[must_use]
685    pub fn new(bus: BusId, p: f64, q: f64) -> Self {
686        Self {
687            bus,
688            p,
689            q,
690            voltage_model: None,
691            in_service: true,
692            uid: None,
693            extras: Extras::new(),
694        }
695    }
696}
697
698/// Voltage dependence for a transmission load.
699#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
700#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
701#[serde(tag = "kind", rename_all = "snake_case")]
702#[non_exhaustive]
703pub enum LoadVoltageModel {
704    /// Explicit constant power marker.
705    ConstantPower,
706    /// ZIP load split in source units. The three active parts sum to
707    /// [`Load::p`], and the three reactive parts sum to [`Load::q`].
708    Zip {
709        p_constant_power: f64,
710        q_constant_power: f64,
711        p_constant_current: f64,
712        q_constant_current: f64,
713        p_constant_impedance: f64,
714        q_constant_impedance: f64,
715        #[serde(default)]
716        v_nom: Option<f64>,
717        /// Source load type code, when a format has one (PSS/E `ID`/`LOADTYPE`
718        /// style metadata).
719        #[serde(default)]
720        load_type: Option<i32>,
721        /// Source scaling factor, when a format has one.
722        #[serde(default)]
723        scaling: Option<f64>,
724    },
725    /// Exponential voltage model: `P = p * (V / v_nom)^gamma_p`,
726    /// `Q = q * (V / v_nom)^gamma_q`.
727    Exponential {
728        p: f64,
729        q: f64,
730        #[serde(default)]
731        v_nom: Option<f64>,
732        gamma_p: f64,
733        gamma_q: f64,
734    },
735}
736
737impl LoadVoltageModel {
738    #[must_use]
739    pub fn has_non_matpower_fields(&self) -> bool {
740        match self {
741            Self::ConstantPower => false,
742            Self::Zip {
743                p_constant_current,
744                q_constant_current,
745                p_constant_impedance,
746                q_constant_impedance,
747                v_nom,
748                load_type,
749                scaling,
750                ..
751            } => {
752                *p_constant_current != 0.0
753                    || *q_constant_current != 0.0
754                    || *p_constant_impedance != 0.0
755                    || *q_constant_impedance != 0.0
756                    || v_nom.is_some()
757                    || load_type.is_some()
758                    || scaling.is_some()
759            }
760            Self::Exponential { .. } => true,
761        }
762    }
763}
764
765#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
766#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
767#[non_exhaustive]
768pub struct Shunt {
769    pub bus: BusId,
770    /// Shunt conductance (MW at V = 1 p.u.).
771    pub g: f64,
772    /// Shunt susceptance (MVAr at V = 1 p.u.). For a switched shunt this is the
773    /// initial (steady-state) value within the [`control`](Shunt::control) blocks.
774    pub b: f64,
775    pub in_service: bool,
776    /// Switching-control data when this is a switched (adjustable) shunt; `None`
777    /// for a fixed shunt. `#[serde(default)]` so JSON written before the field
778    /// existed still deserializes.
779    #[serde(default)]
780    pub control: Option<SwitchedShuntControl>,
781    /// Stable row identity; see [`Bus::uid`].
782    #[serde(default, skip_serializing_if = "Option::is_none")]
783    pub uid: Option<String>,
784    pub extras: Extras,
785}
786
787impl Shunt {
788    #[must_use]
789    pub fn new(bus: BusId, g: f64, b: f64) -> Self {
790        Self {
791            bus,
792            g,
793            b,
794            in_service: true,
795            control: None,
796            uid: None,
797            extras: Extras::new(),
798        }
799    }
800}
801
802/// How a switched shunt adjusts its susceptance. Maps to the PSS/E `MODSW` code.
803#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
804#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
805#[serde(rename_all = "snake_case")]
806#[non_exhaustive]
807pub enum SwitchedShuntMode {
808    /// Fixed at its initial susceptance, no automatic switching (`MODSW` 0).
809    Locked,
810    /// Continuous adjustment within the block range (`MODSW` 1).
811    Continuous,
812    /// Discrete adjustment in fixed steps (`MODSW` 2 and up).
813    Discrete,
814}
815
816/// One block of a switched shunt: `steps` equal increments of susceptance `b`.
817#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
818#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
819#[non_exhaustive]
820pub struct ShuntBlock {
821    pub steps: u32,
822    /// Susceptance increment per step (MVAr at V = 1 p.u.).
823    pub b: f64,
824}
825
826impl ShuntBlock {
827    #[must_use]
828    pub const fn new(steps: u32, b: f64) -> Self {
829        Self { steps, b }
830    }
831}
832
833/// Switching-control data for a switched shunt ([`Shunt::control`]): the mode,
834/// the regulated voltage band and bus, the reactive-range percentage, and the
835/// adjustable susceptance blocks. The shunt's [`b`](Shunt::b) is the initial
836/// value within the blocks' total range.
837#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
838#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
839#[non_exhaustive]
840pub struct SwitchedShuntControl {
841    pub mode: SwitchedShuntMode,
842    /// Regulated voltage band (per unit).
843    pub vhigh: f64,
844    pub vlow: f64,
845    /// The regulated bus; `None` means the shunt regulates its own bus.
846    pub control_bus: Option<BusId>,
847    /// Percent of the controlled device's reactive range to apply (PSS/E `RMPCT`).
848    pub rmpct: f64,
849    pub blocks: Vec<ShuntBlock>,
850}
851
852impl SwitchedShuntControl {
853    #[must_use]
854    pub fn new(mode: SwitchedShuntMode, vhigh: f64, vlow: f64, blocks: Vec<ShuntBlock>) -> Self {
855        Self {
856            mode,
857            vhigh,
858            vlow,
859            control_bus: None,
860            rmpct: 100.0,
861            blocks,
862        }
863    }
864}
865
866#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
867#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
868#[non_exhaustive]
869pub struct Branch {
870    pub from: BusId,
871    pub to: BusId,
872    /// Series resistance (p.u.).
873    pub r: f64,
874    /// Series reactance (p.u.).
875    pub x: f64,
876    /// MATPOWER compatible total line charging susceptance (p.u.). This is the
877    /// legacy total projection; when [`charging`](Branch::charging) is present,
878    /// per terminal admittance is canonical and this field is compatibility data.
879    pub b: f64,
880    /// Per terminal shunt admittance (p.u.). If absent, derive symmetric
881    /// susceptance from [`b`](Branch::b).
882    #[serde(default)]
883    pub charging: Option<BranchCharging>,
884    pub rate_a: f64,
885    pub rate_b: f64,
886    pub rate_c: f64,
887    /// Additional MVA rating sets beyond A/B/C. Matrix builders continue to use
888    /// `rate_a` unless they opt into one of these named sets.
889    #[serde(default)]
890    pub rating_sets: Vec<BranchRatingSet>,
891    /// Current ratings, when the source distinguishes them from MVA ratings.
892    #[serde(default)]
893    pub current_ratings: Option<BranchCurrentRatings>,
894    /// Tap ratio, MATPOWER convention: 0 means "no tap" (a line), treated as 1.
895    pub tap: f64,
896    /// Phase shift (degrees).
897    pub shift: f64,
898    pub in_service: bool,
899    pub angmin: f64,
900    pub angmax: f64,
901    /// Regulating-transformer control data, when this branch is a transformer
902    /// under automatic tap or phase control. `None` for lines and for fixed-ratio
903    /// transformers. `#[serde(default)]` so JSON written before the field existed
904    /// still deserializes.
905    #[serde(default)]
906    pub control: Option<TransformerControl>,
907    /// Solved branch flow values, when present in a case snapshot.
908    #[serde(default)]
909    pub solution: Option<BranchSolution>,
910    /// Stable row identity; see [`Bus::uid`].
911    #[serde(default, skip_serializing_if = "Option::is_none")]
912    pub uid: Option<String>,
913    /// Polyline route in the network's coordinate space (`BalancedNetwork.geo`),
914    /// present only when a source provides intermediate geometry; endpoint
915    /// only rendering derives from the bus locations. `#[serde(default)]` so
916    /// JSON written before the field existed still deserializes.
917    #[serde(default, skip_serializing_if = "Option::is_none")]
918    pub route: Option<Vec<Location>>,
919    pub extras: Extras,
920}
921
922/// Extra branch MVA rating set beyond the canonical A/B/C columns.
923#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
924#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
925#[non_exhaustive]
926pub struct BranchRatingSet {
927    pub name: String,
928    pub rate_mva: f64,
929}
930
931impl BranchRatingSet {
932    #[must_use]
933    pub fn new(name: impl Into<String>, rate_mva: f64) -> Self {
934        Self {
935            name: name.into(),
936            rate_mva,
937        }
938    }
939}
940
941/// Per terminal branch shunt admittance in p.u. This is the canonical
942/// physical branch shunt model when present.
943#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
944#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
945#[non_exhaustive]
946pub struct BranchCharging {
947    pub g_fr: f64,
948    pub b_fr: f64,
949    pub g_to: f64,
950    pub b_to: f64,
951}
952
953impl BranchCharging {
954    #[must_use]
955    pub const fn new(g_fr: f64, b_fr: f64, g_to: f64, b_to: f64) -> Self {
956        Self {
957            g_fr,
958            b_fr,
959            g_to,
960            b_to,
961        }
962    }
963
964    #[must_use]
965    pub fn from_total_b(b: f64) -> Self {
966        Self {
967            g_fr: 0.0,
968            b_fr: b / 2.0,
969            g_to: 0.0,
970            b_to: b / 2.0,
971        }
972    }
973
974    #[must_use]
975    pub fn total_b(self) -> f64 {
976        self.b_fr + self.b_to
977    }
978
979    #[must_use]
980    pub fn total_g(self) -> f64 {
981        self.g_fr + self.g_to
982    }
983
984    #[must_use]
985    pub fn is_matpower_symmetric(self) -> bool {
986        self.g_fr.abs() <= f64::EPSILON
987            && self.g_to.abs() <= f64::EPSILON
988            && (self.b_fr - self.b_to).abs() <= f64::EPSILON
989    }
990}
991
992/// Current limits for a branch, in source units.
993#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
994#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
995#[non_exhaustive]
996pub struct BranchCurrentRatings {
997    pub c_rating_a: f64,
998    pub c_rating_b: f64,
999    pub c_rating_c: f64,
1000}
1001
1002impl BranchCurrentRatings {
1003    #[must_use]
1004    pub const fn new(c_rating_a: f64, c_rating_b: f64, c_rating_c: f64) -> Self {
1005        Self {
1006            c_rating_a,
1007            c_rating_b,
1008            c_rating_c,
1009        }
1010    }
1011}
1012
1013/// Solved branch terminal flows in MW/MVAr.
1014#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1015#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1016#[non_exhaustive]
1017pub struct BranchSolution {
1018    pub pf: f64,
1019    pub qf: f64,
1020    pub pt: f64,
1021    pub qt: f64,
1022}
1023
1024impl BranchSolution {
1025    #[must_use]
1026    pub const fn new(pf: f64, qf: f64, pt: f64, qt: f64) -> Self {
1027        Self { pf, qf, pt, qt }
1028    }
1029}
1030
1031impl Branch {
1032    #[must_use]
1033    pub fn new(from: BusId, to: BusId, r: f64, x: f64) -> Self {
1034        Self {
1035            from,
1036            to,
1037            r,
1038            x,
1039            b: 0.0,
1040            charging: None,
1041            rate_a: 0.0,
1042            rate_b: 0.0,
1043            rate_c: 0.0,
1044            rating_sets: Vec::new(),
1045            current_ratings: None,
1046            tap: 0.0,
1047            shift: 0.0,
1048            in_service: true,
1049            angmin: -360.0,
1050            angmax: 360.0,
1051            control: None,
1052            solution: None,
1053            uid: None,
1054            route: None,
1055            extras: Extras::new(),
1056        }
1057    }
1058
1059    /// Effective tap ratio (0 ⇒ 1).
1060    #[must_use]
1061    pub fn effective_tap(&self) -> f64 {
1062        if self.tap == 0.0 { 1.0 } else { self.tap }
1063    }
1064
1065    /// [`effective_tap`](Self::effective_tap) for a builder that divides by it,
1066    /// which the remap of an exact 0.0 does not make safe on its own.
1067    ///
1068    /// # Errors
1069    /// [`Error::DegenerateTap`] under
1070    /// [`MIN_DIVISIBLE_MAGNITUDE`](crate::dc::MIN_DIVISIBLE_MAGNITUDE), where a
1071    /// tap scales an admittance past anything a matrix can carry. `row` only
1072    /// labels the error.
1073    pub fn divisible_tap(&self, row: usize) -> Result<f64> {
1074        let tap = self.effective_tap();
1075        if !tap.is_finite() || tap.abs() < crate::dc::MIN_DIVISIBLE_MAGNITUDE {
1076            return Err(Error::DegenerateTap { row, tap });
1077        }
1078        Ok(tap)
1079    }
1080
1081    /// Per terminal shunt admittance, deriving the legacy symmetric MATPOWER
1082    /// charging model when the richer field is absent.
1083    #[must_use]
1084    pub fn terminal_charging(&self) -> BranchCharging {
1085        self.charging
1086            .unwrap_or_else(|| BranchCharging::from_total_b(self.b))
1087    }
1088
1089    /// Series admittance `(g, b) = (r, −x) / (r² + x²)` of the branch pi
1090    /// model, the primitive beside [`effective_tap`](Self::effective_tap) and
1091    /// [`terminal_charging`](Self::terminal_charging). `Ok(None)` for a zero
1092    /// impedance branch — one whose impedance magnitude is under
1093    /// [`MIN_DIVISIBLE_MAGNITUDE`](crate::dc::MIN_DIVISIBLE_MAGNITUDE); the
1094    /// caller decides whether that is a skip or an error.
1095    ///
1096    /// # Errors
1097    /// [`Error::NonFiniteSusceptance`] when `r`/`x` are NaN/Inf, so a bad
1098    /// value cannot write NaN or a silent zero downstream. `row` only labels
1099    /// the error.
1100    pub fn series_admittance(&self, row: usize) -> Result<Option<(f64, f64)>> {
1101        series_admittance_of(self.r, self.x, row)
1102    }
1103
1104    /// Apparent power bound, per unit, for a branch the source left unrated
1105    /// (`rate_a == 0`, which reads as unlimited). `angle_window_rad` is the
1106    /// widest angle difference the branch may hold, in radians. That window and
1107    /// the two terminal voltage bands give the widest voltage phasor difference
1108    /// the branch can hold. The difference over `|Z|` bounds the current, and
1109    /// the larger ceiling turns the current into power. Returns `0.0` for a zero
1110    /// impedance branch — one under
1111    /// [`MIN_DIVISIBLE_MAGNITUDE`](crate::dc::MIN_DIVISIBLE_MAGNITUDE), the
1112    /// bound the rest of the builders divide by — which stays unlimited.
1113    ///
1114    /// Both ends of each band are needed, not just the ceilings. `|V_f e^{jδ} −
1115    /// V_t|²` is convex in `(V_f, V_t)`, so its largest value over the voltage
1116    /// box sits at a corner — and below a window of roughly 10° that corner is
1117    /// the mixed one, one terminal high and the other low, not both high.
1118    /// Reading only the ceilings there understates the bound several fold and
1119    /// hands an OPF a limit tighter than the branch physically has.
1120    ///
1121    /// The caller supplies the window in radians, because
1122    /// [`angmin`](Self::angmin) and [`angmax`](Self::angmax) are degrees in
1123    /// the neutral model and radians in a normalized network, and a branch
1124    /// cannot tell which it holds. Convert them with
1125    /// [`IndexedNetwork::angle_radians`](crate::IndexedNetwork::angle_radians),
1126    /// which reads the convention of the network. The method takes the
1127    /// magnitude of the window and holds it at `π`, the widest phasor
1128    /// separation two terminals can have.
1129    #[must_use]
1130    pub fn synthesize_rate_a(
1131        &self,
1132        angle_window_rad: f64,
1133        (fr_vmin, fr_vmax): (f64, f64),
1134        (to_vmin, to_vmax): (f64, f64),
1135    ) -> f64 {
1136        // The same bound `series_admittance_of` divides by, so the two agree on
1137        // which branch has no impedance to bound a current with.
1138        let zmag = self.r.hypot(self.x);
1139        if zmag < crate::dc::MIN_DIVISIBLE_MAGNITUDE {
1140            return 0.0;
1141        }
1142        let window = angle_window_rad.abs().min(std::f64::consts::PI);
1143        let cos_window = window.cos();
1144        // Clamped at zero before the root: the law of cosines is nonnegative in
1145        // exact arithmetic, and rounding on two nearly equal voltages can carry
1146        // it a few ulp under.
1147        let separation = |vf: f64, vt: f64| {
1148            (vf * vf + vt * vt - 2.0 * vf * vt * cos_window)
1149                .max(0.0)
1150                .sqrt()
1151        };
1152        let widest = separation(fr_vmax, to_vmax)
1153            .max(separation(fr_vmax, to_vmin))
1154            .max(separation(fr_vmin, to_vmax))
1155            .max(separation(fr_vmin, to_vmin));
1156        fr_vmax.max(to_vmax) * widest / zmag
1157    }
1158
1159    /// Total susceptance projection for MATPOWER shaped formats that only carry
1160    /// one line charging value.
1161    #[must_use]
1162    pub fn total_charging_b(&self) -> f64 {
1163        self.terminal_charging().total_b()
1164    }
1165
1166    /// Whether this branch has charging that a MATPOWER branch row cannot carry.
1167    #[must_use]
1168    pub fn has_non_matpower_charging(&self) -> bool {
1169        self.charging
1170            .is_some_and(|charging| !charging.is_matpower_symmetric())
1171    }
1172
1173    /// A transformer iff the raw tap field is nonzero (an explicit `1` counts) or
1174    /// there is a phase shift.
1175    #[must_use]
1176    pub fn is_transformer(&self) -> bool {
1177        self.tap != 0.0 || self.shift != 0.0
1178    }
1179
1180    /// True when the branch constrains its angle difference, i.e. the limits
1181    /// deviate from the ±360° "unconstrained" default. Formats without angle
1182    /// limit fields (PSS/E, PowerWorld) use this to warn on what they drop.
1183    #[must_use]
1184    pub fn has_angle_limits(&self) -> bool {
1185        self.angmin > -360.0 || self.angmax < 360.0
1186    }
1187}
1188
1189/// The series admittance `(g, b)` of an impedance, guarded.
1190///
1191/// `None` is an impedance too small to divide by, under
1192/// [`MIN_DIVISIBLE_MAGNITUDE`](crate::dc::MIN_DIVISIBLE_MAGNITUDE); the caller
1193/// decides whether that is a skip or an error. The bound is on the impedance
1194/// magnitude, not on `r² + x²`, which is its square: bounding the square would
1195/// refuse impedances the DC builders divide by.
1196///
1197/// Y_bus takes `r` already zeroed under the XB scheme, so it passes its own
1198/// pair rather than a branch's.
1199///
1200/// # Errors
1201/// [`Error::NonFiniteSusceptance`] when `r`/`x` are NaN/Inf, so a bad value
1202/// cannot write NaN or a silent zero downstream. NaN leaves `hypot` NaN, which
1203/// is not below the bound, so it arrives at that check rather than reading as
1204/// zero impedance. `row` only labels the error.
1205pub fn series_admittance_of(r: f64, x: f64, row: usize) -> Result<Option<(f64, f64)>> {
1206    let magnitude = r.hypot(x);
1207    if magnitude < crate::dc::MIN_DIVISIBLE_MAGNITUDE {
1208        return Ok(None);
1209    }
1210    if !magnitude.is_finite() {
1211        return Err(Error::NonFiniteSusceptance { row });
1212    }
1213    Ok(Some(crate::dc::series_admittance_parts(r, x)))
1214}
1215
1216/// A transmission switch. Closed switches are preserved as data; matrix builders
1217/// do not lower them into zero impedance branches.
1218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1219#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1220#[non_exhaustive]
1221pub struct Switch {
1222    pub from: BusId,
1223    pub to: BusId,
1224    pub closed: bool,
1225    #[serde(default)]
1226    pub thermal_rating: Option<f64>,
1227    #[serde(default)]
1228    pub current_rating: Option<f64>,
1229    #[serde(default)]
1230    pub pf: Option<f64>,
1231    #[serde(default)]
1232    pub qf: Option<f64>,
1233    #[serde(default)]
1234    pub pt: Option<f64>,
1235    #[serde(default)]
1236    pub qt: Option<f64>,
1237    /// Stable row identity; see [`Bus::uid`].
1238    #[serde(default, skip_serializing_if = "Option::is_none")]
1239    pub uid: Option<String>,
1240    pub extras: Extras,
1241}
1242
1243impl Switch {
1244    #[must_use]
1245    pub fn new(from: BusId, to: BusId, closed: bool) -> Self {
1246        Self {
1247            from,
1248            to,
1249            closed,
1250            thermal_rating: None,
1251            current_rating: None,
1252            pf: None,
1253            qf: None,
1254            pt: None,
1255            qt: None,
1256            uid: None,
1257            extras: Extras::new(),
1258        }
1259    }
1260}
1261
1262/// What a regulating transformer's tap (or phase shift) automatically controls.
1263/// Maps to the PSS/E control code `COD` and the PSLF transformer `type`.
1264#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1265#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1266#[serde(rename_all = "snake_case")]
1267#[non_exhaustive]
1268pub enum TransformerControlMode {
1269    /// Fixed ratio, no automatic adjustment (PSS/E `COD` 0/±4, PSLF type 1).
1270    Fixed,
1271    /// Bus voltage control via tap (LTC; PSS/E `COD` ±1, PSLF type 2).
1272    Voltage,
1273    /// Reactive power flow control via tap (PSS/E `COD` ±2).
1274    ReactiveFlow,
1275    /// Active power flow control via phase shift (PSS/E `COD` ±3, PSLF type 4).
1276    ActiveFlow,
1277}
1278
1279/// Automatic-control data for a regulating transformer ([`Branch::control`]).
1280///
1281/// The limits carry whatever the [`mode`](TransformerControl::mode) regulates:
1282/// `tap_min`/`tap_max` bound the tap ratio (or the phase angle, for
1283/// [`ActiveFlow`](TransformerControlMode::ActiveFlow)), and `band_min`/`band_max`
1284/// bound the controlled quantity (the regulated voltage band, or the
1285/// scheduled MW/MVAr). `ntp` is the number of discrete tap positions and
1286/// `controlled_bus` is the regulated bus (`None` = the transformer's own
1287/// terminal). `mva_base` is the winding MVA base the impedance is referred to.
1288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1289#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1290#[non_exhaustive]
1291pub struct TransformerControl {
1292    pub mode: TransformerControlMode,
1293    pub controlled_bus: Option<BusId>,
1294    pub tap_min: f64,
1295    pub tap_max: f64,
1296    pub band_min: f64,
1297    pub band_max: f64,
1298    pub ntp: u32,
1299    pub mva_base: f64,
1300}
1301
1302impl Default for TransformerControl {
1303    fn default() -> Self {
1304        // PSS/E's documented defaults for an unset winding-control block.
1305        TransformerControl {
1306            mode: TransformerControlMode::Fixed,
1307            controlled_bus: None,
1308            tap_min: 0.9,
1309            tap_max: 1.1,
1310            band_min: 0.9,
1311            band_max: 1.1,
1312            ntp: 33,
1313            mva_base: 0.0,
1314        }
1315    }
1316}
1317
1318impl TransformerControl {
1319    #[must_use]
1320    pub fn new(mode: TransformerControlMode) -> Self {
1321        Self {
1322            mode,
1323            ..Self::default()
1324        }
1325    }
1326}
1327
1328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1329#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1330#[non_exhaustive]
1331pub struct Generator {
1332    pub bus: BusId,
1333    /// Real power set point (MW).
1334    pub pg: f64,
1335    /// Reactive power set point (MVAr).
1336    pub qg: f64,
1337    pub pmax: f64,
1338    pub pmin: f64,
1339    pub qmax: f64,
1340    pub qmin: f64,
1341    /// Voltage set point (p.u.).
1342    pub vg: f64,
1343    pub mbase: f64,
1344    pub in_service: bool,
1345    pub cost: Option<GenCost>,
1346    /// The MATPOWER gen capability / ramp columns past `PMIN`, aligned to
1347    /// `GEN_EXTRA_KEYS` by index (`None` for a column the source omitted).
1348    /// A fixed array, not an [`Extras`] map: a string-keyed map per generator
1349    /// costs 11 heap allocations each, which dominates the parse of a large
1350    /// generator-heavy case. Surfaced into formats that name them (PowerModels).
1351    /// On the JSON snapshot it is a name-keyed object (see `caps_serde`) so the
1352    /// schema stays additive when `GEN_EXTRA_KEYS` grows; `#[serde(default)]` so a
1353    /// snapshot that omits it deserializes to the empty set.
1354    #[serde(default = "default_caps", with = "caps_serde")]
1355    #[cfg_attr(feature = "schema", schemars(with = "BTreeMap<String, f64>"))]
1356    pub caps: GenCaps,
1357    /// The remote bus whose voltage this generator regulates, when that is not its
1358    /// own terminal bus (PSS/E `IREG`). `None` means it regulates its own bus.
1359    /// Part of the cross-element voltage-control graph: a format that names a
1360    /// remote regulated bus (PSS/E) keeps it across a round trip instead of
1361    /// collapsing every generator onto its own terminal. `#[serde(default)]` so
1362    /// JSON written before the field existed still deserializes.
1363    #[serde(default)]
1364    pub regulated_bus: Option<BusId>,
1365    /// Stable row identity; see [`Bus::uid`].
1366    #[serde(default, skip_serializing_if = "Option::is_none")]
1367    pub uid: Option<String>,
1368}
1369
1370impl Generator {
1371    #[must_use]
1372    pub fn new(bus: BusId) -> Self {
1373        Self {
1374            bus,
1375            pg: 0.0,
1376            qg: 0.0,
1377            pmax: 0.0,
1378            pmin: 0.0,
1379            qmax: 0.0,
1380            qmin: 0.0,
1381            vg: 1.0,
1382            mbase: 0.0,
1383            in_service: true,
1384            cost: None,
1385            caps: default_caps(),
1386            regulated_bus: None,
1387            uid: None,
1388        }
1389    }
1390
1391    /// True when any capability / ramp column is present. Formats without those
1392    /// fields (PSS/E, PowerWorld) use this to warn on what they drop.
1393    #[must_use]
1394    pub fn has_caps(&self) -> bool {
1395        self.caps.iter().any(Option::is_some)
1396    }
1397}
1398
1399/// A generator's capability / ramp columns, one slot per `GEN_EXTRA_KEYS` name.
1400pub type GenCaps = [Option<f64>; GEN_EXTRA_KEYS.len()];
1401
1402/// The empty capability set, for a JSON snapshot that omits the field entirely.
1403fn default_caps() -> GenCaps {
1404    [None; GEN_EXTRA_KEYS.len()]
1405}
1406
1407/// Serialize [`GenCaps`] as a name-keyed object (`{"ramp_30": 1.2, ...}`) keyed by
1408/// [`GEN_EXTRA_KEYS`], emitting only the present slots, instead of a length-exact
1409/// array. A fixed-length array round-trips through serde only at exactly its
1410/// current length: the day `GEN_EXTRA_KEYS` grows a column, every old snapshot
1411/// fails to deserialize and every new one fails on an old build, and the C ABI
1412/// ties the JSON snapshot schema to its version, so that is a forced ABI break.
1413/// The named map makes a new key purely additive: an old document simply lacks it
1414/// (deserializes to `None`), and an unknown key from a newer document is ignored.
1415/// In memory `caps` stays a fixed array, so the per-generator allocation cost the
1416/// array avoids is unchanged; only the serialized form is named.
1417mod caps_serde {
1418    use super::{GEN_EXTRA_KEYS, GenCaps};
1419    use serde::de::{Deserialize, Deserializer};
1420    use serde::ser::{SerializeMap, Serializer};
1421    use std::collections::BTreeMap;
1422
1423    pub(super) fn serialize<S: Serializer>(caps: &GenCaps, s: S) -> Result<S::Ok, S::Error> {
1424        let present = caps.iter().filter(|v| v.is_some()).count();
1425        let mut map = s.serialize_map(Some(present))?;
1426        for (key, slot) in GEN_EXTRA_KEYS.iter().zip(caps.iter()) {
1427            if let Some(value) = slot {
1428                map.serialize_entry(key, value)?;
1429            }
1430        }
1431        map.end()
1432    }
1433
1434    pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<GenCaps, D::Error> {
1435        // Accept an explicit `null` as the empty set (treated like an omitted
1436        // field), so a producer that encodes "no caps" as `null` round-trips the
1437        // same way `cost: Option<_>` does. `#[serde(default)]` only covers an
1438        // absent key, not a present `null`.
1439        let named = Option::<BTreeMap<String, f64>>::deserialize(d)?.unwrap_or_default();
1440        let mut caps: GenCaps = [None; GEN_EXTRA_KEYS.len()];
1441        for (slot, key) in caps.iter_mut().zip(GEN_EXTRA_KEYS.iter()) {
1442            *slot = named.get(*key).copied();
1443        }
1444        Ok(caps)
1445    }
1446}
1447
1448#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1449#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1450#[non_exhaustive]
1451pub struct Storage {
1452    pub bus: BusId,
1453    pub ps: f64,
1454    pub qs: f64,
1455    pub energy: f64,
1456    pub energy_rating: f64,
1457    pub charge_rating: f64,
1458    pub discharge_rating: f64,
1459    pub charge_efficiency: f64,
1460    pub discharge_efficiency: f64,
1461    pub thermal_rating: f64,
1462    #[serde(default)]
1463    pub current_rating: Option<f64>,
1464    pub qmin: f64,
1465    pub qmax: f64,
1466    pub r: f64,
1467    pub x: f64,
1468    pub p_loss: f64,
1469    pub q_loss: f64,
1470    pub in_service: bool,
1471    /// Stable row identity; see [`Bus::uid`].
1472    #[serde(default, skip_serializing_if = "Option::is_none")]
1473    pub uid: Option<String>,
1474    pub extras: Extras,
1475}
1476
1477impl Storage {
1478    #[must_use]
1479    pub fn new(bus: BusId) -> Self {
1480        Self {
1481            bus,
1482            ps: 0.0,
1483            qs: 0.0,
1484            energy: 0.0,
1485            energy_rating: 0.0,
1486            charge_rating: 0.0,
1487            discharge_rating: 0.0,
1488            charge_efficiency: 1.0,
1489            discharge_efficiency: 1.0,
1490            thermal_rating: 0.0,
1491            current_rating: None,
1492            qmin: 0.0,
1493            qmax: 0.0,
1494            r: 0.0,
1495            x: 0.0,
1496            p_loss: 0.0,
1497            q_loss: 0.0,
1498            in_service: true,
1499            uid: None,
1500            extras: Extras::new(),
1501        }
1502    }
1503}
1504
1505/// A two-terminal HVDC line (MATPOWER `dcline`).
1506///
1507/// `pf`/`pt`/`qf`/`qt` are stored in MATPOWER's sign convention regardless of
1508/// source: the PowerModels reader un-flips `pt`/`qf`/`qt` on the way in, and the
1509/// PowerModels writer re-flips them on the way out (PowerModels.jl uses the
1510/// opposite sign). The flip is a format-boundary translation, so a derived view
1511/// like `to_normalized` keeps the MATPOWER convention and only scales to per unit.
1512#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1513#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1514#[non_exhaustive]
1515pub struct Hvdc {
1516    pub from: BusId,
1517    pub to: BusId,
1518    pub in_service: bool,
1519    pub pf: f64,
1520    pub pt: f64,
1521    pub qf: f64,
1522    pub qt: f64,
1523    pub vf: f64,
1524    pub vt: f64,
1525    pub pmin: f64,
1526    pub pmax: f64,
1527    pub qminf: f64,
1528    pub qmaxf: f64,
1529    pub qmint: f64,
1530    pub qmaxt: f64,
1531    pub loss0: f64,
1532    pub loss1: f64,
1533    #[serde(default)]
1534    pub cost: Option<GenCost>,
1535    /// Stable row identity; see [`Bus::uid`].
1536    #[serde(default, skip_serializing_if = "Option::is_none")]
1537    pub uid: Option<String>,
1538    pub extras: Extras,
1539}
1540
1541impl Hvdc {
1542    /// The power arriving at the `to` end for a sending end setpoint, under the
1543    /// MATPOWER dcline loss model `Pt = Pf - loss0 - loss1·Pf`.
1544    ///
1545    /// [`pf`](Self::pf), [`pt`](Self::pt), and [`loss0`](Self::loss0) are one
1546    /// relation, not three independent fields, and a format that states only
1547    /// the sending end reconstructs the far end from it. Stated here so every
1548    /// reader spells the same rule: `loss0` and `pf` scale together, so this
1549    /// holds in per unit as in MW.
1550    #[must_use]
1551    pub fn delivered_power(pf: f64, loss0: f64, loss1: f64) -> f64 {
1552        pf - loss0 - loss1 * pf
1553    }
1554
1555    /// Whether [`pt`](Self::pt) agrees with this line's own loss model to
1556    /// `tol`. A writer whose format states no received power reports the lines
1557    /// that fail this, because those are the ones it cannot reproduce.
1558    #[must_use]
1559    pub fn pt_matches_loss_model(&self, tol: f64) -> bool {
1560        (self.pt - Self::delivered_power(self.pf, self.loss0, self.loss1)).abs() <= tol
1561    }
1562
1563    #[must_use]
1564    pub fn new(from: BusId, to: BusId) -> Self {
1565        Self {
1566            from,
1567            to,
1568            in_service: true,
1569            pf: 0.0,
1570            pt: 0.0,
1571            qf: 0.0,
1572            qt: 0.0,
1573            vf: 1.0,
1574            vt: 1.0,
1575            pmin: 0.0,
1576            pmax: 0.0,
1577            qminf: 0.0,
1578            qmaxf: 0.0,
1579            qmint: 0.0,
1580            qmaxt: 0.0,
1581            loss0: 0.0,
1582            loss1: 0.0,
1583            cost: None,
1584            uid: None,
1585            extras: Extras::new(),
1586        }
1587    }
1588}
1589
1590/// An area record: the area's scheduled net interchange and its swing bus.
1591///
1592/// The [`number`](Area::number) matches the `area` field carried on each
1593/// [`Bus`]; this table holds the per-area metadata (the interchange target and
1594/// the area slack) that the bus number alone can't. Maps to the PSS/E area record
1595/// (`I, ISW, PDES, PTOL, ARNAME`).
1596#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1597#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1598#[non_exhaustive]
1599pub struct Area {
1600    pub number: usize,
1601    /// The area swing (slack) bus, or `None` when unset.
1602    pub slack_bus: Option<BusId>,
1603    /// Scheduled net interchange (MW); positive is export out of the area.
1604    pub net_interchange: f64,
1605    /// Interchange tolerance bandwidth (MW).
1606    pub tolerance: f64,
1607    pub name: Option<String>,
1608}
1609
1610impl Area {
1611    #[must_use]
1612    pub fn new(number: usize) -> Self {
1613        Self {
1614            number,
1615            slack_bus: None,
1616            net_interchange: 0.0,
1617            tolerance: 0.0,
1618            name: None,
1619        }
1620    }
1621}
1622
1623/// Solver / solution-control metadata: the Newton tolerance and iteration cap,
1624/// the zero-impedance threshold, and the per-quantity adjustment-enable flags.
1625///
1626/// Each field is optional because a source states only the ones it carries. No
1627/// power flow physics, but it determines whether a downstream solver reproduces
1628/// the source tool's converged answer. Maps to the PSS/E v34+ system-wide block
1629/// (`GENERAL THRSHZ`, `NEWTON TOLN`/`ITMXN`, `SOLVER ACTAPS`/`AREAIN`/`PHSHFT`/
1630/// `DCTAPS`/`SWSHNT`).
1631#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1632#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1633#[non_exhaustive]
1634pub struct SolverParams {
1635    /// Newton power flow mismatch tolerance (`NEWTON TOLN`).
1636    pub newton_tolerance: Option<f64>,
1637    /// Newton iteration cap (`NEWTON ITMXN`).
1638    pub max_iterations: Option<u32>,
1639    /// Branches with `|x|` below this are treated as zero impedance (`GENERAL THRSHZ`).
1640    pub zero_impedance_threshold: Option<f64>,
1641    /// Whether the solver adjusts transformer taps (`SOLVER ACTAPS`).
1642    pub adjust_taps: Option<bool>,
1643    /// Whether the solver adjusts area interchange (`SOLVER AREAIN`).
1644    pub adjust_area_interchange: Option<bool>,
1645    /// Whether the solver adjusts phase-shift angles (`SOLVER PHSHFT`).
1646    pub adjust_phase_shift: Option<bool>,
1647    /// Whether the solver adjusts DC line taps (`SOLVER DCTAPS`).
1648    pub adjust_dc_taps: Option<bool>,
1649    /// Whether the solver adjusts switched shunts (`SOLVER SWSHNT`).
1650    pub adjust_switched_shunt: Option<bool>,
1651}
1652
1653impl SolverParams {
1654    #[must_use]
1655    pub fn new() -> Self {
1656        Self::default()
1657    }
1658
1659    /// True when no field is set (so readers can avoid attaching an empty record).
1660    #[must_use]
1661    pub fn is_empty(&self) -> bool {
1662        *self == SolverParams::default()
1663    }
1664}
1665
1666/// A series impedance with the MVA base it is expressed on. Used pairwise by
1667/// [`Transformer3W`]; a self-contained unit so the base travels with the value
1668/// instead of being implied by position.
1669///
1670/// `r`/`x` are per unit on the *system* base (the same `CZ = 1` convention as
1671/// [`Branch::r`]/[`Branch::x`], so the matrix math needs no rebasing); `base_mva`
1672/// records the winding-pair MVA base the source file declared (PSS/E `SBASE1-2`
1673/// and friends), kept so a write-back reproduces it and so a future `CZ = 2`
1674/// reader has somewhere to put the winding base it must rebase from. Room to grow
1675/// (winding voltage base, turns-ratio units) as the transformer control work
1676/// lands without reshaping the [`Transformer3W::z`] array.
1677#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
1678#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1679#[non_exhaustive]
1680pub struct Impedance {
1681    pub r: f64,
1682    pub x: f64,
1683    pub base_mva: f64,
1684}
1685
1686impl Impedance {
1687    #[must_use]
1688    pub const fn new(r: f64, x: f64, base_mva: f64) -> Self {
1689        Self { r, x, base_mva }
1690    }
1691}
1692
1693/// One winding of a [`Transformer3W`]: its terminal bus, off-nominal ratio, phase
1694/// shift, nominal voltage, and thermal ratings.
1695#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1696#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1697#[non_exhaustive]
1698pub struct Winding {
1699    pub bus: BusId,
1700    /// Off-nominal turns ratio (1.0 = nominal); the PSS/E `WINDV`, `CW = 1`.
1701    pub tap: f64,
1702    /// Phase shift (degrees).
1703    pub shift: f64,
1704    /// Winding nominal voltage (kV); 0 defers to the terminal bus base kV.
1705    pub nominal_kv: f64,
1706    pub rate_a: f64,
1707    pub rate_b: f64,
1708    pub rate_c: f64,
1709}
1710
1711impl Winding {
1712    #[must_use]
1713    pub fn new(bus: BusId) -> Self {
1714        Self {
1715            bus,
1716            tap: 1.0,
1717            shift: 0.0,
1718            nominal_kv: 0.0,
1719            rate_a: 0.0,
1720            rate_b: 0.0,
1721            rate_c: 0.0,
1722        }
1723    }
1724}
1725
1726/// A three winding transformer with three terminal buses joined at a star point.
1727///
1728/// Series impedance is stored for winding pairs 1-2, 2-3, and 3-1. The record
1729/// also retains star point voltage and per winding control data. PSS/E three
1730/// winding records and PSLF tertiary winding records map to this type.
1731/// [`star_expansion`](Transformer3W::star_expansion) turns it into the synthetic
1732/// star bus plus three branches for a consumer that works in the bus-branch model;
1733/// [`IndexedNetwork`](crate::IndexedNetwork) applies it before building any matrix,
1734/// so a 3-winding transformer contributes to `Y_bus` and connectivity.
1735#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1736#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1737#[non_exhaustive]
1738pub struct Transformer3W {
1739    /// The three windings, in order (primary, secondary, tertiary).
1740    pub windings: [Winding; 3],
1741    /// Pairwise series impedance `[z12, z23, z31]` (primary-secondary,
1742    /// secondary-tertiary, tertiary-primary), each per unit on the system base
1743    /// with its declared MVA base.
1744    pub z: [Impedance; 3],
1745    /// Star-point voltage magnitude (p.u.) and angle (degrees), as solved.
1746    pub star_vm: f64,
1747    pub star_va: f64,
1748    /// Magnetizing shunt referred to the star point (p.u. on the system base).
1749    pub mag_g: f64,
1750    pub mag_b: f64,
1751    pub in_service: bool,
1752    pub name: Option<String>,
1753    /// Stable row identity; see [`Bus::uid`].
1754    #[serde(default, skip_serializing_if = "Option::is_none")]
1755    pub uid: Option<String>,
1756    pub extras: Extras,
1757}
1758
1759impl Transformer3W {
1760    #[must_use]
1761    pub fn new(windings: [Winding; 3], z: [Impedance; 3]) -> Self {
1762        Self {
1763            windings,
1764            z,
1765            star_vm: 1.0,
1766            star_va: 0.0,
1767            mag_g: 0.0,
1768            mag_b: 0.0,
1769            in_service: true,
1770            name: None,
1771            uid: None,
1772            extras: Extras::new(),
1773        }
1774    }
1775
1776    /// The per-winding star impedances `(r, x)` — winding *k* to the star point —
1777    /// from the pairwise values, per unit on the system base.
1778    ///
1779    /// Standard pairwise→star conversion: `z1 = (z12 + z31 - z23) / 2`, and so on.
1780    /// Because the impedances are already on a common base, the split is linear in
1781    /// `r` and `x` separately.
1782    #[must_use]
1783    pub fn star_impedances(&self) -> [(f64, f64); 3] {
1784        let [z12, z23, z31] = self.z;
1785        let half = |a: f64, b: f64, c: f64| (a + b - c) / 2.0;
1786        [
1787            (half(z12.r, z31.r, z23.r), half(z12.x, z31.x, z23.x)),
1788            (half(z12.r, z23.r, z31.r), half(z12.x, z23.x, z31.x)),
1789            (half(z23.r, z31.r, z12.r), half(z23.x, z31.x, z12.x)),
1790        ]
1791    }
1792
1793    /// Expand into a synthetic star [`Bus`] (id `star_id`) plus three [`Branch`]es,
1794    /// one per winding, for a consumer that works in the bus-branch model.
1795    /// [`IndexedNetwork`](crate::IndexedNetwork) calls this via
1796    /// `BalancedNetwork::expand_transformers_3w` when assembling matrix inputs. The star
1797    /// bus carries the stored star voltage and the magnetizing shunt is left to the
1798    /// caller; each branch takes its winding's tap, phase shift, and ratings.
1799    #[must_use]
1800    pub fn star_expansion(&self, star_id: BusId) -> (Bus, [Branch; 3]) {
1801        let star = Bus {
1802            id: star_id,
1803            kind: BusType::Pq,
1804            vm: self.star_vm,
1805            va: self.star_va,
1806            base_kv: self.windings[0].nominal_kv,
1807            vmax: 1.1,
1808            vmin: 0.9,
1809            evhi: None,
1810            evlo: None,
1811            area: 0,
1812            zone: 0,
1813            name: self.name.clone(),
1814            uid: self.uid.clone(),
1815            location: None,
1816            extras: Extras::new(),
1817        };
1818        let zs = self.star_impedances();
1819        let branch = |w: &Winding, (r, x): (f64, f64)| Branch {
1820            from: w.bus,
1821            to: star_id,
1822            r,
1823            x,
1824            b: 0.0,
1825            charging: None,
1826            rate_a: w.rate_a,
1827            rate_b: w.rate_b,
1828            rate_c: w.rate_c,
1829            rating_sets: Vec::new(),
1830            current_ratings: None,
1831            tap: w.tap,
1832            shift: w.shift,
1833            in_service: self.in_service,
1834            angmin: -360.0,
1835            angmax: 360.0,
1836            control: None,
1837            solution: None,
1838            uid: None,
1839            route: None,
1840            extras: Extras::new(),
1841        };
1842        let branches = [
1843            branch(&self.windings[0], zs[0]),
1844            branch(&self.windings[1], zs[1]),
1845            branch(&self.windings[2], zs[2]),
1846        ];
1847        (star, branches)
1848    }
1849}
1850
1851/// The MATPOWER gen capability / ramp columns past `PMIN`, in order. The index
1852/// into this array is the slot index into a [`GenCaps`].
1853pub(crate) const GEN_EXTRA_KEYS: [&str; 11] = [
1854    "pc1", "pc2", "qc1min", "qc1max", "qc2min", "qc2max", "ramp_agc", "ramp_10", "ramp_30",
1855    "ramp_q", "apf",
1856];
1857
1858/// One value-domain scan finding, internal to the diagnostic and repair
1859/// passes: an element field whose value falls outside its physical range,
1860/// paired with the value the repair sets in its place. The public shapes are
1861/// the coded [`Diagnostic`](crate::Diagnostic) records
1862/// [`BalancedNetwork::validate_values`] returns and the history entry
1863/// [`repair_values`] appends.
1864#[derive(Debug, Clone, PartialEq)]
1865pub(crate) struct ValueFinding {
1866    /// Human-readable element locator, e.g. `"bus 3"` or `"generator at bus 5"`.
1867    pub element: String,
1868    /// The top level JSON field the element serializes under, e.g.
1869    /// `"buses"`. Paired with `index`, this names the finding's RFC 6901
1870    /// target, so it must match [`BalancedNetwork`]'s own field name (the
1871    /// stored module writes this network under `value.data`, unchanged).
1872    pub table: &'static str,
1873    /// The element's position in `table`, for the target — the array index,
1874    /// never the element's id, since an id can differ from its position.
1875    pub index: usize,
1876    pub field: &'static str,
1877    pub old: f64,
1878    pub new: f64,
1879    pub reason: &'static str,
1880}
1881
1882impl ValueFinding {
1883    /// The finding as the coded record: target is an RFC 6901 pointer to the
1884    /// field within the stored document's `value.data`, details carry the
1885    /// other machine readable pieces, and the message stays prose.
1886    pub(crate) fn into_diagnostic(self) -> crate::Diagnostic {
1887        let mut details = serde_json::Map::new();
1888        details.insert("element".to_owned(), serde_json::json!(self.element));
1889        details.insert("field".to_owned(), serde_json::json!(self.field));
1890        details.insert("value".to_owned(), serde_json::json!(self.old));
1891        details.insert("repaired_value".to_owned(), serde_json::json!(self.new));
1892        details.insert("reason".to_owned(), serde_json::json!(self.reason));
1893        crate::Diagnostic::of(
1894            &crate::diagnostics::codes::VALIDATE_BALANCED_VALUE_DOMAIN,
1895            format!(
1896                "{}: `{}` is {} ({}); the repair sets {}",
1897                self.element, self.field, self.old, self.reason, self.new
1898            ),
1899        )
1900        .with_target(format!("/{}/{}/{}", self.table, self.index, self.field))
1901        .expect("scan-built targets are nonempty and bounded")
1902        .with_details(details)
1903        .expect("scan-built details stay within the record bounds")
1904    }
1905}
1906
1907/// Clamp every out-of-domain value of a parsed module to its repaired value
1908/// and record the pass: one `Repair` history entry naming each change in its
1909/// parameters, and one `VALIDATE.BALANCED.VALUE_DOMAIN` finding per repaired
1910/// field. The retained source is severed — the value no longer matches the
1911/// bytes, so a same format write serializes the repaired network rather than
1912/// echoing the input. A module already in domain comes back unchanged.
1913///
1914/// # Errors
1915/// Never on scan output: the record constructors refuse only unbounded
1916/// caller data, and the scan is bounded by the model.
1917pub fn repair_values(
1918    module: powerio_core::PioModule<BalancedNetwork>,
1919) -> std::result::Result<powerio_core::PioModule<BalancedNetwork>, powerio_core::Error> {
1920    let repair_ordinal = module
1921        .history()
1922        .iter()
1923        .filter(|entry| entry.kind() == powerio_core::HistoryKind::Repair)
1924        .count();
1925    let mut network_findings = Vec::new();
1926    let mut module = module.map_value(|mut network| {
1927        network_findings = network.repair_in_place();
1928        network
1929    });
1930    if network_findings.is_empty() {
1931        return Ok(module);
1932    }
1933    let mut parameters = std::collections::BTreeMap::new();
1934    parameters.insert(
1935        "repairs".to_owned(),
1936        serde_json::json!(
1937            network_findings
1938                .iter()
1939                .map(|finding| {
1940                    serde_json::json!({
1941                        "element": finding.element,
1942                        "field": finding.field,
1943                        "value": finding.old,
1944                        "repaired_value": finding.new,
1945                    })
1946                })
1947                .collect::<Vec<_>>()
1948        ),
1949    );
1950    let entry = powerio_core::HistoryEntry::new(
1951        powerio_core::HistoryId::new(format!("repair{repair_ordinal}"))?,
1952        powerio_core::HistoryKind::Repair,
1953        "value_domain_repair",
1954    )?
1955    .with_parameters(parameters)?;
1956    module.add_history_entry(entry)?;
1957    for finding in network_findings {
1958        module.add_diagnostic(finding.into_diagnostic())?;
1959    }
1960    module = module.sever_source();
1961    Ok(module)
1962}
1963
1964/// Voltage magnitude (p.u.) repair: non-positive or above 2 (or non-finite) → 1.0.
1965/// A zero magnitude is treated as out of domain (a de-energized placeholder), not
1966/// a valid 0 p.u.
1967fn repair_vm(vm: f64) -> Option<f64> {
1968    (!vm.is_finite() || vm <= 0.0 || vm > 2.0).then_some(1.0)
1969}
1970
1971/// Voltage angle (degrees) repair: `|va| > 2000` (or non-finite) → 0.0.
1972fn repair_va(va: f64) -> Option<f64> {
1973    (!va.is_finite() || va.abs() > 2000.0).then_some(0.0)
1974}
1975
1976/// Generator MVA base repair: non-positive (or non-finite) → the system base.
1977fn repair_mbase(mbase: f64, sbase: f64) -> Option<f64> {
1978    (!mbase.is_finite() || mbase <= 0.0).then_some(sbase)
1979}
1980
1981/// Generator voltage setpoint (p.u.) repair: non-positive (or non-finite) → 1.0.
1982fn repair_vg(vg: f64) -> Option<f64> {
1983    (!vg.is_finite() || vg <= 0.0).then_some(1.0)
1984}
1985
1986/// The three element counts the star lowering changes, from
1987/// [`BalancedNetwork::lowered_lengths`]. Every other family keeps its length, since the
1988/// lowering only appends a star bus, its winding branches, and a magnetizing
1989/// shunt.
1990#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1991pub(crate) struct LoweredLengths {
1992    pub(crate) buses: usize,
1993    pub(crate) branches: usize,
1994    pub(crate) shunts: usize,
1995}
1996
1997impl BalancedNetwork {
1998    #[must_use]
1999    pub fn new(name: impl Into<String>, base_mva: f64) -> BalancedNetwork {
2000        BalancedNetwork::from_tables(BalancedNetworkTables {
2001            name: name.into(),
2002            base_mva,
2003            base_frequency: DEFAULT_BASE_FREQUENCY,
2004            geo: None,
2005            buses: Vec::new().into(),
2006            loads: Vec::new().into(),
2007            shunts: Vec::new().into(),
2008            branches: Vec::new().into(),
2009            switches: Vec::new().into(),
2010            generators: Vec::new().into(),
2011            storage: Vec::new().into(),
2012            hvdc: Vec::new().into(),
2013            transformers_3w: Vec::new().into(),
2014            areas: Vec::new().into(),
2015            solver: None,
2016            source_format: SourceFormat::InMemory,
2017        })
2018    }
2019
2020    /// A network assembled in memory from buses and branches, with no loads,
2021    /// shunts, generators, storage, HVDC, or retained source document. Synthetic
2022    /// topology generators and tests use it instead of repeating the struct
2023    /// literal. The caller owns reference integrity (run `check_references` if
2024    /// the ids might be inconsistent).
2025    #[must_use]
2026    pub fn in_memory(
2027        name: impl Into<String>,
2028        base_mva: f64,
2029        buses: Vec<Bus>,
2030        branches: Vec<Branch>,
2031    ) -> BalancedNetwork {
2032        let mut net = Self::new(name, base_mva);
2033        *net.buses_mut() = buses;
2034        *net.branches_mut() = branches;
2035        net
2036    }
2037
2038    /// Serialize the structured tables to model JSON. The C ABI and language
2039    /// bindings use this representation. The retained `source` text is
2040    /// excluded (see the field's `#[serde(skip)]`), so the byte-exact echo
2041    /// stays on the same-format write path; a [`from_json`](BalancedNetwork::from_json)
2042    /// round-trip reproduces every field except `source`, which returns `None`.
2043    ///
2044    /// JSON has no `Inf`/`NaN` literal: a nonfinite field is written as
2045    /// `"Infinity"`, `"-Infinity"`, or `"NaN"` (see [`crate::nonfinite`]) and
2046    /// [`from_json`](BalancedNetwork::from_json) reads either a number or one
2047    /// of those spellings back, so every network round trips — readers
2048    /// legitimately produce `Inf` limits, and a document written before
2049    /// 0.9.0 spelled them `null`, which still refuses with a message naming
2050    /// the change.
2051    ///
2052    /// # Errors
2053    /// A `serde_json` serialization failure (none arise from this model today).
2054    pub fn to_json(&self) -> crate::Result<String> {
2055        serde_json::to_string(self).map_err(|e| Error::FormatRead {
2056            format: "JSON",
2057            message: e.to_string(),
2058        })
2059    }
2060
2061    /// [`to_json`](BalancedNetwork::to_json) plus the fidelity records the
2062    /// write produced. The write is faithful today — a nonfinite value spells
2063    /// itself as a string and reads back — so the record list is empty; the
2064    /// channel stays because it is the shape a write-side finding arrives
2065    /// through, and a caller wired to it needs no change when one appears.
2066    ///
2067    /// # Errors
2068    /// A `serde_json` serialization failure (none arise from this model today).
2069    pub fn to_json_with_diagnostics(
2070        &self,
2071    ) -> crate::Result<(String, Vec<crate::diagnostics::Diagnostic>)> {
2072        let text = self.to_json()?;
2073        Ok((text, Vec::new()))
2074    }
2075
2076    /// Serialize this typed network to `format`, the semantic write. The byte
2077    /// exact echo of an unchanged parsed module lives on the module write
2078    /// path, [`write_as`](crate::write_as).
2079    ///
2080    /// # Errors
2081    /// [`Error::WriteUnsupported`](crate::Error) for a read
2082    /// only target, and the writer's own error on a case it cannot state.
2083    pub fn to_format(&self, format: crate::TargetFormat) -> crate::Result<crate::Conversion> {
2084        crate::format::write_conversion(self, format)
2085    }
2086
2087    /// Serialize this network to `format` from the typed model, the balanced
2088    /// twin of `MulticonductorNetwork::to_canonical_format` on the
2089    /// distribution side. Identical to [`to_format`](Self::to_format) now that
2090    /// source echo belongs to the module write path.
2091    ///
2092    /// # Errors
2093    /// As [`to_format`](Self::to_format).
2094    pub fn to_canonical_format(
2095        &self,
2096        format: crate::TargetFormat,
2097    ) -> crate::Result<crate::Conversion> {
2098        crate::format::write_conversion(self, format)
2099    }
2100
2101    /// Serialize this network with write-time cost policies.
2102    ///
2103    /// The network itself is not mutated.
2104    pub fn to_format_with_options(
2105        &self,
2106        format: crate::TargetFormat,
2107        options: &crate::WriteOptions,
2108    ) -> crate::Result<crate::Conversion> {
2109        if options.is_default() {
2110            return self.to_format(format);
2111        }
2112        let (working, policy_warnings) = crate::format::apply_write_cost_policy(self, options)?;
2113        let mut conv = crate::format::write_conversion(&working, format)?;
2114        conv.prepend(policy_warnings);
2115        Ok(conv)
2116    }
2117
2118    /// Serialize this network to MATPOWER `.m` text.
2119    ///
2120    /// This is byte-exact when the network was parsed from MATPOWER and still
2121    /// carries its retained source text.
2122    #[must_use]
2123    pub fn to_matpower(&self) -> String {
2124        crate::write_matpower(self)
2125    }
2126
2127    /// Rebuild a `BalancedNetwork` from JSON produced by [`to_json`](BalancedNetwork::to_json).
2128    ///
2129    /// A float position accepts a number or the nonfinite spellings
2130    /// `"Infinity"`, `"-Infinity"`, `"NaN"` (see [`crate::nonfinite`]).
2131    ///
2132    /// Validates the result (no buses, unique bus ids, no dangling references)
2133    /// before returning, so the JSON transport (the C ABI and Julia bridge ride
2134    /// on it) can't hand back a network the file readers would have rejected
2135    /// (the same no-buses guard `read_source` applies to every parse path).
2136    pub fn from_json(text: &str) -> crate::Result<BalancedNetwork> {
2137        // Tolerate a leading UTF-8 byte order mark, as the format readers do.
2138        let text = text.trim_start_matches('\u{feff}');
2139        let net: BalancedNetwork = serde_json::from_str(text).map_err(|e| Error::FormatRead {
2140            format: "JSON",
2141            message: e.to_string(),
2142        })?;
2143        net.check_references("JSON")?;
2144        if net.buses().is_empty() {
2145            return Err(Error::FormatRead {
2146                format: "JSON",
2147                message: "case has no buses".into(),
2148            });
2149        }
2150        Ok(net)
2151    }
2152
2153    /// Rebuild a `BalancedNetwork` from UTF-8 JSON bytes produced by
2154    /// [`to_json`](BalancedNetwork::to_json).
2155    ///
2156    /// Decoding is strict: invalid UTF-8 returns a coded JSON read error and
2157    /// is never replaced with Unicode replacement characters. A leading UTF-8
2158    /// byte order mark is accepted. The decoded document goes through
2159    /// [`from_json`](BalancedNetwork::from_json), including its reference and
2160    /// nonempty-network validation.
2161    pub fn from_json_bytes(bytes: &[u8]) -> crate::Result<BalancedNetwork> {
2162        let text = std::str::from_utf8(bytes).map_err(|error| Error::FormatRead {
2163            format: "JSON",
2164            message: format!("input is not valid UTF-8: {error}"),
2165        })?;
2166        Self::from_json(text)
2167    }
2168
2169    /// Whether this is a normalized (per-unit, radian, filtered)
2170    /// derived product from [`to_normalized`](BalancedNetwork::to_normalized), rather
2171    /// than a raw network at the file's unit basis. Unit-sensitive code that
2172    /// takes a `&BalancedNetwork` can check this instead of silently assuming MW.
2173    #[must_use]
2174    pub fn is_normalized(&self) -> bool {
2175        self.source_format() == SourceFormat::Normalized
2176    }
2177
2178    /// Error unless `base_mva` is a positive, finite number. It is every
2179    /// per-unit divisor, so a malformed base would otherwise silently poison
2180    /// downstream values with `NaN`/`Inf` or flipped signs. The per-unit
2181    /// consumers ([`to_normalized`](BalancedNetwork::to_normalized), the gridfm
2182    /// export) call this; any other unit-sensitive consumer should too.
2183    pub fn check_base_mva(&self) -> crate::Result<()> {
2184        if self.base_mva().is_finite() && self.base_mva() > 0.0 {
2185            Ok(())
2186        } else {
2187            Err(crate::Error::InvalidBaseMva {
2188                base: self.base_mva(),
2189            })
2190        }
2191    }
2192
2193    /// Report element fields whose values fall outside their physical domain,
2194    /// without changing anything, as coded `VALIDATE.BALANCED.VALUE_DOMAIN`
2195    /// findings. Each record targets the element and carries the field, the
2196    /// current value, the value a repair would set, and why in details.
2197    ///
2198    /// This generalizes the per-reader value clamps (a bus voltage magnitude
2199    /// outside `[0, 2]`, an angle past `±2000°`, a zero generator MVA base or
2200    /// voltage setpoint) into one pass any consumer can run, separate from the
2201    /// structural [`validate`](BalancedNetwork::validate) (which only checks
2202    /// ids and references). It is non-mutating; [`repair_values`] applies the
2203    /// fixes to a parsed module and records them.
2204    #[must_use]
2205    pub fn validate_values(&self) -> Vec<crate::Diagnostic> {
2206        self.value_findings()
2207            .into_iter()
2208            .map(ValueFinding::into_diagnostic)
2209            .collect()
2210    }
2211
2212    pub(crate) fn value_findings(&self) -> Vec<ValueFinding> {
2213        let mut out = Vec::new();
2214        for (index, b) in self.buses().iter().enumerate() {
2215            if let Some(new) = repair_vm(b.vm) {
2216                out.push(ValueFinding {
2217                    element: format!("bus {}", b.id),
2218                    table: "buses",
2219                    index,
2220                    field: "vm",
2221                    old: b.vm,
2222                    new,
2223                    reason: "voltage magnitude outside [0, 2] p.u.",
2224                });
2225            }
2226            if let Some(new) = repair_va(b.va) {
2227                out.push(ValueFinding {
2228                    element: format!("bus {}", b.id),
2229                    table: "buses",
2230                    index,
2231                    field: "va",
2232                    old: b.va,
2233                    new,
2234                    reason: "voltage angle outside ±2000°",
2235                });
2236            }
2237        }
2238        for (index, g) in self.generators().iter().enumerate() {
2239            if let Some(new) = repair_mbase(g.mbase, self.base_mva()) {
2240                out.push(ValueFinding {
2241                    element: format!("generator at bus {}", g.bus),
2242                    table: "generators",
2243                    index,
2244                    field: "mbase",
2245                    old: g.mbase,
2246                    new,
2247                    reason: "non-positive generator MVA base",
2248                });
2249            }
2250            if let Some(new) = repair_vg(g.vg) {
2251                out.push(ValueFinding {
2252                    element: format!("generator at bus {}", g.bus),
2253                    table: "generators",
2254                    index,
2255                    field: "vg",
2256                    old: g.vg,
2257                    new,
2258                    reason: "non-positive voltage setpoint",
2259                });
2260            }
2261        }
2262        out
2263    }
2264
2265    /// Clamp every out-of-domain value to its repaired value (the same rules
2266    /// [`validate_values`](BalancedNetwork::validate_values) reports), returning the list
2267    /// of changes made. A second call returns an empty list (the values are now
2268    /// in domain). Crate private: the recorded public path is
2269    /// [`repair_values`], which appends the history entry and severs the
2270    /// retained source echo the mutation invalidates.
2271    pub(crate) fn repair_in_place(&mut self) -> Vec<ValueFinding> {
2272        let findings = self.value_findings();
2273        let sbase = self.base_mva();
2274        for b in self.buses_mut() {
2275            if let Some(new) = repair_vm(b.vm) {
2276                b.vm = new;
2277            }
2278            if let Some(new) = repair_va(b.va) {
2279                b.va = new;
2280            }
2281        }
2282        for g in self.generators_mut() {
2283            if let Some(new) = repair_mbase(g.mbase, sbase) {
2284                g.mbase = new;
2285            }
2286            if let Some(new) = repair_vg(g.vg) {
2287                g.vg = new;
2288            }
2289        }
2290        findings
2291    }
2292
2293    /// The element counts [`Self::expand_transformers_3w`] would produce, read off
2294    /// the transformer records instead of building the lowering. A caller that
2295    /// only needs the lowered lengths — sizing a per-row map, say — would
2296    /// otherwise pay a whole `BalancedNetwork` clone for three `len()` calls.
2297    /// `lowered_lengths_match_the_expansion` pins the two against each other.
2298    pub(crate) fn lowered_lengths(&self) -> LoweredLengths {
2299        let mut lengths = LoweredLengths {
2300            buses: self.buses().len(),
2301            branches: self.branches().len(),
2302            shunts: self.shunts().len(),
2303        };
2304        for t in self.transformers_3w().iter().filter(|t| t.in_service) {
2305            lengths.buses += 1;
2306            lengths.branches += 3;
2307            if t.mag_g != 0.0 || t.mag_b != 0.0 {
2308                lengths.shunts += 1;
2309            }
2310        }
2311        lengths
2312    }
2313
2314    /// A bus-branch lowering of the network for analysis: each in-service
2315    /// 3-winding transformer becomes a synthetic star bus, its three winding
2316    /// branches, and (when present) its magnetizing shunt, so the matrix builders
2317    /// and connectivity see it. Returns the network unchanged (borrowed) when
2318    /// there are no 3-winding transformers, so the common case allocates nothing.
2319    ///
2320    /// The canonical `BalancedNetwork` keeps the typed [`Transformer3W`] records; this is
2321    /// the derived analysis form that [`IndexedNetwork`](crate::IndexedNetwork)
2322    /// builds behind the scenes, so callers never see the synthetic buses in the
2323    /// model they read or write.
2324    pub(crate) fn expand_transformers_3w(&self) -> std::borrow::Cow<'_, BalancedNetwork> {
2325        if self.transformers_3w().is_empty() {
2326            return std::borrow::Cow::Borrowed(self);
2327        }
2328        let mut net = self.clone();
2329        // The star branches carry per-unit impedance (CZ = 1), the same convention
2330        // the matrix builders read straight off a branch, so no rebasing. The
2331        // magnetizing shunt is an admittance, so it scales like every other shunt:
2332        // by the per-unit base for a raw network, by 1 for a normalized one.
2333        let scale = if net.is_normalized() {
2334            1.0
2335        } else {
2336            net.base_mva()
2337        };
2338        // check_references refuses bus ids without headroom for these
2339        // synthetic ids on every parse path; the checked arithmetic turns a
2340        // programmatic caller's overflow into a loud panic instead of a
2341        // wrapped id aliasing an existing bus.
2342        let base_id = net
2343            .buses()
2344            .iter()
2345            .map(|b| b.id.0)
2346            .max()
2347            .unwrap_or(0)
2348            .checked_add(1)
2349            .expect("bus id space exhausted for star expansion");
2350        for (k, t) in self
2351            .transformers_3w()
2352            .iter()
2353            .filter(|t| t.in_service)
2354            .enumerate()
2355        {
2356            let star_id = BusId(
2357                base_id
2358                    .checked_add(k)
2359                    .expect("bus id space exhausted for star expansion"),
2360            );
2361            let (star, branches) = t.star_expansion(star_id);
2362            net.buses_mut().push(star);
2363            net.branches_mut().extend(branches);
2364            if t.mag_g != 0.0 || t.mag_b != 0.0 {
2365                net.shunts_mut().push(Shunt {
2366                    bus: star_id,
2367                    g: t.mag_g * scale,
2368                    b: t.mag_b * scale,
2369                    in_service: true,
2370                    control: None,
2371                    uid: None,
2372                    extras: Extras::new(),
2373                });
2374            }
2375        }
2376        net.transformers_3w_mut().clear();
2377        std::borrow::Cow::Owned(net)
2378    }
2379
2380    /// Check structural integrity: bus ids are unique and every element
2381    /// references an existing bus. The file readers and [`from_json`](BalancedNetwork::from_json)
2382    /// run this; a `BalancedNetwork` built by hand (or mutated, e.g. by a scenario
2383    /// generator) should call it before handing the network to
2384    /// [`IndexedNetwork`](crate::IndexedNetwork), whose dense indexing assumes it.
2385    pub fn validate(&self) -> crate::Result<()> {
2386        self.check_references("network")
2387    }
2388
2389    /// Error if two buses share an id, or if any element references a bus that
2390    /// doesn't exist. Readers call this after parsing so a missing/garbled id
2391    /// (which would otherwise default to a placeholder and silently re-wire the
2392    /// network) fails loudly instead.
2393    pub(crate) fn check_references(&self, format: &'static str) -> crate::Result<()> {
2394        // HashSet, not BTreeSet: building the id set and probing it once per branch
2395        // endpoint / load / shunt / gen is the dominant cost of a large parse, and
2396        // a BTreeSet pays a log-n pointer-chasing probe each time. Pre-size to skip
2397        // rehashing.
2398        let mut ids = std::collections::HashSet::with_capacity(self.buses().len());
2399        for b in self.buses() {
2400            // The readers parse ids through `as usize`, which saturates rather
2401            // than failing, and the C ABI reports them as int64. Two distinct
2402            // ids above the ceiling would surface as one value there, so a
2403            // branch endpoint would match two bus rows.
2404            if b.id > BusId::MAX {
2405                return Err(Error::FormatRead {
2406                    format,
2407                    message: format!("bus id {} is outside the int64 id space", b.id),
2408                });
2409            }
2410            if !ids.insert(b.id) {
2411                return Err(Error::FormatRead {
2412                    format,
2413                    message: format!("duplicate bus id {}", b.id),
2414                });
2415            }
2416        }
2417        let check = |bus: BusId, what: &str| -> crate::Result<()> {
2418            if ids.contains(&bus) {
2419                Ok(())
2420            } else {
2421                Err(Error::FormatRead {
2422                    format,
2423                    message: format!("{what} references unknown bus {bus}"),
2424                })
2425            }
2426        };
2427        // Format the context only on the error path, not once per branch.
2428        for (i, br) in self.branches().iter().enumerate() {
2429            for bus in [br.from, br.to] {
2430                if !ids.contains(&bus) {
2431                    return Err(Error::FormatRead {
2432                        format,
2433                        message: format!("branch {i} references unknown bus {bus}"),
2434                    });
2435                }
2436            }
2437            if let Some(bus) = br.control.as_ref().and_then(|c| c.controlled_bus) {
2438                check(bus, "transformer control")?;
2439            }
2440        }
2441        for (i, sw) in self.switches().iter().enumerate() {
2442            for bus in [sw.from, sw.to] {
2443                if !ids.contains(&bus) {
2444                    return Err(Error::FormatRead {
2445                        format,
2446                        message: format!("switch {i} references unknown bus {bus}"),
2447                    });
2448                }
2449            }
2450        }
2451        for l in self.loads() {
2452            check(l.bus, "load")?;
2453        }
2454        for s in self.shunts() {
2455            check(s.bus, "shunt")?;
2456            if let Some(bus) = s.control.as_ref().and_then(|c| c.control_bus) {
2457                check(bus, "switched-shunt control")?;
2458            }
2459        }
2460        for g in self.generators() {
2461            check(g.bus, "generator")?;
2462            if let Some(bus) = g.regulated_bus {
2463                check(bus, "generator voltage control")?;
2464            }
2465        }
2466        for d in self.hvdc() {
2467            check(d.from, "dcline")?;
2468            check(d.to, "dcline")?;
2469        }
2470        for s in self.storage() {
2471            check(s.bus, "storage")?;
2472        }
2473        for a in self.areas() {
2474            if let Some(slack) = a.slack_bus {
2475                check(slack, "area swing")?;
2476            }
2477        }
2478        for t in self.transformers_3w() {
2479            for w in &t.windings {
2480                check(w.bus, "3-winding transformer")?;
2481            }
2482        }
2483        self.check_star_expansion_headroom(format)
2484    }
2485
2486    /// Star expansion allocates synthetic bus ids `max_bus_id + 1 + k`, one per
2487    /// in-service 3-winding transformer; a bus id near [`BusId::MAX`] would
2488    /// push those past the ceiling the C ABI reports ids in. The base id
2489    /// `max + 1` is computed whenever any 3-winding transformer is present,
2490    /// even if none is in service, so the headroom is
2491    /// `max(1, in-service count)`. No real case sits there, so refuse it at the
2492    /// boundary like any other malformed reference.
2493    fn check_star_expansion_headroom(&self, format: &'static str) -> crate::Result<()> {
2494        if self.transformers_3w().is_empty() {
2495            return Ok(());
2496        }
2497        let Some(max_id) = self.buses().iter().map(|b| b.id.0).max() else {
2498            return Ok(());
2499        };
2500        let needed = self
2501            .transformers_3w()
2502            .iter()
2503            .filter(|t| t.in_service)
2504            .count()
2505            .max(1);
2506        if max_id
2507            .checked_add(needed)
2508            .is_none_or(|top| top > BusId::MAX.0)
2509        {
2510            return Err(Error::FormatRead {
2511                format,
2512                message: format!(
2513                    "bus id {max_id} leaves no room to allocate synthetic star bus ids \
2514                     for 3-winding transformers"
2515                ),
2516            });
2517        }
2518        Ok(())
2519    }
2520}
2521
2522#[cfg(test)]
2523mod tests {
2524    use super::*;
2525
2526    fn close(actual: f64, expected: f64) {
2527        assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
2528    }
2529
2530    #[test]
2531    fn source_format_serializes_as_its_name_token_and_reads_the_legacy_spelling() {
2532        // The exhaustive match keeps a new variant from shipping with a serde
2533        // spelling that differs from name().
2534        let all = [
2535            SourceFormat::Matpower,
2536            SourceFormat::PowerModelsJson,
2537            SourceFormat::EgretJson,
2538            SourceFormat::Psse,
2539            SourceFormat::PowerWorld,
2540            SourceFormat::PandapowerJson,
2541            SourceFormat::Pslf,
2542            SourceFormat::PowerWorldBinary,
2543            SourceFormat::InMemory,
2544            SourceFormat::Normalized,
2545            SourceFormat::Gridfm,
2546            SourceFormat::PypsaCsv,
2547            SourceFormat::Goc3Json,
2548            SourceFormat::SurgeJson,
2549            SourceFormat::DeepMindOpfDataJson,
2550        ];
2551        for f in all {
2552            match f {
2553                SourceFormat::Matpower
2554                | SourceFormat::PowerModelsJson
2555                | SourceFormat::EgretJson
2556                | SourceFormat::Psse
2557                | SourceFormat::PowerWorld
2558                | SourceFormat::PandapowerJson
2559                | SourceFormat::Pslf
2560                | SourceFormat::PowerWorldBinary
2561                | SourceFormat::InMemory
2562                | SourceFormat::Normalized
2563                | SourceFormat::Gridfm
2564                | SourceFormat::PypsaCsv
2565                | SourceFormat::Goc3Json
2566                | SourceFormat::SurgeJson
2567                | SourceFormat::DeepMindOpfDataJson => {}
2568            }
2569            let token = serde_json::to_value(f).unwrap();
2570            assert_eq!(token, serde_json::Value::String(f.name().to_owned()));
2571            let back: SourceFormat = serde_json::from_value(token).unwrap();
2572            assert_eq!(back, f);
2573            let legacy = serde_json::Value::String(format!("{f:?}"));
2574            let from_legacy: SourceFormat = serde_json::from_value(legacy).unwrap();
2575            assert_eq!(from_legacy, f);
2576        }
2577    }
2578
2579    #[test]
2580    fn quadratic_with_constant_keeps_c0_across_ncost() {
2581        let full = GenCost::new(2, 0.0, 0.0, vec![1.5, 2.0, 5.0]);
2582        assert_eq!(full.quadratic_with_constant(), Some((3.0, 2.0, 5.0)));
2583        assert_eq!(full.quadratic(), Some((3.0, 2.0)));
2584
2585        let linear = GenCost::new(2, 0.0, 0.0, vec![2.0, 5.0]);
2586        assert_eq!(linear.quadratic_with_constant(), Some((0.0, 2.0, 5.0)));
2587
2588        let constant = GenCost::new(2, 0.0, 0.0, vec![5.0]);
2589        assert_eq!(constant.quadratic_with_constant(), Some((0.0, 0.0, 5.0)));
2590
2591        let piecewise = GenCost::new(1, 0.0, 0.0, vec![0.0, 0.0, 1.0, 1.0]);
2592        assert_eq!(piecewise.quadratic_with_constant(), None);
2593
2594        let cubic = GenCost::new(2, 0.0, 0.0, vec![1.0, 1.0, 1.0, 1.0]);
2595        assert_eq!(cubic.quadratic_with_constant(), None);
2596
2597        let truncated = GenCost::with_ncost(2, 0.0, 0.0, 3, vec![1.0]);
2598        assert_eq!(truncated.quadratic_with_constant(), None);
2599    }
2600
2601    #[test]
2602    fn a_leading_coefficient_below_the_tolerance_comes_off_the_row() {
2603        let artifact = GenCost::new(2, 0.0, 0.0, vec![1e-17, 2.0, 5.0]);
2604        assert_eq!(
2605            artifact.quadratic_with_constant(),
2606            Some((2e-17, 2.0, 5.0)),
2607            "the untouched reader keeps the artifact"
2608        );
2609        assert_eq!(
2610            artifact.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
2611            Some((0.0, 2.0, 5.0))
2612        );
2613        assert_eq!(
2614            artifact.quadratic_with_constant_tol(0.0),
2615            Some((2e-17, 2.0, 5.0)),
2616            "a zero tolerance strips an exact zero alone"
2617        );
2618
2619        // A row states a curve of a lower order once the leading zeros are off,
2620        // so a cubic row the untouched reader refuses reads as a quadratic one.
2621        let padded = GenCost::new(2, 0.0, 0.0, vec![0.0, 1.5, 2.0, 5.0]);
2622        assert_eq!(padded.quadratic_with_constant(), None);
2623        assert_eq!(
2624            padded.quadratic_with_constant_tol(0.0),
2625            Some((3.0, 2.0, 5.0))
2626        );
2627
2628        let flat = GenCost::new(2, 0.0, 0.0, vec![1e-17, 1e-17, 1e-17]);
2629        assert_eq!(
2630            flat.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
2631            Some((0.0, 0.0, 1e-17)),
2632            "the last coefficient stays, whatever its magnitude"
2633        );
2634
2635        let piecewise = GenCost::new(1, 0.0, 0.0, vec![0.0, 0.0, 1.0, 1.0]);
2636        assert_eq!(
2637            piecewise.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
2638            None
2639        );
2640
2641        let truncated = GenCost::with_ncost(2, 0.0, 0.0, 3, vec![1.0]);
2642        assert_eq!(
2643            truncated.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
2644            None
2645        );
2646
2647        let quartic = GenCost::new(2, 0.0, 0.0, vec![1.0, 1.0, 1.0, 1.0, 1.0]);
2648        assert_eq!(
2649            quartic.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
2650            None
2651        );
2652    }
2653
2654    /// The bound at one corner of the voltage box: the law of cosines over the
2655    /// angle window, scaled by the larger terminal ceiling and the impedance.
2656    fn expected_rate(window: f64, fr: f64, to: f64, zmag: f64) -> f64 {
2657        let separation = (fr * fr + to * to - 2.0 * fr * to * window.cos()).sqrt();
2658        fr.max(to) * separation / zmag
2659    }
2660
2661    #[test]
2662    fn synthesized_rate_follows_the_angle_window_and_the_voltage_bands() {
2663        let br = Branch::new(BusId(1), BusId(2), 0.03, 0.04);
2664        let expected = |window: f64, fr: f64, to: f64| expected_rate(window, fr, to, 0.05);
2665        // A band pinned to one value, so the four corners collapse to one and
2666        // the bound is the plain law of cosines.
2667        let at = |v: f64| (v, v);
2668        close(
2669            br.synthesize_rate_a(0.5, at(1.1), at(1.06)),
2670            expected(0.5, 1.1, 1.06),
2671        );
2672
2673        // A wider window gives a looser bound.
2674        assert!(
2675            br.synthesize_rate_a(0.8, at(1.1), at(1.06))
2676                > br.synthesize_rate_a(0.5, at(1.1), at(1.06))
2677        );
2678
2679        // The magnitude of the window is what counts, and it holds at π.
2680        close(
2681            br.synthesize_rate_a(-0.5, at(1.1), at(1.06)),
2682            expected(0.5, 1.1, 1.06),
2683        );
2684        for window in [6.0, 2.0 * std::f64::consts::PI, -360.0] {
2685            close(
2686                br.synthesize_rate_a(window, at(1.1), at(1.06)),
2687                expected(std::f64::consts::PI, 1.1, 1.06),
2688            );
2689        }
2690
2691        let ideal = Branch::new(BusId(1), BusId(2), 0.0, 0.0);
2692        close(ideal.synthesize_rate_a(0.5, at(1.1), at(1.1)), 0.0);
2693    }
2694
2695    #[test]
2696    fn a_narrow_window_bounds_at_the_mixed_voltage_corner() {
2697        // The phasor difference is convex in the two voltages, so its largest
2698        // value over the band box is at a corner. Below roughly 10° that corner
2699        // is one terminal at its ceiling and the other at its floor, not both at
2700        // their ceilings. Reading only the ceilings there returns a bound
2701        // several times tighter than the branch physically has, and an OPF
2702        // enforces it.
2703        let br = Branch::new(BusId(1), BusId(2), 0.0, 0.01);
2704        let (vmin, vmax) = (0.9, 1.1);
2705        let corner = |window: f64, fr: f64, to: f64| expected_rate(window, fr, to, 0.01);
2706
2707        let narrow = 2.0_f64.to_radians();
2708        let bound = br.synthesize_rate_a(narrow, (vmin, vmax), (vmin, vmax));
2709        close(bound, corner(narrow, vmax, vmin));
2710        assert!(
2711            bound > 5.0 * corner(narrow, vmax, vmax),
2712            "the mixed corner dominates here: {bound} vs {}",
2713            corner(narrow, vmax, vmax)
2714        );
2715
2716        // Past the crossover both ceilings win again, and the bound follows.
2717        let wide = 30.0_f64.to_radians();
2718        close(
2719            br.synthesize_rate_a(wide, (vmin, vmax), (vmin, vmax)),
2720            corner(wide, vmax, vmax),
2721        );
2722    }
2723
2724    fn bus(id: usize) -> Bus {
2725        Bus {
2726            id: BusId(id),
2727            kind: BusType::Pq,
2728            vm: 1.0,
2729            va: 0.0,
2730            base_kv: 230.0,
2731            vmax: 1.1,
2732            vmin: 0.9,
2733            evhi: None,
2734            evlo: None,
2735            area: 1,
2736            zone: 1,
2737            name: None,
2738            uid: None,
2739            location: None,
2740            extras: Extras::new(),
2741        }
2742    }
2743
2744    #[test]
2745    fn model_json_bytes_are_strict_utf8_and_keep_model_validation() {
2746        let net = BalancedNetwork::in_memory("bytes", 100.0, vec![bus(1)], Vec::new());
2747        let json = net.to_json().expect("serialize model JSON");
2748        let mut with_bom = b"\xef\xbb\xbf".to_vec();
2749        with_bom.extend_from_slice(json.as_bytes());
2750        let back = BalancedNetwork::from_json_bytes(&with_bom).expect("read BOM prefixed JSON");
2751        assert_eq!(back.name(), "bytes");
2752        assert_eq!(back.buses().len(), 1);
2753
2754        let error = BalancedNetwork::from_json_bytes(b"{\"buses\":[]\xff}")
2755            .expect_err("invalid UTF-8 must not be replaced");
2756        assert!(
2757            matches!(&error, crate::Error::FormatRead { format: "JSON", message } if message.starts_with("input is not valid UTF-8:")),
2758            "{error}"
2759        );
2760        assert_eq!(error.code().code, "PARSE.SOURCE.MALFORMED");
2761
2762        let empty = net
2763            .to_json()
2764            .expect("serialize model JSON")
2765            .replace(&serde_json::to_string(&net.buses()).unwrap(), "[]");
2766        let error = BalancedNetwork::from_json_bytes(empty.as_bytes())
2767            .expect_err("the byte API must keep no-bus validation");
2768        assert!(error.to_string().contains("case has no buses"), "{error}");
2769    }
2770
2771    fn winding(b: usize) -> Winding {
2772        Winding {
2773            bus: BusId(b),
2774            tap: 1.0,
2775            shift: 0.0,
2776            nominal_kv: 230.0,
2777            rate_a: 100.0,
2778            rate_b: 0.0,
2779            rate_c: 0.0,
2780        }
2781    }
2782
2783    fn transformer_3w() -> Transformer3W {
2784        let z = |r, x| Impedance {
2785            r,
2786            x,
2787            base_mva: 100.0,
2788        };
2789        Transformer3W {
2790            windings: [winding(1), winding(2), winding(3)],
2791            z: [z(0.01, 0.10), z(0.02, 0.20), z(0.03, 0.30)],
2792            star_vm: 0.98,
2793            star_va: -1.5,
2794            mag_g: 0.0,
2795            mag_b: 0.0,
2796            in_service: true,
2797            name: Some("T1".into()),
2798            uid: None,
2799            extras: Extras::new(),
2800        }
2801    }
2802
2803    #[test]
2804    fn star_impedances_split_the_pairwise_values() {
2805        // z1 = (z12 + z31 - z23)/2, z2 = (z12 + z23 - z31)/2, z3 = (z23 + z31 - z12)/2.
2806        let [(r1, x1), (r2, x2), (r3, x3)] = transformer_3w().star_impedances();
2807        close(r1, 0.01);
2808        close(x1, 0.10);
2809        close(r2, 0.0);
2810        close(x2, 0.0);
2811        close(r3, 0.02);
2812        close(x3, 0.20);
2813    }
2814
2815    #[test]
2816    fn star_expansion_builds_a_star_bus_and_three_branches() {
2817        let t = transformer_3w();
2818        let (star, branches) = t.star_expansion(BusId(99));
2819
2820        assert_eq!(star.id, BusId(99));
2821        close(star.vm, 0.98);
2822        close(star.va, -1.5);
2823        // Each branch runs from its winding bus to the star, carrying the
2824        // winding tap and ratings and the split impedance.
2825        for (i, br) in branches.iter().enumerate() {
2826            assert_eq!(br.from, t.windings[i].bus);
2827            assert_eq!(br.to, BusId(99));
2828            close(br.tap, 1.0);
2829            close(br.rate_a, 100.0);
2830        }
2831        close(branches[2].r, 0.02);
2832        close(branches[2].x, 0.20);
2833    }
2834
2835    #[test]
2836    fn three_winding_transformer_survives_json_transport() {
2837        let mut net =
2838            BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
2839        net.transformers_3w_mut().push(transformer_3w());
2840        net.validate().unwrap();
2841
2842        let back = BalancedNetwork::from_json(&net.to_json().unwrap()).unwrap();
2843        assert_eq!(back.transformers_3w().len(), 1);
2844        close(back.transformers_3w()[0].z[1].x, 0.20);
2845        assert_eq!(back.transformers_3w()[0].windings[2].bus, BusId(3));
2846    }
2847
2848    #[test]
2849    fn lowered_lengths_match_the_expansion() {
2850        // `lowered_lengths` counts what `expand_transformers_3w` would append
2851        // instead of building it. The two must agree on every mix: an
2852        // out-of-service unit appends nothing, and only a unit with magnetizing
2853        // admittance appends a shunt.
2854        let mut magnetizing = transformer_3w();
2855        magnetizing.mag_b = 0.02;
2856        let mut out_of_service = transformer_3w();
2857        out_of_service.in_service = false;
2858        out_of_service.mag_g = 0.01;
2859
2860        for units in [
2861            vec![],
2862            vec![transformer_3w()],
2863            vec![magnetizing.clone()],
2864            vec![out_of_service.clone()],
2865            vec![transformer_3w(), magnetizing, out_of_service],
2866        ] {
2867            let mut net =
2868                BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
2869            net.shunts_mut().push(Shunt::new(BusId(1), 0.0, 0.5));
2870            *net.transformers_3w_mut() = units;
2871            let counted = net.lowered_lengths();
2872            let built = net.expand_transformers_3w();
2873            assert_eq!(counted.buses, built.buses().len());
2874            assert_eq!(counted.branches, built.branches().len());
2875            assert_eq!(counted.shunts, built.shunts().len());
2876        }
2877    }
2878
2879    #[test]
2880    fn check_references_rejects_bus_ids_without_star_expansion_headroom() {
2881        // A bus id at the top of the id space would make the synthetic star id
2882        // `max_bus_id + 1 + k` run past it during indexed analysis; the parse
2883        // boundary refuses it like any other malformed reference.
2884        let mut net = BalancedNetwork::in_memory(
2885            "t",
2886            100.0,
2887            vec![bus(1), bus(2), bus(3), bus(i64::MAX as usize)],
2888            Vec::new(),
2889        );
2890        net.transformers_3w_mut().push(transformer_3w());
2891        let err = net.validate().unwrap_err().to_string();
2892        assert!(
2893            err.contains("no room to allocate synthetic star bus ids"),
2894            "got {err}"
2895        );
2896    }
2897
2898    #[test]
2899    fn star_expansion_headroom_counts_only_in_service_transformers() {
2900        // The headroom needed is the in-service transformer count (plus the
2901        // base id), not the total: an out-of-service unit allocates no star
2902        // bus, so a network that only fits the in-service count must not be
2903        // rejected. A max bus id one under the ceiling fits one in-service
2904        // star id (max + 1) but not two.
2905        let mut net = BalancedNetwork::in_memory(
2906            "t",
2907            100.0,
2908            vec![bus(1), bus(2), bus(3), bus(i64::MAX as usize - 1)],
2909            Vec::new(),
2910        );
2911        net.transformers_3w_mut().push(transformer_3w());
2912        let mut out_of_service = transformer_3w();
2913        out_of_service.in_service = false;
2914        net.transformers_3w_mut().push(out_of_service);
2915        net.validate()
2916            .expect("in-service count fits; must not be rejected");
2917    }
2918
2919    #[test]
2920    fn check_references_rejects_a_bus_id_past_the_int64_ceiling() {
2921        // The C ABI reports bus ids as int64, so two distinct usize ids above
2922        // the ceiling both surface as the same value and a branch endpoint
2923        // matches two bus rows. Refuse them where every other malformed
2924        // reference is refused.
2925        let mut net = BalancedNetwork::in_memory(
2926            "t",
2927            100.0,
2928            vec![bus(1), bus(i64::MAX as usize + 1)],
2929            Vec::new(),
2930        );
2931        let err = net.validate().unwrap_err().to_string();
2932        assert!(err.contains("outside the int64 id space"), "got {err}");
2933
2934        // The ceiling itself is a valid id.
2935        net.buses_mut()[1].id = BusId(i64::MAX as usize);
2936        net.validate().expect("the ceiling itself is representable");
2937    }
2938
2939    #[test]
2940    fn check_references_rejects_a_dangling_winding_bus() {
2941        let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
2942        net.transformers_3w_mut().push(transformer_3w()); // winding 3 references bus 3
2943        let err = net.validate().unwrap_err().to_string();
2944        assert!(
2945            err.contains("3-winding transformer references unknown bus 3"),
2946            "got {err}"
2947        );
2948    }
2949
2950    /// A regulating transformer (bus 1→2) controlling the voltage at bus `reg`.
2951    fn regulating_branch(reg: usize) -> Branch {
2952        Branch {
2953            from: BusId(1),
2954            to: BusId(2),
2955            r: 0.0,
2956            x: 0.1,
2957            b: 0.0,
2958            charging: None,
2959            rate_a: 0.0,
2960            rate_b: 0.0,
2961            rate_c: 0.0,
2962            rating_sets: Vec::new(),
2963            current_ratings: None,
2964            tap: 1.0,
2965            shift: 0.0,
2966            in_service: true,
2967            angmin: -360.0,
2968            angmax: 360.0,
2969            control: Some(TransformerControl {
2970                mode: TransformerControlMode::Voltage,
2971                controlled_bus: Some(BusId(reg)),
2972                tap_min: 0.95,
2973                tap_max: 1.05,
2974                band_min: 1.0,
2975                band_max: 1.02,
2976                ntp: 17,
2977                mva_base: 100.0,
2978            }),
2979            solution: None,
2980            uid: None,
2981            route: None,
2982            extras: Extras::new(),
2983        }
2984    }
2985
2986    #[test]
2987    fn transformer_control_survives_json_transport() {
2988        let mut net =
2989            BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
2990        net.branches_mut().push(regulating_branch(3));
2991        net.validate().unwrap();
2992
2993        let back = BalancedNetwork::from_json(&net.to_json().unwrap()).unwrap();
2994        let c = back.branches()[0].control.as_ref().unwrap();
2995        assert_eq!(c.mode, TransformerControlMode::Voltage);
2996        assert_eq!(c.controlled_bus, Some(BusId(3)));
2997        close(c.tap_max, 1.05);
2998        assert_eq!(c.ntp, 17);
2999    }
3000
3001    #[test]
3002    fn gen_caps_serialize_as_a_named_map_that_grows_additively() {
3003        let mut caps: GenCaps = [None; GEN_EXTRA_KEYS.len()];
3004        caps[8] = Some(1.5); // ramp_30
3005        caps[10] = Some(0.5); // apf
3006        let g = Generator {
3007            bus: BusId(1),
3008            pg: 10.0,
3009            qg: 0.0,
3010            pmax: 100.0,
3011            pmin: 0.0,
3012            qmax: 50.0,
3013            qmin: -50.0,
3014            vg: 1.0,
3015            mbase: 100.0,
3016            in_service: true,
3017            cost: None,
3018            caps,
3019            regulated_bus: None,
3020            uid: None,
3021        };
3022
3023        // caps is a name-keyed object emitting only the present slots, not a
3024        // length-exact array.
3025        let json = serde_json::to_string(&g).unwrap();
3026        assert!(json.contains(r#""caps":{"#), "caps is an object: {json}");
3027        assert!(json.contains(r#""ramp_30":1.5"#) && json.contains(r#""apf":0.5"#));
3028        let back: Generator = serde_json::from_str(&json).unwrap();
3029        assert_eq!(back.caps, g.caps);
3030
3031        // Growing GEN_EXTRA_KEYS stays additive: an unknown future key is ignored,
3032        // a missing key reads as None, and an omitted field is the empty set.
3033        let with_future = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
3034            "vg":1,"mbase":100,"in_service":true,"cost":null,
3035            "caps":{"ramp_30":1.5,"future_ramp":9.9}}"#;
3036        let g2: Generator = serde_json::from_str(with_future).unwrap();
3037        assert_eq!(g2.caps[8], Some(1.5));
3038        assert_eq!(g2.caps.iter().filter(|v| v.is_some()).count(), 1);
3039        let no_caps = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
3040            "vg":1,"mbase":100,"in_service":true,"cost":null}"#;
3041        let g3: Generator = serde_json::from_str(no_caps).unwrap();
3042        assert!(!g3.has_caps());
3043
3044        // An explicit `"caps":null` is the empty set too, the same as omitting it.
3045        let null_caps = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
3046            "vg":1,"mbase":100,"in_service":true,"cost":null,"caps":null}"#;
3047        let g4: Generator = serde_json::from_str(null_caps).unwrap();
3048        assert!(!g4.has_caps());
3049    }
3050
3051    #[test]
3052    #[allow(clippy::float_cmp)]
3053    fn nonfinite_values_round_trip_through_model_json() {
3054        let bus = |id, vm| Bus {
3055            id: BusId(id),
3056            kind: BusType::Pq,
3057            vm,
3058            va: 0.0,
3059            base_kv: 230.0,
3060            vmax: 1.1,
3061            vmin: 0.9,
3062            evhi: None,
3063            evlo: None,
3064            area: 1,
3065            zone: 1,
3066            name: None,
3067            uid: None,
3068            location: None,
3069            extras: Extras::new(),
3070        };
3071        let branch = Branch {
3072            from: BusId(1),
3073            to: BusId(2),
3074            r: 0.0,
3075            x: f64::INFINITY,
3076            b: 0.0,
3077            charging: None,
3078            rate_a: 0.0,
3079            rate_b: 0.0,
3080            rate_c: 0.0,
3081            rating_sets: Vec::new(),
3082            current_ratings: None,
3083            tap: 0.0,
3084            shift: 0.0,
3085            in_service: true,
3086            angmin: -360.0,
3087            angmax: 360.0,
3088            control: None,
3089            solution: None,
3090            uid: None,
3091            route: None,
3092            extras: Extras::new(),
3093        };
3094        // A non-finite generator capability reports at its exact key path
3095        // (caps serializes as a name-keyed object), not the parent `caps`.
3096        let mut g = Generator {
3097            bus: BusId(1),
3098            pg: 0.0,
3099            qg: 0.0,
3100            pmax: 0.0,
3101            pmin: 0.0,
3102            qmax: 0.0,
3103            qmin: 0.0,
3104            vg: 1.0,
3105            mbase: 100.0,
3106            in_service: true,
3107            cost: None,
3108            caps: GenCaps::default(),
3109            regulated_bus: None,
3110            uid: None,
3111        };
3112        g.caps[8] = Some(f64::INFINITY); // ramp_30
3113        // Three nonfinite values at three nesting depths: a bus vm (NaN, a
3114        // struct field in a table), a branch x (Inf), and a generator ramp_30
3115        // cap (Inf, inside the name-keyed caps object).
3116        let mut net = BalancedNetwork::in_memory(
3117            "nf",
3118            100.0,
3119            vec![bus(1, f64::NAN), bus(2, 1.0)],
3120            vec![branch],
3121        );
3122        net.generators_mut().push(g);
3123
3124        let text = net.to_json().unwrap();
3125        assert!(text.contains(r#""vm":"NaN""#), "{text}");
3126        assert!(text.contains(r#""x":"Infinity""#), "{text}");
3127        assert!(text.contains(r#""ramp_30":"Infinity""#), "{text}");
3128
3129        let back = BalancedNetwork::from_json(&text).unwrap();
3130        assert!(back.buses()[0].vm.is_nan());
3131        assert_eq!(back.branches()[0].x, f64::INFINITY);
3132        assert_eq!(back.generators()[0].caps[8], Some(f64::INFINITY));
3133
3134        // Second write is byte stable, and the empty diagnostics channel
3135        // reflects that nothing was dropped.
3136        assert_eq!(back.to_json().unwrap(), text);
3137        let (_, diagnostics) = net.to_json_with_diagnostics().unwrap();
3138        assert!(diagnostics.is_empty());
3139    }
3140
3141    #[test]
3142    fn a_null_at_a_float_position_names_the_pre_090_spelling() {
3143        let net = BalancedNetwork::in_memory("nf", 100.0, vec![bus(1), bus(2)], Vec::new());
3144        let text = net
3145            .to_json()
3146            .unwrap()
3147            .replacen("\"vm\":1.0", "\"vm\":null", 1);
3148        assert!(text.contains("\"vm\":null"), "fixture edit failed: {text}");
3149        let err = BalancedNetwork::from_json(&text).unwrap_err().to_string();
3150        assert!(err.contains("before 0.9.0"), "{err}");
3151    }
3152
3153    #[test]
3154    fn check_references_rejects_a_dangling_controlled_bus() {
3155        let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
3156        net.branches_mut().push(regulating_branch(9)); // controls a bus that doesn't exist
3157        let err = net.validate().unwrap_err().to_string();
3158        assert!(
3159            err.contains("transformer control references unknown bus 9"),
3160            "got {err}"
3161        );
3162    }
3163
3164    /// A discrete switched shunt on bus 1 regulating the voltage at bus `reg`.
3165    fn switched_shunt(reg: usize) -> Shunt {
3166        Shunt {
3167            bus: BusId(1),
3168            g: 0.0,
3169            b: 19.0,
3170            in_service: true,
3171            control: Some(SwitchedShuntControl {
3172                mode: SwitchedShuntMode::Discrete,
3173                vhigh: 1.05,
3174                vlow: 0.95,
3175                control_bus: Some(BusId(reg)),
3176                rmpct: 100.0,
3177                blocks: vec![
3178                    ShuntBlock { steps: 2, b: 25.0 },
3179                    ShuntBlock { steps: 1, b: 50.0 },
3180                ],
3181            }),
3182            uid: None,
3183            extras: Extras::new(),
3184        }
3185    }
3186
3187    #[test]
3188    fn switched_shunt_control_survives_json_transport() {
3189        let mut net =
3190            BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
3191        net.shunts_mut().push(switched_shunt(3));
3192        net.validate().unwrap();
3193
3194        let back = BalancedNetwork::from_json(&net.to_json().unwrap()).unwrap();
3195        let c = back.shunts()[0].control.as_ref().unwrap();
3196        assert_eq!(c.mode, SwitchedShuntMode::Discrete);
3197        assert_eq!(c.control_bus, Some(BusId(3)));
3198        assert_eq!(c.blocks.len(), 2);
3199        close(c.blocks[1].b, 50.0);
3200    }
3201
3202    #[test]
3203    fn check_references_rejects_a_dangling_switched_shunt_control_bus() {
3204        let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
3205        net.shunts_mut().push(switched_shunt(9)); // controls a bus that doesn't exist
3206        let err = net.validate().unwrap_err().to_string();
3207        assert!(
3208            err.contains("switched-shunt control references unknown bus 9"),
3209            "got {err}"
3210        );
3211    }
3212
3213    #[test]
3214    fn validate_values_flags_and_repair_clamps_out_of_domain_values() {
3215        let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
3216        net.buses_mut()[0].vm = 0.0; // outside [0, 2]
3217        net.buses_mut()[1].va = 9000.0; // past ±2000°
3218        net.generators_mut().push(Generator {
3219            bus: BusId(1),
3220            pg: 10.0,
3221            qg: 0.0,
3222            pmax: 100.0,
3223            pmin: 0.0,
3224            qmax: 50.0,
3225            qmin: -50.0,
3226            vg: 0.0,    // non-positive setpoint
3227            mbase: 0.0, // non-positive base
3228            in_service: true,
3229            cost: None,
3230            caps: Default::default(),
3231            regulated_bus: None,
3232            uid: None,
3233        });
3234
3235        let diags = net.validate_values();
3236        let fields: std::collections::BTreeSet<_> = diags
3237            .iter()
3238            .map(|d| d.details()["field"].as_str().unwrap().to_owned())
3239            .collect();
3240        assert_eq!(
3241            fields,
3242            ["mbase", "va", "vg", "vm"]
3243                .into_iter()
3244                .map(str::to_owned)
3245                .collect(),
3246            "all four out-of-domain fields reported"
3247        );
3248        assert!(
3249            diags
3250                .iter()
3251                .all(|d| d.code() == "VALIDATE.BALANCED.VALUE_DOMAIN" && d.target().is_some())
3252        );
3253        // Non-mutating: the network still holds the bad values.
3254        close(net.buses()[0].vm, 0.0);
3255
3256        // The recorded path: repair the module, read the history entry.
3257        let module = powerio_core::PioModule::new(net);
3258        let module = repair_values(module).unwrap();
3259        let net = module.value();
3260        close(net.buses()[0].vm, 1.0);
3261        close(net.buses()[1].va, 0.0);
3262        close(net.generators()[0].mbase, 100.0); // → base_mva
3263        close(net.generators()[0].vg, 1.0);
3264        // Idempotent: nothing left to repair, and a second pass appends
3265        // nothing.
3266        assert!(net.validate_values().is_empty());
3267        let entries = module.history();
3268        assert_eq!(entries.len(), 1);
3269        assert_eq!(entries[0].kind(), powerio_core::HistoryKind::Repair);
3270        assert_eq!(
3271            entries[0].parameters()["repairs"].as_array().unwrap().len(),
3272            diags.len()
3273        );
3274        assert_eq!(module.diagnostics().len(), diags.len());
3275        let module = repair_values(module).unwrap();
3276        assert_eq!(module.history().len(), 1);
3277    }
3278
3279    #[test]
3280    fn validate_values_is_empty_for_a_clean_network() {
3281        let net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
3282        assert!(net.validate_values().is_empty());
3283    }
3284}