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