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