Skip to main content

powerio_tx/
network.rs

1//! Format neutral balanced network model.
2//!
3//! Readers map source formats into a [`BalancedNetwork`], and writers map a network to
4//! target formats. Loads and shunts have separate tables, so formats can retain
5//! several elements at one bus. MATPOWER demand and shunt fields become those
6//! records during parsing. [`IndexedNetwork`](crate::IndexedNetwork) provides
7//! the dense analysis view used by matrix builders.
8//!
9//! A network can retain its source bytes and [`SourceFormat`] for same format
10//! writing. Each element also has an [`Extras`] map for source fields not named
11//! by the typed model.
12//!
13//! Formats represent different data. Cross format writers report unsupported
14//! fields rather than claiming an exact conversion.
15
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
17
18use powerio_core::ComponentId;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22use crate::geo::{GeoMeta, Location};
23use crate::{Error, Result};
24
25mod transformer_3w_serde;
26
27/// Source format fields the neutral model does not name, kept for round trips
28/// and cross format conversion. Keys are field names; values are JSON scalars.
29pub type Extras = BTreeMap<String, Value>;
30
31/// An alternate name for a component, with the optional source supplied alias type.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
34#[non_exhaustive]
35pub struct ComponentAlias {
36    pub value: String,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub alias_type: Option<String>,
39}
40
41/// An identifier assigned by another data system or authority.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
44#[non_exhaustive]
45pub struct ExternalIdentifier {
46    pub value: String,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub authority: Option<String>,
49}
50
51/// Source neutral metadata attached to one stable PowerIO component identity.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54#[non_exhaustive]
55pub struct ComponentMetadata {
56    pub component: ComponentId,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub name: Option<String>,
59    /// The equipment container that owns this component, when the source model
60    /// identifies one.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub equipment_container: Option<ComponentId>,
63    #[serde(default)]
64    pub aliases: Vec<ComponentAlias>,
65    #[serde(default)]
66    pub external_identifiers: Vec<ExternalIdentifier>,
67    #[serde(default)]
68    pub properties: BTreeMap<String, String>,
69    #[serde(default)]
70    pub fictitious: bool,
71}
72
73/// A substation containing one or more voltage levels.
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76#[non_exhaustive]
77pub struct Substation {
78    pub component: ComponentId,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub country: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub operator: Option<String>,
83    #[serde(default)]
84    pub geographical_tags: Vec<String>,
85}
86
87/// The connectivity representation used inside a voltage level.
88#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
90#[serde(rename_all = "snake_case")]
91#[non_exhaustive]
92pub enum TopologyKind {
93    BusBreaker,
94    NodeBreaker,
95}
96
97/// A voltage level and its voltage limits in kV.
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
99#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
100#[non_exhaustive]
101pub struct VoltageLevel {
102    pub component: ComponentId,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub substation: Option<ComponentId>,
105    pub nominal_kv: f64,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub low_voltage_limit_kv: Option<f64>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub high_voltage_limit_kv: Option<f64>,
110    pub topology_kind: TopologyKind,
111    /// Buses in the balanced calculation view that belong to this voltage level.
112    #[serde(default)]
113    pub buses: Vec<BusId>,
114}
115
116/// One connectivity node in a node breaker voltage level.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119#[non_exhaustive]
120pub struct ConnectivityNode {
121    pub component: ComponentId,
122    pub voltage_level: ComponentId,
123    /// Integer node number when the source uses node breaker numbering, as in
124    /// XIIDM and RAWX. Identity based sources such as CGMES leave this unset.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub node_number: Option<i32>,
127    /// The energized bus in the balanced calculation view, when one is known.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub calculated_bus: Option<BusId>,
130}
131
132/// One configured bus in a bus breaker voltage level and the energized bus
133/// it contributes to in the balanced calculation view.
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
135#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
136#[non_exhaustive]
137pub struct BusBreakerBus {
138    pub component: ComponentId,
139    pub voltage_level: ComponentId,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub calculated_bus: Option<BusId>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub voltage_kv: Option<f64>,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub angle_degrees: Option<f64>,
146}
147
148/// One calculated bus explicitly recorded in node breaker topology.
149///
150/// XIIDM calls this record `CalculatedBus`. Its node list distinguishes a bus
151/// present in the source from a connected component PowerIO calculated when
152/// the source omitted the record.
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155#[non_exhaustive]
156pub struct CalculatedBus {
157    pub voltage_level: ComponentId,
158    pub calculated_bus: BusId,
159    pub nodes: Vec<ComponentId>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub voltage_kv: Option<f64>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub angle_degrees: Option<f64>,
164}
165
166/// A busbar section attached to a node breaker connectivity node.
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
169#[non_exhaustive]
170pub struct BusbarSection {
171    pub component: ComponentId,
172    pub voltage_level: ComponentId,
173    pub node: ComponentId,
174}
175
176/// A CIM junction and its conducting equipment identity.
177///
178/// Its electrical connection is recorded by the corresponding entries in
179/// [`DetailedConnectivity::terminals`].
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
181#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
182#[non_exhaustive]
183pub struct Junction {
184    pub component: ComponentId,
185}
186
187/// One equipment terminal and its bus breaker or node breaker connection.
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190#[non_exhaustive]
191pub struct Terminal {
192    /// The terminal's own identity when the source assigns one.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub component: Option<ComponentId>,
195    pub equipment: ComponentId,
196    /// One for single terminal equipment, or the branch/transformer side number.
197    pub terminal: u8,
198    pub voltage_level: ComponentId,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub bus: Option<ComponentId>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub connectable_bus: Option<ComponentId>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub node: Option<ComponentId>,
205    pub connected: bool,
206    /// Active power injected at this AC terminal, in MW.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub active_power_mw: Option<f64>,
209    /// Reactive power injected at this AC terminal, in MVAr.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub reactive_power_mvar: Option<f64>,
212}
213
214/// Physical kind of a breaker topology switch.
215#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
216#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
217#[serde(rename_all = "snake_case")]
218#[non_exhaustive]
219pub enum SwitchKind {
220    Breaker,
221    Disconnector,
222    LoadBreakSwitch,
223}
224
225/// One endpoint of a detailed topology switch.
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
227#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
228#[serde(tag = "kind", content = "component", rename_all = "snake_case")]
229#[non_exhaustive]
230pub enum TopologyEndpoint {
231    Bus(ComponentId),
232    Node(ComponentId),
233}
234
235/// A switch in the authoritative bus breaker or node breaker topology.
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
238#[non_exhaustive]
239pub struct TopologySwitch {
240    pub component: ComponentId,
241    pub voltage_level: ComponentId,
242    pub kind: SwitchKind,
243    pub endpoint1: TopologyEndpoint,
244    pub endpoint2: TopologyEndpoint,
245    pub open: bool,
246    #[serde(default)]
247    pub retained: bool,
248}
249
250/// A permanent connection between two node breaker connectivity nodes.
251#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
252#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
253#[non_exhaustive]
254pub struct InternalConnection {
255    pub voltage_level: ComponentId,
256    pub node1: ComponentId,
257    pub node2: ComponentId,
258}
259
260/// A reference to one numbered terminal of an equipment record.
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
262#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
263#[non_exhaustive]
264pub struct TerminalReference {
265    pub equipment: ComponentId,
266    pub terminal: u8,
267}
268
269/// The operating arrangement of one CGMES DC converter unit.
270#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272#[serde(rename_all = "snake_case")]
273#[non_exhaustive]
274pub enum DcConverterOperatingMode {
275    Bipolar,
276    MonopolarGroundReturn,
277    MonopolarMetallicReturn,
278}
279
280/// A CGMES DC converter unit and its substation containment.
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
282#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
283#[non_exhaustive]
284pub struct DcConverterUnit {
285    pub component: ComponentId,
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub substation: Option<ComponentId>,
288    pub operation_mode: DcConverterOperatingMode,
289}
290
291/// An energized connectivity node in a CGMES DC network.
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
293#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
294#[non_exhaustive]
295pub struct DcTopologicalNode {
296    pub component: ComponentId,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub dc_converter_unit: Option<ComponentId>,
299}
300
301/// Polarity assigned to an AC/DC converter DC terminal.
302#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
303#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
304#[serde(rename_all = "snake_case")]
305#[non_exhaustive]
306pub enum DcPolarity {
307    Positive,
308    Middle,
309    Negative,
310}
311
312/// One terminal of DC conducting equipment.
313#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315#[non_exhaustive]
316pub struct DcTerminal {
317    /// The terminal's own identity when the source assigns one.
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub component: Option<ComponentId>,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub sequence_number: Option<u32>,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub dc_node: Option<ComponentId>,
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub dc_topological_node: Option<ComponentId>,
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub polarity: Option<DcPolarity>,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub connected: Option<bool>,
330    /// Active power injected at this terminal, in MW.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub active_power_mw: Option<f64>,
333    /// Current injected at this terminal, in A.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub current_a: Option<f64>,
336}
337
338/// A physical node in a DC network.
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
340#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
341#[non_exhaustive]
342pub struct DcNode {
343    pub component: ComponentId,
344    /// XIIDM nominal voltage. CGMES DC nodes do not carry this quantity.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub nominal_voltage_kv: Option<f64>,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub dc_converter_unit: Option<ComponentId>,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub dc_topological_node: Option<ComponentId>,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub voltage_kv: Option<f64>,
353}
354
355/// A connection from one DC node to ground.
356#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
357#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
358#[non_exhaustive]
359pub struct DcGround {
360    pub component: ComponentId,
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub equipment_container: Option<ComponentId>,
363    pub dc_terminal: DcTerminal,
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub rated_dc_voltage_kv: Option<f64>,
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub resistance_ohm: Option<f64>,
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub inductance_h: Option<f64>,
370}
371
372/// A CGMES DC busbar.
373#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
374#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
375#[non_exhaustive]
376pub struct DcBusbar {
377    pub component: ComponentId,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub equipment_container: Option<ComponentId>,
380    pub dc_terminal: DcTerminal,
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub rated_dc_voltage_kv: Option<f64>,
383}
384
385/// A line segment between two DC nodes.
386#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
387#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
388#[non_exhaustive]
389pub struct DcLine {
390    pub component: ComponentId,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub equipment_container: Option<ComponentId>,
393    pub dc_terminal1: DcTerminal,
394    pub dc_terminal2: DcTerminal,
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub rated_dc_voltage_kv: Option<f64>,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub resistance_ohm: Option<f64>,
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub inductance_h: Option<f64>,
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub capacitance_f: Option<f64>,
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub length_km: Option<f64>,
405}
406
407/// A CGMES DC series device.
408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
409#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
410#[non_exhaustive]
411pub struct DcSeriesDevice {
412    pub component: ComponentId,
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub equipment_container: Option<ComponentId>,
415    pub dc_terminal1: DcTerminal,
416    pub dc_terminal2: DcTerminal,
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub rated_dc_voltage_kv: Option<f64>,
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub resistance_ohm: Option<f64>,
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub inductance_h: Option<f64>,
423}
424
425/// Physical kind of a DC switch.
426#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
427#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
428#[serde(rename_all = "snake_case")]
429#[non_exhaustive]
430pub enum DcSwitchKind {
431    Switch,
432    Breaker,
433    Disconnector,
434}
435
436/// A switch between two DC nodes.
437#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
438#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
439#[non_exhaustive]
440pub struct DcSwitch {
441    pub component: ComponentId,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub equipment_container: Option<ComponentId>,
444    pub dc_terminal1: DcTerminal,
445    pub dc_terminal2: DcTerminal,
446    pub kind: DcSwitchKind,
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub rated_dc_voltage_kv: Option<f64>,
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub open: Option<bool>,
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub resistance_ohm: Option<f64>,
453}
454
455/// Control quantity assigned to an AC/DC converter.
456#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
457#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
458#[serde(rename_all = "snake_case")]
459#[non_exhaustive]
460pub enum AcDcConverterControlMode {
461    /// Maintain active power at the point of common coupling.
462    ActivePowerAtPcc,
463    /// Maintain DC voltage.
464    DcVoltage,
465    /// Maintain DC current.
466    DcCurrent,
467    /// XIIDM `P_PCC_DROOP`: active power at the point of common coupling
468    /// follows a piecewise droop curve over DC voltage.
469    ActivePowerAtPccAndDcVoltageDroopCurve,
470    /// CGMES `VsPpccControlKind.pPccAndUdcDroop`.
471    ActivePowerAtPccAndDcVoltageDroop,
472    /// CGMES `VsPpccControlKind.pPccAndUdcDroopWithCompensation`.
473    ActivePowerAtPccAndDcVoltageDroopWithCompensation,
474    /// CGMES `VsPpccControlKind.pPccAndUdcDroopPilot`.
475    ActivePowerAtPccAndDcVoltageDroopPilot,
476}
477
478/// One segment of an AC/DC converter's DC voltage droop curve.
479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
480#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
481#[non_exhaustive]
482pub struct DroopCurveSegment {
483    pub minimum_voltage_kv: f64,
484    pub maximum_voltage_kv: f64,
485    pub k: f64,
486}
487
488/// DC voltage droop segments for an AC/DC converter.
489#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
490#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
491#[non_exhaustive]
492pub struct DroopCurve {
493    pub segments: Vec<DroopCurveSegment>,
494}
495
496/// One point of a reactive capability curve.
497#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
498#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
499#[non_exhaustive]
500pub struct ReactiveCapabilityCurvePoint {
501    pub active_power_mw: f64,
502    pub minimum_reactive_power_mvar: f64,
503    pub maximum_reactive_power_mvar: f64,
504    #[serde(default)]
505    pub properties: BTreeMap<String, String>,
506}
507
508/// CIM `CurveStyle` for a reactive capability curve.
509#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
510#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
511#[serde(rename_all = "snake_case")]
512#[non_exhaustive]
513pub enum CurveStyle {
514    /// CIM `CurveStyle.constantYValue`.
515    ConstantYValue,
516    /// CIM `CurveStyle.straightLineYValues`.
517    StraightLineYValues,
518}
519
520/// Reactive power limits that vary with active power.
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
522#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
523#[non_exhaustive]
524pub struct ReactiveCapabilityCurve {
525    pub curve_style: CurveStyle,
526    #[serde(default)]
527    pub properties: BTreeMap<String, String>,
528    pub points: Vec<ReactiveCapabilityCurvePoint>,
529}
530
531/// Reactive power limits that do not vary with active power.
532#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
533#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
534#[non_exhaustive]
535pub struct MinMaxReactiveLimits {
536    pub minimum_reactive_power_mvar: f64,
537    pub maximum_reactive_power_mvar: f64,
538    #[serde(default)]
539    pub properties: BTreeMap<String, String>,
540}
541
542/// The two reactive limit forms carried by XIIDM equipment.
543#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
544#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
545#[serde(tag = "kind", content = "limits", rename_all = "snake_case")]
546#[non_exhaustive]
547pub enum ReactiveLimits {
548    MinMax(MinMaxReactiveLimits),
549    CapabilityCurve(ReactiveCapabilityCurve),
550}
551
552/// Evaluate reactive limits at one active power assignment. Format readers use
553/// this shared calculation when projecting a capability curve onto the
554/// balanced generator row.
555#[allow(clippy::float_cmp, clippy::manual_midpoint)]
556pub(crate) fn calc_reactive_limits_at_active_power(
557    owner: &str,
558    limits: &ReactiveLimits,
559    active_power_mw: f64,
560) -> std::result::Result<(f64, f64), String> {
561    match limits {
562        ReactiveLimits::MinMax(limits) => {
563            if limits.minimum_reactive_power_mvar > limits.maximum_reactive_power_mvar {
564                return Err(format!("{owner} has minQ greater than maxQ"));
565            }
566            Ok((
567                limits.minimum_reactive_power_mvar,
568                limits.maximum_reactive_power_mvar,
569            ))
570        }
571        ReactiveLimits::CapabilityCurve(curve) => {
572            if curve.points.len() < 2 {
573                return Err(format!(
574                    "{owner} reactiveCapabilityCurve has fewer than two points"
575                ));
576            }
577            if !active_power_mw.is_finite() {
578                return Err(format!(
579                    "{owner} reactiveCapabilityCurve cannot be evaluated at a nonfinite active power"
580                ));
581            }
582            let mut points = curve.points.iter().collect::<Vec<_>>();
583            points
584                .sort_by(|first, second| first.active_power_mw.total_cmp(&second.active_power_mw));
585            for pair in points.windows(2) {
586                if pair[0].active_power_mw == pair[1].active_power_mw {
587                    return Err(format!(
588                        "{owner} reactiveCapabilityCurve has duplicate active power points"
589                    ));
590                }
591            }
592            let (first, second) = if active_power_mw <= points[0].active_power_mw {
593                (points[0], points[0])
594            } else if active_power_mw >= points[points.len() - 1].active_power_mw {
595                (points[points.len() - 1], points[points.len() - 1])
596            } else {
597                let upper = points.partition_point(|point| point.active_power_mw < active_power_mw);
598                (points[upper - 1], points[upper])
599            };
600            let (minimum, maximum) = if std::ptr::eq(first, second) {
601                (
602                    first.minimum_reactive_power_mvar,
603                    first.maximum_reactive_power_mvar,
604                )
605            } else {
606                let fraction = (active_power_mw - first.active_power_mw)
607                    / (second.active_power_mw - first.active_power_mw);
608                (
609                    first.minimum_reactive_power_mvar
610                        + fraction
611                            * (second.minimum_reactive_power_mvar
612                                - first.minimum_reactive_power_mvar),
613                    first.maximum_reactive_power_mvar
614                        + fraction
615                            * (second.maximum_reactive_power_mvar
616                                - first.maximum_reactive_power_mvar),
617                )
618            };
619            if minimum <= maximum {
620                Ok((minimum, maximum))
621            } else {
622                let midpoint = (minimum + maximum) / 2.0;
623                Ok((midpoint, midpoint))
624            }
625        }
626    }
627}
628
629/// Reactive limits retained for equipment whose balanced calculation row
630/// carries only the limits evaluated at its current active power assignment.
631#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
632#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
633#[non_exhaustive]
634pub struct EquipmentReactiveLimits {
635    pub equipment: ComponentId,
636    pub limits: ReactiveLimits,
637}
638
639/// Optional generation attached to a PowSybl boundary line.
640#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
641#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
642#[non_exhaustive]
643pub struct BoundaryLineGeneration {
644    pub voltage_regulation_on: bool,
645    #[serde(default, skip_serializing_if = "Option::is_none")]
646    pub minimum_active_power_mw: Option<f64>,
647    #[serde(default, skip_serializing_if = "Option::is_none")]
648    pub maximum_active_power_mw: Option<f64>,
649    #[serde(default, skip_serializing_if = "Option::is_none")]
650    pub target_active_power_mw: Option<f64>,
651    #[serde(default, skip_serializing_if = "Option::is_none")]
652    pub target_reactive_power_mvar: Option<f64>,
653    #[serde(default, skip_serializing_if = "Option::is_none")]
654    pub target_voltage_kv: Option<f64>,
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pub reactive_limits: Option<ReactiveLimits>,
657}
658
659/// One PowSybl boundary line retained beside the balanced calculation view.
660#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
661#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
662#[non_exhaustive]
663pub struct BoundaryLine {
664    pub component: ComponentId,
665    pub voltage_level: ComponentId,
666    pub active_power_setpoint_mw: f64,
667    pub reactive_power_setpoint_mvar: f64,
668    pub resistance_ohm: f64,
669    pub reactance_ohm: f64,
670    pub conductance_siemens: f64,
671    pub susceptance_siemens: f64,
672    #[serde(default, skip_serializing_if = "Option::is_none")]
673    pub pairing_key: Option<String>,
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub generation: Option<BoundaryLineGeneration>,
676    #[serde(default, skip_serializing_if = "Option::is_none")]
677    pub calculation_load: Option<ComponentId>,
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub calculation_generator: Option<ComponentId>,
680}
681
682/// A PowSybl tie line and the two boundary lines that define it.
683#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
684#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
685#[non_exhaustive]
686pub struct TieLine {
687    pub component: ComponentId,
688    pub boundary_line1: ComponentId,
689    pub boundary_line2: ComponentId,
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub calculation_branch: Option<ComponentId>,
692}
693
694/// A voltage source converter connected to an AC voltage level and two DC nodes.
695#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
696#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
697#[non_exhaustive]
698pub struct VoltageSourceConverter {
699    pub component: ComponentId,
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub dc_converter_unit: Option<ComponentId>,
702    pub dc_terminal1: DcTerminal,
703    pub dc_terminal2: DcTerminal,
704    #[serde(default, skip_serializing_if = "Option::is_none")]
705    pub base_apparent_power_mva: Option<f64>,
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub minimum_active_power_mw: Option<f64>,
708    #[serde(default, skip_serializing_if = "Option::is_none")]
709    pub maximum_active_power_mw: Option<f64>,
710    #[serde(default, skip_serializing_if = "Option::is_none")]
711    pub minimum_dc_voltage_kv: Option<f64>,
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub maximum_dc_voltage_kv: Option<f64>,
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub rated_dc_voltage_kv: Option<f64>,
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub valve_u0_kv: Option<f64>,
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub number_of_valves: Option<u32>,
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub idle_loss_mw: Option<f64>,
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub switching_loss_mw_per_ampere: Option<f64>,
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub resistive_loss_ohm: Option<f64>,
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub control_mode: Option<AcDcConverterControlMode>,
728    /// Active power at the point of common coupling, using load sign convention.
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub active_power_at_pcc_mw: Option<f64>,
731    /// Reactive power at the point of common coupling, using load sign convention.
732    #[serde(default, skip_serializing_if = "Option::is_none")]
733    pub reactive_power_at_pcc_mvar: Option<f64>,
734    /// Active power target at the point of common coupling, using load sign convention.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub target_active_power_mw: Option<f64>,
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub target_dc_voltage_kv: Option<f64>,
739    #[serde(default, skip_serializing_if = "Option::is_none")]
740    pub pcc_terminal: Option<TerminalReference>,
741    #[serde(default, skip_serializing_if = "Option::is_none")]
742    pub droop_curve: Option<DroopCurve>,
743    /// CGMES scalar droop. This is not the XIIDM piecewise [`DroopCurve`].
744    #[serde(default, skip_serializing_if = "Option::is_none")]
745    pub droop: Option<f64>,
746    #[serde(default, skip_serializing_if = "Option::is_none")]
747    pub droop_compensation: Option<f64>,
748    #[serde(default, skip_serializing_if = "Option::is_none")]
749    pub q_share: Option<f64>,
750    #[serde(default, skip_serializing_if = "Option::is_none")]
751    pub maximum_modulation_index: Option<f64>,
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    pub maximum_valve_current_a: Option<f64>,
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub voltage_regulator_on: Option<bool>,
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub voltage_setpoint_kv: Option<f64>,
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    pub reactive_power_setpoint_mvar: Option<f64>,
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    pub reactive_limits: Option<ReactiveLimits>,
762    #[serde(default, skip_serializing_if = "Option::is_none")]
763    pub pole_loss_active_power_mw: Option<f64>,
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub dc_current_a: Option<f64>,
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub ac_voltage_kv: Option<f64>,
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub dc_voltage_kv: Option<f64>,
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub delta_degrees: Option<f64>,
772    #[serde(default, skip_serializing_if = "Option::is_none")]
773    pub uf_kv: Option<f64>,
774    #[serde(default, skip_serializing_if = "Option::is_none")]
775    pub uv_kv: Option<f64>,
776}
777
778/// Reactive power model used by a line commutated converter.
779#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
780#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
781#[serde(rename_all = "snake_case")]
782#[non_exhaustive]
783pub enum LineCommutatedConverterReactiveModel {
784    FixedPowerFactor,
785    CalculatedPowerFactor,
786}
787
788/// Rectifier or inverter operation assigned to a line commutated converter.
789#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
790#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
791#[serde(rename_all = "snake_case")]
792#[non_exhaustive]
793pub enum LineCommutatedConverterOperatingMode {
794    Rectifier,
795    Inverter,
796}
797
798/// A line commutated converter connected to an AC voltage level and two DC nodes.
799#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
800#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
801#[non_exhaustive]
802pub struct LineCommutatedConverter {
803    pub component: ComponentId,
804    #[serde(default, skip_serializing_if = "Option::is_none")]
805    pub dc_converter_unit: Option<ComponentId>,
806    pub dc_terminal1: DcTerminal,
807    pub dc_terminal2: DcTerminal,
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub base_apparent_power_mva: Option<f64>,
810    #[serde(default, skip_serializing_if = "Option::is_none")]
811    pub minimum_active_power_mw: Option<f64>,
812    #[serde(default, skip_serializing_if = "Option::is_none")]
813    pub maximum_active_power_mw: Option<f64>,
814    #[serde(default, skip_serializing_if = "Option::is_none")]
815    pub minimum_dc_voltage_kv: Option<f64>,
816    #[serde(default, skip_serializing_if = "Option::is_none")]
817    pub maximum_dc_voltage_kv: Option<f64>,
818    #[serde(default, skip_serializing_if = "Option::is_none")]
819    pub rated_dc_voltage_kv: Option<f64>,
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub valve_u0_kv: Option<f64>,
822    #[serde(default, skip_serializing_if = "Option::is_none")]
823    pub number_of_valves: Option<u32>,
824    #[serde(default, skip_serializing_if = "Option::is_none")]
825    pub idle_loss_mw: Option<f64>,
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub switching_loss_mw_per_ampere: Option<f64>,
828    #[serde(default, skip_serializing_if = "Option::is_none")]
829    pub resistive_loss_ohm: Option<f64>,
830    #[serde(default, skip_serializing_if = "Option::is_none")]
831    pub control_mode: Option<AcDcConverterControlMode>,
832    /// Active power at the point of common coupling, using load sign convention.
833    #[serde(default, skip_serializing_if = "Option::is_none")]
834    pub active_power_at_pcc_mw: Option<f64>,
835    /// Reactive power at the point of common coupling, using load sign convention.
836    #[serde(default, skip_serializing_if = "Option::is_none")]
837    pub reactive_power_at_pcc_mvar: Option<f64>,
838    /// Active power target at the point of common coupling, using load sign convention.
839    #[serde(default, skip_serializing_if = "Option::is_none")]
840    pub target_active_power_mw: Option<f64>,
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub target_dc_voltage_kv: Option<f64>,
843    #[serde(default, skip_serializing_if = "Option::is_none")]
844    pub pcc_terminal: Option<TerminalReference>,
845    #[serde(default, skip_serializing_if = "Option::is_none")]
846    pub droop_curve: Option<DroopCurve>,
847    #[serde(default, skip_serializing_if = "Option::is_none")]
848    pub reactive_model: Option<LineCommutatedConverterReactiveModel>,
849    #[serde(default, skip_serializing_if = "Option::is_none")]
850    pub power_factor: Option<f64>,
851    #[serde(default, skip_serializing_if = "Option::is_none")]
852    pub operating_mode: Option<LineCommutatedConverterOperatingMode>,
853    #[serde(default, skip_serializing_if = "Option::is_none")]
854    pub rated_dc_current_a: Option<f64>,
855    #[serde(default, skip_serializing_if = "Option::is_none")]
856    pub minimum_alpha_degrees: Option<f64>,
857    #[serde(default, skip_serializing_if = "Option::is_none")]
858    pub maximum_alpha_degrees: Option<f64>,
859    #[serde(default, skip_serializing_if = "Option::is_none")]
860    pub minimum_gamma_degrees: Option<f64>,
861    #[serde(default, skip_serializing_if = "Option::is_none")]
862    pub maximum_gamma_degrees: Option<f64>,
863    #[serde(default, skip_serializing_if = "Option::is_none")]
864    pub target_alpha_degrees: Option<f64>,
865    #[serde(default, skip_serializing_if = "Option::is_none")]
866    pub target_gamma_degrees: Option<f64>,
867    #[serde(default, skip_serializing_if = "Option::is_none")]
868    pub target_dc_current_a: Option<f64>,
869    #[serde(default, skip_serializing_if = "Option::is_none")]
870    pub pole_loss_active_power_mw: Option<f64>,
871    #[serde(default, skip_serializing_if = "Option::is_none")]
872    pub dc_current_a: Option<f64>,
873    #[serde(default, skip_serializing_if = "Option::is_none")]
874    pub ac_voltage_kv: Option<f64>,
875    #[serde(default, skip_serializing_if = "Option::is_none")]
876    pub dc_voltage_kv: Option<f64>,
877    #[serde(default, skip_serializing_if = "Option::is_none")]
878    pub alpha_degrees: Option<f64>,
879    #[serde(default, skip_serializing_if = "Option::is_none")]
880    pub gamma_degrees: Option<f64>,
881}
882
883/// One temporary loading limit inside an [`OperationalLimitGroup`].
884#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
885#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
886#[non_exhaustive]
887pub struct TemporaryLimit {
888    pub name: String,
889    pub value: f64,
890    pub acceptable_duration_seconds: u64,
891    #[serde(default)]
892    pub fictitious: bool,
893}
894
895/// A permanent loading limit and its named temporary limits.
896///
897/// The unit is selected by the field that contains this record: amperes for
898/// `current_limits`, MW for `active_power_limits`, and MVA for
899/// `apparent_power_limits`.
900#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
901#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
902#[non_exhaustive]
903pub struct LoadingLimits {
904    #[serde(default, skip_serializing_if = "Option::is_none")]
905    pub permanent_limit: Option<f64>,
906    #[serde(default, skip_serializing_if = "Option::is_none")]
907    pub permanent_limit_name: Option<String>,
908    #[serde(default)]
909    pub temporary_limits: Vec<TemporaryLimit>,
910}
911
912/// One named set of loading limits at an equipment terminal.
913#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
914#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
915#[non_exhaustive]
916pub struct OperationalLimitGroup {
917    pub equipment: ComponentId,
918    pub terminal: u8,
919    pub id: String,
920    #[serde(default)]
921    pub properties: BTreeMap<String, String>,
922    #[serde(default)]
923    pub selected: bool,
924    #[serde(default, skip_serializing_if = "Option::is_none")]
925    pub current_limits: Option<LoadingLimits>,
926    #[serde(default, skip_serializing_if = "Option::is_none")]
927    pub active_power_limits: Option<LoadingLimits>,
928    #[serde(default, skip_serializing_if = "Option::is_none")]
929    pub apparent_power_limits: Option<LoadingLimits>,
930}
931
932/// Whether a transformer tap changer controls voltage ratio or phase angle.
933#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
934#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
935#[serde(rename_all = "snake_case")]
936#[non_exhaustive]
937pub enum TapChangerKind {
938    Ratio,
939    Phase,
940}
941
942/// The electrical quantity regulated by a transformer tap changer.
943#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
944#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
945#[serde(rename_all = "snake_case")]
946#[non_exhaustive]
947pub enum TapChangerRegulationMode {
948    Voltage,
949    ReactivePower,
950    ActivePower,
951    Current,
952}
953
954/// One transformer tap changer step.
955#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
956#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
957#[non_exhaustive]
958pub struct TapChangerStep {
959    pub position: i32,
960    /// Voltage ratio in per unit.
961    pub rho: f64,
962    /// Phase angle difference in degrees.
963    pub alpha_degrees: f64,
964    /// Resistance deviation from the transformer's nominal value, in percent.
965    pub resistance_deviation_percent: f64,
966    /// Reactance deviation from the transformer's nominal value, in percent.
967    pub reactance_deviation_percent: f64,
968    /// Conductance deviation from the transformer's nominal value, in percent.
969    pub conductance_deviation_percent: f64,
970    /// Susceptance deviation from the transformer's nominal value, in percent.
971    pub susceptance_deviation_percent: f64,
972}
973
974/// A ratio or phase tap changer attached to one transformer winding.
975#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
976#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
977#[non_exhaustive]
978pub struct TapChanger {
979    /// The tap changer's own identity when the source assigns one.
980    #[serde(default, skip_serializing_if = "Option::is_none")]
981    pub component: Option<ComponentId>,
982    pub transformer: ComponentId,
983    /// One for a two winding transformer, or one through three for a three
984    /// winding transformer.
985    pub winding: u8,
986    pub kind: TapChangerKind,
987    /// Assigned tap position. XIIDM permits this to be absent for cases whose
988    /// minimum validation level is `EQUIPMENT`.
989    #[serde(default, skip_serializing_if = "Option::is_none")]
990    pub tap_position: Option<i32>,
991    /// Solved tap position when a source records it separately from the
992    /// assigned tap position.
993    #[serde(default, skip_serializing_if = "Option::is_none")]
994    pub solved_tap_position: Option<i32>,
995    pub low_tap_position: i32,
996    /// Tap position at which the ratio or phase shift is neutral.
997    #[serde(default, skip_serializing_if = "Option::is_none")]
998    pub neutral_tap_position: Option<i32>,
999    /// Normal tap position declared by the equipment model.
1000    #[serde(default, skip_serializing_if = "Option::is_none")]
1001    pub normal_tap_position: Option<i32>,
1002    /// Voltage increment per tap position, in percent, for ratio and
1003    /// nonlinear phase tap changers.
1004    #[serde(default, skip_serializing_if = "Option::is_none")]
1005    pub voltage_step_increment_percent: Option<f64>,
1006    pub load_tap_changing_capabilities: bool,
1007    pub regulating: bool,
1008    #[serde(default, skip_serializing_if = "Option::is_none")]
1009    pub regulation_mode: Option<TapChangerRegulationMode>,
1010    /// kV, MVAr, MW, or A according to `regulation_mode`.
1011    #[serde(default, skip_serializing_if = "Option::is_none")]
1012    pub regulation_value: Option<f64>,
1013    /// Deadband in the same unit as `regulation_value`.
1014    #[serde(default, skip_serializing_if = "Option::is_none")]
1015    pub target_deadband: Option<f64>,
1016    #[serde(default, skip_serializing_if = "Option::is_none")]
1017    pub regulation_terminal: Option<TerminalReference>,
1018    pub steps: Vec<TapChangerStep>,
1019}
1020
1021/// Source neutral case metadata recorded by a grid exchange format.
1022#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1023#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1024#[non_exhaustive]
1025pub struct CaseMetadata {
1026    #[serde(default, skip_serializing_if = "Option::is_none")]
1027    pub case_date: Option<String>,
1028    #[serde(default, skip_serializing_if = "Option::is_none")]
1029    pub forecast_distance: Option<i32>,
1030    #[serde(default, skip_serializing_if = "Option::is_none")]
1031    pub source_model_format: Option<String>,
1032    #[serde(default, skip_serializing_if = "Option::is_none")]
1033    pub minimum_validation_level: Option<String>,
1034}
1035
1036/// One PowSybl subnetwork contained directly by a balanced network.
1037#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1038#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1039#[non_exhaustive]
1040pub struct Subnetwork {
1041    pub component: ComponentId,
1042    pub parent: ComponentId,
1043    pub case_metadata: CaseMetadata,
1044    #[serde(default)]
1045    pub components: Vec<ComponentId>,
1046}
1047
1048/// The name of a field omitted by a source representation.
1049///
1050/// The balanced calculation view keeps an ordinary numeric value for these
1051/// fields. This metadata lets an emitter preserve the distinction between a
1052/// field that was absent and one that was explicitly set to that value.
1053#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
1054#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1055#[serde(rename_all = "snake_case")]
1056#[non_exhaustive]
1057pub enum OmittedFieldName {
1058    ActivePower,
1059    ReactivePower,
1060    VoltageSetpoint,
1061    RatedApparentPower,
1062    ShuntConductancePerSection,
1063}
1064
1065/// A field that was absent from a source representation.
1066#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
1067#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1068#[non_exhaustive]
1069pub struct OmittedField {
1070    pub component: ComponentId,
1071    pub field: OmittedFieldName,
1072}
1073
1074impl OmittedField {
1075    #[must_use]
1076    pub fn new(component: ComponentId, field: OmittedFieldName) -> Self {
1077        Self { component, field }
1078    }
1079}
1080
1081/// Source neutral hierarchy and detailed connectivity retained beside the
1082/// balanced calculation view.
1083#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1084#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1085#[non_exhaustive]
1086pub struct DetailedConnectivity {
1087    /// Source fields whose absence must remain distinct from an explicit
1088    /// numeric zero or default value during fresh emission.
1089    #[serde(default)]
1090    pub omitted_fields: Vec<OmittedField>,
1091    #[serde(default)]
1092    pub component_metadata: Vec<ComponentMetadata>,
1093    #[serde(default)]
1094    pub subnetworks: Vec<Subnetwork>,
1095    #[serde(default)]
1096    pub substations: Vec<Substation>,
1097    #[serde(default)]
1098    pub voltage_levels: Vec<VoltageLevel>,
1099    #[serde(default)]
1100    pub bus_breaker_buses: Vec<BusBreakerBus>,
1101    #[serde(default)]
1102    pub calculated_buses: Vec<CalculatedBus>,
1103    #[serde(default)]
1104    pub connectivity_nodes: Vec<ConnectivityNode>,
1105    #[serde(default)]
1106    pub busbar_sections: Vec<BusbarSection>,
1107    #[serde(default)]
1108    pub junctions: Vec<Junction>,
1109    #[serde(default)]
1110    pub terminals: Vec<Terminal>,
1111    #[serde(default)]
1112    pub switches: Vec<TopologySwitch>,
1113    #[serde(default)]
1114    pub internal_connections: Vec<InternalConnection>,
1115    #[serde(default)]
1116    pub operational_limit_groups: Vec<OperationalLimitGroup>,
1117    #[serde(default)]
1118    pub tap_changers: Vec<TapChanger>,
1119    #[serde(default)]
1120    pub equipment_reactive_limits: Vec<EquipmentReactiveLimits>,
1121    #[serde(default)]
1122    pub boundary_lines: Vec<BoundaryLine>,
1123    #[serde(default)]
1124    pub tie_lines: Vec<TieLine>,
1125    #[serde(default)]
1126    pub dc_converter_units: Vec<DcConverterUnit>,
1127    #[serde(default)]
1128    pub dc_topological_nodes: Vec<DcTopologicalNode>,
1129    #[serde(default)]
1130    pub dc_nodes: Vec<DcNode>,
1131    #[serde(default)]
1132    pub dc_grounds: Vec<DcGround>,
1133    #[serde(default)]
1134    pub dc_busbars: Vec<DcBusbar>,
1135    #[serde(default)]
1136    pub dc_lines: Vec<DcLine>,
1137    #[serde(default)]
1138    pub dc_series_devices: Vec<DcSeriesDevice>,
1139    #[serde(default)]
1140    pub dc_switches: Vec<DcSwitch>,
1141    #[serde(default)]
1142    pub voltage_source_converters: Vec<VoltageSourceConverter>,
1143    #[serde(default)]
1144    pub line_commutated_converters: Vec<LineCommutatedConverter>,
1145}
1146
1147/// System base frequency in hertz when a format records none. Power networks run
1148/// at 50 or 60 Hz; 60 is the default for the formats (MATPOWER, PowerModels,
1149/// egret) that carry no frequency field.
1150pub const DEFAULT_BASE_FREQUENCY: f64 = 60.0;
1151
1152/// serde default for [`BalancedNetwork::base_frequency`], so JSON written before the
1153/// field existed still deserializes (the C ABI and Julia bridge ride on the JSON
1154/// transport).
1155fn default_base_frequency() -> f64 {
1156    DEFAULT_BASE_FREQUENCY
1157}
1158
1159/// A source bus ID, preserved from the input format.
1160///
1161/// MATPOWER IDs are 1-based and can contain gaps. They are distinct from the
1162/// zero based dense indices produced by
1163/// [`IndexedNetwork::bus_index`](crate::IndexedNetwork::bus_index). JSON stores
1164/// this type as an integer.
1165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
1166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1167#[serde(transparent)]
1168pub struct BusId(pub usize);
1169
1170impl BusId {
1171    /// The largest id a network may carry. The C ABI reports bus ids as int64,
1172    /// so an id past this ceiling has no distinct value there;
1173    /// [`BalancedNetwork::validate`] refuses one.
1174    pub const MAX: Self = Self(i64::MAX as usize);
1175
1176    #[must_use]
1177    pub const fn new(id: usize) -> Self {
1178        Self(id)
1179    }
1180}
1181
1182impl std::fmt::Display for BusId {
1183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1184        self.0.fmt(f)
1185    }
1186}
1187
1188/// Bus type per MATPOWER convention: 1=PQ, 2=PV, 3=ref/slack, 4=isolated.
1189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1190#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1191#[serde(rename_all = "UPPERCASE")]
1192#[repr(u8)]
1193#[non_exhaustive]
1194pub enum BusType {
1195    Pq = 1,
1196    Pv = 2,
1197    Ref = 3,
1198    Isolated = 4,
1199}
1200
1201impl BusType {
1202    /// Map a MATPOWER bus-type code to the enum; unknown codes fall back to PQ.
1203    pub(crate) fn from_f64(v: f64) -> Self {
1204        match v as i32 {
1205            2 => Self::Pv,
1206            3 => Self::Ref,
1207            4 => Self::Isolated,
1208            _ => Self::Pq,
1209        }
1210    }
1211
1212    /// The canonical short name (`"PQ"`, `"PV"`, `"REF"`, `"ISOLATED"`), shared
1213    /// by the bindings so their bus-type strings can't drift.
1214    #[must_use]
1215    pub fn as_str(self) -> &'static str {
1216        match self {
1217            Self::Pq => "PQ",
1218            Self::Pv => "PV",
1219            Self::Ref => "REF",
1220            Self::Isolated => "ISOLATED",
1221        }
1222    }
1223}
1224
1225/// A generator cost curve (`mpc.gencost` row).
1226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1227#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1228#[non_exhaustive]
1229pub struct GenCost {
1230    /// 1 = piecewise linear, 2 = polynomial.
1231    pub model: u8,
1232    pub startup: f64,
1233    pub shutdown: f64,
1234    /// Number of cost coefficients (polynomial) or breakpoints (piecewise).
1235    pub ncost: usize,
1236    /// Raw coefficients, highest order first for the polynomial model:
1237    /// `[c_{k-1}, …, c1, c0]`.
1238    pub coeffs: Vec<f64>,
1239}
1240
1241impl GenCost {
1242    /// Build a cost row from the values carried after `ncost`.
1243    ///
1244    /// Polynomial rows (`model == 2`) store `ncost` coefficients. Piecewise
1245    /// linear rows (`model == 1`) store flattened `(x, y)` breakpoint pairs, so
1246    /// `ncost` is half the coefficient count. Use [`GenCost::with_ncost`] for
1247    /// malformed source rows or callers that need to preserve an explicit
1248    /// `ncost`.
1249    #[must_use]
1250    pub fn new(model: u8, startup: f64, shutdown: f64, coeffs: Vec<f64>) -> Self {
1251        let ncost = if model == 1 {
1252            coeffs.len() / 2
1253        } else {
1254            coeffs.len()
1255        };
1256        Self {
1257            model,
1258            startup,
1259            shutdown,
1260            ncost,
1261            coeffs,
1262        }
1263    }
1264
1265    #[must_use]
1266    pub fn with_ncost(
1267        model: u8,
1268        startup: f64,
1269        shutdown: f64,
1270        ncost: usize,
1271        coeffs: Vec<f64>,
1272    ) -> Self {
1273        Self {
1274            model,
1275            startup,
1276            shutdown,
1277            ncost,
1278            coeffs,
1279        }
1280    }
1281
1282    /// `(q, c)` for the quadratic cost `½ q p² + c p` from a polynomial
1283    /// (model 2) row. MATPOWER stores `c2 p² + c1 p + c0`, so `q = 2·c2` and
1284    /// `c = c1`. Linear rows (`ncost == 2`) give `q = 0`. Piecewise (model 1)
1285    /// or cubic and higher return `None`.
1286    pub fn calc_quadratic(&self) -> Option<(f64, f64)> {
1287        self.calc_quadratic_with_constant().map(|(q, c, _)| (q, c))
1288    }
1289
1290    /// `(q, c, c0)` for the quadratic cost `½ q p² + c p + c0` from a
1291    /// polynomial (model 2) row, keeping the constant term that
1292    /// [`calc_quadratic`](Self::calc_quadratic) drops. Linear rows (`ncost == 2`) give
1293    /// `q = 0`; constant rows (`ncost == 1`) give `q = c = 0`. Piecewise
1294    /// (model 1) or cubic and higher return `None`.
1295    pub fn calc_quadratic_with_constant(&self) -> Option<(f64, f64, f64)> {
1296        if self.model != 2 {
1297            return None;
1298        }
1299        // Reject a row whose coefficient slice is shorter than `ncost` claims,
1300        // rather than reading the wrong powers by position.
1301        if self.coeffs.len() < self.ncost {
1302            return None;
1303        }
1304        // Matches on the stated arity, so a cubic row is refused even when its
1305        // leading coefficient is zero. `quadratic_with_constant_tol` is the
1306        // reader that lowers the order first.
1307        match self.ncost {
1308            3 => Some((2.0 * self.coeffs[0], self.coeffs[1], self.coeffs[2])),
1309            2 => Some((0.0, self.coeffs[0], self.coeffs[1])),
1310            1 => Some((0.0, 0.0, self.coeffs[0])),
1311            _ => None,
1312        }
1313    }
1314
1315    /// Largest leading polynomial coefficient that
1316    /// [`calc_quadratic_with_constant_tol`](Self::calc_quadratic_with_constant_tol)
1317    /// reads as a rounding artifact of the source, not as a term of the curve.
1318    pub const LEADING_COEFF_TOL: f64 = 1e-12;
1319
1320    /// `(q, c, c0)` as [`calc_quadratic_with_constant`](Self::calc_quadratic_with_constant)
1321    /// gives it, after the leading coefficients at or below `tol` come off the
1322    /// row.
1323    ///
1324    /// A model 2 row often carries a leading coefficient near `1e-17`, which
1325    /// the source produced by rounding. Such a row states a linear curve and
1326    /// reads as a quadratic one. Pass
1327    /// [`LEADING_COEFF_TOL`](Self::LEADING_COEFF_TOL) to strip the artifact,
1328    /// or `0.0` to strip an exact zero alone.
1329    pub fn calc_quadratic_with_constant_tol(&self, tol: f64) -> Option<(f64, f64, f64)> {
1330        if self.model != 2 {
1331            return None;
1332        }
1333        if self.coeffs.len() < self.ncost {
1334            return None;
1335        }
1336        let row = &self.coeffs[..self.ncost];
1337        let mut first = 0;
1338        while first + 1 < row.len() && row[first].abs() <= tol {
1339            first += 1;
1340        }
1341        match row.len() - first {
1342            3 => Some((2.0 * row[first], row[first + 1], row[first + 2])),
1343            2 => Some((0.0, row[first], row[first + 1])),
1344            1 => Some((0.0, 0.0, row[first])),
1345            _ => None,
1346        }
1347    }
1348}
1349
1350/// Which format a [`BalancedNetwork`] was read from. Drives the same format byte exact
1351/// echo on write.
1352///
1353/// Serializes as the same lowercase token [`name`](SourceFormat::name) reports
1354/// and every string entry point accepts.
1355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1356#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1357#[non_exhaustive]
1358pub enum SourceFormat {
1359    #[serde(rename = "matpower")]
1360    Matpower,
1361    #[serde(rename = "powermodels-json")]
1362    PowerModelsJson,
1363    #[serde(rename = "egret-json")]
1364    EgretJson,
1365    #[serde(rename = "psse")]
1366    Psse,
1367    /// Read from the PSS/E revision 35 JSON grid exchange format.
1368    #[serde(rename = "psse-rawx")]
1369    PsseRawx,
1370    #[serde(rename = "powerworld")]
1371    PowerWorld,
1372    #[serde(rename = "pandapower-json")]
1373    PandapowerJson,
1374    /// Read from a GE PSLF `.epc` case. Same source text is retained, so a
1375    /// same-format write echoes it byte-for-byte; a cross-format or
1376    /// source-dropped write goes through the internal `.epc` serializer.
1377    #[serde(rename = "pslf")]
1378    Pslf,
1379    /// Read from a PowerWorld `.pwb` binary case. Read only: there is no
1380    /// `.pwb` writer and no retained source text, so writing goes through
1381    /// another format's writer.
1382    #[serde(rename = "powerworld-pwb")]
1383    PowerWorldBinary,
1384    /// Built in memory, for example from synth or an edited case; no source text.
1385    #[serde(rename = "in-memory")]
1386    InMemory,
1387    /// A normalized derived form ([`BalancedNetwork::to_normalized`]): per unit, radians,
1388    /// filtered, source bus ids preserved. Distinct from
1389    /// [`InMemory`](SourceFormat::InMemory) so consumers can tell a per unit
1390    /// product from a raw in memory network; it has no source text and a different
1391    /// unit basis than a parsed network.
1392    #[serde(rename = "normalized")]
1393    Normalized,
1394    /// Read from a GridFM data kit Parquet dataset. The balanced network follows
1395    /// the dataset's native dense bus indices, nodal demand and shunt totals,
1396    /// fixed quadratic costs, and branch terminal flows.
1397    #[serde(rename = "gridfm")]
1398    Gridfm,
1399    /// Read from a PyPSA CSV folder. This is a folder format rather than a
1400    /// single retained text document, so same-format writes are canonicalized.
1401    #[serde(rename = "pypsa-csv")]
1402    PypsaCsv,
1403    /// Read from a DOE GO Challenge 3 JSON input document. The source is a
1404    /// unit commitment data set; the neutral transmission model keeps a static
1405    /// first interval network and retains the source text for the full data.
1406    #[serde(rename = "goc3-json")]
1407    Goc3Json,
1408    /// Read from a Surge native JSON document.
1409    #[serde(rename = "surge-json")]
1410    SurgeJson,
1411    /// Read from one raw JSON document in a DeepMind OPFData release. The
1412    /// source carries both solver initial values and a solution. The balanced
1413    /// model represents the solved snapshot and retains the source for an
1414    /// exact write back to the same format.
1415    #[serde(rename = "opfdata-json")]
1416    DeepMindOpfDataJson,
1417    /// Read from PowSybl's XIIDM XML grid exchange format, versions 1.0 through 1.17.
1418    #[serde(rename = "xiidm")]
1419    Xiidm,
1420    /// Read from PowSybl's JIIDM JSON grid exchange format, versions 1.0 through 1.17.
1421    #[serde(rename = "jiidm")]
1422    Jiidm,
1423    /// Read from a CGMES profile set (2.4.15/CIM16 or 3.0/CIM100).
1424    #[serde(rename = "cgmes")]
1425    Cgmes,
1426    /// Read from an ENTSO-E UCTE-DEF `.uct` file (revision 2003.09.01 or
1427    /// 2007.05.01).
1428    #[serde(rename = "ucte")]
1429    Ucte,
1430    /// Read from an IEEE Common Data Format case. Read only: there is no
1431    /// writer, so writing goes through another format's writer.
1432    #[serde(rename = "ieee-cdf")]
1433    IeeeCdf,
1434    /// A SINCAL project read using the explicitly selected balanced profile.
1435    #[serde(rename = "sincal")]
1436    Sincal,
1437    /// Read from an MG-RAVENS JSON document (the LANL CIM-derived interchange
1438    /// schema), balanced subset.
1439    #[serde(rename = "ravens-json")]
1440    RavensJson,
1441}
1442
1443impl SourceFormat {
1444    /// Stable lowercase token for the source format in module records, CLI
1445    /// summaries, and language bindings. The match is exhaustive here so a new
1446    /// enum case fails compilation at the one mapping instead of silently
1447    /// reporting "unknown" from a downstream wildcard copy.
1448    #[must_use]
1449    pub fn name(self) -> &'static str {
1450        match self {
1451            SourceFormat::Sincal => "sincal",
1452            SourceFormat::Matpower => "matpower",
1453            SourceFormat::PowerModelsJson => "powermodels-json",
1454            SourceFormat::EgretJson => "egret-json",
1455            SourceFormat::Psse => "psse",
1456            SourceFormat::PsseRawx => "psse-rawx",
1457            SourceFormat::PowerWorld => "powerworld",
1458            SourceFormat::PandapowerJson => "pandapower-json",
1459            SourceFormat::Pslf => "pslf",
1460            SourceFormat::PowerWorldBinary => "powerworld-pwb",
1461            SourceFormat::InMemory => "in-memory",
1462            SourceFormat::Normalized => "normalized",
1463            SourceFormat::Gridfm => "gridfm",
1464            SourceFormat::PypsaCsv => "pypsa-csv",
1465            SourceFormat::Goc3Json => "goc3-json",
1466            SourceFormat::SurgeJson => "surge-json",
1467            SourceFormat::RavensJson => "ravens-json",
1468            SourceFormat::DeepMindOpfDataJson => "opfdata-json",
1469            SourceFormat::Xiidm => "xiidm",
1470            SourceFormat::Jiidm => "jiidm",
1471            SourceFormat::Cgmes => "cgmes",
1472            SourceFormat::Ucte => "ucte",
1473            SourceFormat::IeeeCdf => "ieee-cdf",
1474        }
1475    }
1476}
1477
1478/// A balanced network with stable source bus IDs and separate element tables:
1479/// an immutable cheap to clone owning handle over private shared tables.
1480///
1481/// Cloning the handle bumps one reference count and clones no table
1482/// allocation. Reads go through the per field accessors; the `*_mut`
1483/// accessors copy the shared tables once on first write to a shared handle
1484/// (copy on write), so no other handle ever observes a mutation. The choice
1485/// of whole value sharing is private: clone stays zero allocation because
1486/// the handle wraps its tables in one `Arc`.
1487#[derive(Debug, Clone)]
1488pub struct BalancedNetwork {
1489    tables: std::sync::Arc<BalancedNetworkTables>,
1490}
1491
1492impl BalancedNetwork {
1493    pub(crate) fn from_tables(tables: BalancedNetworkTables) -> Self {
1494        Self {
1495            tables: std::sync::Arc::new(tables),
1496        }
1497    }
1498
1499    /// The one mutation door: copies the shared tables on first write to a
1500    /// shared handle, so no other handle observes the change.
1501    pub(crate) fn tables_mut(&mut self) -> &mut BalancedNetworkTables {
1502        std::sync::Arc::make_mut(&mut self.tables)
1503    }
1504}
1505
1506/// A balanced network with stable source bus IDs and separate element tables.
1507///
1508/// `remote = "Self"` turns the derived serde impls into inherent functions;
1509/// the trait impls beneath the struct route them through PowerIO's shared
1510/// nonfinite JSON spelling.
1511// The one owned table store behind the `BalancedNetwork` handle.
1512#[derive(Debug, Clone, Serialize, Deserialize)]
1513#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1514#[cfg_attr(feature = "schema", schemars(rename = "BalancedNetwork"))]
1515#[serde(remote = "Self")]
1516pub(crate) struct BalancedNetworkTables {
1517    pub name: String,
1518    pub base_mva: f64,
1519    /// System base frequency in hertz (50 or 60). Threaded through the formats
1520    /// that record it (PSS/E `BASFRQ`, pandapower `f_hz`) and defaulted to
1521    /// [`DEFAULT_BASE_FREQUENCY`] for the rest. Load-bearing for any
1522    /// reactance↔henry conversion (pandapower line charging) and reported as a
1523    /// fidelity loss when a non-default value writes to a format with no
1524    /// frequency field.
1525    #[serde(default = "default_base_frequency")]
1526    pub base_frequency: f64,
1527    #[serde(default, skip_serializing_if = "Option::is_none")]
1528    pub geo: Option<GeoMeta>,
1529    /// Source neutral case metadata such as the case date and forecast horizon.
1530    #[serde(default)]
1531    pub case_metadata: CaseMetadata,
1532    /// Authoritative hierarchy and bus breaker or node breaker connectivity,
1533    /// when the source states more than the balanced calculation view.
1534    #[serde(default, skip_serializing_if = "Option::is_none")]
1535    pub detailed_connectivity: Option<std::sync::Arc<DetailedConnectivity>>,
1536    /// Component identities generated by PowerIO rather than supplied by the
1537    /// source. The provenance keeps target diagnostics from treating an
1538    /// internal indexing requirement as source data.
1539    #[serde(default, skip_serializing_if = "generated_uids_is_empty")]
1540    pub generated_uids: std::sync::Arc<BTreeSet<String>>,
1541    pub buses: std::sync::Arc<Vec<Bus>>,
1542    pub loads: std::sync::Arc<Vec<Load>>,
1543    pub shunts: std::sync::Arc<Vec<Shunt>>,
1544    #[serde(default)]
1545    pub static_var_compensators: std::sync::Arc<Vec<StaticVarCompensator>>,
1546    pub branches: std::sync::Arc<Vec<Branch>>,
1547    #[serde(default)]
1548    pub switches: std::sync::Arc<Vec<Switch>>,
1549    pub generators: std::sync::Arc<Vec<Generator>>,
1550    pub storage: std::sync::Arc<Vec<Storage>>,
1551    pub hvdc: std::sync::Arc<Vec<Hvdc>>,
1552    /// Three-winding transformers, kept as typed records rather than folded into
1553    /// `branches`, so a star point and the per-winding data survive a round trip.
1554    /// `#[serde(default)]` so JSON written before the field existed still
1555    /// deserializes. [`IndexedNetwork`](crate::IndexedNetwork) lowers each
1556    /// in-service record into a star bus plus three branches (via
1557    /// [`Transformer3W::to_star_expansion`]) before building any matrix, so a
1558    /// 3-winding transformer does appear in `Y_bus`/connectivity; the canonical
1559    /// model keeps the typed record for round-trip fidelity.
1560    #[serde(default)]
1561    pub transformers_3w: std::sync::Arc<Vec<Transformer3W>>,
1562    /// Area records: scheduled interchange and per-area swing bus. Distinct from
1563    /// the bare `area` number on each [`Bus`]; this is the area's metadata, which
1564    /// every conversion dropped before. `#[serde(default)]` so older JSON still
1565    /// deserializes.
1566    #[serde(default)]
1567    pub areas: std::sync::Arc<Vec<Area>>,
1568    /// Solver / solution-control metadata when the source carries it, else `None`.
1569    /// `#[serde(default)]` so older JSON still deserializes.
1570    #[serde(default)]
1571    pub solver: Option<SolverParams>,
1572    pub source_format: SourceFormat,
1573}
1574
1575impl Serialize for BalancedNetwork {
1576    fn serialize<S: serde::Serializer>(
1577        &self,
1578        serializer: S,
1579    ) -> std::result::Result<S::Ok, S::Error> {
1580        BalancedNetworkTables::serialize(
1581            &self.tables,
1582            powerio_core::__implementation::nonfinite::NonFiniteSer(serializer),
1583        )
1584    }
1585}
1586
1587impl<'de> Deserialize<'de> for BalancedNetwork {
1588    fn deserialize<D: serde::Deserializer<'de>>(
1589        deserializer: D,
1590    ) -> std::result::Result<Self, D::Error> {
1591        BalancedNetworkTables::deserialize(powerio_core::__implementation::nonfinite::NonFiniteDe(
1592            deserializer,
1593        ))
1594        .map(BalancedNetwork::from_tables)
1595    }
1596}
1597
1598#[cfg(feature = "schema")]
1599impl schemars::JsonSchema for BalancedNetwork {
1600    fn schema_name() -> std::borrow::Cow<'static, str> {
1601        <BalancedNetworkTables as schemars::JsonSchema>::schema_name()
1602    }
1603
1604    fn schema_id() -> std::borrow::Cow<'static, str> {
1605        <BalancedNetworkTables as schemars::JsonSchema>::schema_id()
1606    }
1607
1608    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1609        <BalancedNetworkTables as schemars::JsonSchema>::json_schema(generator)
1610    }
1611}
1612
1613macro_rules! table_accessors {
1614    ($($(#[$doc:meta])* $field:ident, $field_mut:ident: $ty:ty;)+) => {
1615        impl BalancedNetwork {
1616            $(
1617                $(#[$doc])*
1618                #[must_use]
1619                pub fn $field(&self) -> &$ty {
1620                    &self.tables.$field
1621                }
1622
1623                /// Mutable access to the same table; a shared handle copies
1624                /// its tables once here, so no other handle observes the
1625                /// change.
1626                #[must_use]
1627                pub fn $field_mut(&mut self) -> &mut $ty {
1628                    &mut self.tables_mut().$field
1629                }
1630            )+
1631        }
1632    };
1633}
1634
1635table_accessors! {
1636    /// The case name.
1637    name, name_mut: String;
1638    /// The geographic metadata when the source carries any.
1639    geo, geo_mut: Option<GeoMeta>;
1640    /// Source neutral case metadata when the source records it.
1641    case_metadata, case_metadata_mut: CaseMetadata;
1642    /// Solver / solution-control metadata when the source carries it.
1643    solver, solver_mut: Option<SolverParams>;
1644}
1645
1646impl BalancedNetwork {
1647    /// Authoritative hierarchy and detailed connectivity when present.
1648    #[must_use]
1649    pub fn detailed_connectivity(&self) -> &Option<std::sync::Arc<DetailedConnectivity>> {
1650        &self.tables.detailed_connectivity
1651    }
1652
1653    /// Mutable access to detailed connectivity. Taking mutable access clears
1654    /// source field omission records because a caller can change any of the
1655    /// corresponding values through this reference.
1656    #[must_use]
1657    pub fn detailed_connectivity_mut(
1658        &mut self,
1659    ) -> &mut Option<std::sync::Arc<DetailedConnectivity>> {
1660        let tables = self.tables_mut();
1661        if let Some(detailed) = tables.detailed_connectivity.as_mut()
1662            && !detailed.omitted_fields.is_empty()
1663        {
1664            std::sync::Arc::make_mut(detailed).omitted_fields.clear();
1665        }
1666        &mut tables.detailed_connectivity
1667    }
1668
1669    fn clear_omitted_fields(&mut self, component_type: &str, fields: &[OmittedFieldName]) {
1670        let Some(detailed) = self.tables_mut().detailed_connectivity.as_mut() else {
1671            return;
1672        };
1673        let affected = |omitted: &OmittedField| {
1674            omitted.component.component_type() == component_type && fields.contains(&omitted.field)
1675        };
1676        // Copy a shared connectivity only when a marker actually goes.
1677        if detailed.omitted_fields.iter().any(affected) {
1678            std::sync::Arc::make_mut(detailed)
1679                .omitted_fields
1680                .retain(|omitted| !affected(omitted));
1681        }
1682    }
1683
1684    fn clear_omitted_field(&mut self, component: &ComponentId, field: OmittedFieldName) {
1685        let Some(detailed) = self.tables_mut().detailed_connectivity.as_mut() else {
1686            return;
1687        };
1688        let affected =
1689            |omitted: &OmittedField| omitted.component == *component && omitted.field == field;
1690        if detailed.omitted_fields.iter().any(affected) {
1691            std::sync::Arc::make_mut(detailed)
1692                .omitted_fields
1693                .retain(|omitted| !affected(omitted));
1694        }
1695    }
1696}
1697
1698/// The element tables sit behind their own shared allocation inside the
1699/// shared table set, so a time series of networks that varies one table
1700/// clones only that table per point while every untouched table stays one
1701/// allocation across the whole series.
1702macro_rules! shared_table_accessors {
1703    ($($(#[$doc:meta])* $field:ident, $field_mut:ident: $ty:ty;)+) => {
1704        impl BalancedNetwork {
1705            $(
1706                $(#[$doc])*
1707                #[must_use]
1708                pub fn $field(&self) -> &$ty {
1709                    &self.tables.$field
1710                }
1711
1712                /// Mutable access to the same table. A shared handle copies
1713                /// the table set spine and this one table here, so no other
1714                /// handle observes the change and untouched tables stay
1715                /// shared.
1716                #[must_use]
1717                pub fn $field_mut(&mut self) -> &mut $ty {
1718                    std::sync::Arc::make_mut(&mut self.tables_mut().$field)
1719                }
1720            )+
1721        }
1722    };
1723}
1724
1725impl BalancedNetwork {
1726    /// Replace each element table's allocation with `donor`'s wherever the
1727    /// contents are equal, so equal tables across derived networks (the
1728    /// scenarios of one dataset, the points of one series) are stored once.
1729    /// No value changes; a table that differs anywhere keeps its own
1730    /// allocation.
1731    pub fn share_equal_tables(&mut self, donor: &Self) {
1732        macro_rules! share {
1733            ($($field:ident),+) => {
1734                $(
1735                    if !std::sync::Arc::ptr_eq(&self.tables.$field, &donor.tables.$field)
1736                        && self.tables.$field == donor.tables.$field
1737                    {
1738                        self.tables_mut().$field = donor.tables.$field.clone();
1739                    }
1740                )+
1741            };
1742        }
1743        share!(
1744            buses,
1745            loads,
1746            shunts,
1747            static_var_compensators,
1748            branches,
1749            switches,
1750            generators,
1751            storage,
1752            hvdc,
1753            transformers_3w,
1754            areas
1755        );
1756    }
1757}
1758
1759shared_table_accessors! {
1760    buses, buses_mut: Vec<Bus>;
1761    static_var_compensators, static_var_compensators_mut: Vec<StaticVarCompensator>;
1762    branches, branches_mut: Vec<Branch>;
1763    switches, switches_mut: Vec<Switch>;
1764    hvdc, hvdc_mut: Vec<Hvdc>;
1765    /// Three-winding transformers, kept as typed records.
1766    transformers_3w, transformers_3w_mut: Vec<Transformer3W>;
1767    /// Area records: scheduled interchange and per-area swing bus.
1768    areas, areas_mut: Vec<Area>;
1769}
1770
1771impl BalancedNetwork {
1772    #[must_use]
1773    pub fn loads(&self) -> &Vec<Load> {
1774        &self.tables.loads
1775    }
1776
1777    /// Mutable load access makes source omission metadata conservative: a
1778    /// subsequent emission writes the numeric active and reactive power values.
1779    #[must_use]
1780    pub fn loads_mut(&mut self) -> &mut Vec<Load> {
1781        self.clear_omitted_fields(
1782            "load",
1783            &[
1784                OmittedFieldName::ActivePower,
1785                OmittedFieldName::ReactivePower,
1786            ],
1787        );
1788        std::sync::Arc::make_mut(&mut self.tables_mut().loads)
1789    }
1790
1791    /// Edit one load assignment while preserving omission metadata for every
1792    /// other source field. This internal primitive is used by typed updates
1793    /// after resolving a stable component identity to `index`.
1794    #[doc(hidden)]
1795    pub(crate) fn edit_load_assignment<R>(
1796        &mut self,
1797        index: usize,
1798        component: &ComponentId,
1799        field: OmittedFieldName,
1800        edit: impl FnOnce(&mut Load) -> R,
1801    ) -> R {
1802        debug_assert_eq!(component.component_type(), "load");
1803        debug_assert!(matches!(
1804            field,
1805            OmittedFieldName::ActivePower | OmittedFieldName::ReactivePower
1806        ));
1807        self.clear_omitted_field(component, field);
1808        edit(&mut std::sync::Arc::make_mut(&mut self.tables_mut().loads)[index])
1809    }
1810
1811    #[must_use]
1812    pub fn generators(&self) -> &Vec<Generator> {
1813        &self.tables.generators
1814    }
1815
1816    /// Mutable generator access makes source omission metadata conservative:
1817    /// a subsequent emission writes all numeric generator assignments.
1818    #[must_use]
1819    pub fn generators_mut(&mut self) -> &mut Vec<Generator> {
1820        self.clear_omitted_fields(
1821            "generator",
1822            &[
1823                OmittedFieldName::ActivePower,
1824                OmittedFieldName::ReactivePower,
1825                OmittedFieldName::VoltageSetpoint,
1826                OmittedFieldName::RatedApparentPower,
1827            ],
1828        );
1829        std::sync::Arc::make_mut(&mut self.tables_mut().generators)
1830    }
1831
1832    /// Edit one generator assignment while preserving omission metadata for
1833    /// every other source field. This internal primitive is used by typed
1834    /// updates after resolving a stable component identity to `index`.
1835    #[doc(hidden)]
1836    pub(crate) fn edit_generator_assignment<R>(
1837        &mut self,
1838        index: usize,
1839        component: &ComponentId,
1840        field: OmittedFieldName,
1841        edit: impl FnOnce(&mut Generator) -> R,
1842    ) -> R {
1843        debug_assert_eq!(component.component_type(), "generator");
1844        debug_assert!(matches!(
1845            field,
1846            OmittedFieldName::ActivePower
1847                | OmittedFieldName::ReactivePower
1848                | OmittedFieldName::VoltageSetpoint
1849        ));
1850        self.clear_omitted_field(component, field);
1851        edit(&mut std::sync::Arc::make_mut(&mut self.tables_mut().generators)[index])
1852    }
1853
1854    #[must_use]
1855    pub fn storage(&self) -> &Vec<Storage> {
1856        &self.tables.storage
1857    }
1858
1859    /// Mutable storage access makes source omission metadata conservative: a
1860    /// subsequent emission writes both numeric power assignments.
1861    #[must_use]
1862    pub fn storage_mut(&mut self) -> &mut Vec<Storage> {
1863        self.clear_omitted_fields(
1864            "storage",
1865            &[
1866                OmittedFieldName::ActivePower,
1867                OmittedFieldName::ReactivePower,
1868            ],
1869        );
1870        std::sync::Arc::make_mut(&mut self.tables_mut().storage)
1871    }
1872
1873    #[must_use]
1874    pub fn shunts(&self) -> &Vec<Shunt> {
1875        &self.tables.shunts
1876    }
1877
1878    /// Mutable shunt access makes source omission metadata conservative: a
1879    /// subsequent emission writes conductance per section.
1880    #[must_use]
1881    pub fn shunts_mut(&mut self) -> &mut Vec<Shunt> {
1882        self.clear_omitted_fields("shunt", &[OmittedFieldName::ShuntConductancePerSection]);
1883        std::sync::Arc::make_mut(&mut self.tables_mut().shunts)
1884    }
1885}
1886
1887impl BalancedNetwork {
1888    /// System MVA base.
1889    #[must_use]
1890    pub fn base_mva(&self) -> f64 {
1891        self.tables.base_mva
1892    }
1893
1894    #[must_use]
1895    pub fn base_mva_mut(&mut self) -> &mut f64 {
1896        &mut self.tables_mut().base_mva
1897    }
1898
1899    /// System base frequency in hertz (50 or 60).
1900    #[must_use]
1901    pub fn base_frequency(&self) -> f64 {
1902        self.tables.base_frequency
1903    }
1904
1905    #[must_use]
1906    pub fn base_frequency_mut(&mut self) -> &mut f64 {
1907        &mut self.tables_mut().base_frequency
1908    }
1909
1910    /// The format the case was parsed from.
1911    #[must_use]
1912    pub fn source_format(&self) -> SourceFormat {
1913        self.tables.source_format
1914    }
1915
1916    #[must_use]
1917    pub fn source_format_mut(&mut self) -> &mut SourceFormat {
1918        &mut self.tables_mut().source_format
1919    }
1920
1921    /// Assign persistent identities to records whose source format supplied
1922    /// none.
1923    ///
1924    /// Source supplied identities are preserved. Generated identities use
1925    /// electrical identifiers such as a bus number or branch terminals and
1926    /// are stored on the record, so later table reordering does not change
1927    /// them. A suffix distinguishes several records attached to the same bus
1928    /// or terminal pair.
1929    ///
1930    /// The identities are unique across the whole network, not only within one
1931    /// table: a load and a generator at bus 3 are two records, and formats that
1932    /// index every element by one identifier (XIIDM, CGMES) reject the same
1933    /// identifier twice. The first record to claim a stem keeps it and the next
1934    /// takes a numeric suffix.
1935    ///
1936    /// Callers that assemble a network by pushing records can call this once
1937    /// after construction. PowerIO parsers call it before returning a module.
1938    pub fn assign_missing_component_ids(&mut self) {
1939        let mut used = self.component_ids_in_use();
1940        let mut next_suffix = HashMap::new();
1941        macro_rules! assign {
1942            ($table:ident, $table_mut:ident, $set_uid:expr, $stem:expr) => {
1943                if self.$table().iter().any(|value| value.uid.is_none()) {
1944                    let generated = assign_missing_ids(
1945                        self.$table_mut(),
1946                        |value| value.uid.as_deref(),
1947                        $set_uid,
1948                        $stem,
1949                        &mut used,
1950                        &mut next_suffix,
1951                    );
1952                    std::sync::Arc::make_mut(&mut self.tables_mut().generated_uids)
1953                        .extend(generated);
1954                }
1955            };
1956        }
1957
1958        assign!(
1959            buses,
1960            buses_mut,
1961            |bus: &mut Bus, uid| bus.uid = Some(uid),
1962            |bus: &Bus| bus.id.to_string()
1963        );
1964        assign!(
1965            loads,
1966            loads_mut,
1967            |load: &mut Load, uid| load.uid = Some(uid),
1968            |load: &Load| format!("bus-{}", load.bus)
1969        );
1970        assign!(
1971            shunts,
1972            shunts_mut,
1973            |shunt: &mut Shunt, uid| shunt.uid = Some(uid),
1974            |shunt: &Shunt| format!("bus-{}", shunt.bus)
1975        );
1976        assign!(
1977            static_var_compensators,
1978            static_var_compensators_mut,
1979            |svc: &mut StaticVarCompensator, uid| svc.uid = Some(uid),
1980            |svc: &StaticVarCompensator| format!("bus-{}", svc.bus)
1981        );
1982        assign!(
1983            branches,
1984            branches_mut,
1985            |branch: &mut Branch, uid| branch.uid = Some(uid),
1986            |branch: &Branch| format!("{}-{}", branch.from, branch.to)
1987        );
1988        assign!(
1989            switches,
1990            switches_mut,
1991            |switch: &mut Switch, uid| switch.uid = Some(uid),
1992            |switch: &Switch| format!("{}-{}", switch.from, switch.to)
1993        );
1994        assign!(
1995            generators,
1996            generators_mut,
1997            |generator: &mut Generator, uid| generator.uid = Some(uid),
1998            |generator: &Generator| format!("bus-{}", generator.bus)
1999        );
2000        assign!(
2001            storage,
2002            storage_mut,
2003            |storage: &mut Storage, uid| storage.uid = Some(uid),
2004            |storage: &Storage| format!("bus-{}", storage.bus)
2005        );
2006        assign!(
2007            hvdc,
2008            hvdc_mut,
2009            |line: &mut Hvdc, uid| line.uid = Some(uid),
2010            |line: &Hvdc| format!("{}-{}", line.from, line.to)
2011        );
2012        assign!(
2013            transformers_3w,
2014            transformers_3w_mut,
2015            |transformer: &mut Transformer3W, uid| transformer.uid = Some(uid),
2016            |transformer: &Transformer3W| {
2017                let [first, second, third] = &transformer.windings;
2018                format!("{}-{}-{}", first.bus, second.bus, third.bus)
2019            }
2020        );
2021    }
2022
2023    /// True when PowerIO generated this identity to make records addressable.
2024    #[must_use]
2025    pub fn uid_is_generated(&self, uid: Option<&str>) -> bool {
2026        uid.is_some_and(|uid| self.tables.generated_uids.contains(uid))
2027    }
2028
2029    pub(crate) fn generated_uids(&self) -> &std::sync::Arc<BTreeSet<String>> {
2030        &self.tables.generated_uids
2031    }
2032
2033    /// Every identity the network's records already carry.
2034    pub(crate) fn component_ids_in_use(&self) -> HashSet<String> {
2035        let mut used = HashSet::new();
2036        macro_rules! collect {
2037            ($($table:ident),+) => {
2038                $(used.extend(
2039                    self.$table()
2040                        .iter()
2041                        .filter_map(|value| value.uid.as_deref().map(str::to_owned)),
2042                );)+
2043            };
2044        }
2045        collect!(
2046            buses,
2047            loads,
2048            shunts,
2049            static_var_compensators,
2050            branches,
2051            switches,
2052            generators,
2053            storage,
2054            hvdc,
2055            transformers_3w
2056        );
2057        used
2058    }
2059}
2060
2061fn generated_uids_is_empty(uids: &std::sync::Arc<BTreeSet<String>>) -> bool {
2062    uids.is_empty()
2063}
2064
2065fn assign_missing_ids<T>(
2066    values: &mut [T],
2067    uid: impl for<'a> Fn(&'a T) -> Option<&'a str>,
2068    mut set_uid: impl FnMut(&mut T, String),
2069    stem: impl Fn(&T) -> String,
2070    used: &mut HashSet<String>,
2071    next_suffix: &mut HashMap<String, usize>,
2072) -> Vec<String> {
2073    let mut generated = Vec::new();
2074    for value in values {
2075        if uid(value).is_some() {
2076            continue;
2077        }
2078        let stem = stem(value);
2079        let suffix = match next_suffix.get_mut(&stem) {
2080            Some(suffix) => suffix,
2081            None => next_suffix.entry(stem.clone()).or_insert(1),
2082        };
2083        loop {
2084            let candidate = if *suffix == 1 {
2085                stem.clone()
2086            } else {
2087                format!("{stem}-{suffix}")
2088            };
2089            *suffix += 1;
2090            if !used.contains(&candidate) {
2091                used.insert(candidate.clone());
2092                set_uid(value, candidate.clone());
2093                generated.push(candidate);
2094                break;
2095            }
2096        }
2097    }
2098    generated
2099}
2100
2101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2103#[non_exhaustive]
2104pub struct Bus {
2105    /// Stable bus id (1-based in MATPOWER; preserved verbatim).
2106    pub id: BusId,
2107    pub kind: BusType,
2108    /// Voltage magnitude (p.u.).
2109    pub vm: f64,
2110    /// Voltage angle (degrees).
2111    pub va: f64,
2112    pub base_kv: f64,
2113    pub vmax: f64,
2114    pub vmin: f64,
2115    /// Emergency (short-term) voltage band, set only when the source states one
2116    /// distinct from the normal [`vmax`](Bus::vmax)/[`vmin`](Bus::vmin) band (PSS/E
2117    /// `EVHI`/`EVLO`). `None` means the emergency band equals the normal band, so
2118    /// read `evhi.unwrap_or(vmax)` / `evlo.unwrap_or(vmin)`. `#[serde(default)]` so
2119    /// JSON written before the fields existed still deserializes.
2120    #[serde(default)]
2121    pub evhi: Option<f64>,
2122    #[serde(default)]
2123    pub evlo: Option<f64>,
2124    pub area: usize,
2125    pub zone: usize,
2126    pub name: Option<String>,
2127    /// Stable row identity for `.pio.json` payloads and operating point updates:
2128    /// the source record uid where the format defines one (GOC3), synthesized at
2129    /// package build otherwise. `#[serde(default)]` so JSON written before the
2130    /// field existed still deserializes.
2131    #[serde(default, skip_serializing_if = "Option::is_none")]
2132    pub uid: Option<String>,
2133    /// Optional bus coordinates in the network coordinate space.
2134    #[serde(default, skip_serializing_if = "Option::is_none")]
2135    pub location: Option<Location>,
2136    pub extras: Extras,
2137}
2138
2139impl Bus {
2140    #[must_use]
2141    pub fn new(id: BusId, kind: BusType, base_kv: f64) -> Self {
2142        Self {
2143            id,
2144            kind,
2145            vm: 1.0,
2146            va: 0.0,
2147            base_kv,
2148            vmax: 1.1,
2149            vmin: 0.9,
2150            evhi: None,
2151            evlo: None,
2152            area: 1,
2153            zone: 1,
2154            name: None,
2155            uid: None,
2156            location: None,
2157            extras: Extras::new(),
2158        }
2159    }
2160}
2161
2162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2163#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2164#[non_exhaustive]
2165pub struct Load {
2166    pub bus: BusId,
2167    /// Active demand (MW).
2168    pub p: f64,
2169    /// Reactive demand (MVAr).
2170    pub q: f64,
2171    /// Voltage dependence, when the source states one. `None` is constant power.
2172    #[serde(default)]
2173    pub voltage_model: Option<LoadVoltageModel>,
2174    pub in_service: bool,
2175    /// Stable row identity; see [`Bus::uid`].
2176    #[serde(default, skip_serializing_if = "Option::is_none")]
2177    pub uid: Option<String>,
2178    pub extras: Extras,
2179}
2180
2181impl Load {
2182    #[must_use]
2183    pub fn new(bus: BusId, p: f64, q: f64) -> Self {
2184        Self {
2185            bus,
2186            p,
2187            q,
2188            voltage_model: None,
2189            in_service: true,
2190            uid: None,
2191            extras: Extras::new(),
2192        }
2193    }
2194}
2195
2196/// Voltage dependence for a transmission load.
2197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2198#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2199#[serde(tag = "kind", rename_all = "snake_case")]
2200#[non_exhaustive]
2201pub enum LoadVoltageModel {
2202    /// Explicit constant power marker.
2203    ConstantPower,
2204    /// ZIP load split in source units. The three active parts sum to
2205    /// [`Load::p`], and the three reactive parts sum to [`Load::q`].
2206    Zip {
2207        p_constant_power: f64,
2208        q_constant_power: f64,
2209        p_constant_current: f64,
2210        q_constant_current: f64,
2211        p_constant_impedance: f64,
2212        q_constant_impedance: f64,
2213        #[serde(default)]
2214        v_nom: Option<f64>,
2215        /// Source load type code, when a format has one (PSS/E `ID`/`LOADTYPE`
2216        /// style metadata).
2217        #[serde(default)]
2218        load_type: Option<i32>,
2219        /// Source scaling factor, when a format has one.
2220        #[serde(default)]
2221        scaling: Option<f64>,
2222    },
2223    /// Exponential voltage model: `P = p * (V / v_nom)^gamma_p`,
2224    /// `Q = q * (V / v_nom)^gamma_q`.
2225    Exponential {
2226        p: f64,
2227        q: f64,
2228        #[serde(default)]
2229        v_nom: Option<f64>,
2230        gamma_p: f64,
2231        gamma_q: f64,
2232    },
2233}
2234
2235impl LoadVoltageModel {
2236    #[must_use]
2237    pub fn has_non_matpower_fields(&self) -> bool {
2238        match self {
2239            Self::ConstantPower => false,
2240            Self::Zip {
2241                p_constant_current,
2242                q_constant_current,
2243                p_constant_impedance,
2244                q_constant_impedance,
2245                v_nom,
2246                load_type,
2247                scaling,
2248                ..
2249            } => {
2250                *p_constant_current != 0.0
2251                    || *q_constant_current != 0.0
2252                    || *p_constant_impedance != 0.0
2253                    || *q_constant_impedance != 0.0
2254                    || v_nom.is_some()
2255                    || load_type.is_some()
2256                    || scaling.is_some()
2257            }
2258            Self::Exponential { .. } => true,
2259        }
2260    }
2261}
2262
2263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2264#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2265#[non_exhaustive]
2266pub struct Shunt {
2267    pub bus: BusId,
2268    /// Shunt conductance (MW at V = 1 p.u.).
2269    pub g: f64,
2270    /// Shunt susceptance (MVAr at V = 1 p.u.). For a switched shunt this is the
2271    /// initial steady state value within the [`control`](Shunt::control) blocks.
2272    pub b: f64,
2273    pub in_service: bool,
2274    /// Number of shunt sections assigned in service. `None` records that the
2275    /// source left the assignment unset.
2276    #[serde(default, skip_serializing_if = "Option::is_none")]
2277    pub section_count: Option<u32>,
2278    /// Switching-control data when this is a switched (adjustable) shunt; `None`
2279    /// for a fixed shunt. `#[serde(default)]` so JSON written before the field
2280    /// existed still deserializes.
2281    #[serde(default)]
2282    pub control: Option<SwitchedShuntControl>,
2283    /// Stable row identity; see [`Bus::uid`].
2284    #[serde(default, skip_serializing_if = "Option::is_none")]
2285    pub uid: Option<String>,
2286    pub extras: Extras,
2287}
2288
2289impl Shunt {
2290    #[must_use]
2291    pub fn new(bus: BusId, g: f64, b: f64) -> Self {
2292        Self {
2293            bus,
2294            g,
2295            b,
2296            in_service: true,
2297            section_count: None,
2298            control: None,
2299            uid: None,
2300            extras: Extras::new(),
2301        }
2302    }
2303}
2304
2305/// How a switched shunt adjusts its susceptance. Maps to the PSS/E `MODSW` code.
2306//
2307// The IR 2 schema catalogs are published byte for byte, and
2308// `powerio/tests/frozen_schemas.rs` holds every record to its published text.
2309// The schema descriptions of the two adjusting variants therefore keep their
2310// 0.11 wording, which swaps `MODSW` 1 and 2, until a new catalog snapshot
2311// takes the code mapping the rustdoc states.
2312#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2313#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2314#[serde(rename_all = "snake_case")]
2315#[non_exhaustive]
2316pub enum SwitchedShuntMode {
2317    /// Fixed at its initial susceptance, no automatic switching (`MODSW` 0).
2318    Locked,
2319    /// Continuous adjustment within the block range to hold the voltage band
2320    /// (`MODSW` 2).
2321    #[cfg_attr(
2322        feature = "schema",
2323        schemars(description = "Continuous adjustment within the block range (`MODSW` 1).")
2324    )]
2325    Continuous,
2326    /// Discrete adjustment in fixed steps (`MODSW` 1, and 3 to 6).
2327    ///
2328    /// `MODSW` 1 holds the voltage band. `MODSW` 3 to 6 control a plant's
2329    /// reactive output, a VSC dc converter's reactive output, another switched
2330    /// shunt's admittance setting, or a FACTS device's reactive output; the
2331    /// PSS/E reader keeps that code in the shunt's `psse_modsw` extra.
2332    #[cfg_attr(
2333        feature = "schema",
2334        schemars(description = "Discrete adjustment in fixed steps (`MODSW` 2 and up).")
2335    )]
2336    Discrete,
2337}
2338
2339/// One block of a switched shunt: `steps` equal admittance increments.
2340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2341#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2342#[non_exhaustive]
2343pub struct ShuntBlock {
2344    pub steps: u32,
2345    /// Conductance increment per step (MW at V = 1 p.u.).
2346    pub g: f64,
2347    /// Susceptance increment per step (MVAr at V = 1 p.u.).
2348    pub b: f64,
2349}
2350
2351impl ShuntBlock {
2352    #[must_use]
2353    pub const fn new(steps: u32, b: f64) -> Self {
2354        Self { steps, g: 0.0, b }
2355    }
2356
2357    #[must_use]
2358    pub const fn with_admittance(steps: u32, g: f64, b: f64) -> Self {
2359        Self { steps, g, b }
2360    }
2361}
2362
2363/// Switching-control data for a switched shunt ([`Shunt::control`]): the mode,
2364/// the regulated voltage band and bus, the reactive-range percentage, and the
2365/// adjustable susceptance blocks. The shunt's [`b`](Shunt::b) is the initial
2366/// value within the blocks' total range.
2367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2368#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2369#[non_exhaustive]
2370pub struct SwitchedShuntControl {
2371    pub mode: SwitchedShuntMode,
2372    /// Regulated voltage band (per unit).
2373    pub vhigh: f64,
2374    pub vlow: f64,
2375    /// The regulated bus; `None` means the shunt regulates its own bus.
2376    pub control_bus: Option<BusId>,
2377    /// The exact regulated equipment terminal when the source identifies one.
2378    #[serde(default, skip_serializing_if = "Option::is_none")]
2379    pub regulating_terminal: Option<TerminalReference>,
2380    /// Percent of the controlled device's reactive range to apply (PSS/E `RMPCT`).
2381    pub rmpct: f64,
2382    pub blocks: Vec<ShuntBlock>,
2383}
2384
2385impl SwitchedShuntControl {
2386    #[must_use]
2387    pub fn new(mode: SwitchedShuntMode, vhigh: f64, vlow: f64, blocks: Vec<ShuntBlock>) -> Self {
2388        Self {
2389            mode,
2390            vhigh,
2391            vlow,
2392            control_bus: None,
2393            regulating_terminal: None,
2394            rmpct: 100.0,
2395            blocks,
2396        }
2397    }
2398}
2399
2400/// The controlled quantity of a static VAR compensator.
2401#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2402#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2403#[serde(rename_all = "snake_case")]
2404#[non_exhaustive]
2405pub enum StaticVarCompensatorRegulationMode {
2406    Voltage,
2407    ReactivePower,
2408}
2409
2410/// A static VAR compensator connected to one balanced network bus.
2411#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2412#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2413#[non_exhaustive]
2414pub struct StaticVarCompensator {
2415    pub bus: BusId,
2416    /// Minimum and maximum susceptance in siemens.
2417    pub b_min_siemens: f64,
2418    pub b_max_siemens: f64,
2419    pub voltage_setpoint_kv: f64,
2420    pub reactive_power_setpoint_mvar: f64,
2421    pub regulation_mode: StaticVarCompensatorRegulationMode,
2422    pub regulating: bool,
2423    #[serde(default, skip_serializing_if = "Option::is_none")]
2424    pub regulating_terminal: Option<TerminalReference>,
2425    /// Assigned active and reactive terminal power in MW and MVAr.
2426    pub p: f64,
2427    pub q: f64,
2428    pub in_service: bool,
2429    #[serde(default, skip_serializing_if = "Option::is_none")]
2430    pub uid: Option<String>,
2431    pub extras: Extras,
2432}
2433
2434impl StaticVarCompensator {
2435    #[must_use]
2436    pub fn new(bus: BusId, b_min_siemens: f64, b_max_siemens: f64) -> Self {
2437        Self {
2438            bus,
2439            b_min_siemens,
2440            b_max_siemens,
2441            voltage_setpoint_kv: 0.0,
2442            reactive_power_setpoint_mvar: 0.0,
2443            regulation_mode: StaticVarCompensatorRegulationMode::Voltage,
2444            regulating: false,
2445            regulating_terminal: None,
2446            p: 0.0,
2447            q: 0.0,
2448            in_service: true,
2449            uid: None,
2450            extras: Extras::new(),
2451        }
2452    }
2453}
2454
2455#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2456#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2457#[non_exhaustive]
2458pub struct Branch {
2459    /// Source supplied equipment name, when present.
2460    #[serde(default, skip_serializing_if = "Option::is_none")]
2461    pub name: Option<String>,
2462    pub from: BusId,
2463    pub to: BusId,
2464    /// Series resistance (p.u.).
2465    pub r: f64,
2466    /// Series reactance (p.u.).
2467    pub x: f64,
2468    /// MATPOWER compatible total line charging susceptance (p.u.). This is the
2469    /// legacy total projection; when [`charging`](Branch::charging) is present,
2470    /// per terminal admittance is canonical and this field is compatibility data.
2471    pub b: f64,
2472    /// Per terminal shunt admittance (p.u.). If absent, derive symmetric
2473    /// susceptance from [`b`](Branch::b).
2474    #[serde(default)]
2475    pub charging: Option<BranchCharging>,
2476    pub rate_a: f64,
2477    pub rate_b: f64,
2478    pub rate_c: f64,
2479    /// Additional MVA rating sets beyond A/B/C. Matrix builders continue to use
2480    /// `rate_a` unless they opt into one of these named sets.
2481    #[serde(default)]
2482    pub rating_sets: Vec<BranchRatingSet>,
2483    /// Current ratings, when the source distinguishes them from MVA ratings.
2484    #[serde(default)]
2485    pub current_ratings: Option<BranchCurrentRatings>,
2486    /// Tap ratio, MATPOWER convention: 0 means "no tap" (a line), treated as 1.
2487    pub tap: f64,
2488    /// Phase shift (degrees).
2489    pub shift: f64,
2490    pub in_service: bool,
2491    pub angmin: f64,
2492    pub angmax: f64,
2493    /// Regulating-transformer control data, when this branch is a transformer
2494    /// under automatic tap or phase control. `None` for lines and for fixed-ratio
2495    /// transformers. `#[serde(default)]` so JSON written before the field existed
2496    /// still deserializes.
2497    #[serde(default)]
2498    pub control: Option<TransformerControl>,
2499    /// Solved branch flow values, when present in a case snapshot.
2500    #[serde(default)]
2501    pub solution: Option<BranchSolution>,
2502    /// Stable row identity; see [`Bus::uid`].
2503    #[serde(default, skip_serializing_if = "Option::is_none")]
2504    pub uid: Option<String>,
2505    /// Polyline route in the network's coordinate space (`BalancedNetwork.geo`),
2506    /// present only when a source provides intermediate geometry; endpoint
2507    /// only rendering derives from the bus locations. `#[serde(default)]` so
2508    /// JSON written before the field existed still deserializes.
2509    #[serde(default, skip_serializing_if = "Option::is_none")]
2510    pub route: Option<Vec<Location>>,
2511    pub extras: Extras,
2512}
2513
2514/// The [`Branch::extras`] and [`Transformer3W::extras`] key behind
2515/// [`Branch::impedance_correction`] and
2516/// [`Transformer3W::winding_impedance_correction`].
2517pub(crate) const IMPEDANCE_CORRECTION_EXTRA: &str = "impedance_correction";
2518/// The extras key where [`BalancedNetwork::apply_impedance_correction`]
2519/// records the nominal series impedance it scales.
2520pub(crate) const IMPEDANCE_CORRECTION_NOMINAL_EXTRA: &str = "impedance_correction_nominal";
2521/// The extras key where [`BalancedNetwork::apply_impedance_correction`]
2522/// records the corrected series impedance it wrote, so a later edit of that
2523/// impedance can be told apart from the correction's own result.
2524pub(crate) const IMPEDANCE_CORRECTION_APPLIED_EXTRA: &str = "impedance_correction_applied";
2525
2526/// The nominal impedance an impedance correction recorded in `extras`, when
2527/// it still describes the present impedance `current`. `None` when no nominal
2528/// is recorded, or when `current` differs from the corrected impedance the
2529/// correction wrote: the impedance was edited since, and the edited value is
2530/// the nominal one. A nominal recorded without the corrected value (0.11.6
2531/// and earlier) is taken as is.
2532pub(crate) fn current_correction_nominal<T>(extras: &Extras, current: &T) -> Option<T>
2533where
2534    T: PartialEq + serde::de::DeserializeOwned,
2535{
2536    let nominal = T::deserialize(extras.get(IMPEDANCE_CORRECTION_NOMINAL_EXTRA)?).ok()?;
2537    let applied = extras
2538        .get(IMPEDANCE_CORRECTION_APPLIED_EXTRA)
2539        .and_then(|value| T::deserialize(value).ok());
2540    applied
2541        .is_none_or(|applied| applied == *current)
2542        .then_some(nominal)
2543}
2544
2545/// Whether the impedance `current` was edited after an impedance correction
2546/// scaled it: a nominal is recorded, and `current` is not the corrected value.
2547pub(crate) fn correction_nominal_edited<T>(extras: &Extras, current: &T) -> bool
2548where
2549    T: PartialEq + serde::de::DeserializeOwned,
2550{
2551    extras.contains_key(IMPEDANCE_CORRECTION_NOMINAL_EXTRA)
2552        && current_correction_nominal(extras, current).is_none()
2553}
2554
2555/// A transformer impedance correction table: the factors by which a winding's
2556/// nominal series impedance is multiplied, tabulated against the winding's
2557/// off-nominal turns ratio or its phase shift. PSS/E states these as
2558/// impedance correction tables that a transformer winding names through
2559/// `TAB`.
2560#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2561#[non_exhaustive]
2562pub struct ImpedanceCorrection {
2563    /// The source's table number (PSS/E `TAB`).
2564    pub table: u32,
2565    /// What the points are tabulated against.
2566    pub variable: ImpedanceCorrectionVariable,
2567    /// The points, in ascending order of [`value`](ImpedanceCorrectionPoint::value).
2568    pub points: Vec<ImpedanceCorrectionPoint>,
2569}
2570
2571/// The winding quantity an [`ImpedanceCorrection`] is tabulated against.
2572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2573#[serde(rename_all = "snake_case")]
2574#[non_exhaustive]
2575pub enum ImpedanceCorrectionVariable {
2576    /// The off-nominal turns ratio as the transformer's `tap` states it.
2577    Ratio,
2578    /// The phase shift, degrees.
2579    Angle,
2580}
2581
2582/// One point of an [`ImpedanceCorrection`]: the complex factor
2583/// `factor_re + j·factor_im` that multiplies the nominal series impedance at
2584/// `value`.
2585#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
2586#[non_exhaustive]
2587pub struct ImpedanceCorrectionPoint {
2588    /// Turns ratio (p.u.) or phase shift (degrees).
2589    pub value: f64,
2590    pub factor_re: f64,
2591    pub factor_im: f64,
2592}
2593
2594impl ImpedanceCorrectionPoint {
2595    #[must_use]
2596    pub fn new(value: f64, factor_re: f64, factor_im: f64) -> Self {
2597        Self {
2598            value,
2599            factor_re,
2600            factor_im,
2601        }
2602    }
2603}
2604
2605impl ImpedanceCorrection {
2606    #[must_use]
2607    pub fn new(
2608        table: u32,
2609        variable: ImpedanceCorrectionVariable,
2610        points: Vec<ImpedanceCorrectionPoint>,
2611    ) -> Self {
2612        Self {
2613            table,
2614            variable,
2615            points,
2616        }
2617    }
2618
2619    /// The complex factor `(re, im)` at `value`, interpolated linearly
2620    /// between the two points around it. Outside the tabulated range the
2621    /// nearest end point applies, and one point applies everywhere. `None`
2622    /// for a table with no points.
2623    #[must_use]
2624    pub fn calc_factor(&self, value: f64) -> Option<(f64, f64)> {
2625        let mut points = self.points.clone();
2626        points.sort_by(|a, b| a.value.total_cmp(&b.value));
2627        let first = points.first()?;
2628        let last = points.last()?;
2629        if value <= first.value {
2630            return Some((first.factor_re, first.factor_im));
2631        }
2632        if value >= last.value {
2633            return Some((last.factor_re, last.factor_im));
2634        }
2635        let upper = points.iter().position(|point| point.value >= value)?;
2636        let (a, b) = (points[upper - 1], points[upper]);
2637        let weight = (value - a.value) / (b.value - a.value);
2638        let lerp = |x: f64, y: f64| x + weight * (y - x);
2639        Some((
2640            lerp(a.factor_re, b.factor_re),
2641            lerp(a.factor_im, b.factor_im),
2642        ))
2643    }
2644}
2645
2646/// Extra branch MVA rating set beyond the canonical A/B/C columns.
2647#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2648#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2649#[non_exhaustive]
2650pub struct BranchRatingSet {
2651    pub name: String,
2652    pub rate_mva: f64,
2653}
2654
2655impl BranchRatingSet {
2656    #[must_use]
2657    pub fn new(name: impl Into<String>, rate_mva: f64) -> Self {
2658        Self {
2659            name: name.into(),
2660            rate_mva,
2661        }
2662    }
2663}
2664
2665/// Per terminal branch shunt admittance in p.u. This is the canonical
2666/// physical branch shunt model when present.
2667#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
2668#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2669#[non_exhaustive]
2670pub struct BranchCharging {
2671    pub g_fr: f64,
2672    pub b_fr: f64,
2673    pub g_to: f64,
2674    pub b_to: f64,
2675}
2676
2677impl BranchCharging {
2678    #[must_use]
2679    pub const fn new(g_fr: f64, b_fr: f64, g_to: f64, b_to: f64) -> Self {
2680        Self {
2681            g_fr,
2682            b_fr,
2683            g_to,
2684            b_to,
2685        }
2686    }
2687
2688    #[must_use]
2689    pub fn from_total_b(b: f64) -> Self {
2690        Self {
2691            g_fr: 0.0,
2692            b_fr: b / 2.0,
2693            g_to: 0.0,
2694            b_to: b / 2.0,
2695        }
2696    }
2697
2698    #[must_use]
2699    pub fn calc_total_b(self) -> f64 {
2700        self.b_fr + self.b_to
2701    }
2702
2703    #[must_use]
2704    pub fn calc_total_g(self) -> f64 {
2705        self.g_fr + self.g_to
2706    }
2707
2708    #[must_use]
2709    pub fn is_matpower_symmetric(self) -> bool {
2710        self.g_fr.abs() <= f64::EPSILON
2711            && self.g_to.abs() <= f64::EPSILON
2712            && (self.b_fr - self.b_to).abs() <= f64::EPSILON
2713    }
2714}
2715
2716/// Current limits for a branch, in source units.
2717#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
2718#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2719#[non_exhaustive]
2720pub struct BranchCurrentRatings {
2721    pub c_rating_a: f64,
2722    pub c_rating_b: f64,
2723    pub c_rating_c: f64,
2724}
2725
2726impl BranchCurrentRatings {
2727    #[must_use]
2728    pub const fn new(c_rating_a: f64, c_rating_b: f64, c_rating_c: f64) -> Self {
2729        Self {
2730            c_rating_a,
2731            c_rating_b,
2732            c_rating_c,
2733        }
2734    }
2735}
2736
2737/// Solved branch terminal flows in MW/MVAr.
2738#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
2739#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2740#[non_exhaustive]
2741pub struct BranchSolution {
2742    pub pf: f64,
2743    pub qf: f64,
2744    pub pt: f64,
2745    pub qt: f64,
2746}
2747
2748impl BranchSolution {
2749    #[must_use]
2750    pub const fn new(pf: f64, qf: f64, pt: f64, qt: f64) -> Self {
2751        Self { pf, qf, pt, qt }
2752    }
2753}
2754
2755impl Branch {
2756    #[must_use]
2757    pub fn new(from: BusId, to: BusId, r: f64, x: f64) -> Self {
2758        Self {
2759            name: None,
2760            from,
2761            to,
2762            r,
2763            x,
2764            b: 0.0,
2765            charging: None,
2766            rate_a: 0.0,
2767            rate_b: 0.0,
2768            rate_c: 0.0,
2769            rating_sets: Vec::new(),
2770            current_ratings: None,
2771            tap: 0.0,
2772            shift: 0.0,
2773            in_service: true,
2774            angmin: -360.0,
2775            angmax: 360.0,
2776            control: None,
2777            solution: None,
2778            uid: None,
2779            route: None,
2780            extras: Extras::new(),
2781        }
2782    }
2783
2784    /// Effective tap ratio (0 ⇒ 1).
2785    #[must_use]
2786    pub fn calc_effective_tap(&self) -> f64 {
2787        if self.tap == 0.0 { 1.0 } else { self.tap }
2788    }
2789
2790    /// [`calc_effective_tap`](Self::calc_effective_tap) for a builder that divides by it,
2791    /// which the remap of an exact 0.0 does not make safe on its own.
2792    ///
2793    /// # Errors
2794    /// [`Error::DegenerateTap`] under
2795    /// [`MIN_DIVISIBLE_MAGNITUDE`](crate::dc::MIN_DIVISIBLE_MAGNITUDE), where a
2796    /// tap scales an admittance past anything a matrix can carry. `row` only
2797    /// labels the error.
2798    pub fn calc_divisible_tap(&self, row: usize) -> Result<f64> {
2799        let tap = self.calc_effective_tap();
2800        if !tap.is_finite() || tap.abs() < crate::dc::MIN_DIVISIBLE_MAGNITUDE {
2801            return Err(Error::DegenerateTap { row, tap });
2802        }
2803        Ok(tap)
2804    }
2805
2806    /// Per terminal shunt admittance, deriving the legacy symmetric MATPOWER
2807    /// charging model when the richer field is absent.
2808    #[must_use]
2809    pub fn calc_terminal_charging(&self) -> BranchCharging {
2810        self.charging
2811            .unwrap_or_else(|| BranchCharging::from_total_b(self.b))
2812    }
2813
2814    /// Series admittance `(g, b) = (r, −x) / (r² + x²)` of the branch pi
2815    /// model, the primitive beside [`calc_effective_tap`](Self::calc_effective_tap) and
2816    /// [`calc_terminal_charging`](Self::calc_terminal_charging). `Ok(None)` for a zero
2817    /// impedance branch — one whose impedance magnitude is under
2818    /// [`MIN_DIVISIBLE_MAGNITUDE`](crate::dc::MIN_DIVISIBLE_MAGNITUDE); the
2819    /// caller decides whether that is a skip or an error.
2820    ///
2821    /// # Errors
2822    /// [`Error::NonFiniteSusceptance`] when `r`/`x` are NaN/Inf, so a bad
2823    /// value cannot write NaN or a silent zero downstream. `row` only labels
2824    /// the error.
2825    pub fn calc_series_admittance(&self, row: usize) -> Result<Option<(f64, f64)>> {
2826        calc_series_admittance_of(self.r, self.x, row)
2827    }
2828
2829    /// Apparent power bound, per unit, for a branch the source left unrated
2830    /// (`rate_a == 0`, which reads as unlimited). `angle_window_rad` is the
2831    /// widest angle difference the branch may hold, in radians. That window and
2832    /// the two terminal voltage bands give the widest voltage phasor difference
2833    /// the branch can hold. The difference over `|Z|` bounds the current, and
2834    /// the larger ceiling turns the current into power. Returns `0.0` for a zero
2835    /// impedance branch — one under
2836    /// [`MIN_DIVISIBLE_MAGNITUDE`](crate::dc::MIN_DIVISIBLE_MAGNITUDE), the
2837    /// bound the rest of the builders divide by — which stays unlimited.
2838    ///
2839    /// Both ends of each band are needed, not just the ceilings. `|V_f e^{jδ} −
2840    /// V_t|²` is convex in `(V_f, V_t)`, so its largest value over the voltage
2841    /// box sits at a corner — and below a window of roughly 10° that corner is
2842    /// the mixed one, one terminal high and the other low, not both high.
2843    /// Reading only the ceilings there understates the bound several fold and
2844    /// hands an OPF a limit tighter than the branch physically has.
2845    ///
2846    /// The caller supplies the window in radians, because
2847    /// [`angmin`](Self::angmin) and [`angmax`](Self::angmax) are degrees in
2848    /// the neutral model and radians in a normalized network, and a branch
2849    /// cannot tell which it holds. Convert them with
2850    /// [`IndexedNetwork::to_radians`](crate::IndexedNetwork::to_radians),
2851    /// which reads the convention of the network. The method takes the
2852    /// magnitude of the window and holds it at `π`, the widest phasor
2853    /// separation two terminals can have.
2854    #[must_use]
2855    pub fn synthesize_rate_a(
2856        &self,
2857        angle_window_rad: f64,
2858        (fr_vmin, fr_vmax): (f64, f64),
2859        (to_vmin, to_vmax): (f64, f64),
2860    ) -> f64 {
2861        // The same bound `calc_series_admittance_of` divides by, so the two agree on
2862        // which branch has no impedance to bound a current with.
2863        let zmag = self.r.hypot(self.x);
2864        if zmag < crate::dc::MIN_DIVISIBLE_MAGNITUDE {
2865            return 0.0;
2866        }
2867        let window = angle_window_rad.abs().min(std::f64::consts::PI);
2868        let cos_window = window.cos();
2869        // Clamped at zero before the root: the law of cosines is nonnegative in
2870        // exact arithmetic, and rounding on two nearly equal voltages can carry
2871        // it a few ulp under.
2872        let separation = |vf: f64, vt: f64| {
2873            (vf * vf + vt * vt - 2.0 * vf * vt * cos_window)
2874                .max(0.0)
2875                .sqrt()
2876        };
2877        let widest = separation(fr_vmax, to_vmax)
2878            .max(separation(fr_vmax, to_vmin))
2879            .max(separation(fr_vmin, to_vmax))
2880            .max(separation(fr_vmin, to_vmin));
2881        fr_vmax.max(to_vmax) * widest / zmag
2882    }
2883
2884    /// Total susceptance projection for MATPOWER shaped formats that only carry
2885    /// one line charging value.
2886    #[must_use]
2887    pub fn calc_total_charging_b(&self) -> f64 {
2888        self.calc_terminal_charging().calc_total_b()
2889    }
2890
2891    /// Whether this branch has charging that a MATPOWER branch row cannot carry.
2892    #[must_use]
2893    pub fn has_non_matpower_charging(&self) -> bool {
2894        self.charging
2895            .is_some_and(|charging| !charging.is_matpower_symmetric())
2896    }
2897
2898    /// A transformer iff the raw tap field is nonzero (an explicit `1` counts) or
2899    /// there is a phase shift.
2900    #[must_use]
2901    pub fn is_transformer(&self) -> bool {
2902        self.tap != 0.0 || self.shift != 0.0
2903    }
2904
2905    /// True when the branch constrains its angle difference, i.e. the limits
2906    /// deviate from the ±360° "unconstrained" default. Formats without angle
2907    /// limit fields (PSS/E, PowerWorld) use this to warn on what they drop.
2908    #[must_use]
2909    pub fn has_angle_limits(&self) -> bool {
2910        self.angmin > -360.0 || self.angmax < 360.0
2911    }
2912
2913    /// The impedance correction table this transformer's series impedance
2914    /// follows. It is kept under `extras["impedance_correction"]`; an absent
2915    /// or malformed entry reads as none.
2916    /// [`BalancedNetwork::apply_impedance_correction`] applies it.
2917    #[must_use]
2918    pub fn impedance_correction(&self) -> Option<ImpedanceCorrection> {
2919        self.extras
2920            .get(IMPEDANCE_CORRECTION_EXTRA)
2921            .and_then(|value| ImpedanceCorrection::deserialize(value).ok())
2922    }
2923
2924    /// Set or clear [`impedance_correction`](Self::impedance_correction).
2925    pub fn set_impedance_correction(&mut self, correction: Option<ImpedanceCorrection>) {
2926        set_impedance_correction_extra(&mut self.extras, correction);
2927    }
2928}
2929
2930fn set_impedance_correction_extra(extras: &mut Extras, correction: Option<ImpedanceCorrection>) {
2931    match correction {
2932        Some(correction) => {
2933            let value = serde_json::to_value(correction).expect("a correction serializes to JSON");
2934            extras.insert(IMPEDANCE_CORRECTION_EXTRA.to_owned(), value);
2935        }
2936        None => {
2937            extras.remove(IMPEDANCE_CORRECTION_EXTRA);
2938        }
2939    }
2940}
2941
2942/// The series admittance `(g, b)` of an impedance, guarded.
2943///
2944/// `None` is an impedance too small to divide by, under
2945/// [`MIN_DIVISIBLE_MAGNITUDE`](crate::dc::MIN_DIVISIBLE_MAGNITUDE); the caller
2946/// decides whether that is a skip or an error. The bound is on the impedance
2947/// magnitude, not on `r² + x²`, which is its square: bounding the square would
2948/// refuse impedances the DC builders divide by.
2949///
2950/// Y_bus takes `r` already zeroed under the XB scheme, so it passes its own
2951/// pair rather than a branch's.
2952///
2953/// # Errors
2954/// [`Error::NonFiniteSusceptance`] when `r`/`x` are NaN/Inf, so a bad value
2955/// cannot write NaN or a silent zero downstream. NaN leaves `hypot` NaN, which
2956/// is not below the bound, so it arrives at that check rather than reading as
2957/// zero impedance. `row` only labels the error.
2958pub fn calc_series_admittance_of(r: f64, x: f64, row: usize) -> Result<Option<(f64, f64)>> {
2959    let magnitude = r.hypot(x);
2960    if magnitude < crate::dc::MIN_DIVISIBLE_MAGNITUDE {
2961        return Ok(None);
2962    }
2963    if !magnitude.is_finite() {
2964        return Err(Error::NonFiniteSusceptance { row });
2965    }
2966    Ok(Some(crate::dc::series_admittance_parts(r, x)))
2967}
2968
2969/// A transmission switch. Closed switches are preserved as data; matrix builders
2970/// do not lower them into zero impedance branches.
2971#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2972#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2973#[non_exhaustive]
2974pub struct Switch {
2975    pub from: BusId,
2976    pub to: BusId,
2977    pub closed: bool,
2978    #[serde(default)]
2979    pub thermal_rating: Option<f64>,
2980    #[serde(default)]
2981    pub current_rating: Option<f64>,
2982    #[serde(default)]
2983    pub pf: Option<f64>,
2984    #[serde(default)]
2985    pub qf: Option<f64>,
2986    #[serde(default)]
2987    pub pt: Option<f64>,
2988    #[serde(default)]
2989    pub qt: Option<f64>,
2990    /// Stable row identity; see [`Bus::uid`].
2991    #[serde(default, skip_serializing_if = "Option::is_none")]
2992    pub uid: Option<String>,
2993    pub extras: Extras,
2994}
2995
2996impl Switch {
2997    #[must_use]
2998    pub fn new(from: BusId, to: BusId, closed: bool) -> Self {
2999        Self {
3000            from,
3001            to,
3002            closed,
3003            thermal_rating: None,
3004            current_rating: None,
3005            pf: None,
3006            qf: None,
3007            pt: None,
3008            qt: None,
3009            uid: None,
3010            extras: Extras::new(),
3011        }
3012    }
3013}
3014
3015/// What a regulating transformer's tap (or phase shift) automatically controls.
3016/// Maps to the PSS/E control code `COD` and the PSLF transformer `type`.
3017#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3018#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3019#[serde(rename_all = "snake_case")]
3020#[non_exhaustive]
3021pub enum TransformerControlMode {
3022    /// Fixed ratio, no automatic adjustment (PSS/E `COD` 0, PSLF type 1).
3023    Fixed,
3024    /// Bus voltage control via tap (LTC; PSS/E `COD` ±1, PSLF type 2).
3025    Voltage,
3026    /// Reactive power flow control via tap (PSS/E `COD` ±2).
3027    ReactiveFlow,
3028    /// Active power flow control via phase shift (PSS/E `COD` ±3, PSLF type 4).
3029    ActiveFlow,
3030    /// Control of a DC line quantity (PSS/E `COD` ±4; two-winding transformers only).
3031    DcLineQuantity,
3032    /// Asymmetric active power flow control via phase shift (PSS/E `COD` ±5).
3033    AsymmetricActiveFlow,
3034}
3035
3036/// Automatic-control data for a regulating transformer ([`Branch::control`]).
3037///
3038/// The limits carry whatever the [`mode`](TransformerControl::mode) regulates:
3039/// `tap_min`/`tap_max` bound the tap ratio (or the phase angle for active power
3040/// control), and `band_min`/`band_max` bound the controlled quantity (the
3041/// regulated voltage band or the scheduled MW/MVAr). `ntp` is the number of
3042/// discrete tap positions and `controlled_bus` is the regulated bus (`None` =
3043/// the transformer's own terminal). `mva_base` is the winding MVA base the
3044/// impedance is referred to.
3045#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3046#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3047#[non_exhaustive]
3048pub struct TransformerControl {
3049    pub mode: TransformerControlMode,
3050    /// Whether automatic regulation is enabled. PSS/E represents this with
3051    /// the sign of `COD` while its magnitude selects [`mode`](Self::mode).
3052    pub enabled: bool,
3053    pub controlled_bus: Option<BusId>,
3054    /// Whether the controlled bus lies on this winding's side of the
3055    /// transformer. PSS/E represents this with a negative `CONT` value.
3056    #[serde(default)]
3057    pub controlled_bus_on_winding_side: bool,
3058    /// Exact regulated terminal when the source identifies one.
3059    #[serde(default, skip_serializing_if = "Option::is_none")]
3060    pub regulating_terminal: Option<TerminalReference>,
3061    pub tap_min: f64,
3062    pub tap_max: f64,
3063    pub band_min: f64,
3064    pub band_max: f64,
3065    pub ntp: u32,
3066    pub mva_base: f64,
3067    /// Winding connection angle for asymmetric active power flow control.
3068    /// It is in degrees in a source network and radians after normalization.
3069    #[serde(default, skip_serializing_if = "Option::is_none")]
3070    pub winding_connection_angle: Option<f64>,
3071}
3072
3073impl Default for TransformerControl {
3074    fn default() -> Self {
3075        // PSS/E's documented defaults for an unset winding-control block.
3076        TransformerControl {
3077            mode: TransformerControlMode::Fixed,
3078            enabled: false,
3079            controlled_bus: None,
3080            controlled_bus_on_winding_side: false,
3081            regulating_terminal: None,
3082            tap_min: 0.9,
3083            tap_max: 1.1,
3084            band_min: 0.9,
3085            band_max: 1.1,
3086            ntp: 33,
3087            mva_base: 0.0,
3088            winding_connection_angle: None,
3089        }
3090    }
3091}
3092
3093impl TransformerControl {
3094    #[must_use]
3095    pub fn new(mode: TransformerControlMode) -> Self {
3096        Self {
3097            mode,
3098            enabled: mode != TransformerControlMode::Fixed,
3099            ..Self::default()
3100        }
3101    }
3102}
3103
3104/// Active power control settings for a generator or storage device.
3105///
3106/// `droop_percent` is the governor speed changer droop: the negated change in
3107/// active power divided by the change in frequency, normalized by nominal
3108/// power and nominal frequency, and expressed as a percentage.
3109/// `participation_factor` is the nonnegative distributed slack participation
3110/// factor. The target limits, when present, override the equipment active power
3111/// limits for active power control operations only.
3112/// It is distinct from MATPOWER's area participation factor (`APF`) in [`Generator::caps`].
3113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3114#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3115#[non_exhaustive]
3116pub struct ActivePowerControl {
3117    pub participate: bool,
3118    #[serde(default, skip_serializing_if = "Option::is_none")]
3119    pub droop_percent: Option<f64>,
3120    #[serde(default, skip_serializing_if = "Option::is_none")]
3121    pub participation_factor: Option<f64>,
3122    #[serde(default, skip_serializing_if = "Option::is_none")]
3123    pub minimum_target_active_power_mw: Option<f64>,
3124    #[serde(default, skip_serializing_if = "Option::is_none")]
3125    pub maximum_target_active_power_mw: Option<f64>,
3126}
3127
3128impl ActivePowerControl {
3129    #[must_use]
3130    pub fn new(participate: bool) -> Self {
3131        Self {
3132            participate,
3133            droop_percent: None,
3134            participation_factor: None,
3135            minimum_target_active_power_mw: None,
3136            maximum_target_active_power_mw: None,
3137        }
3138    }
3139}
3140
3141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3142#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3143#[non_exhaustive]
3144pub struct Generator {
3145    pub bus: BusId,
3146    /// Primary energy source used by the generating equipment.
3147    #[serde(default)]
3148    pub energy_source: GeneratorEnergySource,
3149    /// Real power set point (MW).
3150    pub pg: f64,
3151    /// Reactive power set point (MVAr).
3152    pub qg: f64,
3153    pub pmax: f64,
3154    pub pmin: f64,
3155    pub qmax: f64,
3156    pub qmin: f64,
3157    /// Voltage set point (p.u.).
3158    pub vg: f64,
3159    pub mbase: f64,
3160    pub in_service: bool,
3161    pub cost: Option<GenCost>,
3162    /// The MATPOWER gen capability / ramp columns past `PMIN`, aligned to
3163    /// `GEN_EXTRA_KEYS` by index (`None` for a column the source omitted).
3164    /// A fixed array, not an [`Extras`] map: a string-keyed map per generator
3165    /// costs 11 heap allocations each, which dominates the parse of a large
3166    /// generator-heavy case. Surfaced into formats that name them (PowerModels).
3167    /// On the JSON snapshot it is a name-keyed object (see `caps_serde`) so the
3168    /// schema stays additive when `GEN_EXTRA_KEYS` grows; `#[serde(default)]` so a
3169    /// snapshot that omits it deserializes to the empty set.
3170    #[serde(default = "default_caps", with = "caps_serde")]
3171    #[cfg_attr(feature = "schema", schemars(with = "BTreeMap<String, f64>"))]
3172    pub caps: GenCaps,
3173    /// Whether the generator's voltage regulation is enabled. Formats without
3174    /// an explicit enable field use `true`, matching the voltage set point
3175    /// carried by the balanced generator row.
3176    #[serde(default = "default_voltage_regulation_on")]
3177    pub voltage_regulation_on: bool,
3178    /// The exact equipment terminal whose voltage is regulated. `None` means
3179    /// the generator's own terminal, or that the source format names only a
3180    /// regulated bus.
3181    #[serde(default, skip_serializing_if = "Option::is_none")]
3182    pub regulating_terminal: Option<TerminalReference>,
3183    /// The remote bus whose voltage this generator regulates, when that is not its
3184    /// own terminal bus (PSS/E `IREG`). `None` means it regulates its own bus.
3185    /// Part of the cross-element voltage-control graph: a format that names a
3186    /// remote regulated bus (PSS/E) keeps it across a round trip instead of
3187    /// collapsing every generator onto its own terminal. `#[serde(default)]` so
3188    /// JSON written before the field existed still deserializes.
3189    #[serde(default)]
3190    pub regulated_bus: Option<BusId>,
3191    /// Governor and distributed slack settings, when supplied by the source.
3192    #[serde(default, skip_serializing_if = "Option::is_none")]
3193    pub active_power_control: Option<ActivePowerControl>,
3194    /// Stable row identity; see [`Bus::uid`].
3195    #[serde(default, skip_serializing_if = "Option::is_none")]
3196    pub uid: Option<String>,
3197}
3198
3199impl Generator {
3200    #[must_use]
3201    pub fn new(bus: BusId) -> Self {
3202        Self {
3203            bus,
3204            energy_source: GeneratorEnergySource::Other,
3205            pg: 0.0,
3206            qg: 0.0,
3207            pmax: 0.0,
3208            pmin: 0.0,
3209            qmax: 0.0,
3210            qmin: 0.0,
3211            vg: 1.0,
3212            mbase: 0.0,
3213            in_service: true,
3214            cost: None,
3215            caps: default_caps(),
3216            voltage_regulation_on: true,
3217            regulating_terminal: None,
3218            regulated_bus: None,
3219            active_power_control: None,
3220            uid: None,
3221        }
3222    }
3223
3224    /// True when any capability / ramp column is present. Formats without those
3225    /// fields (PSS/E, PowerWorld) use this to warn on what they drop.
3226    #[must_use]
3227    pub fn has_caps(&self) -> bool {
3228        self.caps.iter().any(Option::is_some)
3229    }
3230}
3231
3232/// Primary energy source used by generating equipment.
3233#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
3234#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3235#[serde(rename_all = "snake_case")]
3236#[non_exhaustive]
3237pub enum GeneratorEnergySource {
3238    Hydro,
3239    Nuclear,
3240    Wind,
3241    Thermal,
3242    Solar,
3243    #[default]
3244    Other,
3245}
3246
3247const fn default_voltage_regulation_on() -> bool {
3248    true
3249}
3250
3251/// A generator's capability / ramp columns, one slot per `GEN_EXTRA_KEYS` name.
3252pub type GenCaps = [Option<f64>; GEN_EXTRA_KEYS.len()];
3253
3254/// The empty capability set, for a JSON snapshot that omits the field entirely.
3255fn default_caps() -> GenCaps {
3256    [None; GEN_EXTRA_KEYS.len()]
3257}
3258
3259/// Serialize [`GenCaps`] as a name-keyed object (`{"ramp_30": 1.2, ...}`) keyed by
3260/// [`GEN_EXTRA_KEYS`], emitting only the present slots, instead of a length-exact
3261/// array. A fixed-length array round-trips through serde only at exactly its
3262/// current length: the day `GEN_EXTRA_KEYS` grows a column, every old snapshot
3263/// fails to deserialize and every new one fails on an old build, and the C ABI
3264/// ties the JSON snapshot schema to its version, so that is a forced ABI break.
3265/// The named map makes a new key purely additive: an old document simply lacks it
3266/// (deserializes to `None`), and an unknown key from a newer document is ignored.
3267/// In memory `caps` stays a fixed array, so the per-generator allocation cost the
3268/// array avoids is unchanged; only the serialized form is named.
3269mod caps_serde {
3270    use super::{GEN_EXTRA_KEYS, GenCaps};
3271    use serde::de::{Deserialize, Deserializer};
3272    use serde::ser::{SerializeMap, Serializer};
3273    use std::collections::BTreeMap;
3274
3275    pub(super) fn serialize<S: Serializer>(caps: &GenCaps, s: S) -> Result<S::Ok, S::Error> {
3276        let present = caps.iter().filter(|v| v.is_some()).count();
3277        let mut map = s.serialize_map(Some(present))?;
3278        for (key, slot) in GEN_EXTRA_KEYS.iter().zip(caps.iter()) {
3279            if let Some(value) = slot {
3280                map.serialize_entry(key, value)?;
3281            }
3282        }
3283        map.end()
3284    }
3285
3286    pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<GenCaps, D::Error> {
3287        // Accept an explicit `null` as the empty set (treated like an omitted
3288        // field), so a producer that encodes "no caps" as `null` round-trips the
3289        // same way `cost: Option<_>` does. `#[serde(default)]` only covers an
3290        // absent key, not a present `null`.
3291        let named = Option::<BTreeMap<String, f64>>::deserialize(d)?.unwrap_or_default();
3292        let mut caps: GenCaps = [None; GEN_EXTRA_KEYS.len()];
3293        for (slot, key) in caps.iter_mut().zip(GEN_EXTRA_KEYS.iter()) {
3294            *slot = named.get(*key).copied();
3295        }
3296        Ok(caps)
3297    }
3298}
3299
3300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3301#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3302#[non_exhaustive]
3303pub struct Storage {
3304    pub bus: BusId,
3305    pub ps: f64,
3306    pub qs: f64,
3307    pub energy: f64,
3308    pub energy_rating: f64,
3309    pub charge_rating: f64,
3310    pub discharge_rating: f64,
3311    pub charge_efficiency: f64,
3312    pub discharge_efficiency: f64,
3313    pub thermal_rating: f64,
3314    #[serde(default)]
3315    pub current_rating: Option<f64>,
3316    pub qmin: f64,
3317    pub qmax: f64,
3318    pub r: f64,
3319    pub x: f64,
3320    pub p_loss: f64,
3321    pub q_loss: f64,
3322    pub in_service: bool,
3323    /// Governor and distributed slack settings, when supplied by the source.
3324    #[serde(default, skip_serializing_if = "Option::is_none")]
3325    pub active_power_control: Option<ActivePowerControl>,
3326    /// Stable row identity; see [`Bus::uid`].
3327    #[serde(default, skip_serializing_if = "Option::is_none")]
3328    pub uid: Option<String>,
3329    pub extras: Extras,
3330}
3331
3332impl Storage {
3333    #[must_use]
3334    pub fn new(bus: BusId) -> Self {
3335        Self {
3336            bus,
3337            ps: 0.0,
3338            qs: 0.0,
3339            energy: 0.0,
3340            energy_rating: 0.0,
3341            charge_rating: 0.0,
3342            discharge_rating: 0.0,
3343            charge_efficiency: 1.0,
3344            discharge_efficiency: 1.0,
3345            thermal_rating: 0.0,
3346            current_rating: None,
3347            qmin: 0.0,
3348            qmax: 0.0,
3349            r: 0.0,
3350            x: 0.0,
3351            p_loss: 0.0,
3352            q_loss: 0.0,
3353            in_service: true,
3354            active_power_control: None,
3355            uid: None,
3356            extras: Extras::new(),
3357        }
3358    }
3359}
3360
3361/// A two-terminal HVDC line (MATPOWER `dcline`).
3362///
3363/// `pf`/`pt`/`qf`/`qt` are stored in MATPOWER's sign convention regardless of
3364/// source: the PowerModels reader un-flips `pt`/`qf`/`qt` on the way in, and the
3365/// PowerModels writer re-flips them on the way out (PowerModels.jl uses the
3366/// opposite sign). The flip is a format-boundary translation, so a derived view
3367/// like `to_normalized` keeps the MATPOWER convention and only scales to per unit.
3368#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3369#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3370#[serde(rename_all = "snake_case")]
3371#[non_exhaustive]
3372pub enum HvdcConvertersMode {
3373    Side1RectifierSide2Inverter,
3374    Side1InverterSide2Rectifier,
3375}
3376
3377/// Converter technology used at both ends of one HVDC line.
3378#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3379#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3380#[serde(rename_all = "snake_case")]
3381#[non_exhaustive]
3382pub enum HvdcConverterKind {
3383    Vsc,
3384    Lcc,
3385}
3386
3387/// One AC terminal converter station of an HVDC line.
3388#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3389#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3390#[non_exhaustive]
3391pub struct HvdcConverter {
3392    pub component: ComponentId,
3393    pub kind: HvdcConverterKind,
3394    /// Converter losses as a percentage of active power.
3395    pub loss_factor_percent: f64,
3396    #[serde(default, skip_serializing_if = "Option::is_none")]
3397    pub voltage_regulator_on: Option<bool>,
3398    #[serde(default, skip_serializing_if = "Option::is_none")]
3399    pub voltage_setpoint_kv: Option<f64>,
3400    #[serde(default, skip_serializing_if = "Option::is_none")]
3401    pub reactive_power_setpoint_mvar: Option<f64>,
3402    #[serde(default, skip_serializing_if = "Option::is_none")]
3403    pub power_factor: Option<f64>,
3404    #[serde(default, skip_serializing_if = "Option::is_none")]
3405    pub regulating_terminal: Option<TerminalReference>,
3406}
3407
3408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3409#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3410#[non_exhaustive]
3411pub struct Hvdc {
3412    pub from: BusId,
3413    pub to: BusId,
3414    pub in_service: bool,
3415    pub pf: f64,
3416    pub pt: f64,
3417    pub qf: f64,
3418    pub qt: f64,
3419    pub vf: f64,
3420    pub vt: f64,
3421    pub pmin: f64,
3422    pub pmax: f64,
3423    pub qminf: f64,
3424    pub qmaxf: f64,
3425    pub qmint: f64,
3426    pub qmaxt: f64,
3427    pub loss0: f64,
3428    pub loss1: f64,
3429    /// Physical line resistance and nominal DC voltage when the source states
3430    /// an explicit converter station model.
3431    #[serde(default, skip_serializing_if = "Option::is_none")]
3432    pub resistance_ohm: Option<f64>,
3433    #[serde(default, skip_serializing_if = "Option::is_none")]
3434    pub nominal_voltage_kv: Option<f64>,
3435    #[serde(default, skip_serializing_if = "Option::is_none")]
3436    pub converters_mode: Option<HvdcConvertersMode>,
3437    #[serde(default, skip_serializing_if = "Option::is_none")]
3438    pub converter1: Option<HvdcConverter>,
3439    #[serde(default, skip_serializing_if = "Option::is_none")]
3440    pub converter2: Option<HvdcConverter>,
3441    #[serde(default)]
3442    pub cost: Option<GenCost>,
3443    /// Stable row identity; see [`Bus::uid`].
3444    #[serde(default, skip_serializing_if = "Option::is_none")]
3445    pub uid: Option<String>,
3446    pub extras: Extras,
3447}
3448
3449impl Hvdc {
3450    /// The power arriving at the `to` end for a sending end setpoint, under the
3451    /// MATPOWER dcline loss model `Pt = Pf - loss0 - loss1·Pf`.
3452    ///
3453    /// [`pf`](Self::pf), [`pt`](Self::pt), and [`loss0`](Self::loss0) are one
3454    /// relation, not three independent fields, and a format that states only
3455    /// the sending end reconstructs the far end from it. Stated here so every
3456    /// reader spells the same rule: `loss0` and `pf` scale together, so this
3457    /// holds in per unit as in MW.
3458    #[must_use]
3459    pub fn calc_delivered_power(pf: f64, loss0: f64, loss1: f64) -> f64 {
3460        pf - loss0 - loss1 * pf
3461    }
3462
3463    /// Whether [`pt`](Self::pt) agrees with this line's own loss model to
3464    /// `tol`. A writer whose format states no received power reports the lines
3465    /// that fail this, because those are the ones it cannot reproduce.
3466    #[must_use]
3467    pub fn pt_matches_loss_model(&self, tol: f64) -> bool {
3468        (self.pt - Self::calc_delivered_power(self.pf, self.loss0, self.loss1)).abs() <= tol
3469    }
3470
3471    #[must_use]
3472    pub fn new(from: BusId, to: BusId) -> Self {
3473        Self {
3474            from,
3475            to,
3476            in_service: true,
3477            pf: 0.0,
3478            pt: 0.0,
3479            qf: 0.0,
3480            qt: 0.0,
3481            vf: 1.0,
3482            vt: 1.0,
3483            pmin: 0.0,
3484            pmax: 0.0,
3485            qminf: 0.0,
3486            qmaxf: 0.0,
3487            qmint: 0.0,
3488            qmaxt: 0.0,
3489            loss0: 0.0,
3490            loss1: 0.0,
3491            resistance_ohm: None,
3492            nominal_voltage_kv: None,
3493            converters_mode: None,
3494            converter1: None,
3495            converter2: None,
3496            cost: None,
3497            uid: None,
3498            extras: Extras::new(),
3499        }
3500    }
3501}
3502
3503/// An area record: the area's scheduled net interchange and its swing bus.
3504///
3505/// The [`number`](Area::number) matches the `area` field carried on each
3506/// [`Bus`]; this table holds the per-area metadata (the interchange target and
3507/// the area slack) that the bus number alone can't. Maps to the PSS/E area record
3508/// (`I, ISW, PDES, PTOL, ARNAME`).
3509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3510#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3511#[non_exhaustive]
3512pub struct Area {
3513    pub number: usize,
3514    /// The area swing (slack) bus, or `None` when unset.
3515    pub slack_bus: Option<BusId>,
3516    /// Scheduled net interchange (MW); positive is export out of the area.
3517    pub net_interchange: f64,
3518    /// Interchange tolerance bandwidth (MW).
3519    pub tolerance: f64,
3520    pub name: Option<String>,
3521    /// Stable source identity when the source uses a named area rather than a
3522    /// numeric area table.
3523    #[serde(default, skip_serializing_if = "Option::is_none")]
3524    pub uid: Option<String>,
3525    /// Area classification used by formats that distinguish control areas,
3526    /// bidding zones, and other area kinds.
3527    #[serde(default, skip_serializing_if = "Option::is_none")]
3528    pub area_type: Option<String>,
3529}
3530
3531impl Area {
3532    #[must_use]
3533    pub fn new(number: usize) -> Self {
3534        Self {
3535            number,
3536            slack_bus: None,
3537            net_interchange: 0.0,
3538            tolerance: 0.0,
3539            name: None,
3540            uid: None,
3541            area_type: None,
3542        }
3543    }
3544}
3545
3546/// Solver / solution-control metadata: the Newton tolerance and iteration cap,
3547/// the zero-impedance threshold, and the per-quantity adjustment-enable flags.
3548///
3549/// Each field is optional because a source states only the ones it carries. No
3550/// power flow physics, but it determines whether a downstream solver reproduces
3551/// the source tool's converged answer. Maps to the PSS/E v34+ system-wide block
3552/// (`GENERAL THRSHZ`, `NEWTON TOLN`/`ITMXN`, `SOLVER ACTAPS`/`AREAIN`/`PHSHFT`/
3553/// `DCTAPS`/`SWSHNT`).
3554#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
3555#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3556#[non_exhaustive]
3557pub struct SolverParams {
3558    /// Newton power flow mismatch tolerance (`NEWTON TOLN`).
3559    pub newton_tolerance: Option<f64>,
3560    /// Newton iteration cap (`NEWTON ITMXN`).
3561    pub max_iterations: Option<u32>,
3562    /// Branches with `|x|` below this are treated as zero impedance (`GENERAL THRSHZ`).
3563    pub zero_impedance_threshold: Option<f64>,
3564    /// Whether the solver adjusts transformer taps (`SOLVER ACTAPS`).
3565    pub adjust_taps: Option<bool>,
3566    /// Whether the solver adjusts area interchange (`SOLVER AREAIN`).
3567    pub adjust_area_interchange: Option<bool>,
3568    /// Whether the solver adjusts phase-shift angles (`SOLVER PHSHFT`).
3569    pub adjust_phase_shift: Option<bool>,
3570    /// Whether the solver adjusts DC line taps (`SOLVER DCTAPS`).
3571    pub adjust_dc_taps: Option<bool>,
3572    /// Whether the solver adjusts switched shunts (`SOLVER SWSHNT`).
3573    pub adjust_switched_shunt: Option<bool>,
3574}
3575
3576impl SolverParams {
3577    #[must_use]
3578    pub fn new() -> Self {
3579        Self::default()
3580    }
3581
3582    /// True when no field is set (so readers can avoid attaching an empty record).
3583    #[must_use]
3584    pub fn is_empty(&self) -> bool {
3585        *self == SolverParams::default()
3586    }
3587}
3588
3589/// A series impedance with the MVA base it is expressed on. Used pairwise by
3590/// [`Transformer3W`]; a self-contained unit so the base travels with the value
3591/// instead of being implied by position.
3592///
3593/// `r`/`x` are per unit on the *system* base (the same `CZ = 1` convention as
3594/// [`Branch::r`]/[`Branch::x`], so the matrix math needs no rebasing); `base_mva`
3595/// records the winding-pair MVA base the source file declared (PSS/E `SBASE1-2`
3596/// and friends), kept so a write-back reproduces it and so a future `CZ = 2`
3597/// reader has somewhere to put the winding base it must rebase from. Room to grow
3598/// (winding voltage base, turns-ratio units) as the transformer control work
3599/// lands without reshaping the [`Transformer3W::z`] array.
3600#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
3601#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3602#[non_exhaustive]
3603pub struct Impedance {
3604    pub r: f64,
3605    pub x: f64,
3606    pub base_mva: f64,
3607}
3608
3609impl Impedance {
3610    #[must_use]
3611    pub const fn new(r: f64, x: f64, base_mva: f64) -> Self {
3612        Self { r, x, base_mva }
3613    }
3614}
3615
3616/// The [`Transformer3W::extras`] key behind
3617/// [`Transformer3W::winding_in_service`].
3618pub(crate) const WINDING_IN_SERVICE_EXTRA: &str = "winding_in_service";
3619/// The [`Transformer3W::extras`] key behind
3620/// [`Transformer3W::winding_rating_sets`].
3621pub(crate) const WINDING_RATING_SETS_EXTRA: &str = "winding_rating_sets";
3622
3623/// One winding of a [`Transformer3W`]: its terminal bus, off-nominal ratio, phase
3624/// shift, nominal voltage, and thermal ratings.
3625#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3626#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3627#[non_exhaustive]
3628pub struct Winding {
3629    pub bus: BusId,
3630    /// Off-nominal turns ratio (1.0 = nominal); the PSS/E `WINDV`, `CW = 1`.
3631    pub tap: f64,
3632    /// Phase shift (degrees).
3633    pub shift: f64,
3634    /// Winding nominal voltage (kV); 0 defers to the terminal bus base kV.
3635    pub nominal_kv: f64,
3636    pub rate_a: f64,
3637    pub rate_b: f64,
3638    pub rate_c: f64,
3639    /// Automatic tap or phase control for this winding.
3640    #[serde(default, skip_serializing_if = "Option::is_none")]
3641    pub control: Option<TransformerControl>,
3642}
3643
3644impl Winding {
3645    #[must_use]
3646    pub fn new(bus: BusId) -> Self {
3647        Self {
3648            bus,
3649            tap: 1.0,
3650            shift: 0.0,
3651            nominal_kv: 0.0,
3652            rate_a: 0.0,
3653            rate_b: 0.0,
3654            rate_c: 0.0,
3655            control: None,
3656        }
3657    }
3658}
3659
3660/// A three winding transformer with three terminal buses joined at a star point.
3661///
3662/// Series impedance is stored for winding pairs 1-2, 2-3, and 3-1. The record
3663/// also retains star point voltage and per winding control data. PSS/E three
3664/// winding records and PSLF tertiary winding records map to this type.
3665/// [`to_star_expansion`](Transformer3W::to_star_expansion) turns it into the synthetic
3666/// star bus plus three branches for a consumer that works in the bus-branch model;
3667/// [`IndexedNetwork`](crate::IndexedNetwork) applies it before building any matrix,
3668/// so a 3-winding transformer contributes to `Y_bus` and connectivity.
3669#[derive(Debug, Clone, PartialEq)]
3670#[non_exhaustive]
3671pub struct Transformer3W {
3672    /// The three windings, in order (primary, secondary, tertiary).
3673    pub windings: [Winding; 3],
3674    /// Pairwise series impedance `[z12, z23, z31]` (primary-secondary,
3675    /// secondary-tertiary, tertiary-primary), each per unit on the system base
3676    /// with its declared MVA base.
3677    pub z: [Impedance; 3],
3678    /// Star-point voltage magnitude (p.u.) and angle (degrees), as solved.
3679    pub star_vm: f64,
3680    pub star_va: f64,
3681    /// Magnetizing shunt referred to the star point (p.u. on the system base).
3682    pub mag_g: f64,
3683    pub mag_b: f64,
3684    pub in_service: bool,
3685    pub name: Option<String>,
3686    /// Stable row identity; see [`Bus::uid`].
3687    pub uid: Option<String>,
3688    pub extras: Extras,
3689}
3690
3691impl Transformer3W {
3692    #[must_use]
3693    pub fn new(windings: [Winding; 3], z: [Impedance; 3]) -> Self {
3694        Self {
3695            windings,
3696            z,
3697            star_vm: 1.0,
3698            star_va: 0.0,
3699            mag_g: 0.0,
3700            mag_b: 0.0,
3701            in_service: true,
3702            name: None,
3703            uid: None,
3704            extras: Extras::new(),
3705        }
3706    }
3707
3708    /// The per-winding star impedances `(r, x)` — winding *k* to the star point —
3709    /// from the pairwise values, per unit on the system base.
3710    ///
3711    /// Standard pairwise→star conversion: `z1 = (z12 + z31 - z23) / 2`, and so on.
3712    /// Because the impedances are already on a common base, the split is linear in
3713    /// `r` and `x` separately.
3714    #[must_use]
3715    pub fn calc_star_impedances(&self) -> [(f64, f64); 3] {
3716        let [z12, z23, z31] = self.z;
3717        let half = |a: f64, b: f64, c: f64| (a + b - c) / 2.0;
3718        [
3719            (half(z12.r, z31.r, z23.r), half(z12.x, z31.x, z23.x)),
3720            (half(z12.r, z23.r, z31.r), half(z12.x, z23.x, z31.x)),
3721            (half(z23.r, z31.r, z12.r), half(z23.x, z31.x, z12.x)),
3722        ]
3723    }
3724
3725    /// Whether each winding is connected while the transformer is in service,
3726    /// in winding order. [`in_service`](Self::in_service) takes the whole
3727    /// transformer out; a winding that is out leaves the other two coupled
3728    /// through the star point.
3729    ///
3730    /// The status is kept as three booleans under
3731    /// `extras["winding_in_service"]`, absent while every winding is in
3732    /// service. An absent or malformed entry reads as every winding in service.
3733    #[must_use]
3734    pub fn winding_in_service(&self) -> [bool; 3] {
3735        self.extras
3736            .get(WINDING_IN_SERVICE_EXTRA)
3737            .and_then(|value| <[bool; 3]>::deserialize(value).ok())
3738            .unwrap_or([true; 3])
3739    }
3740
3741    /// Set [`winding_in_service`](Self::winding_in_service).
3742    pub fn set_winding_in_service(&mut self, in_service: [bool; 3]) {
3743        if in_service == [true; 3] {
3744            self.extras.remove(WINDING_IN_SERVICE_EXTRA);
3745        } else {
3746            self.extras.insert(
3747                WINDING_IN_SERVICE_EXTRA.to_owned(),
3748                Value::from(in_service.to_vec()),
3749            );
3750        }
3751    }
3752
3753    /// The MVA rating sets of winding `winding` (0, 1, or 2) beyond `rate_a`,
3754    /// `rate_b`, and `rate_c`, named as on [`Branch::rating_sets`]: PSS/E
3755    /// states `RATE4` to `RATE12`.
3756    ///
3757    /// The sets are kept under `extras["winding_rating_sets"]` as three arrays
3758    /// of `{name, rate_mva}` objects in winding order, absent while no winding
3759    /// has one. An absent or malformed entry reads as no sets.
3760    ///
3761    /// # Panics
3762    /// If `winding` is 3 or more.
3763    #[must_use]
3764    pub fn winding_rating_sets(&self, winding: usize) -> Vec<BranchRatingSet> {
3765        let mut sets = self.stored_winding_rating_sets();
3766        std::mem::take(&mut sets[winding])
3767    }
3768
3769    /// Set [`winding_rating_sets`](Self::winding_rating_sets) for one winding.
3770    ///
3771    /// # Panics
3772    /// If `winding` is 3 or more.
3773    pub fn set_winding_rating_sets(&mut self, winding: usize, rating_sets: Vec<BranchRatingSet>) {
3774        let mut sets = self.stored_winding_rating_sets();
3775        sets[winding] = rating_sets;
3776        if sets.iter().all(Vec::is_empty) {
3777            self.extras.remove(WINDING_RATING_SETS_EXTRA);
3778        } else {
3779            let value = serde_json::to_value(sets).expect("rating sets serialize to JSON");
3780            self.extras
3781                .insert(WINDING_RATING_SETS_EXTRA.to_owned(), value);
3782        }
3783    }
3784
3785    fn stored_winding_rating_sets(&self) -> [Vec<BranchRatingSet>; 3] {
3786        self.extras
3787            .get(WINDING_RATING_SETS_EXTRA)
3788            .and_then(|value| <[Vec<BranchRatingSet>; 3]>::deserialize(value).ok())
3789            .unwrap_or_default()
3790    }
3791
3792    /// The sole adjustable winding for PSS/E bus-to-bus correction. The
3793    /// sign of COD enables regulation but does not change which winding the
3794    /// tables describe: a disabled nonzero control remains adjustable. A
3795    /// consumer that moves this winding's tap rereads the `psse_zcod = 1`
3796    /// tables at its new position.
3797    #[must_use]
3798    pub fn impedance_correction_control_winding(&self) -> Option<usize> {
3799        let mut adjustable = self
3800            .windings
3801            .iter()
3802            .enumerate()
3803            .filter(|(_, winding)| {
3804                winding
3805                    .control
3806                    .as_ref()
3807                    .is_some_and(|control| control.mode != TransformerControlMode::Fixed)
3808            })
3809            .map(|(index, _)| index);
3810        let winding = adjustable.next()?;
3811        adjustable.next().is_none().then_some(winding)
3812    }
3813
3814    /// The impedance correction table winding `winding` (0, 1, or 2) follows:
3815    /// it scales that winding's star impedance, except with PSS/E `ZCOD=1`,
3816    /// where entries 0/1/2 scale Z12/Z23/Z31 at the sole adjustable winding.
3817    /// The tables are kept under
3818    /// `extras["impedance_correction"]` as three entries in winding order, each
3819    /// a table or `null`; an absent or malformed entry reads as none.
3820    ///
3821    /// # Panics
3822    /// If `winding` is 3 or more.
3823    #[must_use]
3824    pub fn winding_impedance_correction(&self, winding: usize) -> Option<ImpedanceCorrection> {
3825        let mut corrections = self.stored_winding_impedance_corrections();
3826        corrections[winding].take()
3827    }
3828
3829    /// Set or clear [`winding_impedance_correction`](Self::winding_impedance_correction)
3830    /// for one winding.
3831    ///
3832    /// # Panics
3833    /// If `winding` is 3 or more.
3834    pub fn set_winding_impedance_correction(
3835        &mut self,
3836        winding: usize,
3837        correction: Option<ImpedanceCorrection>,
3838    ) {
3839        let mut corrections = self.stored_winding_impedance_corrections();
3840        corrections[winding] = correction;
3841        if corrections.iter().all(Option::is_none) {
3842            self.extras.remove(IMPEDANCE_CORRECTION_EXTRA);
3843        } else {
3844            let value = serde_json::to_value(corrections).expect("corrections serialize to JSON");
3845            self.extras
3846                .insert(IMPEDANCE_CORRECTION_EXTRA.to_owned(), value);
3847        }
3848    }
3849
3850    fn stored_winding_impedance_corrections(&self) -> [Option<ImpedanceCorrection>; 3] {
3851        self.extras
3852            .get(IMPEDANCE_CORRECTION_EXTRA)
3853            .and_then(|value| <[Option<ImpedanceCorrection>; 3]>::deserialize(value).ok())
3854            .unwrap_or_default()
3855    }
3856
3857    /// Expand into a synthetic star [`Bus`] (id `star_id`) plus three [`Branch`]es,
3858    /// one per winding, for a consumer that works in the bus-branch model.
3859    /// [`IndexedNetwork`](crate::IndexedNetwork) calls this via
3860    /// `BalancedNetwork::expand_transformers_3w` when assembling matrix inputs. The star
3861    /// bus carries the stored star voltage and the magnetizing shunt is left to the
3862    /// caller; each branch takes its winding's tap, phase shift, ratings, and
3863    /// status, so a winding out of service gives an out of service branch.
3864    /// A winding's own impedance correction table follows its branch, so
3865    /// [`BalancedNetwork::apply_impedance_correction`] on the expanded network
3866    /// scales each star impedance as it scales the native transformer's. Once
3867    /// the native transformer is corrected, its nominal follows too, split
3868    /// into star impedances. PSS/E `ZCOD = 1` tables scale the pairwise
3869    /// impedances together, which no single branch can carry: correct the
3870    /// native transformer before expanding it, and expand it again after
3871    /// moving its controlling winding.
3872    #[must_use]
3873    pub fn to_star_expansion(&self, star_id: BusId) -> (Bus, [Branch; 3]) {
3874        let star = Bus {
3875            id: star_id,
3876            kind: BusType::Pq,
3877            vm: self.star_vm,
3878            va: self.star_va,
3879            base_kv: self.windings[0].nominal_kv,
3880            vmax: 1.1,
3881            vmin: 0.9,
3882            evhi: None,
3883            evlo: None,
3884            area: 0,
3885            zone: 0,
3886            name: self.name.clone(),
3887            uid: self.uid.clone(),
3888            location: None,
3889            extras: Extras::new(),
3890        };
3891        let zs = self.calc_star_impedances();
3892        let branch = |w: &Winding, (r, x): (f64, f64)| Branch {
3893            name: None,
3894            from: w.bus,
3895            to: star_id,
3896            r,
3897            x,
3898            b: 0.0,
3899            charging: None,
3900            rate_a: w.rate_a,
3901            rate_b: w.rate_b,
3902            rate_c: w.rate_c,
3903            rating_sets: Vec::new(),
3904            current_ratings: None,
3905            tap: w.tap,
3906            shift: w.shift,
3907            in_service: self.in_service,
3908            angmin: -360.0,
3909            angmax: 360.0,
3910            control: w.control.clone(),
3911            solution: None,
3912            uid: None,
3913            route: None,
3914            extras: Extras::new(),
3915        };
3916        let mut branches = [
3917            branch(&self.windings[0], zs[0]),
3918            branch(&self.windings[1], zs[1]),
3919            branch(&self.windings[2], zs[2]),
3920        ];
3921        let windings_in_service = self.winding_in_service();
3922        let independent = self.extras.get("psse_zcod").and_then(Value::as_i64) != Some(1);
3923        let pairwise = self.z.map(|z| [z.r, z.x]);
3924        let nominal = current_correction_nominal::<[[f64; 2]; 3]>(&self.extras, &pairwise);
3925        for (k, branch) in branches.iter_mut().enumerate() {
3926            branch.in_service &= windings_in_service[k];
3927            branch.rating_sets = self.winding_rating_sets(k);
3928            let Some(correction) = self.winding_impedance_correction(k).filter(|_| independent)
3929            else {
3930                continue;
3931            };
3932            branch.set_impedance_correction(Some(correction));
3933            if let Some(z) = nominal {
3934                // Star leg k from the pairwise nominal: (Z_ka + Z_kb - Z_ab) / 2.
3935                let (a, b, c) = [(0, 2, 1), (0, 1, 2), (1, 2, 0)][k];
3936                let leg: [f64; 2] =
3937                    std::array::from_fn(|part| (z[a][part] + z[b][part] - z[c][part]) / 2.0);
3938                branch.extras.insert(
3939                    IMPEDANCE_CORRECTION_NOMINAL_EXTRA.into(),
3940                    serde_json::json!(leg),
3941                );
3942                branch.extras.insert(
3943                    IMPEDANCE_CORRECTION_APPLIED_EXTRA.into(),
3944                    serde_json::json!([branch.r, branch.x]),
3945                );
3946            }
3947        }
3948        (star, branches)
3949    }
3950}
3951
3952/// The MATPOWER gen capability / ramp columns past `PMIN`, in order. The index
3953/// into this array is the slot index into a [`GenCaps`].
3954pub const GEN_EXTRA_KEYS: [&str; 11] = [
3955    "pc1", "pc2", "qc1min", "qc1max", "qc2min", "qc2max", "ramp_agc", "ramp_10", "ramp_30",
3956    "ramp_q", "apf",
3957];
3958
3959/// One value-domain scan finding, internal to the diagnostic and repair
3960/// passes: an element field whose value falls outside its physical range,
3961/// paired with the value the repair sets in its place. The public shapes are
3962/// the coded [`Diagnostic`](crate::Diagnostic) records
3963/// [`BalancedNetwork::validate_values`] returns and the history entry
3964/// [`repair_values`] appends.
3965#[derive(Debug, Clone, PartialEq)]
3966pub(crate) struct ValueFinding {
3967    /// Human-readable element locator, e.g. `"bus 3"` or `"generator at bus 5"`.
3968    pub element: String,
3969    /// The top level JSON field the element serializes under, e.g.
3970    /// `"buses"`. Paired with `index`, this names the finding's RFC 6901
3971    /// target, so it must match [`BalancedNetwork`]'s own field name (the
3972    /// stored module writes this network under `value.data`, unchanged).
3973    pub table: &'static str,
3974    /// The element's position in `table`, for the target — the array index,
3975    /// never the element's id, since an id can differ from its position.
3976    pub index: usize,
3977    pub field: &'static str,
3978    pub old: f64,
3979    pub new: f64,
3980    pub reason: &'static str,
3981}
3982
3983impl ValueFinding {
3984    /// The finding as the coded record: target is an RFC 6901 pointer to the
3985    /// field within the stored document's `value.data`, details carry the
3986    /// other machine readable pieces, and the message stays prose.
3987    pub(crate) fn into_diagnostic(self) -> crate::Diagnostic {
3988        let mut details = serde_json::Map::new();
3989        details.insert("element".to_owned(), serde_json::json!(self.element));
3990        details.insert("field".to_owned(), serde_json::json!(self.field));
3991        details.insert("value".to_owned(), serde_json::json!(self.old));
3992        details.insert("repaired_value".to_owned(), serde_json::json!(self.new));
3993        details.insert("reason".to_owned(), serde_json::json!(self.reason));
3994        crate::Diagnostic::of(
3995            &crate::diagnostics::codes::VALIDATE_BALANCED_VALUE_DOMAIN,
3996            format!(
3997                "{}: `{}` is {} ({}); the repair sets {}",
3998                self.element, self.field, self.old, self.reason, self.new
3999            ),
4000        )
4001        .with_target(format!("/{}/{}/{}", self.table, self.index, self.field))
4002        .expect("scan-built targets are nonempty and bounded")
4003        .with_details(details)
4004        .expect("scan-built details stay within the record bounds")
4005    }
4006}
4007
4008/// Clamp every out-of-domain value of a parsed module to its repaired value
4009/// and record the pass: one `Repair` history entry naming each change in its
4010/// parameters, and one `VALIDATE.BALANCED.VALUE_DOMAIN` finding per repaired
4011/// field. The retained source is severed — the value no longer matches the
4012/// bytes, so a same format write serializes the repaired network rather than
4013/// echoing the input. A module already in domain comes back unchanged.
4014///
4015/// # Errors
4016/// Never on scan output: the record constructors refuse only unbounded
4017/// caller data, and the scan is bounded by the model.
4018pub fn repair_values(
4019    module: powerio_core::PioModule<BalancedNetwork>,
4020) -> std::result::Result<powerio_core::PioModule<BalancedNetwork>, powerio_core::Error> {
4021    let repair_ordinal = module
4022        .history()
4023        .iter()
4024        .filter(|entry| entry.kind() == powerio_core::HistoryKind::Repair)
4025        .count();
4026    let mut network_findings = Vec::new();
4027    let mut module = module.map_value(|mut network| {
4028        network_findings = network.repair_in_place();
4029        network
4030    });
4031    if network_findings.is_empty() {
4032        return Ok(module);
4033    }
4034    let mut parameters = std::collections::BTreeMap::new();
4035    parameters.insert(
4036        "repairs".to_owned(),
4037        serde_json::json!(
4038            network_findings
4039                .iter()
4040                .map(|finding| {
4041                    serde_json::json!({
4042                        "element": finding.element,
4043                        "field": finding.field,
4044                        "value": finding.old,
4045                        "repaired_value": finding.new,
4046                    })
4047                })
4048                .collect::<Vec<_>>()
4049        ),
4050    );
4051    let entry = powerio_core::HistoryEntry::new(
4052        powerio_core::HistoryId::new(format!("repair{repair_ordinal}"))?,
4053        powerio_core::HistoryKind::Repair,
4054        "value_domain_repair",
4055    )?
4056    .with_parameters(parameters)?;
4057    module.add_history_entry(entry)?;
4058    for finding in network_findings {
4059        module.add_diagnostic(finding.into_diagnostic())?;
4060    }
4061    module = module.sever_source();
4062    Ok(module)
4063}
4064
4065/// Voltage magnitude (p.u.) repair: non-positive or above 2 (or non-finite) → 1.0.
4066/// A zero magnitude is treated as out of domain (a de-energized placeholder), not
4067/// a valid 0 p.u.
4068fn repair_vm(vm: f64) -> Option<f64> {
4069    (!vm.is_finite() || vm <= 0.0 || vm > 2.0).then_some(1.0)
4070}
4071
4072/// Voltage angle (degrees) repair: `|va| > 2000` (or non-finite) → 0.0.
4073fn repair_va(va: f64) -> Option<f64> {
4074    (!va.is_finite() || va.abs() > 2000.0).then_some(0.0)
4075}
4076
4077/// Generator MVA base repair: non-positive (or non-finite) → the system base.
4078fn repair_mbase(mbase: f64, sbase: f64) -> Option<f64> {
4079    (!mbase.is_finite() || mbase <= 0.0).then_some(sbase)
4080}
4081
4082/// Generator voltage setpoint (p.u.) repair: non-positive (or non-finite) → 1.0.
4083fn repair_vg(vg: f64) -> Option<f64> {
4084    (!vg.is_finite() || vg <= 0.0).then_some(1.0)
4085}
4086
4087/// The three element counts the star lowering changes, from
4088/// [`BalancedNetwork::lowered_lengths`]. Every other family keeps its length, since the
4089/// lowering only appends a star bus, its winding branches, and a magnetizing
4090/// shunt.
4091#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4092pub(crate) struct LoweredLengths {
4093    pub(crate) buses: usize,
4094    pub(crate) branches: usize,
4095    pub(crate) shunts: usize,
4096}
4097
4098impl BalancedNetwork {
4099    #[must_use]
4100    pub fn new(name: impl Into<String>, base_mva: f64) -> BalancedNetwork {
4101        BalancedNetwork::from_tables(BalancedNetworkTables {
4102            name: name.into(),
4103            base_mva,
4104            base_frequency: DEFAULT_BASE_FREQUENCY,
4105            geo: None,
4106            case_metadata: CaseMetadata::default(),
4107            detailed_connectivity: None,
4108            generated_uids: std::sync::Arc::default(),
4109            buses: Vec::new().into(),
4110            loads: Vec::new().into(),
4111            shunts: Vec::new().into(),
4112            static_var_compensators: Vec::new().into(),
4113            branches: Vec::new().into(),
4114            switches: Vec::new().into(),
4115            generators: Vec::new().into(),
4116            storage: Vec::new().into(),
4117            hvdc: Vec::new().into(),
4118            transformers_3w: Vec::new().into(),
4119            areas: Vec::new().into(),
4120            solver: None,
4121            source_format: SourceFormat::InMemory,
4122        })
4123    }
4124
4125    /// A network assembled in memory from buses and branches, with no loads,
4126    /// shunts, generators, storage, HVDC, or retained source document. Synthetic
4127    /// topology generators and tests use it instead of repeating the struct
4128    /// literal. The caller owns reference integrity (run `check_references` if
4129    /// the ids might be inconsistent).
4130    #[must_use]
4131    pub fn in_memory(
4132        name: impl Into<String>,
4133        base_mva: f64,
4134        buses: Vec<Bus>,
4135        branches: Vec<Branch>,
4136    ) -> BalancedNetwork {
4137        let mut net = Self::new(name, base_mva);
4138        *net.buses_mut() = buses;
4139        *net.branches_mut() = branches;
4140        net.assign_missing_component_ids();
4141        net
4142    }
4143
4144    /// Whether this is a normalized (per-unit, radian, filtered)
4145    /// derived product from [`to_normalized`](BalancedNetwork::to_normalized), rather
4146    /// than a raw network at the file's unit basis. Unit-sensitive code that
4147    /// takes a `&BalancedNetwork` can check this instead of silently assuming MW.
4148    #[must_use]
4149    pub fn is_normalized(&self) -> bool {
4150        self.source_format() == SourceFormat::Normalized
4151    }
4152
4153    /// Error unless `base_mva` is a positive, finite number. It is every
4154    /// per-unit divisor, so a malformed base would otherwise silently poison
4155    /// downstream values with `NaN`/`Inf` or flipped signs. The per-unit
4156    /// consumers ([`to_normalized`](BalancedNetwork::to_normalized), the gridfm
4157    /// export) call this; any other unit-sensitive consumer should too.
4158    pub fn check_base_mva(&self) -> crate::Result<()> {
4159        if self.base_mva().is_finite() && self.base_mva() > 0.0 {
4160            Ok(())
4161        } else {
4162            Err(crate::Error::InvalidBaseMva {
4163                base: self.base_mva(),
4164            })
4165        }
4166    }
4167
4168    /// Report element fields whose values fall outside their physical domain,
4169    /// without changing anything, as coded `VALIDATE.BALANCED.VALUE_DOMAIN`
4170    /// findings. Each record targets the element and carries the field, the
4171    /// current value, the value a repair would set, and why in details.
4172    ///
4173    /// This generalizes the per-reader value clamps (a bus voltage magnitude
4174    /// outside `[0, 2]`, an angle past `±2000°`, a zero generator MVA base or
4175    /// voltage setpoint) into one pass any consumer can run, separate from the
4176    /// structural [`validate`](BalancedNetwork::validate) (which only checks
4177    /// ids and references). It is non-mutating; [`repair_values`] applies the
4178    /// fixes to a parsed module and records them.
4179    #[must_use]
4180    pub fn validate_values(&self) -> Vec<crate::Diagnostic> {
4181        self.value_findings()
4182            .into_iter()
4183            .map(ValueFinding::into_diagnostic)
4184            .collect()
4185    }
4186
4187    pub(crate) fn value_findings(&self) -> Vec<ValueFinding> {
4188        let mut out = Vec::new();
4189        for (index, b) in self.buses().iter().enumerate() {
4190            if let Some(new) = repair_vm(b.vm) {
4191                out.push(ValueFinding {
4192                    element: format!("bus {}", b.id),
4193                    table: "buses",
4194                    index,
4195                    field: "vm",
4196                    old: b.vm,
4197                    new,
4198                    reason: "voltage magnitude outside [0, 2] p.u.",
4199                });
4200            }
4201            if let Some(new) = repair_va(b.va) {
4202                out.push(ValueFinding {
4203                    element: format!("bus {}", b.id),
4204                    table: "buses",
4205                    index,
4206                    field: "va",
4207                    old: b.va,
4208                    new,
4209                    reason: "voltage angle outside ±2000°",
4210                });
4211            }
4212        }
4213        for (index, g) in self.generators().iter().enumerate() {
4214            if let Some(new) = repair_mbase(g.mbase, self.base_mva()) {
4215                out.push(ValueFinding {
4216                    element: format!("generator at bus {}", g.bus),
4217                    table: "generators",
4218                    index,
4219                    field: "mbase",
4220                    old: g.mbase,
4221                    new,
4222                    reason: "non-positive generator MVA base",
4223                });
4224            }
4225            if let Some(new) = repair_vg(g.vg) {
4226                out.push(ValueFinding {
4227                    element: format!("generator at bus {}", g.bus),
4228                    table: "generators",
4229                    index,
4230                    field: "vg",
4231                    old: g.vg,
4232                    new,
4233                    reason: "non-positive voltage setpoint",
4234                });
4235            }
4236        }
4237        out
4238    }
4239
4240    /// Clamp every out-of-domain value to its repaired value (the same rules
4241    /// [`validate_values`](BalancedNetwork::validate_values) reports), returning the list
4242    /// of changes made. A second call returns an empty list (the values are now
4243    /// in domain). Crate private: the recorded public path is
4244    /// [`repair_values`], which appends the history entry and severs the
4245    /// retained source echo the mutation invalidates.
4246    pub(crate) fn repair_in_place(&mut self) -> Vec<ValueFinding> {
4247        let findings = self.value_findings();
4248        let sbase = self.base_mva();
4249        for b in self.buses_mut() {
4250            if let Some(new) = repair_vm(b.vm) {
4251                b.vm = new;
4252            }
4253            if let Some(new) = repair_va(b.va) {
4254                b.va = new;
4255            }
4256        }
4257        for g in self.generators_mut() {
4258            if let Some(new) = repair_mbase(g.mbase, sbase) {
4259                g.mbase = new;
4260            }
4261            if let Some(new) = repair_vg(g.vg) {
4262                g.vg = new;
4263            }
4264        }
4265        findings
4266    }
4267
4268    /// The element counts [`Self::expand_transformers_3w`] would produce, read off
4269    /// the transformer records instead of building the lowering. A caller that
4270    /// only needs the lowered lengths — sizing a per-row map, say — would
4271    /// otherwise pay a whole `BalancedNetwork` clone for three `len()` calls.
4272    /// `lowered_lengths_match_the_expansion` pins the two against each other.
4273    pub(crate) fn lowered_lengths(&self) -> LoweredLengths {
4274        let mut lengths = LoweredLengths {
4275            buses: self.buses().len(),
4276            branches: self.branches().len(),
4277            shunts: self.shunts().len(),
4278        };
4279        for t in self.transformers_3w().iter().filter(|t| t.in_service) {
4280            lengths.buses += 1;
4281            lengths.branches += 3;
4282            if t.mag_g != 0.0 || t.mag_b != 0.0 {
4283                lengths.shunts += 1;
4284            }
4285        }
4286        lengths
4287    }
4288
4289    /// A bus-branch lowering of the network for analysis: each in-service
4290    /// 3-winding transformer becomes a synthetic star bus, its three winding
4291    /// branches, and (when present) its magnetizing shunt, so the matrix builders
4292    /// and connectivity see it. Returns the network unchanged (borrowed) when
4293    /// no 3-winding transformer is in service, so a network without one, or
4294    /// with only out-of-service units, allocates nothing.
4295    ///
4296    /// The canonical `BalancedNetwork` keeps the typed [`Transformer3W`] records; this is
4297    /// the derived analysis form that [`IndexedNetwork`](crate::IndexedNetwork)
4298    /// builds behind the scenes, so callers never see the synthetic buses in the
4299    /// model they read or write.
4300    pub(crate) fn expand_transformers_3w(&self) -> std::borrow::Cow<'_, BalancedNetwork> {
4301        if !self.transformers_3w().iter().any(|t| t.in_service) {
4302            return std::borrow::Cow::Borrowed(self);
4303        }
4304        let mut net = self.clone();
4305        // The star branches carry per-unit impedance (CZ = 1), the same convention
4306        // the matrix builders read straight off a branch, so no rebasing. The
4307        // magnetizing shunt is an admittance, so it scales like every other shunt:
4308        // by the per-unit base for a raw network, by 1 for a normalized one.
4309        let scale = if net.is_normalized() {
4310            1.0
4311        } else {
4312            net.base_mva()
4313        };
4314        // check_references refuses bus ids without headroom for these
4315        // synthetic ids on every parse path; the checked arithmetic turns a
4316        // programmatic caller's overflow into a loud panic instead of a
4317        // wrapped id aliasing an existing bus.
4318        let base_id = net
4319            .buses()
4320            .iter()
4321            .map(|b| b.id.0)
4322            .max()
4323            .unwrap_or(0)
4324            .checked_add(1)
4325            .expect("bus id space exhausted for star expansion");
4326        for (k, (source_row, t)) in self
4327            .transformers_3w()
4328            .iter()
4329            .enumerate()
4330            .filter(|(_, t)| t.in_service)
4331            .enumerate()
4332        {
4333            let star_id = BusId(
4334                base_id
4335                    .checked_add(k)
4336                    .expect("bus id space exhausted for star expansion"),
4337            );
4338            let (star, mut branches) = t.to_star_expansion(star_id);
4339            let transformer_identity = t
4340                .uid
4341                .clone()
4342                .unwrap_or_else(|| format!("transformers_3w:{source_row}"));
4343            for (winding, branch) in branches.iter_mut().enumerate() {
4344                branch.uid = Some(format!("{transformer_identity}/winding:{}", winding + 1));
4345            }
4346            net.buses_mut().push(star);
4347            net.branches_mut().extend(branches);
4348            if t.mag_g != 0.0 || t.mag_b != 0.0 {
4349                net.shunts_mut().push(Shunt {
4350                    bus: star_id,
4351                    g: t.mag_g * scale,
4352                    b: t.mag_b * scale,
4353                    in_service: true,
4354                    section_count: None,
4355                    control: None,
4356                    uid: None,
4357                    extras: Extras::new(),
4358                });
4359            }
4360        }
4361        net.transformers_3w_mut().clear();
4362        std::borrow::Cow::Owned(net)
4363    }
4364
4365    /// Check structural integrity: bus ids are unique and every element
4366    /// references an existing bus. File readers run this; a `BalancedNetwork`
4367    /// built by hand (or mutated, e.g. by a scenario generator) should call it
4368    /// before handing the network to
4369    /// [`IndexedNetwork`](crate::IndexedNetwork), whose dense indexing assumes it.
4370    pub fn validate(&self) -> crate::Result<()> {
4371        self.check_references("network")
4372    }
4373
4374    /// Error if two buses share an id, or if any element references a bus that
4375    /// doesn't exist. Readers call this after parsing so a missing/garbled id
4376    /// (which would otherwise default to a placeholder and silently re-wire the
4377    /// network) fails loudly instead.
4378    #[allow(clippy::too_many_lines)]
4379    pub(crate) fn check_references(&self, format: &'static str) -> crate::Result<()> {
4380        // HashSet, not BTreeSet: building the id set and probing it once per branch
4381        // endpoint / load / shunt / gen is the dominant cost of a large parse, and
4382        // a BTreeSet pays a log-n pointer-chasing probe each time. Pre-size to skip
4383        // rehashing.
4384        let mut ids = std::collections::HashSet::with_capacity(self.buses().len());
4385        for b in self.buses() {
4386            // The readers parse ids through `as usize`, which saturates rather
4387            // than failing, and the C ABI reports them as int64. Two distinct
4388            // ids above the ceiling would read as one value there, so a
4389            // branch endpoint would match two bus rows.
4390            if b.id > BusId::MAX {
4391                return Err(Error::FormatRead {
4392                    format,
4393                    message: format!("bus id {} is outside the int64 id space", b.id),
4394                });
4395            }
4396            if !ids.insert(b.id) {
4397                return Err(Error::FormatRead {
4398                    format,
4399                    message: format!("duplicate bus id {}", b.id),
4400                });
4401            }
4402        }
4403        let check = |bus: BusId, what: &str| -> crate::Result<()> {
4404            if ids.contains(&bus) {
4405                Ok(())
4406            } else {
4407                Err(Error::FormatRead {
4408                    format,
4409                    message: format!("{what} references unknown bus {bus}"),
4410                })
4411            }
4412        };
4413        // Format the context only on the error path, not once per branch.
4414        for (i, br) in self.branches().iter().enumerate() {
4415            for bus in [br.from, br.to] {
4416                if !ids.contains(&bus) {
4417                    return Err(Error::FormatRead {
4418                        format,
4419                        message: format!("branch {i} references unknown bus {bus}"),
4420                    });
4421                }
4422            }
4423            if let Some(control) = br.control.as_ref() {
4424                if control.controlled_bus_on_winding_side
4425                    && control.controlled_bus.is_none_or(|bus| bus.0 == 0)
4426                {
4427                    return Err(Error::FormatRead {
4428                        format,
4429                        message: format!(
4430                            "transformer control on branch {i} marks its controlled bus as lying on the winding side but has no nonzero controlled bus"
4431                        ),
4432                    });
4433                }
4434                if let Some(bus) = control.controlled_bus {
4435                    check(bus, "transformer control")?;
4436                }
4437            }
4438        }
4439        for (i, sw) in self.switches().iter().enumerate() {
4440            for bus in [sw.from, sw.to] {
4441                if !ids.contains(&bus) {
4442                    return Err(Error::FormatRead {
4443                        format,
4444                        message: format!("switch {i} references unknown bus {bus}"),
4445                    });
4446                }
4447            }
4448        }
4449        for l in self.loads() {
4450            check(l.bus, "load")?;
4451        }
4452        for s in self.shunts() {
4453            check(s.bus, "shunt")?;
4454            if let Some(bus) = s.control.as_ref().and_then(|c| c.control_bus) {
4455                check(bus, "switched-shunt control")?;
4456            }
4457        }
4458        for svc in self.static_var_compensators() {
4459            check(svc.bus, "static VAR compensator")?;
4460        }
4461        for g in self.generators() {
4462            check(g.bus, "generator")?;
4463            if let Some(bus) = g.regulated_bus {
4464                check(bus, "generator voltage control")?;
4465            }
4466        }
4467        for d in self.hvdc() {
4468            check(d.from, "dcline")?;
4469            check(d.to, "dcline")?;
4470        }
4471        for s in self.storage() {
4472            check(s.bus, "storage")?;
4473        }
4474        for a in self.areas() {
4475            if let Some(slack) = a.slack_bus {
4476                check(slack, "area swing")?;
4477            }
4478        }
4479        for t in self.transformers_3w() {
4480            for w in &t.windings {
4481                check(w.bus, "3-winding transformer")?;
4482                if let Some(control) = w.control.as_ref() {
4483                    if control.controlled_bus_on_winding_side
4484                        && control.controlled_bus.is_none_or(|bus| bus.0 == 0)
4485                    {
4486                        return Err(Error::FormatRead {
4487                            format,
4488                            message: format!(
4489                                "3-winding transformer control at bus {} marks its controlled bus as lying on the winding side but has no nonzero controlled bus",
4490                                w.bus
4491                            ),
4492                        });
4493                    }
4494                    if let Some(bus) = control.controlled_bus {
4495                        check(bus, "3-winding transformer control")?;
4496                    }
4497                }
4498            }
4499        }
4500        if let Some(detailed) = self.detailed_connectivity().as_ref() {
4501            let nodes = detailed
4502                .connectivity_nodes
4503                .iter()
4504                .map(|node| &node.component)
4505                .collect::<std::collections::HashSet<_>>();
4506            for calculated in &detailed.calculated_buses {
4507                check(calculated.calculated_bus, "calculated bus")?;
4508                if calculated.nodes.is_empty() {
4509                    return Err(Error::FormatRead {
4510                        format,
4511                        message: "calculated bus has no connectivity nodes".into(),
4512                    });
4513                }
4514                for node in &calculated.nodes {
4515                    if !nodes.contains(node) {
4516                        return Err(Error::FormatRead {
4517                            format,
4518                            message: format!(
4519                                "calculated bus references unknown connectivity node `{}`",
4520                                node.local_id()
4521                            ),
4522                        });
4523                    }
4524                }
4525            }
4526            let declared_components = detailed
4527                .component_metadata
4528                .iter()
4529                .map(|metadata| &metadata.component)
4530                .chain(
4531                    detailed
4532                        .substations
4533                        .iter()
4534                        .map(|substation| &substation.component),
4535                )
4536                .chain(detailed.voltage_levels.iter().map(|level| &level.component))
4537                .chain(
4538                    detailed
4539                        .dc_converter_units
4540                        .iter()
4541                        .map(|unit| &unit.component),
4542                )
4543                .collect::<std::collections::HashSet<_>>();
4544            for metadata in &detailed.component_metadata {
4545                if let Some(container) = metadata.equipment_container.as_ref()
4546                    && !declared_components.contains(container)
4547                {
4548                    return Err(Error::FormatRead {
4549                        format,
4550                        message: format!(
4551                            "component `{}` references unknown equipment container `{container}`",
4552                            metadata.component
4553                        ),
4554                    });
4555                }
4556            }
4557            let mut junctions = std::collections::HashSet::new();
4558            let terminal_equipment: std::collections::HashSet<&ComponentId> = detailed
4559                .terminals
4560                .iter()
4561                .map(|terminal| &terminal.equipment)
4562                .collect();
4563            for junction in &detailed.junctions {
4564                if !junctions.insert(&junction.component) {
4565                    return Err(Error::FormatRead {
4566                        format,
4567                        message: format!("duplicate Junction `{}`", junction.component.local_id()),
4568                    });
4569                }
4570                if !declared_components.contains(&junction.component) {
4571                    return Err(Error::FormatRead {
4572                        format,
4573                        message: format!(
4574                            "Junction `{}` has no component metadata",
4575                            junction.component.local_id()
4576                        ),
4577                    });
4578                }
4579                if !terminal_equipment.contains(&junction.component) {
4580                    return Err(Error::FormatRead {
4581                        format,
4582                        message: format!(
4583                            "Junction `{}` has no terminal",
4584                            junction.component.local_id()
4585                        ),
4586                    });
4587                }
4588            }
4589        }
4590        self.check_detailed_ac_references(format, &ids)?;
4591        self.check_detailed_dc_references(format)?;
4592        self.check_star_expansion_headroom(format)
4593    }
4594
4595    #[allow(clippy::too_many_lines)]
4596    fn check_detailed_ac_references(
4597        &self,
4598        format: &'static str,
4599        balanced_buses: &std::collections::HashSet<BusId>,
4600    ) -> crate::Result<()> {
4601        let Some(detailed) = self.detailed_connectivity().as_ref() else {
4602            return Ok(());
4603        };
4604        let error = |message: String| Error::FormatRead { format, message };
4605
4606        let mut metadata_components = std::collections::HashSet::new();
4607        for metadata in &detailed.component_metadata {
4608            if !metadata_components.insert(metadata.component.clone()) {
4609                return Err(error(format!(
4610                    "duplicate component metadata `{}`",
4611                    metadata.component
4612                )));
4613            }
4614        }
4615
4616        let mut substations = std::collections::HashSet::new();
4617        for substation in &detailed.substations {
4618            if !substations.insert(substation.component.clone()) {
4619                return Err(error(format!(
4620                    "duplicate substation `{}`",
4621                    substation.component
4622                )));
4623            }
4624        }
4625
4626        let mut voltage_levels = std::collections::HashSet::new();
4627        let mut voltage_level_topology = std::collections::HashMap::new();
4628        let mut calculated_bus_levels = std::collections::HashMap::new();
4629        for level in &detailed.voltage_levels {
4630            if !voltage_levels.insert(level.component.clone()) {
4631                return Err(error(format!(
4632                    "duplicate voltage level `{}`",
4633                    level.component
4634                )));
4635            }
4636            voltage_level_topology.insert(level.component.clone(), level.topology_kind);
4637            if let Some(substation) = &level.substation
4638                && !substations.contains(substation)
4639            {
4640                return Err(error(format!(
4641                    "voltage level `{}` references unknown substation `{substation}`",
4642                    level.component
4643                )));
4644            }
4645            let mut level_buses = std::collections::HashSet::new();
4646            for bus in &level.buses {
4647                if !balanced_buses.contains(bus) {
4648                    return Err(error(format!(
4649                        "voltage level `{}` references unknown calculated bus {bus}",
4650                        level.component
4651                    )));
4652                }
4653                if !level_buses.insert(*bus) {
4654                    return Err(error(format!(
4655                        "voltage level `{}` repeats calculated bus {bus}",
4656                        level.component
4657                    )));
4658                }
4659                if let Some(first_level) =
4660                    calculated_bus_levels.insert(*bus, level.component.clone())
4661                    && first_level != level.component
4662                {
4663                    return Err(error(format!(
4664                        "calculated bus {bus} belongs to both voltage levels `{first_level}` and `{}`",
4665                        level.component
4666                    )));
4667                }
4668            }
4669        }
4670        let known_container = |container: &ComponentId| {
4671            voltage_levels.contains(container) || metadata_components.contains(container)
4672        };
4673
4674        let mut configured_buses = std::collections::HashSet::new();
4675        let mut configured_bus_levels = std::collections::HashMap::new();
4676        for bus in &detailed.bus_breaker_buses {
4677            if !configured_buses.insert(bus.component.clone()) {
4678                return Err(error(format!(
4679                    "duplicate bus breaker bus `{}`",
4680                    bus.component
4681                )));
4682            }
4683            if !known_container(&bus.voltage_level) {
4684                let detail = if bus.calculated_bus.is_none() {
4685                    " has no calculated bus and"
4686                } else {
4687                    ""
4688                };
4689                return Err(error(format!(
4690                    "TopologicalNode `{}`{detail} references unknown voltage level or connectivity container `{}`",
4691                    bus.component, bus.voltage_level
4692                )));
4693            }
4694            configured_bus_levels.insert(bus.component.clone(), bus.voltage_level.clone());
4695            if let Some(calculated) = bus.calculated_bus
4696                && !balanced_buses.contains(&calculated)
4697            {
4698                return Err(error(format!(
4699                    "bus breaker bus `{}` references unknown calculated bus {calculated}",
4700                    bus.component
4701                )));
4702            }
4703            if voltage_levels.contains(&bus.voltage_level)
4704                && let Some(calculated) = bus.calculated_bus
4705                && let Some(first_level) =
4706                    calculated_bus_levels.insert(calculated, bus.voltage_level.clone())
4707                && first_level != bus.voltage_level
4708            {
4709                return Err(error(format!(
4710                    "calculated bus {calculated} belongs to both connectivity containers `{first_level}` and `{}`",
4711                    bus.voltage_level
4712                )));
4713            }
4714        }
4715
4716        let mut connectivity_nodes = std::collections::HashSet::new();
4717        let mut connectivity_node_levels = std::collections::HashMap::new();
4718        let mut connectivity_node_calculated_buses = std::collections::HashMap::new();
4719        for node in &detailed.connectivity_nodes {
4720            if !connectivity_nodes.insert(node.component.clone()) {
4721                return Err(error(format!(
4722                    "duplicate connectivity node `{}`",
4723                    node.component
4724                )));
4725            }
4726            if !known_container(&node.voltage_level) {
4727                let detail = if node.calculated_bus.is_none() {
4728                    " has no calculated bus and"
4729                } else {
4730                    ""
4731                };
4732                return Err(error(format!(
4733                    "ConnectivityNode `{}`{detail} references unknown voltage level or connectivity container `{}`",
4734                    node.component, node.voltage_level
4735                )));
4736            }
4737            connectivity_node_levels.insert(node.component.clone(), node.voltage_level.clone());
4738            connectivity_node_calculated_buses.insert(node.component.clone(), node.calculated_bus);
4739            if let Some(calculated) = node.calculated_bus
4740                && !balanced_buses.contains(&calculated)
4741            {
4742                return Err(error(format!(
4743                    "connectivity node `{}` references unknown calculated bus {calculated}",
4744                    node.component
4745                )));
4746            }
4747            if voltage_levels.contains(&node.voltage_level)
4748                && let Some(calculated) = node.calculated_bus
4749                && let Some(first_level) =
4750                    calculated_bus_levels.insert(calculated, node.voltage_level.clone())
4751                && first_level != node.voltage_level
4752            {
4753                return Err(error(format!(
4754                    "calculated bus {calculated} belongs to both connectivity containers `{first_level}` and `{}`",
4755                    node.voltage_level
4756                )));
4757            }
4758        }
4759
4760        let mut calculated_buses = std::collections::HashSet::new();
4761        let mut calculated_bus_nodes = std::collections::HashMap::new();
4762        for calculated in &detailed.calculated_buses {
4763            if !calculated_buses.insert(calculated.calculated_bus) {
4764                return Err(error(format!(
4765                    "duplicate calculated bus {} in detailed connectivity",
4766                    calculated.calculated_bus
4767                )));
4768            }
4769            if !balanced_buses.contains(&calculated.calculated_bus) {
4770                return Err(error(format!(
4771                    "detailed connectivity references unknown calculated bus {}",
4772                    calculated.calculated_bus
4773                )));
4774            }
4775            if !known_container(&calculated.voltage_level) {
4776                return Err(error(format!(
4777                    "calculated bus {} references unknown voltage level or connectivity container `{}`",
4778                    calculated.calculated_bus, calculated.voltage_level
4779                )));
4780            }
4781            if voltage_levels.contains(&calculated.voltage_level)
4782                && let Some(first_level) = calculated_bus_levels
4783                    .insert(calculated.calculated_bus, calculated.voltage_level.clone())
4784                && first_level != calculated.voltage_level
4785            {
4786                return Err(error(format!(
4787                    "calculated bus {} belongs to both connectivity containers `{first_level}` and `{}`",
4788                    calculated.calculated_bus, calculated.voltage_level
4789                )));
4790            }
4791            for node in &calculated.nodes {
4792                let Some(node_level) = connectivity_node_levels.get(node) else {
4793                    return Err(error(format!(
4794                        "calculated bus {} references unknown connectivity node `{node}`",
4795                        calculated.calculated_bus
4796                    )));
4797                };
4798                if node_level != &calculated.voltage_level {
4799                    return Err(error(format!(
4800                        "calculated bus {} in `{}` contains connectivity node `{node}` from `{node_level}`",
4801                        calculated.calculated_bus, calculated.voltage_level
4802                    )));
4803                }
4804                if let Some(Some(node_bus)) = connectivity_node_calculated_buses.get(node)
4805                    && *node_bus != calculated.calculated_bus
4806                {
4807                    return Err(error(format!(
4808                        "connectivity node `{node}` names calculated bus {node_bus}, but calculated bus {} also claims that node",
4809                        calculated.calculated_bus
4810                    )));
4811                }
4812                if let Some(first_bus) =
4813                    calculated_bus_nodes.insert(node.clone(), calculated.calculated_bus)
4814                {
4815                    return Err(error(format!(
4816                        "connectivity node `{node}` is listed by calculated buses {first_bus} and {}",
4817                        calculated.calculated_bus
4818                    )));
4819                }
4820            }
4821        }
4822
4823        let mut busbars = std::collections::HashSet::new();
4824        for busbar in &detailed.busbar_sections {
4825            if !busbars.insert(busbar.component.clone()) {
4826                return Err(error(format!(
4827                    "duplicate busbar section `{}`",
4828                    busbar.component
4829                )));
4830            }
4831            if !known_container(&busbar.voltage_level) {
4832                return Err(error(format!(
4833                    "busbar section `{}` references unknown voltage level or connectivity container `{}`",
4834                    busbar.component, busbar.voltage_level
4835                )));
4836            }
4837            let Some(node_level) = connectivity_node_levels.get(&busbar.node) else {
4838                return Err(error(format!(
4839                    "busbar section `{}` references unknown connectivity node `{}`",
4840                    busbar.component, busbar.node
4841                )));
4842            };
4843            if voltage_levels.contains(&busbar.voltage_level)
4844                && voltage_levels.contains(node_level)
4845                && node_level != &busbar.voltage_level
4846            {
4847                return Err(error(format!(
4848                    "busbar section `{}` in `{}` references connectivity node `{}` from `{node_level}`",
4849                    busbar.component, busbar.voltage_level, busbar.node
4850                )));
4851            }
4852        }
4853
4854        let mut terminal_components = std::collections::HashSet::new();
4855        let mut terminal_keys = std::collections::HashSet::new();
4856        for terminal in &detailed.terminals {
4857            if terminal.terminal == 0 {
4858                return Err(error(format!(
4859                    "equipment `{}` has terminal 0",
4860                    terminal.equipment
4861                )));
4862            }
4863            if !terminal_keys.insert((terminal.equipment.clone(), terminal.terminal)) {
4864                return Err(error(format!(
4865                    "equipment `{}` repeats terminal {}",
4866                    terminal.equipment, terminal.terminal
4867                )));
4868            }
4869            if let Some(component) = &terminal.component
4870                && !terminal_components.insert(component.clone())
4871            {
4872                return Err(error(format!("duplicate terminal identity `{component}`")));
4873            }
4874            if !known_container(&terminal.voltage_level) {
4875                return Err(error(format!(
4876                    "equipment `{}` terminal {} references unknown voltage level or connectivity container `{}`",
4877                    terminal.equipment, terminal.terminal, terminal.voltage_level
4878                )));
4879            }
4880            for bus in [terminal.bus.as_ref(), terminal.connectable_bus.as_ref()]
4881                .into_iter()
4882                .flatten()
4883            {
4884                let Some(bus_level) = configured_bus_levels.get(bus) else {
4885                    return Err(error(format!(
4886                        "equipment `{}` terminal {} references unknown bus breaker bus `{bus}`",
4887                        terminal.equipment, terminal.terminal
4888                    )));
4889                };
4890                if bus_level != &terminal.voltage_level {
4891                    return Err(error(format!(
4892                        "equipment `{}` terminal {} in `{}` references bus `{bus}` from `{bus_level}`",
4893                        terminal.equipment, terminal.terminal, terminal.voltage_level
4894                    )));
4895                }
4896            }
4897            if let Some(node) = &terminal.node {
4898                let Some(node_level) = connectivity_node_levels.get(node) else {
4899                    return Err(error(format!(
4900                        "equipment `{}` terminal {} references unknown connectivity node `{node}`",
4901                        terminal.equipment, terminal.terminal
4902                    )));
4903                };
4904                if node_level != &terminal.voltage_level {
4905                    return Err(error(format!(
4906                        "equipment `{}` terminal {} in `{}` references connectivity node `{node}` from `{node_level}`",
4907                        terminal.equipment, terminal.terminal, terminal.voltage_level
4908                    )));
4909                }
4910            }
4911        }
4912
4913        let mut switches = std::collections::HashSet::new();
4914        for switch in &detailed.switches {
4915            if !switches.insert(switch.component.clone()) {
4916                return Err(error(format!(
4917                    "duplicate topology switch `{}`",
4918                    switch.component
4919                )));
4920            }
4921            if !voltage_levels.contains(&switch.voltage_level)
4922                && !metadata_components.contains(&switch.voltage_level)
4923            {
4924                return Err(error(format!(
4925                    "topology switch `{}` references unknown voltage level or connectivity container `{}`",
4926                    switch.component, switch.voltage_level
4927                )));
4928            }
4929            for endpoint in [&switch.endpoint1, &switch.endpoint2] {
4930                match endpoint {
4931                    TopologyEndpoint::Bus(bus) => {
4932                        if !configured_bus_levels.contains_key(bus) {
4933                            return Err(error(format!(
4934                                "topology switch `{}` references unknown bus breaker bus `{bus}`",
4935                                switch.component
4936                            )));
4937                        }
4938                        if voltage_level_topology.get(&switch.voltage_level)
4939                            == Some(&TopologyKind::NodeBreaker)
4940                        {
4941                            return Err(error(format!(
4942                                "topology switch `{}` uses a bus endpoint in node breaker voltage level `{}`",
4943                                switch.component, switch.voltage_level
4944                            )));
4945                        }
4946                    }
4947                    TopologyEndpoint::Node(node) => {
4948                        if !connectivity_node_levels.contains_key(node) {
4949                            return Err(error(format!(
4950                                "topology switch `{}` references unknown connectivity node `{node}`",
4951                                switch.component
4952                            )));
4953                        }
4954                        if voltage_level_topology.get(&switch.voltage_level)
4955                            == Some(&TopologyKind::BusBreaker)
4956                        {
4957                            return Err(error(format!(
4958                                "topology switch `{}` uses a node endpoint in bus breaker voltage level `{}`",
4959                                switch.component, switch.voltage_level
4960                            )));
4961                        }
4962                    }
4963                }
4964            }
4965        }
4966
4967        for connection in &detailed.internal_connections {
4968            if !voltage_levels.contains(&connection.voltage_level) {
4969                return Err(error(format!(
4970                    "internal connection references unknown voltage level `{}`",
4971                    connection.voltage_level
4972                )));
4973            }
4974            for node in [&connection.node1, &connection.node2] {
4975                let Some(node_level) = connectivity_node_levels.get(node) else {
4976                    return Err(error(format!(
4977                        "internal connection references unknown connectivity node `{node}`"
4978                    )));
4979                };
4980                if node_level != &connection.voltage_level {
4981                    return Err(error(format!(
4982                        "internal connection in `{}` references connectivity node `{node}` from `{node_level}`",
4983                        connection.voltage_level
4984                    )));
4985                }
4986            }
4987        }
4988
4989        let mut tap_components = std::collections::HashSet::new();
4990        let mut tap_keys = std::collections::HashSet::new();
4991        for tap in &detailed.tap_changers {
4992            if tap.winding == 0 {
4993                return Err(error(format!(
4994                    "transformer `{}` has a tap changer on winding 0",
4995                    tap.transformer
4996                )));
4997            }
4998            let kind = match tap.kind {
4999                TapChangerKind::Ratio => 0_u8,
5000                TapChangerKind::Phase => 1_u8,
5001            };
5002            if !tap_keys.insert((tap.transformer.clone(), tap.winding, kind)) {
5003                return Err(error(format!(
5004                    "transformer `{}` repeats its {:?} tap changer on winding {}",
5005                    tap.transformer, tap.kind, tap.winding
5006                )));
5007            }
5008            if let Some(component) = &tap.component
5009                && !tap_components.insert(component.clone())
5010            {
5011                return Err(error(format!(
5012                    "duplicate tap changer identity `{component}`"
5013                )));
5014            }
5015            let mut positions = std::collections::HashSet::new();
5016            for step in &tap.steps {
5017                if !positions.insert(step.position) {
5018                    return Err(error(format!(
5019                        "transformer `{}` winding {} repeats tap position {}",
5020                        tap.transformer, tap.winding, step.position
5021                    )));
5022                }
5023            }
5024            if let Some(reference) = &tap.regulation_terminal
5025                && !terminal_keys.contains(&(reference.equipment.clone(), reference.terminal))
5026            {
5027                return Err(error(format!(
5028                    "transformer `{}` winding {} regulates unknown equipment terminal `{}` terminal {}",
5029                    tap.transformer, tap.winding, reference.equipment, reference.terminal
5030                )));
5031            }
5032        }
5033
5034        Ok(())
5035    }
5036
5037    #[allow(clippy::too_many_lines)]
5038    fn check_detailed_dc_references(&self, format: &'static str) -> crate::Result<()> {
5039        let Some(detailed) = self.detailed_connectivity().as_ref() else {
5040            return Ok(());
5041        };
5042
5043        let substations = detailed
5044            .substations
5045            .iter()
5046            .map(|substation| &substation.component)
5047            .collect::<std::collections::HashSet<_>>();
5048        let mut converter_units =
5049            std::collections::HashSet::with_capacity(detailed.dc_converter_units.len());
5050        for unit in &detailed.dc_converter_units {
5051            if !converter_units.insert(&unit.component) {
5052                return Err(Error::FormatRead {
5053                    format,
5054                    message: format!("duplicate DCConverterUnit `{}`", unit.component.local_id()),
5055                });
5056            }
5057            if let Some(substation) = unit.substation.as_ref()
5058                && !substations.contains(substation)
5059            {
5060                return Err(Error::FormatRead {
5061                    format,
5062                    message: format!(
5063                        "DCConverterUnit `{}` references unknown Substation `{}`",
5064                        unit.component.local_id(),
5065                        substation.local_id()
5066                    ),
5067                });
5068            }
5069        }
5070        let known_components = detailed
5071            .component_metadata
5072            .iter()
5073            .map(|metadata| &metadata.component)
5074            .chain(converter_units.iter().copied())
5075            .collect::<std::collections::HashSet<_>>();
5076
5077        let mut nodes = std::collections::HashSet::with_capacity(detailed.dc_nodes.len());
5078        for node in &detailed.dc_nodes {
5079            if !nodes.insert(&node.component) {
5080                return Err(Error::FormatRead {
5081                    format,
5082                    message: format!("duplicate DCNode `{}`", node.component.local_id()),
5083                });
5084            }
5085        }
5086        let mut topological_nodes =
5087            std::collections::HashSet::with_capacity(detailed.dc_topological_nodes.len());
5088        for node in &detailed.dc_topological_nodes {
5089            if !topological_nodes.insert(&node.component) {
5090                return Err(Error::FormatRead {
5091                    format,
5092                    message: format!(
5093                        "duplicate DCTopologicalNode `{}`",
5094                        node.component.local_id()
5095                    ),
5096                });
5097            }
5098        }
5099
5100        let check_unit = |class: &str,
5101                          component: &ComponentId,
5102                          unit: Option<&ComponentId>|
5103         -> crate::Result<()> {
5104            if let Some(unit) = unit
5105                && !converter_units.contains(unit)
5106            {
5107                return Err(Error::FormatRead {
5108                    format,
5109                    message: format!(
5110                        "{class} `{}` references unknown DCConverterUnit `{}`",
5111                        component.local_id(),
5112                        unit.local_id()
5113                    ),
5114                });
5115            }
5116            Ok(())
5117        };
5118        let check_container = |class: &str,
5119                               component: &ComponentId,
5120                               container: Option<&ComponentId>|
5121         -> crate::Result<()> {
5122            if let Some(container) = container
5123                && !known_components.contains(container)
5124            {
5125                return Err(Error::FormatRead {
5126                    format,
5127                    message: format!(
5128                        "{class} `{}` references unknown equipment container `{}`",
5129                        component.local_id(),
5130                        container.local_id()
5131                    ),
5132                });
5133            }
5134            Ok(())
5135        };
5136        for node in &detailed.dc_topological_nodes {
5137            check_unit(
5138                "DCTopologicalNode",
5139                &node.component,
5140                node.dc_converter_unit.as_ref(),
5141            )?;
5142        }
5143        for node in &detailed.dc_nodes {
5144            check_unit("DCNode", &node.component, node.dc_converter_unit.as_ref())?;
5145            if let Some(topological_node) = node.dc_topological_node.as_ref()
5146                && !topological_nodes.contains(topological_node)
5147            {
5148                return Err(Error::FormatRead {
5149                    format,
5150                    message: format!(
5151                        "DCNode `{}` references unknown DCTopologicalNode `{}`",
5152                        node.component.local_id(),
5153                        topological_node.local_id()
5154                    ),
5155                });
5156            }
5157        }
5158
5159        let mut terminal_ids = std::collections::HashSet::<ComponentId>::new();
5160        let mut check_terminal = |class: &str,
5161                                  equipment: &ComponentId,
5162                                  terminal: &DcTerminal|
5163         -> crate::Result<()> {
5164            if let Some(terminal_id) = terminal.component.as_ref()
5165                && !terminal_ids.insert(terminal_id.clone())
5166            {
5167                return Err(Error::FormatRead {
5168                    format,
5169                    message: format!("duplicate DCTerminal `{}`", terminal_id.local_id()),
5170                });
5171            }
5172            if terminal.dc_node.is_none() && terminal.dc_topological_node.is_none() {
5173                return Err(Error::FormatRead {
5174                    format,
5175                    message: format!(
5176                        "DCTerminal on {class} `{}` references neither a DCNode nor a DCTopologicalNode",
5177                        equipment.local_id()
5178                    ),
5179                });
5180            }
5181            if let Some(node) = terminal.dc_node.as_ref()
5182                && !nodes.contains(node)
5183            {
5184                return Err(Error::FormatRead {
5185                    format,
5186                    message: format!(
5187                        "DCTerminal on {class} `{}` references unknown DCNode `{}`",
5188                        equipment.local_id(),
5189                        node.local_id()
5190                    ),
5191                });
5192            }
5193            if let Some(node) = terminal.dc_topological_node.as_ref()
5194                && !topological_nodes.contains(node)
5195            {
5196                return Err(Error::FormatRead {
5197                    format,
5198                    message: format!(
5199                        "DCTerminal on {class} `{}` references unknown DCTopologicalNode `{}`",
5200                        equipment.local_id(),
5201                        node.local_id()
5202                    ),
5203                });
5204            }
5205            Ok(())
5206        };
5207
5208        for ground in &detailed.dc_grounds {
5209            check_container(
5210                "DCGround",
5211                &ground.component,
5212                ground.equipment_container.as_ref(),
5213            )?;
5214            check_terminal("DCGround", &ground.component, &ground.dc_terminal)?;
5215        }
5216        for busbar in &detailed.dc_busbars {
5217            check_container(
5218                "DCBusbar",
5219                &busbar.component,
5220                busbar.equipment_container.as_ref(),
5221            )?;
5222            check_terminal("DCBusbar", &busbar.component, &busbar.dc_terminal)?;
5223        }
5224        for line in &detailed.dc_lines {
5225            check_container(
5226                "DCLineSegment",
5227                &line.component,
5228                line.equipment_container.as_ref(),
5229            )?;
5230            check_terminal("DCLineSegment", &line.component, &line.dc_terminal1)?;
5231            check_terminal("DCLineSegment", &line.component, &line.dc_terminal2)?;
5232        }
5233        for device in &detailed.dc_series_devices {
5234            check_container(
5235                "DCSeriesDevice",
5236                &device.component,
5237                device.equipment_container.as_ref(),
5238            )?;
5239            check_terminal("DCSeriesDevice", &device.component, &device.dc_terminal1)?;
5240            check_terminal("DCSeriesDevice", &device.component, &device.dc_terminal2)?;
5241        }
5242        for switch in &detailed.dc_switches {
5243            check_container(
5244                "DCSwitch",
5245                &switch.component,
5246                switch.equipment_container.as_ref(),
5247            )?;
5248            check_terminal("DCSwitch", &switch.component, &switch.dc_terminal1)?;
5249            check_terminal("DCSwitch", &switch.component, &switch.dc_terminal2)?;
5250        }
5251        for converter in &detailed.voltage_source_converters {
5252            check_unit(
5253                "VsConverter",
5254                &converter.component,
5255                converter.dc_converter_unit.as_ref(),
5256            )?;
5257            check_terminal("VsConverter", &converter.component, &converter.dc_terminal1)?;
5258            check_terminal("VsConverter", &converter.component, &converter.dc_terminal2)?;
5259        }
5260        for converter in &detailed.line_commutated_converters {
5261            check_unit(
5262                "CsConverter",
5263                &converter.component,
5264                converter.dc_converter_unit.as_ref(),
5265            )?;
5266            check_terminal("CsConverter", &converter.component, &converter.dc_terminal1)?;
5267            check_terminal("CsConverter", &converter.component, &converter.dc_terminal2)?;
5268        }
5269
5270        let ac_terminals = detailed
5271            .terminals
5272            .iter()
5273            .map(|terminal| (&terminal.equipment, terminal.terminal))
5274            .collect::<std::collections::HashSet<_>>();
5275        let check_regulating_terminal =
5276            |what: &str, reference: Option<&TerminalReference>| -> crate::Result<()> {
5277                if let Some(reference) = reference
5278                    && !ac_terminals.contains(&(&reference.equipment, reference.terminal))
5279                {
5280                    return Err(Error::FormatRead {
5281                        format,
5282                        message: format!(
5283                            "{what} references undeclared regulating Terminal `{}` number {}",
5284                            reference.equipment.local_id(),
5285                            reference.terminal
5286                        ),
5287                    });
5288                }
5289                Ok(())
5290            };
5291        for (index, generator) in self.generators().iter().enumerate() {
5292            check_regulating_terminal(
5293                &format!("generator {index}"),
5294                generator.regulating_terminal.as_ref(),
5295            )?;
5296        }
5297        for (index, branch) in self.branches().iter().enumerate() {
5298            if let Some(control) = &branch.control {
5299                check_regulating_terminal(
5300                    &format!("transformer branch {index}"),
5301                    control.regulating_terminal.as_ref(),
5302                )?;
5303            }
5304        }
5305        for (index, shunt) in self.shunts().iter().enumerate() {
5306            if let Some(control) = &shunt.control {
5307                check_regulating_terminal(
5308                    &format!("switched shunt {index}"),
5309                    control.regulating_terminal.as_ref(),
5310                )?;
5311            }
5312        }
5313        for (transformer_index, transformer) in self.transformers_3w().iter().enumerate() {
5314            for (winding_index, winding) in transformer.windings.iter().enumerate() {
5315                if let Some(control) = &winding.control {
5316                    check_regulating_terminal(
5317                        &format!(
5318                            "three winding transformer {transformer_index} winding {winding_index}"
5319                        ),
5320                        control.regulating_terminal.as_ref(),
5321                    )?;
5322                }
5323            }
5324        }
5325        let check_pcc_terminal = |class: &str,
5326                                  component: &ComponentId,
5327                                  pcc: Option<&TerminalReference>|
5328         -> crate::Result<()> {
5329            if let Some(pcc) = pcc
5330                && !ac_terminals.contains(&(&pcc.equipment, pcc.terminal))
5331            {
5332                return Err(Error::FormatRead {
5333                    format,
5334                    message: format!(
5335                        "{class} `{}` references undeclared PCC Terminal `{}` number {}",
5336                        component.local_id(),
5337                        pcc.equipment.local_id(),
5338                        pcc.terminal
5339                    ),
5340                });
5341            }
5342            Ok(())
5343        };
5344        for converter in &detailed.voltage_source_converters {
5345            check_pcc_terminal(
5346                "VoltageSourceConverter",
5347                &converter.component,
5348                converter.pcc_terminal.as_ref(),
5349            )?;
5350        }
5351        for converter in &detailed.line_commutated_converters {
5352            check_pcc_terminal(
5353                "LineCommutatedConverter",
5354                &converter.component,
5355                converter.pcc_terminal.as_ref(),
5356            )?;
5357        }
5358        Ok(())
5359    }
5360
5361    /// Star expansion allocates synthetic bus ids `max_bus_id + 1 + k`, one per
5362    /// in-service 3-winding transformer; a bus id near [`BusId::MAX`] would
5363    /// push those past the ceiling the C ABI reports ids in. The base id
5364    /// `max + 1` is computed whenever any 3-winding transformer is present,
5365    /// even if none is in service, so the headroom is
5366    /// `max(1, in-service count)`. No real case sits there, so refuse it at the
5367    /// boundary like any other malformed reference.
5368    fn check_star_expansion_headroom(&self, format: &'static str) -> crate::Result<()> {
5369        if self.transformers_3w().is_empty() {
5370            return Ok(());
5371        }
5372        let Some(max_id) = self.buses().iter().map(|b| b.id.0).max() else {
5373            return Ok(());
5374        };
5375        let needed = self
5376            .transformers_3w()
5377            .iter()
5378            .filter(|t| t.in_service)
5379            .count()
5380            .max(1);
5381        if max_id
5382            .checked_add(needed)
5383            .is_none_or(|top| top > BusId::MAX.0)
5384        {
5385            return Err(Error::FormatRead {
5386                format,
5387                message: format!(
5388                    "bus id {max_id} leaves no room to allocate synthetic star bus ids \
5389                     for 3-winding transformers"
5390                ),
5391            });
5392        }
5393        Ok(())
5394    }
5395}
5396
5397/// A network as it comes back from its own serde representation, the form
5398/// PowerIO IR nests as `value.data`. Shared by the unit tests of every module
5399/// that checks a field survives that trip.
5400#[cfg(test)]
5401pub(crate) fn serde_round_trip(network: &BalancedNetwork) -> BalancedNetwork {
5402    serde_json::from_str(&serde_json::to_string(network).unwrap()).unwrap()
5403}
5404
5405#[cfg(test)]
5406mod tests {
5407    use super::*;
5408
5409    fn close(actual: f64, expected: f64) {
5410        assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
5411    }
5412
5413    fn component(component_type: &str, local_id: &str) -> ComponentId {
5414        ComponentId::new(component_type, local_id).unwrap()
5415    }
5416
5417    fn reference_test_dc_terminal(local_id: &str, node: &ComponentId) -> DcTerminal {
5418        DcTerminal {
5419            component: Some(component("dc_terminal", local_id)),
5420            sequence_number: None,
5421            dc_node: Some(node.clone()),
5422            dc_topological_node: None,
5423            polarity: None,
5424            connected: None,
5425            active_power_mw: None,
5426            current_a: None,
5427        }
5428    }
5429
5430    #[allow(clippy::too_many_lines)]
5431    fn detailed_dc_reference_test_network() -> BalancedNetwork {
5432        let substation = component("substation", "S");
5433        let converter_unit = component("dc_converter_unit", "U");
5434        let topological_node = component("dc_topological_node", "T");
5435        let node = component("dc_node", "N");
5436        let pcc_equipment = component("ac_equipment", "PCC");
5437        let voltage_level = component("voltage_level", "VL");
5438
5439        let voltage_source_converter: VoltageSourceConverter =
5440            serde_json::from_value(serde_json::json!({
5441                "component": component("voltage_source_converter", "VSC"),
5442                "dc_converter_unit": converter_unit.clone(),
5443                "dc_terminal1": reference_test_dc_terminal("VSC-1", &node),
5444                "dc_terminal2": reference_test_dc_terminal("VSC-2", &node),
5445                "pcc_terminal": {
5446                    "equipment": pcc_equipment.clone(),
5447                    "terminal": 1
5448                }
5449            }))
5450            .unwrap();
5451        let line_commutated_converter: LineCommutatedConverter =
5452            serde_json::from_value(serde_json::json!({
5453                "component": component("line_commutated_converter", "LCC"),
5454                "dc_converter_unit": converter_unit.clone(),
5455                "dc_terminal1": reference_test_dc_terminal("LCC-1", &node),
5456                "dc_terminal2": reference_test_dc_terminal("LCC-2", &node),
5457                "pcc_terminal": {
5458                    "equipment": pcc_equipment.clone(),
5459                    "terminal": 1
5460                }
5461            }))
5462            .unwrap();
5463
5464        let detailed = DetailedConnectivity {
5465            substations: vec![Substation {
5466                component: substation.clone(),
5467                country: None,
5468                operator: None,
5469                geographical_tags: Vec::new(),
5470            }],
5471            voltage_levels: vec![VoltageLevel {
5472                component: voltage_level.clone(),
5473                substation: Some(substation.clone()),
5474                nominal_kv: 230.0,
5475                low_voltage_limit_kv: None,
5476                high_voltage_limit_kv: None,
5477                topology_kind: TopologyKind::BusBreaker,
5478                buses: Vec::new(),
5479            }],
5480            terminals: vec![Terminal {
5481                component: None,
5482                equipment: pcc_equipment,
5483                terminal: 1,
5484                voltage_level,
5485                bus: None,
5486                connectable_bus: None,
5487                node: None,
5488                connected: true,
5489                active_power_mw: None,
5490                reactive_power_mvar: None,
5491            }],
5492            dc_converter_units: vec![DcConverterUnit {
5493                component: converter_unit.clone(),
5494                substation: Some(substation),
5495                operation_mode: DcConverterOperatingMode::Bipolar,
5496            }],
5497            dc_topological_nodes: vec![DcTopologicalNode {
5498                component: topological_node.clone(),
5499                dc_converter_unit: Some(converter_unit.clone()),
5500            }],
5501            dc_nodes: vec![DcNode {
5502                component: node.clone(),
5503                nominal_voltage_kv: None,
5504                dc_converter_unit: Some(converter_unit.clone()),
5505                dc_topological_node: Some(topological_node),
5506                voltage_kv: None,
5507            }],
5508            dc_grounds: vec![DcGround {
5509                component: component("dc_ground", "G"),
5510                equipment_container: Some(converter_unit.clone()),
5511                dc_terminal: reference_test_dc_terminal("G-1", &node),
5512                rated_dc_voltage_kv: None,
5513                resistance_ohm: None,
5514                inductance_h: None,
5515            }],
5516            dc_lines: vec![DcLine {
5517                component: component("dc_line", "L"),
5518                equipment_container: Some(converter_unit.clone()),
5519                dc_terminal1: reference_test_dc_terminal("L-1", &node),
5520                dc_terminal2: reference_test_dc_terminal("L-2", &node),
5521                rated_dc_voltage_kv: None,
5522                resistance_ohm: None,
5523                inductance_h: None,
5524                capacitance_f: None,
5525                length_km: None,
5526            }],
5527            dc_switches: vec![DcSwitch {
5528                component: component("dc_switch", "SW"),
5529                equipment_container: Some(converter_unit),
5530                dc_terminal1: reference_test_dc_terminal("SW-1", &node),
5531                dc_terminal2: reference_test_dc_terminal("SW-2", &node),
5532                kind: DcSwitchKind::Switch,
5533                rated_dc_voltage_kv: None,
5534                open: None,
5535                resistance_ohm: None,
5536            }],
5537            voltage_source_converters: vec![voltage_source_converter],
5538            line_commutated_converters: vec![line_commutated_converter],
5539            ..DetailedConnectivity::default()
5540        };
5541        let mut network = BalancedNetwork::new("dc-reference-test", 100.0);
5542        *network.detailed_connectivity_mut() = Some(std::sync::Arc::new(detailed));
5543        network
5544    }
5545
5546    fn reference_test_detailed_mut(network: &mut BalancedNetwork) -> &mut DetailedConnectivity {
5547        std::sync::Arc::make_mut(
5548            network
5549                .detailed_connectivity_mut()
5550                .as_mut()
5551                .expect("reference test has detailed connectivity"),
5552        )
5553    }
5554
5555    fn assert_invalid(network: &BalancedNetwork, expected: &str) {
5556        let error = network.validate().unwrap_err().to_string();
5557        assert!(
5558            error.contains(expected),
5559            "expected `{expected}` in `{error}`"
5560        );
5561    }
5562
5563    fn assert_duplicate_dc_converter_unit_rejected(valid: &BalancedNetwork) {
5564        let mut network = valid.clone();
5565        let unit = reference_test_detailed_mut(&mut network).dc_converter_units[0].clone();
5566        reference_test_detailed_mut(&mut network)
5567            .dc_converter_units
5568            .push(unit);
5569        assert_invalid(&network, "duplicate DCConverterUnit");
5570    }
5571
5572    #[test]
5573    fn detailed_dc_references_are_complete_and_checked() {
5574        let valid = detailed_dc_reference_test_network();
5575        valid.validate().unwrap();
5576
5577        assert_duplicate_dc_converter_unit_rejected(&valid);
5578
5579        let mut missing_substation = valid.clone();
5580        reference_test_detailed_mut(&mut missing_substation).dc_converter_units[0].substation =
5581            Some(component("substation", "missing"));
5582        assert_invalid(&missing_substation, "references unknown Substation");
5583
5584        let mut duplicate_topological_node = valid.clone();
5585        let node = reference_test_detailed_mut(&mut duplicate_topological_node)
5586            .dc_topological_nodes[0]
5587            .clone();
5588        reference_test_detailed_mut(&mut duplicate_topological_node)
5589            .dc_topological_nodes
5590            .push(node);
5591        assert_invalid(&duplicate_topological_node, "duplicate DCTopologicalNode");
5592
5593        let mut missing_topological_unit = valid.clone();
5594        reference_test_detailed_mut(&mut missing_topological_unit).dc_topological_nodes[0]
5595            .dc_converter_unit = Some(component("dc_converter_unit", "missing"));
5596        assert_invalid(
5597            &missing_topological_unit,
5598            "DCTopologicalNode `T` references unknown DCConverterUnit",
5599        );
5600
5601        let mut missing_node_unit = valid.clone();
5602        reference_test_detailed_mut(&mut missing_node_unit).dc_nodes[0].dc_converter_unit =
5603            Some(component("dc_converter_unit", "missing"));
5604        assert_invalid(
5605            &missing_node_unit,
5606            "DCNode `N` references unknown DCConverterUnit",
5607        );
5608
5609        let mut missing_node_topology = valid.clone();
5610        reference_test_detailed_mut(&mut missing_node_topology).dc_nodes[0].dc_topological_node =
5611            Some(component("dc_topological_node", "missing"));
5612        assert_invalid(
5613            &missing_node_topology,
5614            "DCNode `N` references unknown DCTopologicalNode",
5615        );
5616
5617        let mut missing_container = valid.clone();
5618        reference_test_detailed_mut(&mut missing_container).dc_lines[0].equipment_container =
5619            Some(component("dc_converter_unit", "missing"));
5620        assert_invalid(
5621            &missing_container,
5622            "DCLineSegment `L` references unknown equipment container",
5623        );
5624
5625        let mut missing_converter_unit = valid.clone();
5626        reference_test_detailed_mut(&mut missing_converter_unit).voltage_source_converters[0]
5627            .dc_converter_unit = Some(component("dc_converter_unit", "missing"));
5628        assert_invalid(
5629            &missing_converter_unit,
5630            "VsConverter `VSC` references unknown DCConverterUnit",
5631        );
5632
5633        let mut terminal_without_node = valid.clone();
5634        let terminal =
5635            &mut reference_test_detailed_mut(&mut terminal_without_node).dc_grounds[0].dc_terminal;
5636        terminal.dc_node = None;
5637        terminal.dc_topological_node = None;
5638        assert_invalid(
5639            &terminal_without_node,
5640            "references neither a DCNode nor a DCTopologicalNode",
5641        );
5642
5643        let mut missing_physical_node = valid.clone();
5644        reference_test_detailed_mut(&mut missing_physical_node).dc_grounds[0]
5645            .dc_terminal
5646            .dc_node = Some(component("dc_node", "missing"));
5647        assert_invalid(&missing_physical_node, "references unknown DCNode");
5648
5649        let mut missing_terminal_topology = valid.clone();
5650        let terminal = &mut reference_test_detailed_mut(&mut missing_terminal_topology).dc_grounds
5651            [0]
5652        .dc_terminal;
5653        terminal.dc_node = None;
5654        terminal.dc_topological_node = Some(component("dc_topological_node", "missing"));
5655        assert_invalid(
5656            &missing_terminal_topology,
5657            "references unknown DCTopologicalNode",
5658        );
5659
5660        let mut duplicate_terminal = valid.clone();
5661        let terminal_id = reference_test_detailed_mut(&mut duplicate_terminal).dc_grounds[0]
5662            .dc_terminal
5663            .component
5664            .clone();
5665        reference_test_detailed_mut(&mut duplicate_terminal).dc_lines[0]
5666            .dc_terminal1
5667            .component = terminal_id;
5668        assert_invalid(&duplicate_terminal, "duplicate DCTerminal");
5669
5670        let mut missing_pcc_terminal = valid;
5671        reference_test_detailed_mut(&mut missing_pcc_terminal).voltage_source_converters[0]
5672            .pcc_terminal
5673            .as_mut()
5674            .unwrap()
5675            .terminal = 2;
5676        assert_invalid(&missing_pcc_terminal, "references undeclared PCC Terminal");
5677    }
5678
5679    #[test]
5680    #[allow(clippy::too_many_lines)] // the fixture names every physical DC equipment record
5681    fn detailed_connectivity_dc_equipment_round_trips_and_defaults() {
5682        let empty: DetailedConnectivity = serde_json::from_str("{}").unwrap();
5683        assert_eq!(empty, DetailedConnectivity::default());
5684
5685        let node1 = component("dc_node", "dc-1");
5686        let node2 = component("dc_node", "dc-2");
5687        let terminal = |dc_node: ComponentId, connected: bool, power| DcTerminal {
5688            component: None,
5689            sequence_number: None,
5690            dc_node: Some(dc_node),
5691            dc_topological_node: None,
5692            polarity: None,
5693            connected: Some(connected),
5694            active_power_mw: power,
5695            current_a: power.map(|value| value / 320.0),
5696        };
5697        let unit = component("dc_converter_unit", "unit");
5698        let topological_node = component("dc_topological_node", "dc-topology");
5699        let pcc_terminal = TerminalReference {
5700            equipment: component("branch", "pcc"),
5701            terminal: 2,
5702        };
5703        let droop_curve = DroopCurve {
5704            segments: vec![DroopCurveSegment {
5705                minimum_voltage_kv: 300.0,
5706                maximum_voltage_kv: 340.0,
5707                k: 0.4,
5708            }],
5709        };
5710        let mut curve_properties = BTreeMap::new();
5711        curve_properties.insert("source".into(), "test".into());
5712        let reactive_limits = ReactiveLimits::CapabilityCurve(ReactiveCapabilityCurve {
5713            curve_style: CurveStyle::StraightLineYValues,
5714            properties: curve_properties,
5715            points: vec![
5716                ReactiveCapabilityCurvePoint {
5717                    active_power_mw: -100.0,
5718                    minimum_reactive_power_mvar: -50.0,
5719                    maximum_reactive_power_mvar: 40.0,
5720                    properties: BTreeMap::new(),
5721                },
5722                ReactiveCapabilityCurvePoint {
5723                    active_power_mw: 100.0,
5724                    minimum_reactive_power_mvar: -40.0,
5725                    maximum_reactive_power_mvar: 50.0,
5726                    properties: BTreeMap::new(),
5727                },
5728            ],
5729        });
5730        let detailed = DetailedConnectivity {
5731            terminals: vec![Terminal {
5732                component: None,
5733                equipment: component("voltage_source_converter", "vsc"),
5734                terminal: 1,
5735                voltage_level: component("voltage_level", "vl"),
5736                bus: None,
5737                connectable_bus: None,
5738                node: Some(component("connectivity_node", "n1")),
5739                connected: true,
5740                active_power_mw: Some(-95.0),
5741                reactive_power_mvar: Some(12.0),
5742            }],
5743            dc_converter_units: vec![DcConverterUnit {
5744                component: unit.clone(),
5745                substation: Some(component("substation", "station")),
5746                operation_mode: DcConverterOperatingMode::Bipolar,
5747            }],
5748            dc_topological_nodes: vec![DcTopologicalNode {
5749                component: topological_node.clone(),
5750                dc_converter_unit: Some(unit.clone()),
5751            }],
5752            dc_nodes: vec![
5753                DcNode {
5754                    component: node1.clone(),
5755                    nominal_voltage_kv: Some(320.0),
5756                    dc_converter_unit: Some(unit.clone()),
5757                    dc_topological_node: Some(topological_node.clone()),
5758                    voltage_kv: Some(318.0),
5759                },
5760                DcNode {
5761                    component: node2.clone(),
5762                    nominal_voltage_kv: Some(320.0),
5763                    dc_converter_unit: Some(unit.clone()),
5764                    dc_topological_node: Some(topological_node),
5765                    voltage_kv: None,
5766                },
5767            ],
5768            dc_grounds: vec![DcGround {
5769                component: component("dc_ground", "ground"),
5770                equipment_container: Some(unit.clone()),
5771                dc_terminal: terminal(node1.clone(), true, Some(0.5)),
5772                rated_dc_voltage_kv: Some(320.0),
5773                resistance_ohm: Some(2.0),
5774                inductance_h: Some(0.01),
5775            }],
5776            dc_lines: vec![DcLine {
5777                component: component("dc_line", "line"),
5778                equipment_container: Some(unit.clone()),
5779                dc_terminal1: terminal(node1.clone(), true, Some(100.0)),
5780                dc_terminal2: terminal(node2.clone(), false, Some(-99.0)),
5781                rated_dc_voltage_kv: Some(320.0),
5782                resistance_ohm: Some(1.5),
5783                inductance_h: Some(0.02),
5784                capacitance_f: Some(0.001),
5785                length_km: Some(20.0),
5786            }],
5787            dc_switches: vec![DcSwitch {
5788                component: component("dc_switch", "breaker"),
5789                equipment_container: Some(unit.clone()),
5790                dc_terminal1: terminal(node1.clone(), true, None),
5791                dc_terminal2: terminal(node2.clone(), true, None),
5792                kind: DcSwitchKind::Breaker,
5793                rated_dc_voltage_kv: Some(320.0),
5794                open: Some(false),
5795                resistance_ohm: Some(0.01),
5796            }],
5797            voltage_source_converters: vec![VoltageSourceConverter {
5798                component: component("voltage_source_converter", "vsc"),
5799                dc_converter_unit: Some(unit.clone()),
5800                dc_terminal1: terminal(node1.clone(), true, Some(-100.0)),
5801                dc_terminal2: terminal(node2.clone(), true, Some(100.0)),
5802                base_apparent_power_mva: Some(200.0),
5803                minimum_active_power_mw: Some(-150.0),
5804                maximum_active_power_mw: Some(150.0),
5805                minimum_dc_voltage_kv: Some(300.0),
5806                maximum_dc_voltage_kv: Some(340.0),
5807                rated_dc_voltage_kv: Some(320.0),
5808                valve_u0_kv: Some(0.1),
5809                number_of_valves: Some(4),
5810                idle_loss_mw: Some(1.0),
5811                switching_loss_mw_per_ampere: Some(0.002),
5812                resistive_loss_ohm: Some(0.1),
5813                control_mode: Some(
5814                    AcDcConverterControlMode::ActivePowerAtPccAndDcVoltageDroopCurve,
5815                ),
5816                active_power_at_pcc_mw: Some(95.0),
5817                reactive_power_at_pcc_mvar: Some(12.0),
5818                target_active_power_mw: Some(95.0),
5819                target_dc_voltage_kv: Some(320.0),
5820                pcc_terminal: Some(pcc_terminal.clone()),
5821                droop_curve: Some(droop_curve.clone()),
5822                droop: Some(2.0),
5823                droop_compensation: Some(0.1),
5824                q_share: Some(0.5),
5825                maximum_modulation_index: Some(0.9),
5826                maximum_valve_current_a: Some(800.0),
5827                voltage_regulator_on: Some(true),
5828                voltage_setpoint_kv: Some(230.0),
5829                reactive_power_setpoint_mvar: None,
5830                reactive_limits: Some(reactive_limits),
5831                pole_loss_active_power_mw: Some(1.2),
5832                dc_current_a: Some(300.0),
5833                ac_voltage_kv: Some(230.0),
5834                dc_voltage_kv: Some(318.0),
5835                delta_degrees: Some(1.0),
5836                uf_kv: Some(229.0),
5837                uv_kv: Some(231.0),
5838            }],
5839            line_commutated_converters: vec![LineCommutatedConverter {
5840                component: component("line_commutated_converter", "lcc"),
5841                dc_converter_unit: Some(unit),
5842                dc_terminal1: terminal(node1, true, Some(-75.0)),
5843                dc_terminal2: terminal(node2, true, Some(75.0)),
5844                base_apparent_power_mva: Some(160.0),
5845                minimum_active_power_mw: Some(-120.0),
5846                maximum_active_power_mw: Some(120.0),
5847                minimum_dc_voltage_kv: Some(300.0),
5848                maximum_dc_voltage_kv: Some(340.0),
5849                rated_dc_voltage_kv: Some(320.0),
5850                valve_u0_kv: Some(0.2),
5851                number_of_valves: Some(6),
5852                idle_loss_mw: Some(0.5),
5853                switching_loss_mw_per_ampere: Some(0.001),
5854                resistive_loss_ohm: Some(0.2),
5855                control_mode: Some(AcDcConverterControlMode::DcVoltage),
5856                active_power_at_pcc_mw: Some(-75.0),
5857                reactive_power_at_pcc_mvar: Some(-20.0),
5858                target_active_power_mw: None,
5859                target_dc_voltage_kv: Some(320.0),
5860                pcc_terminal: Some(pcc_terminal),
5861                droop_curve: Some(droop_curve),
5862                reactive_model: Some(LineCommutatedConverterReactiveModel::FixedPowerFactor),
5863                power_factor: Some(0.95),
5864                operating_mode: Some(LineCommutatedConverterOperatingMode::Rectifier),
5865                rated_dc_current_a: Some(500.0),
5866                minimum_alpha_degrees: Some(5.0),
5867                maximum_alpha_degrees: Some(30.0),
5868                minimum_gamma_degrees: Some(10.0),
5869                maximum_gamma_degrees: Some(35.0),
5870                target_alpha_degrees: Some(15.0),
5871                target_gamma_degrees: Some(20.0),
5872                target_dc_current_a: Some(250.0),
5873                pole_loss_active_power_mw: Some(0.8),
5874                dc_current_a: Some(250.0),
5875                ac_voltage_kv: Some(230.0),
5876                dc_voltage_kv: Some(319.0),
5877                alpha_degrees: Some(14.0),
5878                gamma_degrees: Some(19.0),
5879            }],
5880            ..DetailedConnectivity::default()
5881        };
5882
5883        let json = serde_json::to_string(&detailed).unwrap();
5884        let restored: DetailedConnectivity = serde_json::from_str(&json).unwrap();
5885        assert_eq!(restored, detailed);
5886
5887        let mut older_terminal = serde_json::to_value(&detailed.terminals[0]).unwrap();
5888        let older_terminal = older_terminal.as_object_mut().unwrap();
5889        older_terminal.remove("active_power_mw");
5890        older_terminal.remove("reactive_power_mvar");
5891        let older_terminal: Terminal =
5892            serde_json::from_value(serde_json::Value::Object(older_terminal.clone())).unwrap();
5893        assert_eq!(older_terminal.active_power_mw, None);
5894        assert_eq!(older_terminal.reactive_power_mvar, None);
5895
5896        let min_max = ReactiveLimits::MinMax(MinMaxReactiveLimits {
5897            minimum_reactive_power_mvar: -25.0,
5898            maximum_reactive_power_mvar: 30.0,
5899            properties: BTreeMap::new(),
5900        });
5901        let json = serde_json::to_string(&min_max).unwrap();
5902        assert_eq!(
5903            serde_json::from_str::<ReactiveLimits>(&json).unwrap(),
5904            min_max
5905        );
5906    }
5907
5908    #[test]
5909    fn converter_droop_control_modes_have_distinct_serialized_names() {
5910        for (mode, name) in [
5911            (
5912                AcDcConverterControlMode::ActivePowerAtPccAndDcVoltageDroopCurve,
5913                "active_power_at_pcc_and_dc_voltage_droop_curve",
5914            ),
5915            (
5916                AcDcConverterControlMode::ActivePowerAtPccAndDcVoltageDroop,
5917                "active_power_at_pcc_and_dc_voltage_droop",
5918            ),
5919            (
5920                AcDcConverterControlMode::ActivePowerAtPccAndDcVoltageDroopWithCompensation,
5921                "active_power_at_pcc_and_dc_voltage_droop_with_compensation",
5922            ),
5923            (
5924                AcDcConverterControlMode::ActivePowerAtPccAndDcVoltageDroopPilot,
5925                "active_power_at_pcc_and_dc_voltage_droop_pilot",
5926            ),
5927        ] {
5928            let value = serde_json::to_value(mode).unwrap();
5929            assert_eq!(value, serde_json::Value::String(name.to_owned()));
5930            assert_eq!(
5931                serde_json::from_value::<AcDcConverterControlMode>(value).unwrap(),
5932                mode
5933            );
5934        }
5935    }
5936
5937    #[test]
5938    fn assigned_component_ids_follow_records_after_reordering() {
5939        let mut network = BalancedNetwork::in_memory(
5940            "ids",
5941            100.0,
5942            vec![
5943                Bus::new(BusId(10), BusType::Ref, 230.0),
5944                Bus::new(BusId(20), BusType::Pq, 230.0),
5945            ],
5946            vec![
5947                Branch::new(BusId(10), BusId(20), 0.0, 0.1),
5948                Branch::new(BusId(10), BusId(20), 0.0, 0.2),
5949            ],
5950        );
5951        network.loads_mut().extend([
5952            Load::new(BusId(20), 10.0, 1.0),
5953            Load::new(BusId(20), 20.0, 2.0),
5954        ]);
5955        network.assign_missing_component_ids();
5956
5957        assert_eq!(network.buses()[0].uid.as_deref(), Some("10"));
5958        assert_eq!(network.branches()[0].uid.as_deref(), Some("10-20"));
5959        assert_eq!(network.branches()[1].uid.as_deref(), Some("10-20-2"));
5960        assert_eq!(network.loads()[0].uid.as_deref(), Some("bus-20"));
5961        assert_eq!(network.loads()[1].uid.as_deref(), Some("bus-20-2"));
5962        assert!(network.uid_is_generated(network.loads()[0].uid.as_deref()));
5963
5964        network.loads_mut().swap(0, 1);
5965        network.branches_mut().swap(0, 1);
5966        network.assign_missing_component_ids();
5967        assert_eq!(network.loads()[0].uid.as_deref(), Some("bus-20-2"));
5968        assert_eq!(network.loads()[1].uid.as_deref(), Some("bus-20"));
5969        assert_eq!(network.branches()[0].uid.as_deref(), Some("10-20-2"));
5970        assert_eq!(network.branches()[1].uid.as_deref(), Some("10-20"));
5971
5972        let restored = serde_round_trip(&network);
5973        assert_eq!(restored.loads()[0].uid, network.loads()[0].uid);
5974        assert_eq!(restored.branches()[0].uid, network.branches()[0].uid);
5975        assert!(restored.uid_is_generated(restored.loads()[0].uid.as_deref()));
5976    }
5977
5978    #[test]
5979    fn source_and_generated_component_identities_remain_distinct() {
5980        let mut explicit = Bus::new(BusId(10), BusType::Ref, 230.0);
5981        explicit.uid = Some("source-bus".to_owned());
5982        let mut network = BalancedNetwork::new("identity provenance", 100.0);
5983        network
5984            .buses_mut()
5985            .extend([explicit, Bus::new(BusId(20), BusType::Pq, 230.0)]);
5986        network.assign_missing_component_ids();
5987
5988        assert!(!network.uid_is_generated(network.buses()[0].uid.as_deref()));
5989        assert!(network.uid_is_generated(network.buses()[1].uid.as_deref()));
5990    }
5991
5992    /// XIIDM and CGMES index every element by one identifier, so a load and a
5993    /// generator at the same bus cannot both be `bus-20`.
5994    #[test]
5995    fn generated_identities_are_unique_across_tables() {
5996        let mut network = BalancedNetwork::in_memory(
5997            "cross table ids",
5998            100.0,
5999            vec![
6000                Bus::new(BusId(10), BusType::Ref, 230.0),
6001                Bus::new(BusId(20), BusType::Pq, 230.0),
6002            ],
6003            vec![Branch::new(BusId(10), BusId(20), 0.0, 0.1)],
6004        );
6005        network.loads_mut().push(Load::new(BusId(20), 10.0, 1.0));
6006        network.shunts_mut().push(Shunt::new(BusId(20), 0.0, 0.5));
6007        network.generators_mut().push(Generator::new(BusId(20)));
6008        network.assign_missing_component_ids();
6009
6010        let mut identities = vec![
6011            network.loads()[0].uid.clone().unwrap(),
6012            network.shunts()[0].uid.clone().unwrap(),
6013            network.generators()[0].uid.clone().unwrap(),
6014        ];
6015        identities.sort();
6016        assert_eq!(identities, ["bus-20", "bus-20-2", "bus-20-3"]);
6017
6018        // Assigning again leaves every identity alone, so an identity stays
6019        // stable across a reload.
6020        let before = identities.clone();
6021        network.assign_missing_component_ids();
6022        let mut after = vec![
6023            network.loads()[0].uid.clone().unwrap(),
6024            network.shunts()[0].uid.clone().unwrap(),
6025            network.generators()[0].uid.clone().unwrap(),
6026        ];
6027        after.sort();
6028        assert_eq!(after, before);
6029    }
6030
6031    #[test]
6032    fn source_format_serializes_as_its_name_token() {
6033        // The exhaustive match keeps a new enum case from shipping with a serde
6034        // spelling that differs from name().
6035        let all = [
6036            SourceFormat::Sincal,
6037            SourceFormat::Matpower,
6038            SourceFormat::PowerModelsJson,
6039            SourceFormat::EgretJson,
6040            SourceFormat::Psse,
6041            SourceFormat::PsseRawx,
6042            SourceFormat::PowerWorld,
6043            SourceFormat::PandapowerJson,
6044            SourceFormat::Pslf,
6045            SourceFormat::PowerWorldBinary,
6046            SourceFormat::InMemory,
6047            SourceFormat::Normalized,
6048            SourceFormat::Gridfm,
6049            SourceFormat::PypsaCsv,
6050            SourceFormat::Goc3Json,
6051            SourceFormat::SurgeJson,
6052            SourceFormat::RavensJson,
6053            SourceFormat::DeepMindOpfDataJson,
6054            SourceFormat::Xiidm,
6055            SourceFormat::Jiidm,
6056            SourceFormat::Cgmes,
6057            SourceFormat::Ucte,
6058            SourceFormat::IeeeCdf,
6059        ];
6060        for f in all {
6061            match f {
6062                SourceFormat::Matpower
6063                | SourceFormat::PowerModelsJson
6064                | SourceFormat::EgretJson
6065                | SourceFormat::Psse
6066                | SourceFormat::PsseRawx
6067                | SourceFormat::PowerWorld
6068                | SourceFormat::PandapowerJson
6069                | SourceFormat::Pslf
6070                | SourceFormat::PowerWorldBinary
6071                | SourceFormat::InMemory
6072                | SourceFormat::Normalized
6073                | SourceFormat::Gridfm
6074                | SourceFormat::PypsaCsv
6075                | SourceFormat::Goc3Json
6076                | SourceFormat::SurgeJson
6077                | SourceFormat::RavensJson
6078                | SourceFormat::DeepMindOpfDataJson
6079                | SourceFormat::Xiidm
6080                | SourceFormat::Jiidm
6081                | SourceFormat::Cgmes
6082                | SourceFormat::Ucte
6083                | SourceFormat::IeeeCdf
6084                | SourceFormat::Sincal => {}
6085            }
6086            let token = serde_json::to_value(f).unwrap();
6087            assert_eq!(token, serde_json::Value::String(f.name().to_owned()));
6088            let back: SourceFormat = serde_json::from_value(token).unwrap();
6089            assert_eq!(back, f);
6090        }
6091    }
6092
6093    #[test]
6094    fn quadratic_with_constant_keeps_c0_across_ncost() {
6095        let full = GenCost::new(2, 0.0, 0.0, vec![1.5, 2.0, 5.0]);
6096        assert_eq!(full.calc_quadratic_with_constant(), Some((3.0, 2.0, 5.0)));
6097        assert_eq!(full.calc_quadratic(), Some((3.0, 2.0)));
6098        assert_eq!(
6099            full.calc_quadratic_with_constant(),
6100            full.calc_quadratic_with_constant()
6101        );
6102        assert_eq!(full.calc_quadratic(), full.calc_quadratic());
6103
6104        let linear = GenCost::new(2, 0.0, 0.0, vec![2.0, 5.0]);
6105        assert_eq!(linear.calc_quadratic_with_constant(), Some((0.0, 2.0, 5.0)));
6106
6107        let constant = GenCost::new(2, 0.0, 0.0, vec![5.0]);
6108        assert_eq!(
6109            constant.calc_quadratic_with_constant(),
6110            Some((0.0, 0.0, 5.0))
6111        );
6112
6113        let piecewise = GenCost::new(1, 0.0, 0.0, vec![0.0, 0.0, 1.0, 1.0]);
6114        assert_eq!(piecewise.calc_quadratic_with_constant(), None);
6115
6116        let cubic = GenCost::new(2, 0.0, 0.0, vec![1.0, 1.0, 1.0, 1.0]);
6117        assert_eq!(cubic.calc_quadratic_with_constant(), None);
6118
6119        let truncated = GenCost::with_ncost(2, 0.0, 0.0, 3, vec![1.0]);
6120        assert_eq!(truncated.calc_quadratic_with_constant(), None);
6121    }
6122
6123    #[test]
6124    fn a_leading_coefficient_below_the_tolerance_comes_off_the_row() {
6125        let artifact = GenCost::new(2, 0.0, 0.0, vec![1e-17, 2.0, 5.0]);
6126        assert_eq!(
6127            artifact.calc_quadratic_with_constant(),
6128            Some((2e-17, 2.0, 5.0)),
6129            "the untouched reader keeps the artifact"
6130        );
6131        assert_eq!(
6132            artifact.calc_quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
6133            Some((0.0, 2.0, 5.0))
6134        );
6135        assert_eq!(
6136            artifact.calc_quadratic_with_constant_tol(0.0),
6137            Some((2e-17, 2.0, 5.0)),
6138            "a zero tolerance strips an exact zero alone"
6139        );
6140
6141        // A row states a curve of a lower order once the leading zeros are off,
6142        // so a cubic row the untouched reader refuses reads as a quadratic one.
6143        let padded = GenCost::new(2, 0.0, 0.0, vec![0.0, 1.5, 2.0, 5.0]);
6144        assert_eq!(padded.calc_quadratic_with_constant(), None);
6145        assert_eq!(
6146            padded.calc_quadratic_with_constant_tol(0.0),
6147            Some((3.0, 2.0, 5.0))
6148        );
6149
6150        let flat = GenCost::new(2, 0.0, 0.0, vec![1e-17, 1e-17, 1e-17]);
6151        assert_eq!(
6152            flat.calc_quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
6153            Some((0.0, 0.0, 1e-17)),
6154            "the last coefficient stays, whatever its magnitude"
6155        );
6156
6157        let piecewise = GenCost::new(1, 0.0, 0.0, vec![0.0, 0.0, 1.0, 1.0]);
6158        assert_eq!(
6159            piecewise.calc_quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
6160            None
6161        );
6162
6163        let truncated = GenCost::with_ncost(2, 0.0, 0.0, 3, vec![1.0]);
6164        assert_eq!(
6165            truncated.calc_quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
6166            None
6167        );
6168
6169        let quartic = GenCost::new(2, 0.0, 0.0, vec![1.0, 1.0, 1.0, 1.0, 1.0]);
6170        assert_eq!(
6171            quartic.calc_quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
6172            None
6173        );
6174    }
6175
6176    /// The bound at one corner of the voltage box: the law of cosines over the
6177    /// angle window, scaled by the larger terminal ceiling and the impedance.
6178    fn expected_rate(window: f64, fr: f64, to: f64, zmag: f64) -> f64 {
6179        let separation = (fr * fr + to * to - 2.0 * fr * to * window.cos()).sqrt();
6180        fr.max(to) * separation / zmag
6181    }
6182
6183    #[test]
6184    fn synthesized_rate_follows_the_angle_window_and_the_voltage_bands() {
6185        let br = Branch::new(BusId(1), BusId(2), 0.03, 0.04);
6186        let expected = |window: f64, fr: f64, to: f64| expected_rate(window, fr, to, 0.05);
6187        // A band pinned to one value, so the four corners collapse to one and
6188        // the bound is the plain law of cosines.
6189        let at = |v: f64| (v, v);
6190        close(
6191            br.synthesize_rate_a(0.5, at(1.1), at(1.06)),
6192            expected(0.5, 1.1, 1.06),
6193        );
6194
6195        // A wider window gives a looser bound.
6196        assert!(
6197            br.synthesize_rate_a(0.8, at(1.1), at(1.06))
6198                > br.synthesize_rate_a(0.5, at(1.1), at(1.06))
6199        );
6200
6201        // The magnitude of the window is what counts, and it holds at π.
6202        close(
6203            br.synthesize_rate_a(-0.5, at(1.1), at(1.06)),
6204            expected(0.5, 1.1, 1.06),
6205        );
6206        for window in [6.0, 2.0 * std::f64::consts::PI, -360.0] {
6207            close(
6208                br.synthesize_rate_a(window, at(1.1), at(1.06)),
6209                expected(std::f64::consts::PI, 1.1, 1.06),
6210            );
6211        }
6212
6213        let ideal = Branch::new(BusId(1), BusId(2), 0.0, 0.0);
6214        close(ideal.synthesize_rate_a(0.5, at(1.1), at(1.1)), 0.0);
6215    }
6216
6217    #[test]
6218    fn a_narrow_window_bounds_at_the_mixed_voltage_corner() {
6219        // The phasor difference is convex in the two voltages, so its largest
6220        // value over the band box is at a corner. Below roughly 10° that corner
6221        // is one terminal at its ceiling and the other at its floor, not both at
6222        // their ceilings. Reading only the ceilings there returns a bound
6223        // several times tighter than the branch physically has, and an OPF
6224        // enforces it.
6225        let br = Branch::new(BusId(1), BusId(2), 0.0, 0.01);
6226        let (vmin, vmax) = (0.9, 1.1);
6227        let corner = |window: f64, fr: f64, to: f64| expected_rate(window, fr, to, 0.01);
6228
6229        let narrow = 2.0_f64.to_radians();
6230        let bound = br.synthesize_rate_a(narrow, (vmin, vmax), (vmin, vmax));
6231        close(bound, corner(narrow, vmax, vmin));
6232        assert!(
6233            bound > 5.0 * corner(narrow, vmax, vmax),
6234            "the mixed corner dominates here: {bound} vs {}",
6235            corner(narrow, vmax, vmax)
6236        );
6237
6238        // Past the crossover both ceilings win again, and the bound follows.
6239        let wide = 30.0_f64.to_radians();
6240        close(
6241            br.synthesize_rate_a(wide, (vmin, vmax), (vmin, vmax)),
6242            corner(wide, vmax, vmax),
6243        );
6244    }
6245
6246    fn bus(id: usize) -> Bus {
6247        Bus {
6248            id: BusId(id),
6249            kind: BusType::Pq,
6250            vm: 1.0,
6251            va: 0.0,
6252            base_kv: 230.0,
6253            vmax: 1.1,
6254            vmin: 0.9,
6255            evhi: None,
6256            evlo: None,
6257            area: 1,
6258            zone: 1,
6259            name: None,
6260            uid: None,
6261            location: None,
6262            extras: Extras::new(),
6263        }
6264    }
6265
6266    fn winding(b: usize) -> Winding {
6267        Winding {
6268            bus: BusId(b),
6269            tap: 1.0,
6270            shift: 0.0,
6271            nominal_kv: 230.0,
6272            rate_a: 100.0,
6273            rate_b: 0.0,
6274            rate_c: 0.0,
6275            control: None,
6276        }
6277    }
6278
6279    fn transformer_3w() -> Transformer3W {
6280        let z = |r, x| Impedance {
6281            r,
6282            x,
6283            base_mva: 100.0,
6284        };
6285        Transformer3W {
6286            windings: [winding(1), winding(2), winding(3)],
6287            z: [z(0.01, 0.10), z(0.02, 0.20), z(0.03, 0.30)],
6288            star_vm: 0.98,
6289            star_va: -1.5,
6290            mag_g: 0.0,
6291            mag_b: 0.0,
6292            in_service: true,
6293            name: Some("T1".into()),
6294            uid: None,
6295            extras: Extras::new(),
6296        }
6297    }
6298
6299    #[test]
6300    fn star_impedances_split_the_pairwise_values() {
6301        // z1 = (z12 + z31 - z23)/2, z2 = (z12 + z23 - z31)/2, z3 = (z23 + z31 - z12)/2.
6302        let [(r1, x1), (r2, x2), (r3, x3)] = transformer_3w().calc_star_impedances();
6303        close(r1, 0.01);
6304        close(x1, 0.10);
6305        close(r2, 0.0);
6306        close(x2, 0.0);
6307        close(r3, 0.02);
6308        close(x3, 0.20);
6309    }
6310
6311    #[test]
6312    fn star_expansion_builds_a_star_bus_and_three_branches() {
6313        let t = transformer_3w();
6314        let (star, branches) = t.to_star_expansion(BusId(99));
6315
6316        assert_eq!(star.id, BusId(99));
6317        close(star.vm, 0.98);
6318        close(star.va, -1.5);
6319        // Each branch runs from its winding bus to the star, carrying the
6320        // winding tap and ratings and the split impedance.
6321        for (i, br) in branches.iter().enumerate() {
6322            assert_eq!(br.from, t.windings[i].bus);
6323            assert_eq!(br.to, BusId(99));
6324            close(br.tap, 1.0);
6325            close(br.rate_a, 100.0);
6326        }
6327        close(branches[2].r, 0.02);
6328        close(branches[2].x, 0.20);
6329    }
6330
6331    #[test]
6332    fn star_expansion_carries_winding_status_and_rating_sets() {
6333        let mut t = transformer_3w();
6334        t.set_winding_in_service([true, false, true]);
6335        t.set_winding_rating_sets(2, vec![BranchRatingSet::new("RATE4", 75.0)]);
6336        let (_, branches) = t.to_star_expansion(BusId(99));
6337        assert_eq!(
6338            branches.each_ref().map(|br| br.in_service),
6339            [true, false, true]
6340        );
6341        assert_eq!(
6342            branches[2].rating_sets,
6343            [BranchRatingSet::new("RATE4", 75.0)]
6344        );
6345        assert_eq!(branches[0].rating_sets, []);
6346
6347        // The transformer status still takes every winding out.
6348        t.in_service = false;
6349        let (_, branches) = t.to_star_expansion(BusId(99));
6350        assert!(branches.iter().all(|br| !br.in_service));
6351
6352        // The lowering keeps all three branches of a partly in service
6353        // transformer, so analysis rows still map to windings one for one.
6354        t.in_service = true;
6355        let mut net =
6356            BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
6357        net.transformers_3w_mut().push(t);
6358        let lowered = net.expand_transformers_3w();
6359        assert_eq!(
6360            lowered
6361                .branches()
6362                .iter()
6363                .map(|br| (br.from, br.in_service))
6364                .collect::<Vec<_>>(),
6365            [(BusId(1), true), (BusId(2), false), (BusId(3), true)]
6366        );
6367        assert_eq!(net.lowered_lengths().branches, lowered.branches().len());
6368    }
6369
6370    #[test]
6371    fn winding_status_and_rating_sets_live_in_extras_only_when_stated() {
6372        let mut t = transformer_3w();
6373        assert_eq!(t.winding_in_service(), [true; 3]);
6374        assert_eq!(t.winding_rating_sets(1), []);
6375
6376        t.set_winding_in_service([false, true, true]);
6377        t.set_winding_rating_sets(1, vec![BranchRatingSet::new("RATE4", 75.0)]);
6378        assert_eq!(
6379            t.extras["winding_in_service"],
6380            serde_json::json!([false, true, true])
6381        );
6382        assert_eq!(
6383            t.extras["winding_rating_sets"],
6384            serde_json::json!([[], [{"name": "RATE4", "rate_mva": 75.0}], []])
6385        );
6386        let mut net =
6387            BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
6388        net.transformers_3w_mut().push(t.clone());
6389        let back = serde_round_trip(&net);
6390        assert_eq!(
6391            back.transformers_3w()[0].winding_in_service(),
6392            [false, true, true]
6393        );
6394        assert_eq!(
6395            back.transformers_3w()[0].winding_rating_sets(1),
6396            [BranchRatingSet::new("RATE4", 75.0)]
6397        );
6398
6399        // Restating the defaults removes both entries.
6400        t.set_winding_in_service([true; 3]);
6401        t.set_winding_rating_sets(1, Vec::new());
6402        assert!(t.extras.is_empty(), "{:?}", t.extras);
6403
6404        // A malformed entry reads as the default rather than failing.
6405        t.extras
6406            .insert("winding_in_service".to_owned(), Value::from("open"));
6407        assert_eq!(t.winding_in_service(), [true; 3]);
6408    }
6409
6410    #[test]
6411    fn three_winding_transformer_survives_serde_round_trip() {
6412        let mut net =
6413            BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
6414        net.transformers_3w_mut().push(transformer_3w());
6415        net.validate().unwrap();
6416
6417        let back = serde_round_trip(&net);
6418        assert_eq!(back.transformers_3w().len(), 1);
6419        close(back.transformers_3w()[0].z[1].x, 0.20);
6420        assert_eq!(back.transformers_3w()[0].windings[2].bus, BusId(3));
6421    }
6422
6423    #[test]
6424    fn a_network_with_only_out_of_service_three_winding_units_is_not_copied() {
6425        let mut out_of_service = transformer_3w();
6426        out_of_service.in_service = false;
6427        let mut net =
6428            BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
6429        net.transformers_3w_mut().push(out_of_service);
6430        assert!(matches!(
6431            net.expand_transformers_3w(),
6432            std::borrow::Cow::Borrowed(_)
6433        ));
6434        // A cached core serves the view as is.
6435        let core = crate::IndexCore::build(&net);
6436        let view = crate::IndexedNetwork::with_core(&net, &core);
6437        assert!(std::ptr::eq(view.network(), std::ptr::from_ref(&net)));
6438        net.transformers_3w_mut()[0].in_service = true;
6439        assert!(matches!(
6440            net.expand_transformers_3w(),
6441            std::borrow::Cow::Owned(_)
6442        ));
6443    }
6444
6445    #[test]
6446    fn lowered_lengths_match_the_expansion() {
6447        // `lowered_lengths` counts what `expand_transformers_3w` would append
6448        // instead of building it. The two must agree on every mix: an
6449        // out-of-service unit appends nothing, and only a unit with magnetizing
6450        // admittance appends a shunt.
6451        let mut magnetizing = transformer_3w();
6452        magnetizing.mag_b = 0.02;
6453        let mut out_of_service = transformer_3w();
6454        out_of_service.in_service = false;
6455        out_of_service.mag_g = 0.01;
6456
6457        for units in [
6458            vec![],
6459            vec![transformer_3w()],
6460            vec![magnetizing.clone()],
6461            vec![out_of_service.clone()],
6462            vec![transformer_3w(), magnetizing, out_of_service],
6463        ] {
6464            let mut net =
6465                BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
6466            net.shunts_mut().push(Shunt::new(BusId(1), 0.0, 0.5));
6467            *net.transformers_3w_mut() = units;
6468            let counted = net.lowered_lengths();
6469            let built = net.expand_transformers_3w();
6470            assert_eq!(counted.buses, built.buses().len());
6471            assert_eq!(counted.branches, built.branches().len());
6472            assert_eq!(counted.shunts, built.shunts().len());
6473        }
6474    }
6475
6476    #[test]
6477    fn check_references_rejects_bus_ids_without_star_expansion_headroom() {
6478        // A bus id at the top of the id space would make the synthetic star id
6479        // `max_bus_id + 1 + k` run past it during indexed analysis; the parse
6480        // boundary refuses it like any other malformed reference.
6481        let mut net = BalancedNetwork::in_memory(
6482            "t",
6483            100.0,
6484            vec![bus(1), bus(2), bus(3), bus(i64::MAX as usize)],
6485            Vec::new(),
6486        );
6487        net.transformers_3w_mut().push(transformer_3w());
6488        let err = net.validate().unwrap_err().to_string();
6489        assert!(
6490            err.contains("no room to allocate synthetic star bus ids"),
6491            "got {err}"
6492        );
6493    }
6494
6495    #[test]
6496    fn star_expansion_headroom_counts_only_in_service_transformers() {
6497        // The headroom needed is the in-service transformer count (plus the
6498        // base id), not the total: an out-of-service unit allocates no star
6499        // bus, so a network that only fits the in-service count must not be
6500        // rejected. A max bus id one under the ceiling fits one in-service
6501        // star id (max + 1) but not two.
6502        let mut net = BalancedNetwork::in_memory(
6503            "t",
6504            100.0,
6505            vec![bus(1), bus(2), bus(3), bus(i64::MAX as usize - 1)],
6506            Vec::new(),
6507        );
6508        net.transformers_3w_mut().push(transformer_3w());
6509        let mut out_of_service = transformer_3w();
6510        out_of_service.in_service = false;
6511        net.transformers_3w_mut().push(out_of_service);
6512        net.validate()
6513            .expect("in-service count fits; must not be rejected");
6514    }
6515
6516    #[test]
6517    fn check_references_rejects_a_bus_id_past_the_int64_ceiling() {
6518        // The C ABI reports bus ids as int64, so two distinct usize ids above
6519        // the ceiling both surface as the same value and a branch endpoint
6520        // matches two bus rows. Refuse them where every other malformed
6521        // reference is refused.
6522        let mut net = BalancedNetwork::in_memory(
6523            "t",
6524            100.0,
6525            vec![bus(1), bus(i64::MAX as usize + 1)],
6526            Vec::new(),
6527        );
6528        let err = net.validate().unwrap_err().to_string();
6529        assert!(err.contains("outside the int64 id space"), "got {err}");
6530
6531        // The ceiling itself is a valid id.
6532        net.buses_mut()[1].id = BusId(i64::MAX as usize);
6533        net.validate().expect("the ceiling itself is representable");
6534    }
6535
6536    #[test]
6537    fn check_references_rejects_a_dangling_winding_bus() {
6538        let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
6539        net.transformers_3w_mut().push(transformer_3w()); // winding 3 references bus 3
6540        let err = net.validate().unwrap_err().to_string();
6541        assert!(
6542            err.contains("3-winding transformer references unknown bus 3"),
6543            "got {err}"
6544        );
6545    }
6546
6547    #[test]
6548    fn check_references_rejects_a_dangling_winding_control_bus() {
6549        let mut net =
6550            BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
6551        let mut transformer = transformer_3w();
6552        let mut control = TransformerControl::new(TransformerControlMode::Voltage);
6553        control.controlled_bus = Some(BusId(4));
6554        transformer.windings[2].control = Some(control);
6555        net.transformers_3w_mut().push(transformer);
6556        let err = net.validate().unwrap_err().to_string();
6557        assert!(
6558            err.contains("3-winding transformer control references unknown bus 4"),
6559            "got {err}"
6560        );
6561    }
6562
6563    /// A regulating transformer (bus 1→2) controlling the voltage at bus `reg`.
6564    fn regulating_branch(reg: usize) -> Branch {
6565        Branch {
6566            name: None,
6567            from: BusId(1),
6568            to: BusId(2),
6569            r: 0.0,
6570            x: 0.1,
6571            b: 0.0,
6572            charging: None,
6573            rate_a: 0.0,
6574            rate_b: 0.0,
6575            rate_c: 0.0,
6576            rating_sets: Vec::new(),
6577            current_ratings: None,
6578            tap: 1.0,
6579            shift: 0.0,
6580            in_service: true,
6581            angmin: -360.0,
6582            angmax: 360.0,
6583            control: Some(TransformerControl {
6584                mode: TransformerControlMode::Voltage,
6585                enabled: true,
6586                controlled_bus: Some(BusId(reg)),
6587                controlled_bus_on_winding_side: false,
6588                regulating_terminal: None,
6589                tap_min: 0.95,
6590                tap_max: 1.05,
6591                band_min: 1.0,
6592                band_max: 1.02,
6593                ntp: 17,
6594                mva_base: 100.0,
6595                winding_connection_angle: None,
6596            }),
6597            solution: None,
6598            uid: None,
6599            route: None,
6600            extras: Extras::new(),
6601        }
6602    }
6603
6604    #[test]
6605    fn transformer_control_survives_serde_round_trip() {
6606        let mut net =
6607            BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
6608        net.branches_mut().push(regulating_branch(3));
6609        net.validate().unwrap();
6610
6611        let back = serde_round_trip(&net);
6612        let c = back.branches()[0].control.as_ref().unwrap();
6613        assert_eq!(c.mode, TransformerControlMode::Voltage);
6614        assert_eq!(c.controlled_bus, Some(BusId(3)));
6615        close(c.tap_max, 1.05);
6616        assert_eq!(c.ntp, 17);
6617    }
6618
6619    #[test]
6620    fn gen_caps_serialize_as_a_named_map_that_grows_additively() {
6621        let mut caps: GenCaps = [None; GEN_EXTRA_KEYS.len()];
6622        caps[8] = Some(1.5); // ramp_30
6623        caps[10] = Some(0.5); // apf
6624        let g = Generator {
6625            bus: BusId(1),
6626            energy_source: GeneratorEnergySource::default(),
6627            pg: 10.0,
6628            qg: 0.0,
6629            pmax: 100.0,
6630            pmin: 0.0,
6631            qmax: 50.0,
6632            qmin: -50.0,
6633            vg: 1.0,
6634            mbase: 100.0,
6635            in_service: true,
6636            cost: None,
6637            caps,
6638            voltage_regulation_on: false,
6639            regulating_terminal: Some(TerminalReference {
6640                equipment: ComponentId::new("load", "L1").unwrap(),
6641                terminal: 1,
6642            }),
6643            regulated_bus: Some(BusId(2)),
6644            active_power_control: None,
6645            uid: None,
6646        };
6647
6648        // caps is a name-keyed object emitting only the present slots, not a
6649        // length-exact array.
6650        let json = serde_json::to_string(&g).unwrap();
6651        assert!(json.contains(r#""caps":{"#), "caps is an object: {json}");
6652        assert!(json.contains(r#""ramp_30":1.5"#) && json.contains(r#""apf":0.5"#));
6653        let back: Generator = serde_json::from_str(&json).unwrap();
6654        assert_eq!(back.caps, g.caps);
6655        assert!(!back.voltage_regulation_on);
6656        assert_eq!(back.regulating_terminal, g.regulating_terminal);
6657        assert_eq!(back.regulated_bus, Some(BusId(2)));
6658
6659        // Growing GEN_EXTRA_KEYS stays additive: an unknown future key is ignored,
6660        // a missing key reads as None, and an omitted field is the empty set.
6661        let with_future = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
6662            "vg":1,"mbase":100,"in_service":true,"cost":null,
6663            "caps":{"ramp_30":1.5,"future_ramp":9.9}}"#;
6664        let g2: Generator = serde_json::from_str(with_future).unwrap();
6665        assert_eq!(g2.caps[8], Some(1.5));
6666        assert_eq!(g2.caps.iter().filter(|v| v.is_some()).count(), 1);
6667        let no_caps = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
6668            "vg":1,"mbase":100,"in_service":true,"cost":null}"#;
6669        let g3: Generator = serde_json::from_str(no_caps).unwrap();
6670        assert!(!g3.has_caps());
6671        assert!(g3.voltage_regulation_on);
6672        assert_eq!(g3.regulating_terminal, None);
6673
6674        // An explicit `"caps":null` is the empty set too, the same as omitting it.
6675        let null_caps = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
6676            "vg":1,"mbase":100,"in_service":true,"cost":null,"caps":null}"#;
6677        let g4: Generator = serde_json::from_str(null_caps).unwrap();
6678        assert!(!g4.has_caps());
6679    }
6680
6681    #[test]
6682    #[allow(clippy::float_cmp)]
6683    fn nonfinite_values_round_trip_through_serde() {
6684        let bus = |id, vm| Bus {
6685            id: BusId(id),
6686            kind: BusType::Pq,
6687            vm,
6688            va: 0.0,
6689            base_kv: 230.0,
6690            vmax: 1.1,
6691            vmin: 0.9,
6692            evhi: None,
6693            evlo: None,
6694            area: 1,
6695            zone: 1,
6696            name: None,
6697            uid: None,
6698            location: None,
6699            extras: Extras::new(),
6700        };
6701        let branch = Branch {
6702            name: None,
6703            from: BusId(1),
6704            to: BusId(2),
6705            r: 0.0,
6706            x: f64::INFINITY,
6707            b: 0.0,
6708            charging: None,
6709            rate_a: 0.0,
6710            rate_b: 0.0,
6711            rate_c: 0.0,
6712            rating_sets: Vec::new(),
6713            current_ratings: None,
6714            tap: 0.0,
6715            shift: 0.0,
6716            in_service: true,
6717            angmin: -360.0,
6718            angmax: 360.0,
6719            control: None,
6720            solution: None,
6721            uid: None,
6722            route: None,
6723            extras: Extras::new(),
6724        };
6725        // A non-finite generator capability reports at its exact key path
6726        // (caps serializes as a name-keyed object), not the parent `caps`.
6727        let mut g = Generator {
6728            bus: BusId(1),
6729            energy_source: GeneratorEnergySource::default(),
6730            pg: 0.0,
6731            qg: 0.0,
6732            pmax: 0.0,
6733            pmin: 0.0,
6734            qmax: 0.0,
6735            qmin: 0.0,
6736            vg: 1.0,
6737            mbase: 100.0,
6738            in_service: true,
6739            cost: None,
6740            caps: GenCaps::default(),
6741            voltage_regulation_on: true,
6742            regulating_terminal: None,
6743            regulated_bus: None,
6744            active_power_control: None,
6745            uid: None,
6746        };
6747        g.caps[8] = Some(f64::INFINITY); // ramp_30
6748        // Three nonfinite values at three nesting depths: a bus vm (NaN, a
6749        // struct field in a table), a branch x (Inf), and a generator ramp_30
6750        // cap (Inf, inside the name-keyed caps object).
6751        let mut net = BalancedNetwork::in_memory(
6752            "nf",
6753            100.0,
6754            vec![bus(1, f64::NAN), bus(2, 1.0)],
6755            vec![branch],
6756        );
6757        net.generators_mut().push(g);
6758
6759        let text = serde_json::to_string(&net).unwrap();
6760        assert!(text.contains(r#""vm":"NaN""#), "{text}");
6761        assert!(text.contains(r#""x":"Infinity""#), "{text}");
6762        assert!(text.contains(r#""ramp_30":"Infinity""#), "{text}");
6763
6764        let back: BalancedNetwork = serde_json::from_str(&text).unwrap();
6765        assert!(back.buses()[0].vm.is_nan());
6766        assert_eq!(back.branches()[0].x, f64::INFINITY);
6767        assert_eq!(back.generators()[0].caps[8], Some(f64::INFINITY));
6768
6769        // A second serialization is byte stable.
6770        assert_eq!(serde_json::to_string(&back).unwrap(), text);
6771    }
6772
6773    #[test]
6774    fn a_null_at_a_float_position_is_rejected() {
6775        let net = BalancedNetwork::in_memory("nf", 100.0, vec![bus(1), bus(2)], Vec::new());
6776        let text = serde_json::to_string(&net)
6777            .unwrap()
6778            .replacen("\"vm\":1.0", "\"vm\":null", 1);
6779        assert!(text.contains("\"vm\":null"), "fixture edit failed: {text}");
6780        let err = serde_json::from_str::<BalancedNetwork>(&text)
6781            .unwrap_err()
6782            .to_string();
6783        assert!(err.contains("cannot be null"), "{err}");
6784    }
6785
6786    #[test]
6787    fn check_references_rejects_a_dangling_controlled_bus() {
6788        let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
6789        net.branches_mut().push(regulating_branch(9)); // controls a bus that doesn't exist
6790        let err = net.validate().unwrap_err().to_string();
6791        assert!(
6792            err.contains("transformer control references unknown bus 9"),
6793            "got {err}"
6794        );
6795    }
6796
6797    #[test]
6798    fn check_references_rejects_winding_side_without_a_controlled_bus() {
6799        let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
6800        let mut branch = regulating_branch(2);
6801        let control = branch.control.as_mut().unwrap();
6802        control.controlled_bus = None;
6803        control.controlled_bus_on_winding_side = true;
6804        net.branches_mut().push(branch);
6805
6806        let err = net.validate().unwrap_err().to_string();
6807        assert!(
6808            err.contains("winding side but has no nonzero controlled bus"),
6809            "got {err}"
6810        );
6811    }
6812
6813    /// A discrete switched shunt on bus 1 regulating the voltage at bus `reg`.
6814    fn switched_shunt(reg: usize) -> Shunt {
6815        Shunt {
6816            bus: BusId(1),
6817            g: 0.0,
6818            b: 19.0,
6819            in_service: true,
6820            section_count: None,
6821            control: Some(SwitchedShuntControl {
6822                mode: SwitchedShuntMode::Discrete,
6823                vhigh: 1.05,
6824                vlow: 0.95,
6825                control_bus: Some(BusId(reg)),
6826                regulating_terminal: None,
6827                rmpct: 100.0,
6828                blocks: vec![ShuntBlock::new(2, 25.0), ShuntBlock::new(1, 50.0)],
6829            }),
6830            uid: None,
6831            extras: Extras::new(),
6832        }
6833    }
6834
6835    #[test]
6836    fn switched_shunt_control_survives_serde_round_trip() {
6837        let mut net =
6838            BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
6839        net.shunts_mut().push(switched_shunt(3));
6840        net.validate().unwrap();
6841
6842        let back = serde_round_trip(&net);
6843        let c = back.shunts()[0].control.as_ref().unwrap();
6844        assert_eq!(c.mode, SwitchedShuntMode::Discrete);
6845        assert_eq!(c.control_bus, Some(BusId(3)));
6846        assert_eq!(c.blocks.len(), 2);
6847        close(c.blocks[1].b, 50.0);
6848    }
6849
6850    #[test]
6851    fn check_references_rejects_a_dangling_switched_shunt_control_bus() {
6852        let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
6853        net.shunts_mut().push(switched_shunt(9)); // controls a bus that doesn't exist
6854        let err = net.validate().unwrap_err().to_string();
6855        assert!(
6856            err.contains("switched-shunt control references unknown bus 9"),
6857            "got {err}"
6858        );
6859    }
6860
6861    #[test]
6862    fn validate_values_flags_and_repair_clamps_out_of_domain_values() {
6863        let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
6864        net.buses_mut()[0].vm = 0.0; // outside [0, 2]
6865        net.buses_mut()[1].va = 9000.0; // past ±2000°
6866        net.generators_mut().push(Generator {
6867            bus: BusId(1),
6868            energy_source: GeneratorEnergySource::default(),
6869            pg: 10.0,
6870            qg: 0.0,
6871            pmax: 100.0,
6872            pmin: 0.0,
6873            qmax: 50.0,
6874            qmin: -50.0,
6875            vg: 0.0,    // non-positive setpoint
6876            mbase: 0.0, // non-positive base
6877            in_service: true,
6878            cost: None,
6879            voltage_regulation_on: true,
6880            regulating_terminal: None,
6881            caps: Default::default(),
6882            regulated_bus: None,
6883            active_power_control: None,
6884            uid: None,
6885        });
6886
6887        let diags = net.validate_values();
6888        let fields: std::collections::BTreeSet<_> = diags
6889            .iter()
6890            .map(|d| d.details()["field"].as_str().unwrap().to_owned())
6891            .collect();
6892        assert_eq!(
6893            fields,
6894            ["mbase", "va", "vg", "vm"]
6895                .into_iter()
6896                .map(str::to_owned)
6897                .collect(),
6898            "all four out-of-domain fields reported"
6899        );
6900        assert!(
6901            diags
6902                .iter()
6903                .all(|d| d.code() == "VALIDATE.BALANCED.VALUE_DOMAIN" && d.target().is_some())
6904        );
6905        // Non-mutating: the network still holds the bad values.
6906        close(net.buses()[0].vm, 0.0);
6907
6908        // The recorded path: repair the module, read the history entry.
6909        let module = powerio_core::PioModule::new(net);
6910        let module = repair_values(module).unwrap();
6911        let net = &module.value();
6912        close(net.buses()[0].vm, 1.0);
6913        close(net.buses()[1].va, 0.0);
6914        close(net.generators()[0].mbase, 100.0); // → base_mva
6915        close(net.generators()[0].vg, 1.0);
6916        // Idempotent: nothing left to repair, and a second pass appends
6917        // nothing.
6918        assert_eq!(net.validate_values().as_slice(), []);
6919        let entries = module.history();
6920        assert_eq!(entries.len(), 1);
6921        assert_eq!(entries[0].kind(), powerio_core::HistoryKind::Repair);
6922        assert_eq!(
6923            entries[0].parameters()["repairs"].as_array().unwrap().len(),
6924            diags.len()
6925        );
6926        assert_eq!(module.diagnostics().len(), diags.len());
6927        let module = repair_values(module).unwrap();
6928        assert_eq!(module.history().len(), 1);
6929    }
6930
6931    #[test]
6932    fn validate_values_is_empty_for_a_clean_network() {
6933        let net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
6934        assert_eq!(net.validate_values().as_slice(), []);
6935    }
6936
6937    #[test]
6938    fn capability_curve_rejects_a_nonfinite_active_power() {
6939        let limits = ReactiveLimits::CapabilityCurve(ReactiveCapabilityCurve {
6940            curve_style: CurveStyle::StraightLineYValues,
6941            properties: BTreeMap::new(),
6942            points: vec![
6943                ReactiveCapabilityCurvePoint {
6944                    active_power_mw: 0.0,
6945                    minimum_reactive_power_mvar: -10.0,
6946                    maximum_reactive_power_mvar: 10.0,
6947                    properties: BTreeMap::new(),
6948                },
6949                ReactiveCapabilityCurvePoint {
6950                    active_power_mw: 100.0,
6951                    minimum_reactive_power_mvar: -5.0,
6952                    maximum_reactive_power_mvar: 5.0,
6953                    properties: BTreeMap::new(),
6954                },
6955            ],
6956        });
6957
6958        let error = calc_reactive_limits_at_active_power("generator 1", &limits, f64::NAN)
6959            .expect_err("NaN must not be used to index a capability curve");
6960        assert!(error.contains("nonfinite active power"), "{error}");
6961    }
6962    #[test]
6963    fn impedance_correction_factor_interpolates_and_holds_the_end_points() {
6964        let point = ImpedanceCorrectionPoint::new;
6965        let table = ImpedanceCorrection::new(
6966            1,
6967            ImpedanceCorrectionVariable::Angle,
6968            vec![
6969                point(30.0, 1.2, -0.2),
6970                point(-30.0, 1.2, 0.2),
6971                point(0.0, 1.0, 0.0),
6972            ],
6973        );
6974        assert_eq!(table.calc_factor(-45.0), Some((1.2, 0.2)));
6975        assert_eq!(table.calc_factor(45.0), Some((1.2, -0.2)));
6976        let (re, im) = table.calc_factor(15.0).unwrap();
6977        close(re, 1.1);
6978        close(im, -0.1);
6979        let single = ImpedanceCorrection::new(
6980            2,
6981            ImpedanceCorrectionVariable::Ratio,
6982            vec![point(1.0, 0.9, 0.0)],
6983        );
6984        assert_eq!(single.calc_factor(1.1), Some((0.9, 0.0)));
6985        let empty = ImpedanceCorrection::new(3, ImpedanceCorrectionVariable::Ratio, Vec::new());
6986        assert_eq!(empty.calc_factor(1.0), None);
6987    }
6988}