Skip to main content

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