Skip to main content

surge_network/network/
model.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Network model — the complete power system representation.
3
4use std::collections::{HashMap, HashSet};
5
6use tracing::debug;
7
8use crate::market::{
9    AmbientConditions, CombinedCyclePlant, DispatchableLoad, EmissionPolicy, MarketRules,
10    OutageEntry, PumpedHydroUnit, ReserveZone,
11};
12use crate::network::asset::AssetCatalog;
13use crate::network::boundary::BoundaryData;
14use crate::network::breaker::BreakerRating;
15use crate::network::cgmes_roundtrip::CgmesRoundtripData;
16use crate::network::grounding::GroundingEntry;
17use crate::network::impedance_correction::ImpedanceCorrectionTable;
18use crate::network::induction_machine::InductionMachine;
19use crate::network::market_data::MarketData;
20use crate::network::measurement::CimMeasurement;
21use crate::network::multi_section_line::MultiSectionLineGroup;
22use crate::network::net_ops::NetworkOperationsData;
23use crate::network::op_limits::OperationalLimits;
24use crate::network::protection::ProtectionData;
25use crate::network::scheduled_area_transfer::ScheduledAreaTransfer;
26use crate::network::switching_device_rating::SwitchingDeviceRatingSet;
27use crate::network::types::DEFAULT_BASE_MVA;
28use crate::network::voltage_droop_control::VoltageDroopControl;
29use crate::network::{
30    AreaSchedule, Branch, Bus, BusType, FactsDevice, FixedShunt, Flowgate, Generator, HvdcModel,
31    Interface, Load, NodeBreakerTopology, OltcSpec, Owner, ParSpec, PowerInjection, Region,
32    SwitchedShunt, SwitchedShuntOpf, TopologyMappingState, generator::StorageValidationError,
33};
34use serde::{Deserialize, Serialize};
35
36/// Structured error type returned by [`Network::validate`].
37#[derive(Debug, thiserror::Error)]
38pub enum NetworkError {
39    /// The network has no buses.
40    #[error("network has no buses")]
41    EmptyNetwork,
42
43    /// `base_mva` is not positive or not finite.
44    #[error("base_mva must be positive and finite, got {0}")]
45    InvalidBaseMva(f64),
46
47    /// Two or more buses share the same external bus number.
48    #[error("duplicate bus number {0}")]
49    DuplicateBusNumber(u32),
50
51    /// No bus has `BusType::Slack` (every network needs at least one angle reference).
52    #[error("network has no slack bus")]
53    NoSlackBus,
54
55    /// A branch references a bus number that does not exist in the bus list.
56    #[error("branch ({branch_from}-{branch_to}) references missing bus {missing_bus}")]
57    InvalidBranchEndpoint {
58        branch_from: u32,
59        branch_to: u32,
60        missing_bus: u32,
61    },
62
63    /// A branch has the same bus on both ends.
64    #[error("branch ({0}-{0}) is a self-loop")]
65    SelfLoopBranch(u32),
66
67    /// A generator references a bus number not present in the bus list.
68    #[error("generator references missing bus {0}")]
69    InvalidGeneratorBus(u32),
70
71    /// A generator references a remote regulated bus number not present in the bus list.
72    #[error("generator at bus {bus} references missing regulated bus {reg_bus}")]
73    InvalidGeneratorRegulatedBus { bus: u32, reg_bus: u32 },
74
75    /// A load references a bus number not present in the bus list.
76    #[error("load references missing bus {0}")]
77    InvalidLoadBus(u32),
78
79    /// A power injection references a bus number not present in the bus list.
80    #[error("power injection references missing bus {0}")]
81    InvalidPowerInjectionBus(u32),
82
83    /// A fixed shunt references a bus number not present in the bus list.
84    #[error("fixed shunt references missing bus {0}")]
85    InvalidFixedShuntBus(u32),
86
87    /// A dispatchable load references a bus number not present in the bus list.
88    #[error("dispatchable load references missing bus {0}")]
89    InvalidDispatchableLoadBus(u32),
90
91    /// Two generators share the same canonical ID (after whitespace trimming).
92    #[error("duplicate canonical generator id `{id}`")]
93    DuplicateGeneratorId { id: String },
94
95    /// A switched shunt references a missing host bus.
96    #[error("switched shunt `{id}` references missing host bus {bus}")]
97    InvalidSwitchedShuntBus { id: String, bus: u32 },
98
99    /// A switched shunt references a missing regulated bus.
100    #[error("switched shunt `{id}` references missing regulated bus {bus}")]
101    InvalidSwitchedShuntRegulatedBus { id: String, bus: u32 },
102
103    /// A switched-shunt OPF relaxation references a missing host bus.
104    #[error("switched shunt OPF `{id}` references missing host bus {bus}")]
105    InvalidSwitchedShuntOpfBus { id: String, bus: u32 },
106
107    /// A generator has `pmin > pmax`.
108    #[error("generator at bus {bus} has pmin > pmax")]
109    InvalidGeneratorLimits { bus: u32 },
110
111    /// A generator has `qmin > qmax`.
112    #[error("generator at bus {bus} has qmin > qmax")]
113    InvalidGeneratorReactiveLimits { bus: u32 },
114
115    /// A storage-capable generator has invalid `StorageParams` (e.g. negative capacity).
116    #[error("generator at bus {bus} has invalid storage parameters: {source}")]
117    InvalidStorageParameters {
118        bus: u32,
119        #[source]
120        source: StorageValidationError,
121    },
122
123    /// A bus field required for solve readiness is not finite or out of range.
124    #[error("bus {bus} field `{field}` is invalid: {value}")]
125    InvalidBusField {
126        bus: u32,
127        field: &'static str,
128        value: f64,
129    },
130
131    /// A load field required for solve readiness is not finite or out of range.
132    #[error("load at bus {bus} field `{field}` is invalid: {value}")]
133    InvalidLoadField {
134        bus: u32,
135        field: &'static str,
136        value: f64,
137    },
138
139    /// A fixed shunt field required for solve readiness is not finite or out of range.
140    #[error("fixed shunt at bus {bus} field `{field}` is invalid: {value}")]
141    InvalidFixedShuntField {
142        bus: u32,
143        field: &'static str,
144        value: f64,
145    },
146
147    /// A power injection field required for solve readiness is not finite or out of range.
148    #[error("power injection at bus {bus} field `{field}` is invalid: {value}")]
149    InvalidPowerInjectionField {
150        bus: u32,
151        field: &'static str,
152        value: f64,
153    },
154
155    /// A generator field required for solve readiness is not finite or out of range.
156    #[error("generator at bus {bus} field `{field}` is invalid: {value}")]
157    InvalidGeneratorField {
158        bus: u32,
159        field: &'static str,
160        value: f64,
161    },
162
163    /// A branch field required for solve readiness is not finite or out of range.
164    #[error("branch ({from_bus}-{to_bus}) field `{field}` is invalid: {value}")]
165    InvalidBranchField {
166        from_bus: u32,
167        to_bus: u32,
168        field: &'static str,
169        value: f64,
170    },
171
172    /// Branch angle-difference limits are finite but inverted.
173    #[error(
174        "branch ({from_bus}-{to_bus}) has invalid angle bounds (min={min_rad:?}, max={max_rad:?})"
175    )]
176    InvalidBranchAngleBounds {
177        from_bus: u32,
178        to_bus: u32,
179        min_rad: Option<f64>,
180        max_rad: Option<f64>,
181    },
182
183    /// A bus component does not have exactly one slack bus.
184    #[error(
185        "connected component with buses {buses:?} has slack buses {slack_buses:?}; expected exactly one slack bus"
186    )]
187    InvalidSlackPlacement {
188        buses: Vec<u32>,
189        slack_buses: Vec<u32>,
190    },
191
192    /// An isolated bus is still connected by an in-service branch.
193    #[error("bus {bus} is marked isolated but still has in-service connectivity")]
194    InvalidIsolatedBusConnectivity { bus: u32 },
195
196    /// A bus has NaN or Inf in its voltage magnitude or angle initial condition.
197    #[error("bus {0} has non-finite voltage initial condition (vm or va is NaN/Inf)")]
198    NonFiniteBusVoltage(u32),
199
200    /// A bus has non-finite or inverted voltage bounds (`vmin > vmax`).
201    #[error(
202        "bus {0} has invalid voltage bounds (vmin={1}, vmax={2}): must be finite with vmin <= vmax"
203    )]
204    InvalidBusVoltageBounds(u32, f64, f64),
205
206    /// A branch has NaN or Inf in `r`, `x`, `b`, or `tap`.
207    #[error("branch ({0}-{1}) has non-finite impedance parameter (r, x, b, or tap is NaN/Inf)")]
208    NonFiniteBranchImpedance(u32, u32),
209
210    /// Two or more branches share the same `(from_bus, to_bus, circuit)` key.
211    #[error("duplicate branch key ({from_bus}-{to_bus} ckt {circuit})")]
212    DuplicateBranchKey {
213        from_bus: u32,
214        to_bus: u32,
215        circuit: String,
216    },
217
218    /// Node-breaker topology is present but no bus-branch mapping has been built yet.
219    #[error("network has node-breaker topology but no mapped bus-branch view yet")]
220    MissingTopologyMapping,
221
222    /// Node-breaker topology mapping is stale (switches changed since last rebuild).
223    #[error("network node-breaker topology is stale; call rebuild_topology() before solving")]
224    StaleNodeBreakerTopology,
225
226    /// An interface uses mismatched or invalid branch/coefficient metadata.
227    #[error("interface `{name}` is invalid: {detail}")]
228    InvalidInterfaceDefinition { name: String, detail: String },
229
230    /// A flowgate uses mismatched or invalid monitored branch/coefficient metadata.
231    #[error("flowgate `{name}` is invalid: {detail}")]
232    InvalidFlowgateDefinition { name: String, detail: String },
233
234    /// Two or more area schedules share the same area number.
235    #[error("duplicate area schedule number {0}")]
236    DuplicateAreaScheduleNumber(u32),
237
238    /// An area schedule references a missing or invalid slack bus.
239    #[error("area {area} references invalid slack bus {slack_bus}")]
240    InvalidAreaScheduleSlackBus { area: u32, slack_bus: u32 },
241
242    /// An area-schedule field required for runtime correctness is invalid.
243    #[error("area {area} field `{field}` is invalid: {value}")]
244    InvalidAreaScheduleField {
245        area: u32,
246        field: &'static str,
247        value: f64,
248    },
249
250    /// Two or more point-to-point HVDC links share the same stable name.
251    #[error("duplicate HVDC link name `{name}`")]
252    DuplicateHvdcLinkName { name: String },
253
254    /// A point-to-point HVDC link references a missing AC bus.
255    #[error("HVDC link `{name}` references missing AC bus {bus}")]
256    InvalidHvdcLinkEndpoint { name: String, bus: u32 },
257
258    /// Two explicit DC grids share the same canonical grid id.
259    #[error("duplicate explicit DC grid id {id}")]
260    DuplicateDcGridId { id: u32 },
261
262    /// Two buses inside the same explicit DC grid share the same bus id.
263    #[error("explicit DC grid {grid_id} has duplicate DC bus id {bus_id}")]
264    DuplicateDcBusId { grid_id: u32, bus_id: u32 },
265
266    /// An explicit DC-grid converter references a missing AC bus.
267    #[error("explicit DC grid {grid_id} converter references missing AC bus {ac_bus}")]
268    InvalidDcConverterAcBus { grid_id: u32, ac_bus: u32 },
269
270    /// An explicit DC-grid converter references a missing DC bus.
271    #[error("explicit DC grid {grid_id} converter references missing DC bus {dc_bus}")]
272    InvalidDcConverterDcBus { grid_id: u32, dc_bus: u32 },
273
274    /// An explicit DC-grid branch references a missing DC bus.
275    #[error(
276        "explicit DC grid {grid_id} branch ({from_bus}-{to_bus}) references missing DC bus {missing_bus}"
277    )]
278    InvalidDcBranchEndpoint {
279        grid_id: u32,
280        from_bus: u32,
281        to_bus: u32,
282        missing_bus: u32,
283    },
284
285    /// A solve-ready network mixes the two canonical HVDC representations.
286    #[error(
287        "network mixes point-to-point HVDC links with explicit DC-network topology; choose one canonical HVDC representation per solve"
288    )]
289    MixedHvdcRepresentation,
290}
291
292fn is_valid_lower_bound(value: f64) -> bool {
293    value.is_finite() || value == f64::NEG_INFINITY
294}
295
296fn is_valid_upper_bound(value: f64) -> bool {
297    value.is_finite() || value == f64::INFINITY
298}
299
300/// Stable branch identity for metadata that must survive branch reordering.
301///
302/// Conditional ratings come from equipment-level data (for example CGMES
303/// `ConditionalLimit` objects), so they must follow the physical branch
304/// across topology rebuilds and vector compaction. The identity is therefore
305/// keyed by the undirected terminal pair plus circuit identifier rather than
306/// an ephemeral branch array index.
307#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
308pub struct BranchEquipmentKey {
309    pub bus_a: u32,
310    pub bus_b: u32,
311    pub circuit: String,
312}
313
314impl BranchEquipmentKey {
315    pub fn new(from_bus: u32, to_bus: u32, circuit: impl Into<String>) -> Self {
316        let circuit = circuit.into();
317        if from_bus <= to_bus {
318            Self {
319                bus_a: from_bus,
320                bus_b: to_bus,
321                circuit,
322            }
323        } else {
324            Self {
325                bus_a: to_bus,
326                bus_b: from_bus,
327                circuit,
328            }
329        }
330    }
331
332    pub fn from_branch(branch: &Branch) -> Self {
333        Self::new(branch.from_bus, branch.to_bus, branch.circuit.clone())
334    }
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
338struct BranchConditionalRatingEntry {
339    branch: BranchEquipmentKey,
340    ratings: Vec<ConditionalRating>,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
344struct BranchThermalRatingSnapshot {
345    branch: BranchEquipmentKey,
346    rating_a_mva: f64,
347    rating_c_mva: f64,
348}
349
350/// A branch-identity-indexed collection of conditional thermal ratings.
351///
352/// The cached base-ratings snapshot remains internal so callers cannot
353/// invalidate reset behaviour by mutating a separate public side table.
354#[derive(Debug, Clone, Serialize, Deserialize, Default)]
355pub struct BranchConditionalRatings {
356    #[serde(default, skip_serializing_if = "Vec::is_empty")]
357    entries: Vec<BranchConditionalRatingEntry>,
358    #[serde(default, skip_serializing_if = "Vec::is_empty")]
359    base_thermal_ratings: Vec<BranchThermalRatingSnapshot>,
360}
361
362impl BranchConditionalRatings {
363    /// Returns `true` if no branches have conditional ratings.
364    pub fn is_empty(&self) -> bool {
365        self.entries.is_empty()
366    }
367
368    /// Number of branches that have conditional ratings.
369    pub fn len(&self) -> usize {
370        self.entries.len()
371    }
372
373    /// Iterate over `(branch_key, ratings)` pairs.
374    pub fn iter(&self) -> impl Iterator<Item = (&BranchEquipmentKey, &Vec<ConditionalRating>)> {
375        self.entries
376            .iter()
377            .map(|entry| (&entry.branch, &entry.ratings))
378    }
379
380    /// Iterate over the rating vectors (without branch indices).
381    pub fn values(&self) -> impl Iterator<Item = &Vec<ConditionalRating>> {
382        self.entries.iter().map(|entry| &entry.ratings)
383    }
384
385    /// Look up conditional ratings for a specific stable branch key.
386    pub fn get(&self, branch: &BranchEquipmentKey) -> Option<&[ConditionalRating]> {
387        self.entries
388            .iter()
389            .find(|entry| entry.branch == *branch)
390            .map(|entry| entry.ratings.as_slice())
391    }
392
393    /// Look up conditional ratings for a branch value.
394    pub fn get_for_branch(&self, branch: &Branch) -> Option<&[ConditionalRating]> {
395        self.get(&BranchEquipmentKey::from_branch(branch))
396    }
397
398    /// Insert or replace conditional ratings for a stable branch key.
399    pub fn insert(&mut self, branch: BranchEquipmentKey, ratings: Vec<ConditionalRating>) {
400        if let Some(entry) = self.entries.iter_mut().find(|entry| entry.branch == branch) {
401            entry.ratings = ratings;
402        } else {
403            self.entries
404                .push(BranchConditionalRatingEntry { branch, ratings });
405        }
406    }
407
408    /// Insert or replace conditional ratings for a branch value.
409    pub fn insert_for_branch(&mut self, branch: &Branch, ratings: Vec<ConditionalRating>) {
410        self.insert(BranchEquipmentKey::from_branch(branch), ratings);
411    }
412
413    /// Remove all conditional ratings.
414    pub fn clear(&mut self) {
415        self.entries.clear();
416    }
417
418    /// Merge another set of conditional ratings into this one.
419    pub fn extend(&mut self, other: Self) {
420        for entry in other.entries {
421            self.insert(entry.branch, entry.ratings);
422        }
423        for snapshot in other.base_thermal_ratings {
424            if !self
425                .base_thermal_ratings
426                .iter()
427                .any(|existing| existing.branch == snapshot.branch)
428            {
429                self.base_thermal_ratings.push(snapshot);
430            }
431        }
432    }
433
434    fn apply_to(&mut self, branches: &mut [Branch], active_conditions: &[String]) {
435        if self.entries.is_empty() {
436            return;
437        }
438        let branch_positions: HashMap<BranchEquipmentKey, usize> = branches
439            .iter()
440            .enumerate()
441            .map(|(idx, branch)| (BranchEquipmentKey::from_branch(branch), idx))
442            .collect();
443        // Snapshot base ratings for any branches we haven't seen yet.
444        for entry in &self.entries {
445            if let Some(&br_idx) = branch_positions.get(&entry.branch)
446                && let Some(branch) = branches.get(br_idx)
447                && !self
448                    .base_thermal_ratings
449                    .iter()
450                    .any(|snapshot| snapshot.branch == entry.branch)
451            {
452                self.base_thermal_ratings.push(BranchThermalRatingSnapshot {
453                    branch: entry.branch.clone(),
454                    rating_a_mva: branch.rating_a_mva,
455                    rating_c_mva: branch.rating_c_mva,
456                });
457            }
458        }
459        // Reset all to base ratings before applying conditions.
460        for snapshot in &self.base_thermal_ratings {
461            if let Some(&br_idx) = branch_positions.get(&snapshot.branch)
462                && let Some(branch) = branches.get_mut(br_idx)
463            {
464                branch.rating_a_mva = snapshot.rating_a_mva;
465                branch.rating_c_mva = snapshot.rating_c_mva;
466            }
467        }
468        // Apply the most restrictive matching conditional rating.
469        for entry in &self.entries {
470            let Some(&br_idx) = branch_positions.get(&entry.branch) else {
471                continue;
472            };
473            let Some(branch) = branches.get_mut(br_idx) else {
474                continue;
475            };
476            let matching: Vec<&ConditionalRating> = entry
477                .ratings
478                .iter()
479                .filter(|cr| active_conditions.iter().any(|c| c == &cr.condition_id))
480                .collect();
481            if matching.is_empty() {
482                continue;
483            }
484            if let Some(min_a) = matching
485                .iter()
486                .filter(|cr| cr.rating_a_mva > 0.0)
487                .map(|cr| cr.rating_a_mva)
488                .reduce(f64::min)
489            {
490                branch.rating_a_mva = min_a;
491            }
492            if let Some(min_c) = matching
493                .iter()
494                .filter(|cr| cr.rating_c_mva > 0.0)
495                .map(|cr| cr.rating_c_mva)
496                .reduce(f64::min)
497            {
498                branch.rating_c_mva = min_c;
499            }
500        }
501    }
502
503    fn reset_on(&mut self, branches: &mut [Branch]) {
504        let branch_positions: HashMap<BranchEquipmentKey, usize> = branches
505            .iter()
506            .enumerate()
507            .map(|(idx, branch)| (BranchEquipmentKey::from_branch(branch), idx))
508            .collect();
509        for snapshot in &self.base_thermal_ratings {
510            if let Some(&br_idx) = branch_positions.get(&snapshot.branch)
511                && let Some(branch) = branches.get_mut(br_idx)
512            {
513                branch.rating_a_mva = snapshot.rating_a_mva;
514                branch.rating_c_mva = snapshot.rating_c_mva;
515            }
516        }
517        self.base_thermal_ratings.clear();
518    }
519}
520
521/// Per-phase impedance entry from CGMES `PhaseImpedanceData`.
522#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct PhaseImpedanceEntry {
524    /// Matrix row index (0-based phase index).
525    pub row: u8,
526    /// Matrix column index (0-based phase index).
527    pub col: u8,
528    /// Series resistance (ohm/m).
529    pub r: f64,
530    /// Series reactance (ohm/m).
531    pub x: f64,
532    /// Shunt susceptance (S/m).
533    pub b: f64,
534}
535
536/// Mutual coupling between two line segments from CGMES `MutualCoupling`.
537#[derive(Debug, Clone, Serialize, Deserialize)]
538pub struct MutualCoupling {
539    /// mRID of the first coupled line segment terminal.
540    pub line1_id: String,
541    /// mRID of the second coupled line segment terminal.
542    pub line2_id: String,
543    /// Mutual zero-sequence resistance (pu, system base).
544    pub r: f64,
545    /// Mutual zero-sequence reactance (pu, system base).
546    pub x: f64,
547}
548
549/// A geographic coordinate point (WGS84).
550#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
551pub struct GeoPoint {
552    /// Longitude in decimal degrees.
553    pub x: f64,
554    /// Latitude in decimal degrees.
555    pub y: f64,
556}
557
558/// CIM/CGMES supplementary data grouped for clarity.
559///
560/// Contains metadata, assets, measurements, protection, grounding, geographic
561/// locations, and operational data imported from CIM/CGMES profiles. Not used
562/// by power flow or OPF solvers. Serialized flat (via `#[serde(flatten)]`) so
563/// JSON backward compatibility is preserved.
564#[derive(Debug, Clone, Default, Serialize, Deserialize)]
565pub struct NetworkCimData {
566    /// Per-phase impedance data from CGMES `PerLengthPhaseImpedance` + `PhaseImpedanceData`.
567    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
568    pub per_length_phase_impedances: HashMap<String, Vec<PhaseImpedanceEntry>>,
569
570    /// Mutual coupling pairs from CGMES `MutualCoupling`.
571    #[serde(default, skip_serializing_if = "Vec::is_empty")]
572    pub mutual_couplings: Vec<MutualCoupling>,
573
574    /// Neutral-point grounding impedances from CGMES `Ground`, `GroundingImpedance`,
575    /// and `PetersenCoil`.
576    #[serde(default, skip_serializing_if = "Vec::is_empty")]
577    pub grounding_impedances: Vec<GroundingEntry>,
578
579    /// Geographic positions of network equipment (CGMES GL profile).
580    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
581    pub geo_locations: HashMap<String, Vec<GeoPoint>>,
582
583    /// CIM-aligned measurements from the CGMES Measurement profile.
584    #[serde(default, skip_serializing_if = "Vec::is_empty")]
585    pub measurements: Vec<CimMeasurement>,
586
587    /// Physical asset and wire information from CGMES Asset/WireInfo profiles.
588    #[serde(default, skip_serializing_if = "AssetCatalog::is_empty")]
589    pub asset_catalog: AssetCatalog,
590
591    /// IEC 61970-302 Operational Limits — full CIM-aligned limit hierarchy.
592    #[serde(default, skip_serializing_if = "OperationalLimits::is_empty")]
593    pub operational_limits: OperationalLimits,
594
595    /// ENTSO-E boundary profile data (EQBD/BD).
596    #[serde(default, skip_serializing_if = "BoundaryData::is_empty")]
597    pub boundary_data: BoundaryData,
598
599    /// Original CGMES source objects preserved for faithful export of lowered
600    /// classes such as `EquivalentInjection`, `ExternalNetworkInjection`, and
601    /// `DanglingLine`.
602    #[serde(default, skip_serializing_if = "CgmesRoundtripData::is_empty")]
603    pub cgmes_roundtrip: CgmesRoundtripData,
604
605    /// IEC 61970-302 Protection equipment.
606    #[serde(default, skip_serializing_if = "ProtectionData::is_empty")]
607    pub protection_data: ProtectionData,
608
609    /// IEC 62325 Energy Market Data.
610    #[serde(default, skip_serializing_if = "MarketData::is_empty")]
611    pub market_data: MarketData,
612
613    /// IEC 61968 Network Operations — switching plans, outage records, crew dispatch.
614    #[serde(default, skip_serializing_if = "NetworkOperationsData::is_empty")]
615    pub network_operations: NetworkOperationsData,
616}
617
618/// Supplementary metadata: regions, owners, impedance corrections, and other
619/// reference data imported from PSS/E, CDF, or CGMES.
620///
621/// Not used directly by power flow or OPF solvers. Populated by parsers and
622/// preserved for round-tripping, reporting, and specialized analysis.
623#[derive(Debug, Clone, Default, Serialize, Deserialize)]
624pub struct NetworkMetadata {
625    /// Region (zone) name lookup table from PSS/E RAW "ZONE DATA".
626    #[serde(default, skip_serializing_if = "Vec::is_empty")]
627    pub regions: Vec<Region>,
628    /// Owner name lookup table from PSS/E RAW "OWNER DATA".
629    #[serde(default, skip_serializing_if = "Vec::is_empty")]
630    pub owners: Vec<Owner>,
631    /// Voltage droop control records from PSS/E v36 "VOLTAGE DROOP CONTROL DATA".
632    #[serde(default, skip_serializing_if = "Vec::is_empty")]
633    pub voltage_droop_controls: Vec<VoltageDroopControl>,
634    /// Switching device rating sets from PSS/E v36 "SWITCHING DEVICE RATING SET DATA".
635    #[serde(default, skip_serializing_if = "Vec::is_empty")]
636    pub switching_device_rating_sets: Vec<SwitchingDeviceRatingSet>,
637    /// Scheduled inter-area power transfers from PSS/E RAW "INTER-AREA TRANSFER DATA".
638    #[serde(default, skip_serializing_if = "Vec::is_empty")]
639    pub scheduled_area_transfers: Vec<ScheduledAreaTransfer>,
640    /// Impedance correction tables from PSS/E RAW "IMPEDANCE CORRECTION DATA".
641    /// Referenced by transformer `tab` field for tap-dependent R/X scaling.
642    #[serde(default, skip_serializing_if = "Vec::is_empty")]
643    pub impedance_corrections: Vec<ImpedanceCorrectionTable>,
644    /// Multi-section line groupings from PSS/E RAW "MULTI-SECTION LINE DATA".
645    #[serde(default, skip_serializing_if = "Vec::is_empty")]
646    pub multi_section_line_groups: Vec<MultiSectionLineGroup>,
647}
648
649/// Market and dispatch data: dispatchable loads, reserves, combined-cycle plants,
650/// outage schedules, and system-wide policies.
651#[derive(Debug, Clone, Default, Serialize, Deserialize)]
652pub struct NetworkMarketData {
653    /// Dispatchable loads (demand-response resources).
654    #[serde(default, skip_serializing_if = "Vec::is_empty")]
655    pub dispatchable_loads: Vec<DispatchableLoad>,
656    /// Pumped hydro storage units (synchronous machine overlay).
657    #[serde(default, skip_serializing_if = "Vec::is_empty")]
658    pub pumped_hydro_units: Vec<PumpedHydroUnit>,
659    /// Combined cycle power plants with configuration-based commitment.
660    #[serde(default, skip_serializing_if = "Vec::is_empty")]
661    pub combined_cycle_plants: Vec<CombinedCyclePlant>,
662    /// Outage / derate schedule for planning and dispatch.
663    #[serde(default, skip_serializing_if = "Vec::is_empty")]
664    pub outage_schedule: Vec<OutageEntry>,
665    /// Reserve zones defining zonal AS requirements.
666    #[serde(default, skip_serializing_if = "Vec::is_empty")]
667    pub reserve_zones: Vec<ReserveZone>,
668    /// System-wide ambient conditions fallback.
669    #[serde(default, skip_serializing_if = "Option::is_none")]
670    pub ambient: Option<AmbientConditions>,
671    /// System-wide emission constraints and carbon pricing.
672    #[serde(default, skip_serializing_if = "Option::is_none")]
673    pub emission_policy: Option<EmissionPolicy>,
674    /// Market rules (VOLL, AS requirements).
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub market_rules: Option<MarketRules>,
677}
678
679/// Discrete voltage control devices: switched shunts, OLTCs, and PARs.
680#[derive(Debug, Clone, Default, Serialize, Deserialize)]
681pub struct NetworkControlData {
682    /// Discrete switched shunt banks for voltage control in the NR outer loop.
683    #[serde(default, skip_serializing_if = "Vec::is_empty")]
684    pub switched_shunts: Vec<SwitchedShunt>,
685    /// OPF-relaxed switched shunts for AC-OPF optimization.
686    #[serde(default, skip_serializing_if = "Vec::is_empty")]
687    pub switched_shunts_opf: Vec<SwitchedShuntOpf>,
688    /// OLTC transformer control specifications.
689    #[serde(default, skip_serializing_if = "Vec::is_empty")]
690    pub oltc_specs: Vec<OltcSpec>,
691    /// Phase Angle Regulator (PAR) specifications.
692    #[serde(default, skip_serializing_if = "Vec::is_empty")]
693    pub par_specs: Vec<ParSpec>,
694}
695
696/// A complete power system network model.
697///
698/// Holds all electrical equipment (buses, branches, generators, loads) and
699/// supplementary data (HVDC links, FACTS devices, flowgates, topology, etc.)
700/// needed to run power flow, OPF, contingency analysis, and dispatch.
701///
702/// Typically constructed by a parser in `surge-io` (MATPOWER, PSS/E RAW,
703/// CGMES, XIIDM) rather than built by hand. After parsing, call
704/// [`validate()`](Self::validate) to check invariants before passing the
705/// network to any solver.
706#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct Network {
708    /// System name.
709    pub name: String,
710    /// System base power in MVA.
711    pub base_mva: f64,
712    /// Nominal system frequency in Hz.  Defaults to 60 Hz (North America / Korea).
713    /// Set to 50 Hz for Europe / most of Asia.
714    #[serde(default = "Network::default_freq_hz")]
715    pub freq_hz: f64,
716    /// All buses in the network.
717    pub buses: Vec<Bus>,
718    /// All branches (lines and transformers) in the network.
719    pub branches: Vec<Branch>,
720    /// All generators in the network.
721    pub generators: Vec<Generator>,
722    /// All loads in the network.
723    pub loads: Vec<Load>,
724    /// Discrete voltage control devices: switched shunts, OLTCs, and PARs.
725    #[serde(default)]
726    pub controls: NetworkControlData,
727
728    /// Canonical HVDC namespace: point-to-point links and explicit DC grids.
729    #[serde(default, skip_serializing_if = "HvdcModel::is_empty")]
730    pub hvdc: HvdcModel,
731
732    /// Area interchange records parsed from PSS/E RAW "AREA INTERCHANGE DATA".
733    ///
734    /// Metadata only — does not affect the Newton-Raphson solve directly.
735    #[serde(default, skip_serializing_if = "Vec::is_empty")]
736    pub area_schedules: Vec<AreaSchedule>,
737
738    /// FACTS device records parsed from PSS/E RAW "FACTS DEVICE DATA".
739    ///
740    /// Processed by `surge_ac::facts_expansion::expand_facts()` before solving
741    /// to convert them into Generator and Branch modifications.
742    #[serde(default, skip_serializing_if = "Vec::is_empty")]
743    pub facts_devices: Vec<FactsDevice>,
744
745    /// Supplementary metadata: regions, owners, impedance corrections, and other
746    /// reference data.
747    #[serde(default)]
748    pub metadata: NetworkMetadata,
749
750    /// CIM/CGMES supplementary data — metadata, assets, measurements, protection,
751    /// grounding, geographic locations, and operational data.
752    ///
753    /// Not used by power flow or OPF solvers. Populated by the CGMES parser and
754    /// preserved for round-tripping and specialized analysis tools.
755    #[serde(default)]
756    pub cim: NetworkCimData,
757
758    /// Transmission interfaces — sets of branches defining flow boundaries
759    /// between areas.  Enforced in DC-OPF as linear constraints on bus angles.
760    #[serde(default, skip_serializing_if = "Vec::is_empty")]
761    pub interfaces: Vec<Interface>,
762
763    /// Flowgates — monitored elements under specific contingencies.
764    /// All in-service flowgates are enforced in DC-OPF/SCED/SCUC as linear
765    /// constraints on base-case monitored-element flow.  Contingency flowgates
766    /// carry pre-computed OTDF-adjusted limits in `limit_mw`.  Dynamic OTDF
767    /// constraint generation belongs in SCOPF.
768    #[serde(default, skip_serializing_if = "Vec::is_empty")]
769    pub flowgates: Vec<Flowgate>,
770
771    /// Operating nomograms: piecewise-linear inter-flowgate limit dependencies.
772    ///
773    /// Each nomogram restricts the MW limit of one flowgate based on the
774    /// real-time flow measured on a second "index" flowgate.  Applied
775    /// iteratively in SCED/SCUC (see `DispatchOptions::max_nomogram_iter`).
776    #[serde(default, skip_serializing_if = "Vec::is_empty")]
777    pub nomograms: Vec<crate::network::flowgate::OperatingNomogram>,
778
779    /// Physical node-breaker topology model.
780    ///
781    /// When present, the network was built from a node-breaker source (CGMES,
782    /// XIIDM node-breaker).  The model retains the full physical hierarchy
783    /// (substations, voltage levels, bays, connectivity nodes, switches) and
784    /// the mapping from connectivity nodes to bus-branch buses.
785    ///
786    /// When absent, the network is purely bus-branch (MATPOWER, PSS/E, XIIDM
787    /// bus-breaker).  All existing workflows are unaffected.
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub topology: Option<NodeBreakerTopology>,
790
791    /// Induction machine (motor load) records from PSS/E RAW v35+ "INDUCTION MACHINE DATA".
792    ///
793    /// Stores steady-state equivalent-circuit parameters for each motor load.
794    /// Empty for MATPOWER, IEEE-CDF, and PSS/E v33 cases.
795    #[serde(default, skip_serializing_if = "Vec::is_empty")]
796    pub induction_machines: Vec<InductionMachine>,
797
798    /// Conditional thermal limits from CGMES `ConditionalLimit` objects.
799    ///
800    /// Keyed by branch index.  Each entry holds one or more condition-dependent
801    /// ratings that override `rate_a`/`rate_c` when the user activates the
802    /// matching condition via [`Network::apply_conditional_limits`].
803    #[serde(default, skip_serializing_if = "BranchConditionalRatings::is_empty")]
804    pub conditional_limits: BranchConditionalRatings,
805
806    /// Circuit breaker ratings for fault duty comparison.
807    #[serde(default, skip_serializing_if = "Vec::is_empty")]
808    pub breaker_ratings: Vec<BreakerRating>,
809    /// Fixed shunt equipment (preserves identity lost when baked into Bus.shunt_susceptance_mvar).
810    #[serde(default, skip_serializing_if = "Vec::is_empty")]
811    pub fixed_shunts: Vec<FixedShunt>,
812    /// Explicit fixed P/Q injections that must survive topology remap.
813    #[serde(default, skip_serializing_if = "Vec::is_empty")]
814    pub power_injections: Vec<PowerInjection>,
815
816    /// Market and dispatch data: dispatchable loads, reserves, combined-cycle plants,
817    /// outage schedules, and system-wide policies.
818    #[serde(default)]
819    pub market_data: NetworkMarketData,
820}
821
822/// A condition-dependent thermal rating for a branch.
823///
824/// From CGMES `ConditionalLimit`: when the named condition is active,
825/// the branch's `rate_a` and/or `rate_c` should be replaced with the
826/// values in this struct.
827#[derive(Debug, Clone, Serialize, Deserialize)]
828pub struct ConditionalRating {
829    /// Condition identifier (CGMES mRID of the condition reference).
830    pub condition_id: String,
831    /// Normal (PATL) rating under this condition (MVA).  0.0 = no override.
832    pub rating_a_mva: f64,
833    /// Emergency (TATL) rating under this condition (MVA).  0.0 = no override.
834    pub rating_c_mva: f64,
835}
836
837/// Trait for equipment types that carry a canonicalizable string ID.
838pub(crate) trait HasCanonicalId {
839    fn canonical_id(&self) -> &str;
840    fn set_canonical_id(&mut self, id: String);
841    fn bus_number(&self) -> u32;
842}
843
844impl HasCanonicalId for Generator {
845    fn canonical_id(&self) -> &str {
846        &self.id
847    }
848    fn set_canonical_id(&mut self, id: String) {
849        self.id = id;
850    }
851    fn bus_number(&self) -> u32 {
852        self.bus
853    }
854}
855
856impl HasCanonicalId for Load {
857    fn canonical_id(&self) -> &str {
858        &self.id
859    }
860    fn set_canonical_id(&mut self, id: String) {
861        self.id = id;
862    }
863    fn bus_number(&self) -> u32 {
864        self.bus
865    }
866}
867
868impl HasCanonicalId for FixedShunt {
869    fn canonical_id(&self) -> &str {
870        &self.id
871    }
872    fn set_canonical_id(&mut self, id: String) {
873        self.id = id;
874    }
875    fn bus_number(&self) -> u32 {
876        self.bus
877    }
878}
879
880impl HasCanonicalId for SwitchedShunt {
881    fn canonical_id(&self) -> &str {
882        &self.id
883    }
884    fn set_canonical_id(&mut self, id: String) {
885        self.id = id;
886    }
887    fn bus_number(&self) -> u32 {
888        self.bus
889    }
890}
891
892impl HasCanonicalId for SwitchedShuntOpf {
893    fn canonical_id(&self) -> &str {
894        &self.id
895    }
896    fn set_canonical_id(&mut self, id: String) {
897        self.id = id;
898    }
899    fn bus_number(&self) -> u32 {
900        self.bus
901    }
902}
903
904/// Fill missing IDs on a slice of equipment with deterministic network-local IDs.
905///
906/// Existing non-empty IDs are preserved after trimming surrounding whitespace.
907/// Generated IDs use the format `"{prefix}_{bus}_{ordinal}"` and are stable for
908/// a fixed ordering.
909fn canonicalize_ids(items: &mut [impl HasCanonicalId], prefix: &str) {
910    let mut used_ids = HashSet::new();
911    for item in items.iter_mut() {
912        let trimmed = item.canonical_id().trim().to_string();
913        if trimmed.is_empty() {
914            continue;
915        }
916        if item.canonical_id() != trimmed {
917            item.set_canonical_id(trimmed.clone());
918        }
919        used_ids.insert(trimmed);
920    }
921
922    let mut ordinal_by_bus: HashMap<u32, usize> = HashMap::new();
923    for item in items.iter_mut() {
924        let bus = item.bus_number();
925        let ordinal = ordinal_by_bus.entry(bus).or_insert(0);
926        *ordinal += 1;
927
928        if !item.canonical_id().trim().is_empty() {
929            continue;
930        }
931
932        let base = format!("{prefix}_{bus}_{ordinal}");
933        let mut candidate = base.clone();
934        let mut collision = 2usize;
935        while used_ids.contains(&candidate) {
936            candidate = format!("{base}_{collision}");
937            collision += 1;
938        }
939
940        used_ids.insert(candidate.clone());
941        item.set_canonical_id(candidate);
942    }
943}
944
945impl Default for Network {
946    fn default() -> Self {
947        Self {
948            name: String::new(),
949            base_mva: DEFAULT_BASE_MVA,
950            freq_hz: 60.0,
951            buses: Vec::new(),
952            branches: Vec::new(),
953            generators: Vec::new(),
954            loads: Vec::new(),
955            controls: NetworkControlData::default(),
956            hvdc: HvdcModel::default(),
957            area_schedules: Vec::new(),
958            facts_devices: Vec::new(),
959            metadata: NetworkMetadata::default(),
960            cim: NetworkCimData::default(),
961            interfaces: Vec::new(),
962            flowgates: Vec::new(),
963            nomograms: Vec::new(),
964            topology: None,
965            induction_machines: Vec::new(),
966            conditional_limits: BranchConditionalRatings::default(),
967            breaker_ratings: Vec::new(),
968            fixed_shunts: Vec::new(),
969            power_injections: Vec::new(),
970            market_data: NetworkMarketData::default(),
971        }
972    }
973}
974
975impl Network {
976    /// Default nominal frequency: 60 Hz (North America).
977    fn default_freq_hz() -> f64 {
978        60.0
979    }
980
981    /// Create an empty network with the given name and default settings
982    /// (`base_mva = 100`, `freq_hz = 60`).
983    pub fn new(name: &str) -> Self {
984        debug!(name, "creating new network");
985        Self {
986            name: name.to_string(),
987            ..Default::default()
988        }
989    }
990
991    /// Apply conditional thermal limits for the given active conditions.
992    ///
993    /// For each branch that has conditional ratings matching any of the
994    /// `active_conditions`, overwrites `rate_a`/`rate_c` with the most
995    /// restrictive matching conditional value.  Snapshots original ratings
996    /// on first call so `reset_conditional_limits` can restore them.
997    pub fn apply_conditional_limits(&mut self, active_conditions: &[String]) {
998        self.conditional_limits
999            .apply_to(&mut self.branches, active_conditions);
1000    }
1001
1002    /// Reset all conditional limits back to base thermal ratings.
1003    pub fn reset_conditional_limits(&mut self) {
1004        self.conditional_limits.reset_on(&mut self.branches);
1005    }
1006
1007    /// Iterator over generators that have storage capability.
1008    pub fn storage_generators(&self) -> impl Iterator<Item = (usize, &Generator)> {
1009        self.generators
1010            .iter()
1011            .enumerate()
1012            .filter(|(_, g)| g.is_storage())
1013    }
1014
1015    /// Number of buses in the network.
1016    pub fn n_buses(&self) -> usize {
1017        self.buses.len()
1018    }
1019
1020    /// Number of branches in the network.
1021    pub fn n_branches(&self) -> usize {
1022        self.branches.len()
1023    }
1024
1025    /// Maximum external bus number in the network, or 0 if empty.
1026    pub fn max_bus_number(&self) -> u32 {
1027        self.buses.iter().map(|b| b.number).max().unwrap_or(0)
1028    }
1029
1030    /// Number of generators in the network (total, including out-of-service).
1031    pub fn n_generators(&self) -> usize {
1032        self.generators.len()
1033    }
1034
1035    /// Number of in-service generators.
1036    pub fn n_generators_in_service(&self) -> usize {
1037        self.generators.iter().filter(|g| g.in_service).count()
1038    }
1039
1040    /// Validate the structural integrity of the network graph and identity
1041    /// references.
1042    ///
1043    /// This checks that the model is internally connected the way the solver
1044    /// code expects, but does not try to prove solve-time numeric readiness.
1045    pub fn validate_structure(&self) -> Result<(), NetworkError> {
1046        // 1. Non-empty buses
1047        if self.buses.is_empty() {
1048            return Err(NetworkError::EmptyNetwork);
1049        }
1050
1051        // 2. base_mva > 0 and finite
1052        if !self.base_mva.is_finite() || self.base_mva <= 0.0 {
1053            return Err(NetworkError::InvalidBaseMva(self.base_mva));
1054        }
1055
1056        // 3. Build bus number set and detect duplicates
1057        let mut bus_numbers = std::collections::HashSet::new();
1058        for bus in &self.buses {
1059            if !bus_numbers.insert(bus.number) {
1060                return Err(NetworkError::DuplicateBusNumber(bus.number));
1061            }
1062        }
1063
1064        // 3b. If the network retains node-breaker topology, the mapped bus-branch
1065        // view must be present and current before any solve-time validation can
1066        // trust the bus-indexed equipment arrays.
1067        if let Some(topology) = &self.topology {
1068            match topology.status() {
1069                TopologyMappingState::Missing => return Err(NetworkError::MissingTopologyMapping),
1070                TopologyMappingState::Stale => return Err(NetworkError::StaleNodeBreakerTopology),
1071                TopologyMappingState::Current => {}
1072            }
1073        }
1074
1075        // 4. Branch endpoints reference valid buses; no self-loops.
1076        for branch in &self.branches {
1077            if !bus_numbers.contains(&branch.from_bus) {
1078                return Err(NetworkError::InvalidBranchEndpoint {
1079                    branch_from: branch.from_bus,
1080                    branch_to: branch.to_bus,
1081                    missing_bus: branch.from_bus,
1082                });
1083            }
1084            if !bus_numbers.contains(&branch.to_bus) {
1085                return Err(NetworkError::InvalidBranchEndpoint {
1086                    branch_from: branch.from_bus,
1087                    branch_to: branch.to_bus,
1088                    missing_bus: branch.to_bus,
1089                });
1090            }
1091            if branch.from_bus == branch.to_bus {
1092                return Err(NetworkError::SelfLoopBranch(branch.from_bus));
1093            }
1094        }
1095
1096        // 5. No duplicate branch keys (from_bus, to_bus, circuit).
1097        let mut branch_keys = std::collections::HashSet::new();
1098        for branch in &self.branches {
1099            let key = BranchEquipmentKey::from_branch(branch);
1100            if !branch_keys.insert(key) {
1101                return Err(NetworkError::DuplicateBranchKey {
1102                    from_bus: branch.from_bus.min(branch.to_bus),
1103                    to_bus: branch.from_bus.max(branch.to_bus),
1104                    circuit: branch.circuit.clone(),
1105                });
1106            }
1107        }
1108
1109        self.validate_interface_definitions()?;
1110        self.validate_internal_control_indices()?;
1111        self.validate_hvdc_structure(&bus_numbers)?;
1112
1113        // 6. Validate all remaining bus-backed equipment arrays so imported
1114        // networks cannot silently drop demand, shunts, or DR state at solve time.
1115        for load in &self.loads {
1116            if !bus_numbers.contains(&load.bus) {
1117                return Err(NetworkError::InvalidLoadBus(load.bus));
1118            }
1119        }
1120        for injection in &self.power_injections {
1121            if !bus_numbers.contains(&injection.bus) {
1122                return Err(NetworkError::InvalidPowerInjectionBus(injection.bus));
1123            }
1124        }
1125        for shunt in &self.fixed_shunts {
1126            if !bus_numbers.contains(&shunt.bus) {
1127                return Err(NetworkError::InvalidFixedShuntBus(shunt.bus));
1128            }
1129        }
1130        for resource in &self.market_data.dispatchable_loads {
1131            if !bus_numbers.contains(&resource.bus) {
1132                return Err(NetworkError::InvalidDispatchableLoadBus(resource.bus));
1133            }
1134        }
1135
1136        // 7. Generator bus references valid buses; explicit canonical IDs are
1137        //    unique after trimming surrounding whitespace; pmin <= pmax.
1138        // Missing IDs are allowed here and can be synthesized later via
1139        // canonicalize_generator_ids() at runtime boundaries.
1140        let mut generator_ids = std::collections::HashSet::new();
1141        for g in &self.generators {
1142            if !bus_numbers.contains(&g.bus) {
1143                return Err(NetworkError::InvalidGeneratorBus(g.bus));
1144            }
1145            if let Some(reg_bus) = g.reg_bus
1146                && !bus_numbers.contains(&reg_bus)
1147            {
1148                return Err(NetworkError::InvalidGeneratorRegulatedBus {
1149                    bus: g.bus,
1150                    reg_bus,
1151                });
1152            }
1153            let canonical_id = g.id.trim();
1154            if !canonical_id.is_empty() && !generator_ids.insert(canonical_id.to_string()) {
1155                return Err(NetworkError::DuplicateGeneratorId {
1156                    id: canonical_id.to_string(),
1157                });
1158            }
1159            if g.pmin > g.pmax {
1160                return Err(NetworkError::InvalidGeneratorLimits { bus: g.bus });
1161            }
1162        }
1163
1164        // 8. Storage parameters on storage-capable generators.
1165        for g in &self.generators {
1166            if let Some(storage) = &g.storage {
1167                storage
1168                    .validate()
1169                    .map_err(|source| NetworkError::InvalidStorageParameters {
1170                        bus: g.bus,
1171                        source,
1172                    })?;
1173            }
1174        }
1175
1176        Ok(())
1177    }
1178
1179    /// Validate the network for solver readiness.
1180    ///
1181    /// This is the release-grade contract that callers should rely on before
1182    /// invoking power flow, OPF, or contingency analysis. Explicit unbounded
1183    /// optimization limits are permitted where the runtime already models them
1184    /// as open-ended bounds.
1185    pub fn validate_for_solve(&self) -> Result<(), NetworkError> {
1186        self.validate_structure()?;
1187        self.validate_hvdc_solve_contract()?;
1188        self.validate_area_schedules()?;
1189        self.validate_component_slack()?;
1190        self.validate_numerics_for_solve()?;
1191        Ok(())
1192    }
1193
1194    /// Validate the network for DC solver readiness.
1195    ///
1196    /// DC studies do not consume the full AC voltage/reactive-control state, so
1197    /// this contract intentionally checks only the structural and numeric fields
1198    /// that the DC formulation actually uses.
1199    pub fn validate_for_dc_solve(&self) -> Result<(), NetworkError> {
1200        self.validate_structure()?;
1201        self.validate_hvdc_solve_contract()?;
1202        self.validate_area_schedules()?;
1203        self.validate_component_slack()?;
1204        self.validate_numerics_for_dc_solve()?;
1205        Ok(())
1206    }
1207
1208    /// Validate network invariants required before any solver is invoked.
1209    ///
1210    /// This now means "solve-ready" rather than merely structurally coherent.
1211    pub fn validate(&self) -> Result<(), NetworkError> {
1212        self.validate_for_solve()
1213    }
1214
1215    fn validate_component_slack(&self) -> Result<(), NetworkError> {
1216        let bus_index = self.bus_index_map();
1217        let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); self.buses.len()];
1218        let mut electrically_active = vec![false; self.buses.len()];
1219
1220        for (idx, bus) in self.buses.iter().enumerate() {
1221            electrically_active[idx] =
1222                bus.shunt_conductance_mw != 0.0 || bus.shunt_susceptance_mvar != 0.0;
1223        }
1224        let mark_active_bus_number = |bus_number: u32, active: &mut [bool]| {
1225            if let Some(&idx) = bus_index.get(&bus_number) {
1226                active[idx] = true;
1227            }
1228        };
1229        for load in self.loads.iter().filter(|load| load.in_service) {
1230            mark_active_bus_number(load.bus, &mut electrically_active);
1231        }
1232        for generator in self
1233            .generators
1234            .iter()
1235            .filter(|generator| generator.in_service)
1236        {
1237            mark_active_bus_number(generator.bus, &mut electrically_active);
1238        }
1239        let regulating_targets: HashMap<u32, usize> = self
1240            .generators
1241            .iter()
1242            .filter(|generator| generator.can_voltage_regulate())
1243            .fold(HashMap::new(), |mut counts, generator| {
1244                let target_bus = generator.reg_bus.unwrap_or(generator.bus);
1245                *counts.entry(target_bus).or_insert(0) += 1;
1246                counts
1247            });
1248        for injection in self
1249            .power_injections
1250            .iter()
1251            .filter(|injection| injection.in_service)
1252        {
1253            mark_active_bus_number(injection.bus, &mut electrically_active);
1254        }
1255        for shunt in self.fixed_shunts.iter().filter(|shunt| shunt.in_service) {
1256            mark_active_bus_number(shunt.bus, &mut electrically_active);
1257        }
1258        for shunt in &self.controls.switched_shunts {
1259            if shunt.b_injected() != 0.0
1260                && let Some(&idx) = bus_index.get(&shunt.bus)
1261                && let Some(active) = electrically_active.get_mut(idx)
1262            {
1263                *active = true;
1264            }
1265        }
1266        for shunt in &self.controls.switched_shunts_opf {
1267            if shunt.b_init_pu != 0.0
1268                && let Some(&idx) = bus_index.get(&shunt.bus)
1269                && let Some(active) = electrically_active.get_mut(idx)
1270            {
1271                *active = true;
1272            }
1273        }
1274
1275        for branch in self.branches.iter().filter(|br| br.in_service) {
1276            let Some(&from_idx) = bus_index.get(&branch.from_bus) else {
1277                continue;
1278            };
1279            let Some(&to_idx) = bus_index.get(&branch.to_bus) else {
1280                continue;
1281            };
1282            adjacency[from_idx].push(to_idx);
1283            adjacency[to_idx].push(from_idx);
1284        }
1285
1286        let mut visited = vec![false; self.buses.len()];
1287        for start_idx in 0..self.buses.len() {
1288            if visited[start_idx] {
1289                continue;
1290            }
1291
1292            if self.buses[start_idx].bus_type == BusType::Isolated {
1293                visited[start_idx] = true;
1294                let bus_number = self.buses[start_idx].number;
1295                if !adjacency[start_idx].is_empty() || electrically_active[start_idx] {
1296                    return Err(NetworkError::InvalidIsolatedBusConnectivity { bus: bus_number });
1297                }
1298                continue;
1299            }
1300
1301            let mut stack = vec![start_idx];
1302            let mut component = Vec::new();
1303            while let Some(idx) = stack.pop() {
1304                if visited[idx] {
1305                    continue;
1306                }
1307                if self.buses[idx].bus_type == BusType::Isolated {
1308                    return Err(NetworkError::InvalidIsolatedBusConnectivity {
1309                        bus: self.buses[idx].number,
1310                    });
1311                }
1312                visited[idx] = true;
1313                component.push(idx);
1314                for &next in &adjacency[idx] {
1315                    if !visited[next] {
1316                        stack.push(next);
1317                    }
1318                }
1319            }
1320
1321            if component.is_empty() {
1322                continue;
1323            }
1324
1325            let buses: Vec<u32> = component
1326                .iter()
1327                .map(|&idx| self.buses[idx].number)
1328                .collect();
1329            let slack_buses: Vec<u32> = component
1330                .iter()
1331                .filter(|&&idx| self.buses[idx].bus_type == BusType::Slack)
1332                .map(|&idx| self.buses[idx].number)
1333                .collect();
1334            if slack_buses.len() != 1 {
1335                return Err(NetworkError::InvalidSlackPlacement { buses, slack_buses });
1336            }
1337            for &idx in &component {
1338                let bus = &self.buses[idx];
1339                let reg_count = regulating_targets.get(&bus.number).copied().unwrap_or(0);
1340                match bus.bus_type {
1341                    BusType::Slack if reg_count == 0 => {
1342                        return Err(NetworkError::InvalidSlackPlacement { buses, slack_buses });
1343                    }
1344                    BusType::PV if reg_count == 0 => {
1345                        return Err(NetworkError::InvalidGeneratorField {
1346                            bus: bus.number,
1347                            field: "voltage_regulated",
1348                            value: 0.0,
1349                        });
1350                    }
1351                    _ => {}
1352                }
1353            }
1354        }
1355
1356        Ok(())
1357    }
1358
1359    fn validate_area_schedules(&self) -> Result<(), NetworkError> {
1360        let bus_index = self.bus_index_map();
1361        let mut seen_areas = HashSet::new();
1362
1363        for area in &self.area_schedules {
1364            if !seen_areas.insert(area.number) {
1365                return Err(NetworkError::DuplicateAreaScheduleNumber(area.number));
1366            }
1367            if area.slack_bus == 0 || !bus_index.contains_key(&area.slack_bus) {
1368                return Err(NetworkError::InvalidAreaScheduleSlackBus {
1369                    area: area.number,
1370                    slack_bus: area.slack_bus,
1371                });
1372            }
1373            if !area.p_desired_mw.is_finite() {
1374                return Err(NetworkError::InvalidAreaScheduleField {
1375                    area: area.number,
1376                    field: "p_desired_mw",
1377                    value: area.p_desired_mw,
1378                });
1379            }
1380            if !area.p_tolerance_mw.is_finite() || area.p_tolerance_mw < 0.0 {
1381                return Err(NetworkError::InvalidAreaScheduleField {
1382                    area: area.number,
1383                    field: "p_tolerance_mw",
1384                    value: area.p_tolerance_mw,
1385                });
1386            }
1387        }
1388
1389        Ok(())
1390    }
1391
1392    fn validate_numerics_for_solve(&self) -> Result<(), NetworkError> {
1393        for bus in &self.buses {
1394            if !bus.shunt_conductance_mw.is_finite() {
1395                return Err(NetworkError::InvalidBusField {
1396                    bus: bus.number,
1397                    field: "shunt_conductance_mw",
1398                    value: bus.shunt_conductance_mw,
1399                });
1400            }
1401            if !bus.shunt_susceptance_mvar.is_finite() {
1402                return Err(NetworkError::InvalidBusField {
1403                    bus: bus.number,
1404                    field: "shunt_susceptance_mvar",
1405                    value: bus.shunt_susceptance_mvar,
1406                });
1407            }
1408            if !bus.voltage_magnitude_pu.is_finite() || !bus.voltage_angle_rad.is_finite() {
1409                return Err(NetworkError::NonFiniteBusVoltage(bus.number));
1410            }
1411            if !bus.voltage_min_pu.is_finite()
1412                || !bus.voltage_max_pu.is_finite()
1413                || bus.voltage_min_pu > bus.voltage_max_pu
1414            {
1415                return Err(NetworkError::InvalidBusVoltageBounds(
1416                    bus.number,
1417                    bus.voltage_min_pu,
1418                    bus.voltage_max_pu,
1419                ));
1420            }
1421        }
1422
1423        for load in &self.loads {
1424            for (field, value) in [
1425                ("active_power_demand_mw", load.active_power_demand_mw),
1426                (
1427                    "reactive_power_demand_mvar",
1428                    load.reactive_power_demand_mvar,
1429                ),
1430                ("zip_p_impedance_frac", load.zip_p_impedance_frac),
1431                ("zip_p_current_frac", load.zip_p_current_frac),
1432                ("zip_p_power_frac", load.zip_p_power_frac),
1433                ("zip_q_impedance_frac", load.zip_q_impedance_frac),
1434                ("zip_q_current_frac", load.zip_q_current_frac),
1435                ("zip_q_power_frac", load.zip_q_power_frac),
1436                (
1437                    "freq_sensitivity_p_pct_per_hz",
1438                    load.freq_sensitivity_p_pct_per_hz,
1439                ),
1440                (
1441                    "freq_sensitivity_q_pct_per_hz",
1442                    load.freq_sensitivity_q_pct_per_hz,
1443                ),
1444                ("frac_motor_a", load.frac_motor_a),
1445                ("frac_motor_b", load.frac_motor_b),
1446                ("frac_motor_c", load.frac_motor_c),
1447                ("frac_motor_d", load.frac_motor_d),
1448                ("frac_electronic", load.frac_electronic),
1449                ("frac_static", load.frac_static),
1450            ] {
1451                if !value.is_finite() || (field.ends_with("_frac") && !(0.0..=1.0).contains(&value))
1452                {
1453                    return Err(NetworkError::InvalidLoadField {
1454                        bus: load.bus,
1455                        field,
1456                        value,
1457                    });
1458                }
1459            }
1460        }
1461
1462        for injection in &self.power_injections {
1463            for (field, value) in [
1464                (
1465                    "active_power_injection_mw",
1466                    injection.active_power_injection_mw,
1467                ),
1468                (
1469                    "reactive_power_injection_mvar",
1470                    injection.reactive_power_injection_mvar,
1471                ),
1472            ] {
1473                if !value.is_finite() {
1474                    return Err(NetworkError::InvalidPowerInjectionField {
1475                        bus: injection.bus,
1476                        field,
1477                        value,
1478                    });
1479                }
1480            }
1481        }
1482
1483        for shunt in &self.fixed_shunts {
1484            for (field, value) in [("g_mw", shunt.g_mw), ("b_mvar", shunt.b_mvar)] {
1485                if !value.is_finite() {
1486                    return Err(NetworkError::InvalidFixedShuntField {
1487                        bus: shunt.bus,
1488                        field,
1489                        value,
1490                    });
1491                }
1492            }
1493            if let Some(rated_kv) = shunt.rated_kv {
1494                if !rated_kv.is_finite() {
1495                    return Err(NetworkError::InvalidFixedShuntField {
1496                        bus: shunt.bus,
1497                        field: "rated_kv",
1498                        value: rated_kv,
1499                    });
1500                }
1501            }
1502            if let Some(rated_mvar) = shunt.rated_mvar {
1503                if !rated_mvar.is_finite() {
1504                    return Err(NetworkError::InvalidFixedShuntField {
1505                        bus: shunt.bus,
1506                        field: "rated_mvar",
1507                        value: rated_mvar,
1508                    });
1509                }
1510            }
1511        }
1512
1513        for g in &self.generators {
1514            for (field, value) in [
1515                ("p", g.p),
1516                ("q", g.q),
1517                ("voltage_setpoint_pu", g.voltage_setpoint_pu),
1518                ("machine_base_mva", g.machine_base_mva),
1519            ] {
1520                if !value.is_finite() {
1521                    return Err(NetworkError::InvalidGeneratorField {
1522                        bus: g.bus,
1523                        field,
1524                        value,
1525                    });
1526                }
1527            }
1528            if g.machine_base_mva <= 0.0 {
1529                return Err(NetworkError::InvalidGeneratorField {
1530                    bus: g.bus,
1531                    field: "machine_base_mva",
1532                    value: g.machine_base_mva,
1533                });
1534            }
1535            if g.voltage_setpoint_pu <= 0.0 {
1536                return Err(NetworkError::InvalidGeneratorField {
1537                    bus: g.bus,
1538                    field: "voltage_setpoint_pu",
1539                    value: g.voltage_setpoint_pu,
1540                });
1541            }
1542            for (field, value) in [("pmin", g.pmin), ("qmin", g.qmin)] {
1543                if !is_valid_lower_bound(value) {
1544                    return Err(NetworkError::InvalidGeneratorField {
1545                        bus: g.bus,
1546                        field,
1547                        value,
1548                    });
1549                }
1550            }
1551            for (field, value) in [("pmax", g.pmax), ("qmax", g.qmax)] {
1552                if !is_valid_upper_bound(value) {
1553                    return Err(NetworkError::InvalidGeneratorField {
1554                        bus: g.bus,
1555                        field,
1556                        value,
1557                    });
1558                }
1559            }
1560            if g.pmin > g.pmax {
1561                return Err(NetworkError::InvalidGeneratorLimits { bus: g.bus });
1562            }
1563            if g.qmin > g.qmax {
1564                return Err(NetworkError::InvalidGeneratorReactiveLimits { bus: g.bus });
1565            }
1566            if let Some(apf) = g.agc_participation_factor {
1567                if !apf.is_finite() || apf < 0.0 {
1568                    return Err(NetworkError::InvalidGeneratorField {
1569                        bus: g.bus,
1570                        field: "agc_participation_factor",
1571                        value: apf,
1572                    });
1573                }
1574            }
1575            if let Some(forced_outage_rate) = g.forced_outage_rate {
1576                if !forced_outage_rate.is_finite() || !(0.0..=1.0).contains(&forced_outage_rate) {
1577                    return Err(NetworkError::InvalidGeneratorField {
1578                        bus: g.bus,
1579                        field: "forced_outage_rate",
1580                        value: forced_outage_rate,
1581                    });
1582                }
1583            }
1584        }
1585
1586        for branch in &self.branches {
1587            if branch.tap < 0.0 {
1588                return Err(NetworkError::InvalidBranchField {
1589                    from_bus: branch.from_bus,
1590                    to_bus: branch.to_bus,
1591                    field: "tap",
1592                    value: branch.tap,
1593                });
1594            }
1595            for (field, value) in [
1596                ("r", branch.r),
1597                ("x", branch.x),
1598                ("b", branch.b),
1599                ("g_pi", branch.g_pi),
1600                ("tap", branch.tap),
1601                ("phase_shift_rad", branch.phase_shift_rad),
1602                ("g_mag", branch.g_mag),
1603                ("b_mag", branch.b_mag),
1604            ] {
1605                if !value.is_finite() {
1606                    return Err(NetworkError::InvalidBranchField {
1607                        from_bus: branch.from_bus,
1608                        to_bus: branch.to_bus,
1609                        field,
1610                        value,
1611                    });
1612                }
1613            }
1614            for (field, value) in [
1615                ("rating_a_mva", branch.rating_a_mva),
1616                ("rating_b_mva", branch.rating_b_mva),
1617                ("rating_c_mva", branch.rating_c_mva),
1618            ] {
1619                if !is_valid_upper_bound(value) {
1620                    return Err(NetworkError::InvalidBranchField {
1621                        from_bus: branch.from_bus,
1622                        to_bus: branch.to_bus,
1623                        field,
1624                        value,
1625                    });
1626                }
1627            }
1628            if let (Some(min), Some(max)) = (branch.angle_diff_min_rad, branch.angle_diff_max_rad) {
1629                if !is_valid_lower_bound(min) || !is_valid_upper_bound(max) || min > max {
1630                    return Err(NetworkError::InvalidBranchAngleBounds {
1631                        from_bus: branch.from_bus,
1632                        to_bus: branch.to_bus,
1633                        min_rad: branch.angle_diff_min_rad,
1634                        max_rad: branch.angle_diff_max_rad,
1635                    });
1636                }
1637            }
1638            if let Some(min) = branch.angle_diff_min_rad {
1639                if !is_valid_lower_bound(min) {
1640                    return Err(NetworkError::InvalidBranchField {
1641                        from_bus: branch.from_bus,
1642                        to_bus: branch.to_bus,
1643                        field: "angle_diff_min_rad",
1644                        value: min,
1645                    });
1646                }
1647            }
1648            if let Some(max) = branch.angle_diff_max_rad {
1649                if !is_valid_upper_bound(max) {
1650                    return Err(NetworkError::InvalidBranchField {
1651                        from_bus: branch.from_bus,
1652                        to_bus: branch.to_bus,
1653                        field: "angle_diff_max_rad",
1654                        value: max,
1655                    });
1656                }
1657            }
1658        }
1659
1660        Ok(())
1661    }
1662
1663    fn validate_numerics_for_dc_solve(&self) -> Result<(), NetworkError> {
1664        for bus in &self.buses {
1665            for (field, value) in [
1666                ("shunt_conductance_mw", bus.shunt_conductance_mw),
1667                ("shunt_susceptance_mvar", bus.shunt_susceptance_mvar),
1668            ] {
1669                if !value.is_finite() {
1670                    return Err(NetworkError::InvalidBusField {
1671                        bus: bus.number,
1672                        field,
1673                        value,
1674                    });
1675                }
1676            }
1677        }
1678
1679        for load in &self.loads {
1680            for (field, value) in [
1681                ("active_power_demand_mw", load.active_power_demand_mw),
1682                (
1683                    "reactive_power_demand_mvar",
1684                    load.reactive_power_demand_mvar,
1685                ),
1686            ] {
1687                if !value.is_finite() {
1688                    return Err(NetworkError::InvalidLoadField {
1689                        bus: load.bus,
1690                        field,
1691                        value,
1692                    });
1693                }
1694            }
1695        }
1696
1697        for injection in &self.power_injections {
1698            for (field, value) in [
1699                (
1700                    "active_power_injection_mw",
1701                    injection.active_power_injection_mw,
1702                ),
1703                (
1704                    "reactive_power_injection_mvar",
1705                    injection.reactive_power_injection_mvar,
1706                ),
1707            ] {
1708                if !value.is_finite() {
1709                    return Err(NetworkError::InvalidPowerInjectionField {
1710                        bus: injection.bus,
1711                        field,
1712                        value,
1713                    });
1714                }
1715            }
1716        }
1717
1718        for g in &self.generators {
1719            if !g.p.is_finite() {
1720                return Err(NetworkError::InvalidGeneratorField {
1721                    bus: g.bus,
1722                    field: "p",
1723                    value: g.p,
1724                });
1725            }
1726            for (field, value) in [("pmin", g.pmin)] {
1727                if !is_valid_lower_bound(value) {
1728                    return Err(NetworkError::InvalidGeneratorField {
1729                        bus: g.bus,
1730                        field,
1731                        value,
1732                    });
1733                }
1734            }
1735            for (field, value) in [("pmax", g.pmax)] {
1736                if !is_valid_upper_bound(value) {
1737                    return Err(NetworkError::InvalidGeneratorField {
1738                        bus: g.bus,
1739                        field,
1740                        value,
1741                    });
1742                }
1743            }
1744            if g.pmin > g.pmax {
1745                return Err(NetworkError::InvalidGeneratorLimits { bus: g.bus });
1746            }
1747            if let Some(apf) = g.agc_participation_factor {
1748                if !apf.is_finite() || apf < 0.0 {
1749                    return Err(NetworkError::InvalidGeneratorField {
1750                        bus: g.bus,
1751                        field: "agc_participation_factor",
1752                        value: apf,
1753                    });
1754                }
1755            }
1756        }
1757
1758        for branch in &self.branches {
1759            if branch.tap < 0.0 {
1760                return Err(NetworkError::InvalidBranchField {
1761                    from_bus: branch.from_bus,
1762                    to_bus: branch.to_bus,
1763                    field: "tap",
1764                    value: branch.tap,
1765                });
1766            }
1767            for (field, value) in [
1768                ("x", branch.x),
1769                ("tap", branch.tap),
1770                ("phase_shift_rad", branch.phase_shift_rad),
1771            ] {
1772                if !value.is_finite() {
1773                    return Err(NetworkError::InvalidBranchField {
1774                        from_bus: branch.from_bus,
1775                        to_bus: branch.to_bus,
1776                        field,
1777                        value,
1778                    });
1779                }
1780            }
1781            for (field, value) in [
1782                ("rating_a_mva", branch.rating_a_mva),
1783                ("rating_b_mva", branch.rating_b_mva),
1784                ("rating_c_mva", branch.rating_c_mva),
1785            ] {
1786                if !is_valid_upper_bound(value) {
1787                    return Err(NetworkError::InvalidBranchField {
1788                        from_bus: branch.from_bus,
1789                        to_bus: branch.to_bus,
1790                        field,
1791                        value,
1792                    });
1793                }
1794            }
1795        }
1796
1797        Ok(())
1798    }
1799
1800    /// Fill missing canonical generator IDs with deterministic network-local IDs.
1801    ///
1802    /// Existing non-empty IDs are preserved (after trimming surrounding
1803    /// whitespace). Only missing IDs are synthesized, and the generated values
1804    /// are stable for a fixed generator ordering.
1805    pub fn canonicalize_branch_circuit_ids(&mut self) {
1806        let mut used = HashSet::new();
1807        let mut next_suffix_by_key: HashMap<(u32, u32, String), usize> = HashMap::new();
1808
1809        for branch in &mut self.branches {
1810            let base_circuit = {
1811                let trimmed = branch.circuit.trim();
1812                if trimmed.is_empty() {
1813                    "1".to_string()
1814                } else {
1815                    trimmed.to_string()
1816                }
1817            };
1818            let base_key =
1819                BranchEquipmentKey::new(branch.from_bus, branch.to_bus, base_circuit.clone());
1820            let counter_key = (base_key.bus_a, base_key.bus_b, base_circuit.clone());
1821
1822            if used.insert(base_key) {
1823                branch.circuit = base_circuit;
1824                next_suffix_by_key.entry(counter_key).or_insert(2);
1825                continue;
1826            }
1827
1828            let next_suffix = next_suffix_by_key.entry(counter_key).or_insert(2);
1829            loop {
1830                let candidate = format!("{base_circuit}#{}", *next_suffix);
1831                *next_suffix += 1;
1832                let candidate_key =
1833                    BranchEquipmentKey::new(branch.from_bus, branch.to_bus, candidate.clone());
1834                if used.insert(candidate_key) {
1835                    branch.circuit = candidate;
1836                    break;
1837                }
1838            }
1839        }
1840    }
1841
1842    pub fn canonicalize_generator_ids(&mut self) {
1843        canonicalize_ids(&mut self.generators, "gen");
1844    }
1845
1846    /// Fill missing load identifiers with deterministic network-local IDs.
1847    ///
1848    /// Existing non-empty IDs are preserved after trimming surrounding
1849    /// whitespace. Generated IDs are stable for a fixed load ordering.
1850    pub fn canonicalize_load_ids(&mut self) {
1851        canonicalize_ids(&mut self.loads, "load");
1852    }
1853
1854    /// Fill missing fixed-shunt identifiers with deterministic network-local IDs.
1855    ///
1856    /// Existing non-empty IDs are preserved after trimming surrounding
1857    /// whitespace. Generated IDs are stable for a fixed shunt ordering.
1858    pub fn canonicalize_shunt_ids(&mut self) {
1859        canonicalize_ids(&mut self.fixed_shunts, "shunt");
1860    }
1861
1862    /// Fill missing switched-shunt identifiers with deterministic network-local IDs.
1863    pub fn canonicalize_switched_shunt_ids(&mut self) {
1864        canonicalize_ids(&mut self.controls.switched_shunts, "switched_shunt");
1865        canonicalize_ids(&mut self.controls.switched_shunts_opf, "switched_shunt_opf");
1866    }
1867
1868    /// Fill missing explicit-DC converter identifiers with deterministic grid-local IDs.
1869    pub fn canonicalize_hvdc_converter_ids(&mut self) {
1870        self.hvdc.canonicalize_converter_ids();
1871    }
1872
1873    /// Fill missing dispatchable-load identifiers with deterministic network-local IDs.
1874    ///
1875    /// Existing non-empty IDs are preserved after trimming surrounding
1876    /// whitespace. Generated IDs are stable for a fixed resource ordering and
1877    /// use the external bus number.
1878    pub fn canonicalize_dispatchable_load_ids(&mut self) {
1879        struct DlWrapper<'a> {
1880            inner: &'a mut DispatchableLoad,
1881        }
1882        impl HasCanonicalId for DlWrapper<'_> {
1883            fn canonical_id(&self) -> &str {
1884                &self.inner.resource_id
1885            }
1886            fn set_canonical_id(&mut self, id: String) {
1887                self.inner.resource_id = id;
1888            }
1889            fn bus_number(&self) -> u32 {
1890                self.inner.bus
1891            }
1892        }
1893
1894        let mut wrappers: Vec<DlWrapper> = self
1895            .market_data
1896            .dispatchable_loads
1897            .iter_mut()
1898            .map(|dl| DlWrapper { inner: dl })
1899            .collect();
1900        canonicalize_ids(&mut wrappers, "dispatchable_load");
1901    }
1902
1903    /// Canonicalize source-facing identities that must be unambiguous at runtime.
1904    ///
1905    /// This keeps solver and study code agnostic to source-format quirks such
1906    /// as reverse-direction duplicate branch records, stale PV bus tags on
1907    /// buses without an in-service regulating generator, or missing explicit
1908    /// generator / DC-converter identifiers.
1909    pub fn canonicalize_runtime_identities(&mut self) {
1910        self.canonicalize_runtime_bus_types();
1911        self.canonicalize_branch_circuit_ids();
1912        self.canonicalize_generator_ids();
1913        self.canonicalize_switched_shunt_ids();
1914        self.canonicalize_hvdc_converter_ids();
1915    }
1916
1917    fn canonicalize_runtime_bus_types(&mut self) {
1918        let regulating_targets: HashSet<u32> = self
1919            .generators
1920            .iter()
1921            .filter(|generator| generator.can_voltage_regulate())
1922            .map(|generator| generator.reg_bus.unwrap_or(generator.bus))
1923            .collect();
1924
1925        for bus in &mut self.buses {
1926            if bus.bus_type == BusType::PV && !regulating_targets.contains(&bus.number) {
1927                bus.bus_type = BusType::PQ;
1928            }
1929        }
1930    }
1931
1932    /// Build a mapping from external bus number to internal 0-based index.
1933    pub fn bus_index_map(&self) -> HashMap<u32, usize> {
1934        self.buses
1935            .iter()
1936            .enumerate()
1937            .map(|(i, b)| (b.number, i))
1938            .collect()
1939    }
1940
1941    /// Build a mapping from canonical generator ID to generator array index.
1942    ///
1943    /// Duplicate IDs are deduplicated: only the first occurrence is kept.
1944    pub fn gen_index_by_id(&self) -> HashMap<String, usize> {
1945        let mut map = HashMap::new();
1946        for (i, g) in self.generators.iter().enumerate() {
1947            map.entry(g.id.trim().to_string()).or_insert(i);
1948        }
1949        map
1950    }
1951
1952    /// Find the internal array index of the generator matching a canonical ID.
1953    pub fn find_gen_index_by_id(&self, id: &str) -> Option<usize> {
1954        let canonical = id.trim();
1955        self.generators
1956            .iter()
1957            .position(|g| g.id.trim() == canonical)
1958    }
1959
1960    /// Build a mapping from `(bus, machine_id)` to generator array index.
1961    ///
1962    /// This is a source-format convenience lookup, not the canonical generator
1963    /// identity contract. Multiple generators at the same bus with the same
1964    /// machine ID are deduplicated: only the first occurrence is kept.
1965    pub fn gen_index_map(&self) -> HashMap<(u32, Option<String>), usize> {
1966        let mut map = HashMap::new();
1967        for (i, g) in self.generators.iter().enumerate() {
1968            map.entry((g.bus, g.machine_id.clone())).or_insert(i);
1969        }
1970        map
1971    }
1972
1973    /// Find the internal array index of the generator matching `(bus, machine_id)`.
1974    ///
1975    /// This is a source-format convenience lookup. When `machine_id` is `None`,
1976    /// the first generator (in array order) at that bus is returned regardless
1977    /// of its `machine_id` field.
1978    pub fn find_gen_index(&self, bus: u32, machine_id: Option<&str>) -> Option<usize> {
1979        match machine_id {
1980            Some(mid) => self
1981                .generators
1982                .iter()
1983                .position(|g| g.bus == bus && g.machine_id.as_deref().unwrap_or("1") == mid),
1984            None => self.generators.iter().position(|g| g.bus == bus),
1985        }
1986    }
1987
1988    /// Build a mapping from `(from_bus, to_bus, circuit)` to branch array index.
1989    ///
1990    /// Parallel branches (same terminal buses, different circuit IDs) are all
1991    /// included.  The first occurrence wins for duplicate keys.
1992    pub fn branch_index_map(&self) -> HashMap<(u32, u32, String), usize> {
1993        let mut map = HashMap::new();
1994        for (i, b) in self.branches.iter().enumerate() {
1995            map.entry((b.from_bus, b.to_bus, b.circuit.clone()))
1996                .or_insert(i);
1997        }
1998        map
1999    }
2000
2001    /// Find the internal array index of the branch matching `(from, to, circuit)`.
2002    ///
2003    /// Matches either direction, preserving the circuit identifier.
2004    pub fn find_branch_index(&self, from_bus: u32, to_bus: u32, circuit: &str) -> Option<usize> {
2005        self.branches.iter().position(|branch| {
2006            let same_direction = branch.from_bus == from_bus && branch.to_bus == to_bus;
2007            let reverse_direction = branch.from_bus == to_bus && branch.to_bus == from_bus;
2008            (same_direction || reverse_direction) && branch.circuit == circuit
2009        })
2010    }
2011
2012    /// Build a mapping from canonical load ID to load array index.
2013    ///
2014    /// Duplicate IDs are deduplicated: only the first occurrence is kept.
2015    pub fn load_index_by_id(&self) -> HashMap<String, usize> {
2016        let mut map = HashMap::new();
2017        for (i, load) in self.loads.iter().enumerate() {
2018            map.entry(load.id.clone()).or_insert(i);
2019        }
2020        map
2021    }
2022
2023    /// Find the internal array index of the load matching a canonical ID.
2024    pub fn find_load_index_by_id(&self, id: &str) -> Option<usize> {
2025        self.loads.iter().position(|load| load.id == id)
2026    }
2027
2028    /// Find the internal array index of the first load matching `(bus, id)`.
2029    ///
2030    /// When `id` is `None`, returns the first load at the bus.
2031    pub fn find_load_index(&self, bus: u32, id: Option<&str>) -> Option<usize> {
2032        match id {
2033            Some(load_id) => self
2034                .loads
2035                .iter()
2036                .position(|load| load.bus == bus && load.id == load_id),
2037            None => self.loads.iter().position(|load| load.bus == bus),
2038        }
2039    }
2040
2041    /// Build a mapping from canonical fixed-shunt ID to shunt array index.
2042    ///
2043    /// Duplicate IDs are deduplicated: only the first occurrence is kept.
2044    pub fn shunt_index_by_id(&self) -> HashMap<String, usize> {
2045        let mut map = HashMap::new();
2046        for (i, shunt) in self.fixed_shunts.iter().enumerate() {
2047            map.entry(shunt.id.clone()).or_insert(i);
2048        }
2049        map
2050    }
2051
2052    /// Find the internal array index of the fixed shunt matching a canonical ID.
2053    pub fn find_shunt_index_by_id(&self, id: &str) -> Option<usize> {
2054        self.fixed_shunts.iter().position(|shunt| shunt.id == id)
2055    }
2056
2057    /// Find the internal array index of the first fixed shunt matching `(bus, id)`.
2058    ///
2059    /// When `id` is `None`, returns the first shunt at the bus.
2060    pub fn find_shunt_index(&self, bus: u32, id: Option<&str>) -> Option<usize> {
2061        match id {
2062            Some(shunt_id) => self
2063                .fixed_shunts
2064                .iter()
2065                .position(|shunt| shunt.bus == bus && shunt.id == shunt_id),
2066            None => self.fixed_shunts.iter().position(|shunt| shunt.bus == bus),
2067        }
2068    }
2069
2070    /// Find the internal array index of the HVDC link matching its stable name.
2071    pub fn find_hvdc_link_index_by_name(&self, name: &str) -> Option<usize> {
2072        self.hvdc.links.iter().position(|link| link.name() == name)
2073    }
2074
2075    fn validate_interface_definitions(&self) -> Result<(), NetworkError> {
2076        let existing_branches: HashSet<_> = self
2077            .branches
2078            .iter()
2079            .map(crate::network::BranchRef::from)
2080            .collect();
2081        for interface in &self.interfaces {
2082            if interface.members.is_empty() {
2083                return Err(NetworkError::InvalidInterfaceDefinition {
2084                    name: interface.name.clone(),
2085                    detail: "interface has no weighted branch members".to_string(),
2086                });
2087            }
2088            for member in &interface.members {
2089                if !member.coefficient.is_finite() {
2090                    return Err(NetworkError::InvalidInterfaceDefinition {
2091                        name: interface.name.clone(),
2092                        detail: format!(
2093                            "interface member ({}, {}, {}) has non-finite coefficient {}",
2094                            member.branch.from_bus,
2095                            member.branch.to_bus,
2096                            member.branch.circuit,
2097                            member.coefficient
2098                        ),
2099                    });
2100                }
2101                if !existing_branches.contains(&member.branch) {
2102                    return Err(NetworkError::InvalidInterfaceDefinition {
2103                        name: interface.name.clone(),
2104                        detail: format!(
2105                            "interface references missing branch ({}, {}, {})",
2106                            member.branch.from_bus, member.branch.to_bus, member.branch.circuit
2107                        ),
2108                    });
2109                }
2110            }
2111        }
2112
2113        for flowgate in &self.flowgates {
2114            if flowgate.monitored.is_empty() && flowgate.contingency_branch.is_none() {
2115                return Err(NetworkError::InvalidFlowgateDefinition {
2116                    name: flowgate.name.clone(),
2117                    detail: "flowgate has neither monitored members nor a contingency branch"
2118                        .to_string(),
2119                });
2120            }
2121            for member in &flowgate.monitored {
2122                if !member.coefficient.is_finite() {
2123                    return Err(NetworkError::InvalidFlowgateDefinition {
2124                        name: flowgate.name.clone(),
2125                        detail: format!(
2126                            "flowgate monitored branch ({}, {}, {}) has non-finite coefficient {}",
2127                            member.branch.from_bus,
2128                            member.branch.to_bus,
2129                            member.branch.circuit,
2130                            member.coefficient
2131                        ),
2132                    });
2133                }
2134                if !existing_branches.contains(&member.branch) {
2135                    return Err(NetworkError::InvalidFlowgateDefinition {
2136                        name: flowgate.name.clone(),
2137                        detail: format!(
2138                            "flowgate references missing monitored branch ({}, {}, {})",
2139                            member.branch.from_bus, member.branch.to_bus, member.branch.circuit
2140                        ),
2141                    });
2142                }
2143            }
2144            if let Some(branch) = &flowgate.contingency_branch
2145                && !existing_branches.contains(branch)
2146            {
2147                return Err(NetworkError::InvalidFlowgateDefinition {
2148                    name: flowgate.name.clone(),
2149                    detail: format!(
2150                        "flowgate references missing contingency branch ({}, {}, {})",
2151                        branch.from_bus, branch.to_bus, branch.circuit
2152                    ),
2153                });
2154            }
2155        }
2156
2157        Ok(())
2158    }
2159
2160    fn validate_internal_control_indices(&self) -> Result<(), NetworkError> {
2161        let bus_numbers: HashSet<u32> = self.buses.iter().map(|bus| bus.number).collect();
2162
2163        for shunt in &self.controls.switched_shunts {
2164            if !bus_numbers.contains(&shunt.bus) {
2165                return Err(NetworkError::InvalidSwitchedShuntBus {
2166                    id: shunt.id.clone(),
2167                    bus: shunt.bus,
2168                });
2169            }
2170            if !bus_numbers.contains(&shunt.bus_regulated) {
2171                return Err(NetworkError::InvalidSwitchedShuntRegulatedBus {
2172                    id: shunt.id.clone(),
2173                    bus: shunt.bus_regulated,
2174                });
2175            }
2176        }
2177
2178        for shunt in &self.controls.switched_shunts_opf {
2179            if !bus_numbers.contains(&shunt.bus) {
2180                return Err(NetworkError::InvalidSwitchedShuntOpfBus {
2181                    id: shunt.id.clone(),
2182                    bus: shunt.bus,
2183                });
2184            }
2185        }
2186
2187        Ok(())
2188    }
2189
2190    fn validate_hvdc_structure(&self, bus_numbers: &HashSet<u32>) -> Result<(), NetworkError> {
2191        let mut link_names = HashSet::new();
2192        for link in &self.hvdc.links {
2193            let name = link.name().trim().to_string();
2194            if !name.is_empty() && !link_names.insert(name.clone()) {
2195                return Err(NetworkError::DuplicateHvdcLinkName { name });
2196            }
2197
2198            match link {
2199                crate::network::HvdcLink::Lcc(link) => {
2200                    for terminal in [&link.rectifier, &link.inverter] {
2201                        if !bus_numbers.contains(&terminal.bus) {
2202                            return Err(NetworkError::InvalidHvdcLinkEndpoint {
2203                                name: link.name.clone(),
2204                                bus: terminal.bus,
2205                            });
2206                        }
2207                    }
2208                }
2209                crate::network::HvdcLink::Vsc(link) => {
2210                    for terminal in [&link.converter1, &link.converter2] {
2211                        if !bus_numbers.contains(&terminal.bus) {
2212                            return Err(NetworkError::InvalidHvdcLinkEndpoint {
2213                                name: link.name.clone(),
2214                                bus: terminal.bus,
2215                            });
2216                        }
2217                    }
2218                }
2219            }
2220        }
2221
2222        let mut grid_ids = HashSet::new();
2223        for grid in self.hvdc.dc_grids.iter().filter(|grid| !grid.is_empty()) {
2224            if !grid_ids.insert(grid.id) {
2225                return Err(NetworkError::DuplicateDcGridId { id: grid.id });
2226            }
2227
2228            let mut dc_bus_ids = HashSet::new();
2229            for bus in &grid.buses {
2230                if !dc_bus_ids.insert(bus.bus_id) {
2231                    return Err(NetworkError::DuplicateDcBusId {
2232                        grid_id: grid.id,
2233                        bus_id: bus.bus_id,
2234                    });
2235                }
2236            }
2237
2238            for converter in &grid.converters {
2239                if !bus_numbers.contains(&converter.ac_bus()) {
2240                    return Err(NetworkError::InvalidDcConverterAcBus {
2241                        grid_id: grid.id,
2242                        ac_bus: converter.ac_bus(),
2243                    });
2244                }
2245                if !dc_bus_ids.contains(&converter.dc_bus()) {
2246                    return Err(NetworkError::InvalidDcConverterDcBus {
2247                        grid_id: grid.id,
2248                        dc_bus: converter.dc_bus(),
2249                    });
2250                }
2251            }
2252
2253            for branch in &grid.branches {
2254                if !dc_bus_ids.contains(&branch.from_bus) {
2255                    return Err(NetworkError::InvalidDcBranchEndpoint {
2256                        grid_id: grid.id,
2257                        from_bus: branch.from_bus,
2258                        to_bus: branch.to_bus,
2259                        missing_bus: branch.from_bus,
2260                    });
2261                }
2262                if !dc_bus_ids.contains(&branch.to_bus) {
2263                    return Err(NetworkError::InvalidDcBranchEndpoint {
2264                        grid_id: grid.id,
2265                        from_bus: branch.from_bus,
2266                        to_bus: branch.to_bus,
2267                        missing_bus: branch.to_bus,
2268                    });
2269                }
2270            }
2271        }
2272
2273        Ok(())
2274    }
2275
2276    fn validate_hvdc_solve_contract(&self) -> Result<(), NetworkError> {
2277        if self.hvdc.has_point_to_point_links() && self.hvdc.has_explicit_dc_topology() {
2278            return Err(NetworkError::MixedHvdcRepresentation);
2279        }
2280        Ok(())
2281    }
2282
2283    /// Find the internal array index of the pumped-hydro unit matching its stable name.
2284    pub fn find_pumped_hydro_index_by_name(&self, name: &str) -> Option<usize> {
2285        self.market_data
2286            .pumped_hydro_units
2287            .iter()
2288            .position(|unit| unit.name == name)
2289    }
2290
2291    /// Find the internal array index of the dispatchable load matching `(resource_id, bus)`.
2292    pub fn find_dispatchable_load_index(
2293        &self,
2294        resource_id: &str,
2295        bus: Option<u32>,
2296    ) -> Option<usize> {
2297        self.market_data
2298            .dispatchable_loads
2299            .iter()
2300            .enumerate()
2301            .find_map(|(index, resource)| {
2302                if resource.resource_id != resource_id {
2303                    return None;
2304                }
2305                if bus.is_none_or(|value| resource.bus == value) {
2306                    Some(index)
2307                } else {
2308                    None
2309                }
2310            })
2311    }
2312
2313    /// Find the internal array index of the combined-cycle plant matching its stable name.
2314    pub fn find_combined_cycle_index_by_name(&self, name: &str) -> Option<usize> {
2315        self.market_data
2316            .combined_cycle_plants
2317            .iter()
2318            .position(|plant| plant.name == name)
2319    }
2320
2321    /// Find the internal index of the first slack bus in bus-array order.
2322    ///
2323    /// All DC/PTDF/OPF solvers use this as the single angle reference — only
2324    /// the first slack bus is removed from the B' matrix.  AC NR excludes ALL
2325    /// slack buses from the Jacobian.  Use `slack_buses()` to enumerate all of
2326    /// them; call [`validate_for_solve`](Self::validate_for_solve) to enforce
2327    /// one slack bus per connected component.
2328    pub fn slack_bus_index(&self) -> Option<usize> {
2329        self.buses.iter().position(|b| b.bus_type == BusType::Slack)
2330    }
2331
2332    /// Return references to all slack buses in the network.
2333    ///
2334    /// For systems with distributed slack or multiple slack buses, this returns
2335    /// all of them. For single-slack systems, the returned `Vec` has one element.
2336    /// Use [`slack_bus_index`](Self::slack_bus_index) when only the first (reference) bus is needed.
2337    pub fn slack_buses(&self) -> Vec<&Bus> {
2338        self.buses
2339            .iter()
2340            .filter(|b| b.bus_type == BusType::Slack)
2341            .collect()
2342    }
2343
2344    /// Aggregate generator AGC participation factors by internal bus index.
2345    ///
2346    /// Returns `(internal_bus_index, total_participation_factor)` pairs for
2347    /// buses that have at least one in-service generator with a positive
2348    /// `agc_participation_factor`. Weights are **not** normalized (the caller
2349    /// should normalize to sum to 1.0 if needed).
2350    pub fn agc_participation_by_bus(&self) -> Vec<(usize, f64)> {
2351        let bus_map = self.bus_index_map();
2352        let mut by_bus = vec![0.0f64; self.n_buses()];
2353        for g in self.generators.iter().filter(|g| g.in_service) {
2354            if let Some(apf) = g.agc_participation_factor {
2355                if apf > 0.0 && apf.is_finite() {
2356                    if let Some(&idx) = bus_map.get(&g.bus) {
2357                        by_bus[idx] += apf;
2358                    }
2359                }
2360            }
2361        }
2362        by_bus
2363            .into_iter()
2364            .enumerate()
2365            .filter(|&(_, w)| w > 0.0)
2366            .collect()
2367    }
2368
2369    /// Compute per-bus real power demand (MW) by summing in-service Load objects
2370    /// and subtracting in-service PowerInjection objects.
2371    pub fn bus_load_p_mw(&self) -> Vec<f64> {
2372        self.bus_load_p_mw_with_map(&self.bus_index_map())
2373    }
2374
2375    /// Compute per-bus reactive power demand (MVAr) by summing in-service Load objects
2376    /// and subtracting in-service PowerInjection objects.
2377    pub fn bus_load_q_mvar(&self) -> Vec<f64> {
2378        self.bus_load_q_mvar_with_map(&self.bus_index_map())
2379    }
2380
2381    /// Compute per-bus real power demand (MW) with a pre-built bus index map.
2382    pub fn bus_load_p_mw_with_map(&self, bus_map: &HashMap<u32, usize>) -> Vec<f64> {
2383        let mut demand = vec![0.0; self.buses.len()];
2384        for load in &self.loads {
2385            if load.in_service {
2386                if let Some(&idx) = bus_map.get(&load.bus) {
2387                    demand[idx] += load.active_power_demand_mw;
2388                }
2389            }
2390        }
2391        for injection in &self.power_injections {
2392            if injection.in_service {
2393                if let Some(&idx) = bus_map.get(&injection.bus) {
2394                    demand[idx] -= injection.active_power_injection_mw;
2395                }
2396            }
2397        }
2398        demand
2399    }
2400
2401    /// Compute per-bus reactive power demand (MVAr) with a pre-built bus index map.
2402    pub fn bus_load_q_mvar_with_map(&self, bus_map: &HashMap<u32, usize>) -> Vec<f64> {
2403        let mut demand = vec![0.0; self.buses.len()];
2404        for load in &self.loads {
2405            if load.in_service {
2406                if let Some(&idx) = bus_map.get(&load.bus) {
2407                    demand[idx] += load.reactive_power_demand_mvar;
2408                }
2409            }
2410        }
2411        for injection in &self.power_injections {
2412            if injection.in_service {
2413                if let Some(&idx) = bus_map.get(&injection.bus) {
2414                    demand[idx] -= injection.reactive_power_injection_mvar;
2415                }
2416            }
2417        }
2418        demand
2419    }
2420
2421    /// Compute real power injection at each bus in per-unit.
2422    /// P_inject\[i\] = (sum of Pg at bus i - Pd at bus i) / base_mva
2423    ///
2424    /// Demand is computed from in-service `Load` objects (and `PowerInjection`
2425    /// objects, which act as negative load). Generator output is summed from
2426    /// in-service generators.
2427    pub fn bus_p_injection_pu(&self) -> Vec<f64> {
2428        self.bus_p_injection_pu_with_map(&self.bus_index_map())
2429    }
2430
2431    /// Compute real power injection at each bus in per-unit with a pre-built bus index map.
2432    pub fn bus_p_injection_pu_with_map(&self, bus_map: &HashMap<u32, usize>) -> Vec<f64> {
2433        let n = self.buses.len();
2434        let demand = self.bus_load_p_mw_with_map(bus_map);
2435        let mut p_inj = vec![0.0; n];
2436
2437        for (i, d) in demand.iter().enumerate() {
2438            p_inj[i] -= d / self.base_mva;
2439        }
2440
2441        for g in &self.generators {
2442            if g.in_service
2443                && let Some(&idx) = bus_map.get(&g.bus)
2444            {
2445                p_inj[idx] += g.p / self.base_mva;
2446            }
2447        }
2448
2449        p_inj
2450    }
2451
2452    /// Compute reactive power injection at each bus in per-unit.
2453    ///
2454    /// Demand is computed from in-service `Load` objects (and `PowerInjection`
2455    /// objects, which act as negative load). Generator output is summed from
2456    /// in-service generators.
2457    pub fn bus_q_injection_pu(&self) -> Vec<f64> {
2458        self.bus_q_injection_pu_with_map(&self.bus_index_map())
2459    }
2460
2461    /// Compute reactive power injection at each bus in per-unit with a pre-built bus index map.
2462    pub fn bus_q_injection_pu_with_map(&self, bus_map: &HashMap<u32, usize>) -> Vec<f64> {
2463        let n = self.buses.len();
2464        let demand = self.bus_load_q_mvar_with_map(bus_map);
2465        let mut q_inj = vec![0.0; n];
2466
2467        for (i, d) in demand.iter().enumerate() {
2468            q_inj[i] -= d / self.base_mva;
2469        }
2470
2471        for g in &self.generators {
2472            if g.in_service
2473                && let Some(&idx) = bus_map.get(&g.bus)
2474            {
2475                q_inj[idx] += g.q / self.base_mva;
2476            }
2477        }
2478
2479        q_inj
2480    }
2481
2482    /// Total real power generation (MW).
2483    pub fn total_generation_mw(&self) -> f64 {
2484        self.generators
2485            .iter()
2486            .filter(|g| g.in_service)
2487            .map(|g| g.p)
2488            .sum()
2489    }
2490
2491    /// Total real power demand (MW) from in-service Load objects.
2492    pub fn total_load_mw(&self) -> f64 {
2493        self.loads
2494            .iter()
2495            .filter(|l| l.in_service)
2496            .map(|l| l.active_power_demand_mw)
2497            .sum()
2498    }
2499
2500    /// Rebuild bus-level fixed shunt aggregates from explicit equipment.
2501    ///
2502    /// This is the canonical path for node-breaker-backed networks where
2503    /// topology-sensitive state must survive bus splits and merges. Dynamic
2504    /// control overlays such as switched shunts or AC/DC controller injections
2505    /// are intentionally excluded; they are applied by solver/control loops.
2506    ///
2507    /// Load demand is not stored on Bus; it lives exclusively on Load objects
2508    /// and is computed at solve time via [`bus_load_p_mw`](Self::bus_load_p_mw).
2509    pub fn rebuild_bus_state_from_explicit_equipment(&mut self) {
2510        self.rebuild_bus_state_from_explicit_equipment_with_map(&self.bus_index_map());
2511    }
2512
2513    /// Rebuild bus-level fixed shunt aggregates with a pre-built bus index map.
2514    pub fn rebuild_bus_state_from_explicit_equipment_with_map(
2515        &mut self,
2516        bus_map: &HashMap<u32, usize>,
2517    ) {
2518        for bus in &mut self.buses {
2519            bus.shunt_conductance_mw = 0.0;
2520            bus.shunt_susceptance_mvar = 0.0;
2521        }
2522
2523        for shunt in &self.fixed_shunts {
2524            if shunt.in_service
2525                && let Some(&idx) = bus_map.get(&shunt.bus)
2526            {
2527                self.buses[idx].shunt_conductance_mw += shunt.g_mw;
2528                self.buses[idx].shunt_susceptance_mvar += shunt.b_mvar;
2529            }
2530        }
2531    }
2532
2533    /// Scale all loads by a factor. If `area` is `Some`, only loads in that area
2534    /// are affected.
2535    ///
2536    /// Scales Load objects only. Real and reactive power are both multiplied by `factor`.
2537    pub fn scale_loads(&mut self, factor: f64, area: Option<u32>) {
2538        self.scale_loads_with_map(factor, area, &self.bus_index_map());
2539    }
2540
2541    /// Scale all loads by a factor with a pre-built bus index map.
2542    pub fn scale_loads_with_map(
2543        &mut self,
2544        factor: f64,
2545        area: Option<u32>,
2546        bus_map: &HashMap<u32, usize>,
2547    ) {
2548        let bus_area: Vec<u32> = self.buses.iter().map(|b| b.area).collect();
2549        for load in &mut self.loads {
2550            if let Some(a) = area {
2551                if let Some(&idx) = bus_map.get(&load.bus) {
2552                    if bus_area.get(idx).copied() != Some(a) {
2553                        continue;
2554                    }
2555                }
2556            }
2557            load.active_power_demand_mw *= factor;
2558            load.reactive_power_demand_mvar *= factor;
2559        }
2560    }
2561
2562    /// Scale all in-service generator real power output by a factor.
2563    ///
2564    /// Each generator's `p` is multiplied by `factor` and then clamped to
2565    /// `[pmin, pmax]`. If `area` is `Some`, only generators connected to buses
2566    /// in that area are affected.
2567    pub fn scale_generation(&mut self, factor: f64, area: Option<u32>) {
2568        self.scale_generation_with_map(factor, area, &self.bus_index_map());
2569    }
2570
2571    /// Scale all in-service generator real power output with a pre-built bus index map.
2572    pub fn scale_generation_with_map(
2573        &mut self,
2574        factor: f64,
2575        area: Option<u32>,
2576        bus_map: &HashMap<u32, usize>,
2577    ) {
2578        let bus_area: Vec<u32> = self.buses.iter().map(|b| b.area).collect();
2579        for g in &mut self.generators {
2580            if !g.in_service {
2581                continue;
2582            }
2583            if let Some(a) = area {
2584                if let Some(&idx) = bus_map.get(&g.bus) {
2585                    if bus_area.get(idx).copied() != Some(a) {
2586                        continue;
2587                    }
2588                }
2589            }
2590            g.p = (g.p * factor).clamp(g.pmin, g.pmax);
2591        }
2592    }
2593
2594    /// Set zero-sequence impedance for a branch identified by (from, to, circuit).
2595    ///
2596    /// Returns `true` if the branch was found and updated, `false` otherwise.
2597    pub fn set_branch_sequence(
2598        &mut self,
2599        from_bus: u32,
2600        to_bus: u32,
2601        circuit: &str,
2602        r0: f64,
2603        x0: f64,
2604        b0: f64,
2605    ) -> bool {
2606        use crate::network::ZeroSeqData;
2607        for br in &mut self.branches {
2608            let matched = (br.from_bus == from_bus && br.to_bus == to_bus && br.circuit == circuit)
2609                || (br.from_bus == to_bus && br.to_bus == from_bus && br.circuit == circuit);
2610            if matched {
2611                let zs = br.zero_seq.get_or_insert_with(ZeroSeqData::default);
2612                zs.r0 = r0;
2613                zs.x0 = x0;
2614                zs.b0 = b0;
2615                return true;
2616            }
2617        }
2618        false
2619    }
2620
2621    /// Get zero-sequence impedance for a branch identified by (from, to, circuit).
2622    ///
2623    /// Returns `Some((r0, x0, b0))` if the branch exists and has zero-sequence
2624    /// data, `None` otherwise.
2625    pub fn get_branch_sequence(
2626        &self,
2627        from_bus: u32,
2628        to_bus: u32,
2629        circuit: &str,
2630    ) -> Option<(f64, f64, f64)> {
2631        for br in &self.branches {
2632            let matched = (br.from_bus == from_bus && br.to_bus == to_bus && br.circuit == circuit)
2633                || (br.from_bus == to_bus && br.to_bus == from_bus && br.circuit == circuit);
2634            if matched {
2635                return br.zero_seq.as_ref().map(|zs| (zs.r0, zs.x0, zs.b0));
2636            }
2637        }
2638        None
2639    }
2640}
2641
2642#[cfg(test)]
2643mod tests {
2644    use super::*;
2645    use crate::network::{
2646        Branch, Bus, BusType, FixedShunt, Generator, GeneratorRef, Load, StorageParams,
2647    };
2648
2649    /// Build a minimal 3-bus network for testing:
2650    ///   Bus 1 (Slack, Pd=0)  -- Branch --> Bus 2 (PQ, Pd=50)
2651    ///   Bus 2 (PQ, Pd=50)    -- Branch --> Bus 3 (PV, Pd=30)
2652    ///   Generator on bus 1: Pg=90 MW, in_service
2653    ///   Generator on bus 3: Pg=20 MW, in_service
2654    fn make_3bus_network() -> Network {
2655        let mut net = Network::new("test-3bus");
2656
2657        let bus1 = Bus::new(1, BusType::Slack, 138.0);
2658        let bus2 = Bus::new(2, BusType::PQ, 138.0);
2659        let bus3 = Bus::new(3, BusType::PV, 138.0);
2660
2661        net.buses.push(bus1);
2662        net.buses.push(bus2);
2663        net.buses.push(bus3);
2664
2665        // Load demand lives on Load objects, not Bus fields.
2666        net.loads.push(Load::new(2, 50.0, 0.0));
2667        net.loads.push(Load::new(3, 30.0, 0.0));
2668
2669        net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.02));
2670        net.branches.push(Branch::new_line(2, 3, 0.02, 0.2, 0.04));
2671
2672        net.generators.push(Generator::new(1, 90.0, 1.0));
2673        net.generators.push(Generator::new(3, 20.0, 1.0));
2674        net.canonicalize_generator_ids();
2675
2676        net
2677    }
2678
2679    // -----------------------------------------------------------------------
2680    // Count accessors
2681    // -----------------------------------------------------------------------
2682
2683    #[test]
2684    fn test_n_buses() {
2685        let net = make_3bus_network();
2686        assert_eq!(net.n_buses(), 3);
2687    }
2688
2689    #[test]
2690    fn test_n_branches() {
2691        let net = make_3bus_network();
2692        assert_eq!(net.n_branches(), 2);
2693    }
2694
2695    #[test]
2696    fn test_n_generators() {
2697        let net = make_3bus_network();
2698        assert_eq!(net.n_generators(), 2);
2699    }
2700
2701    #[test]
2702    fn test_n_generators_in_service_excludes_out_of_service() {
2703        let mut net = make_3bus_network();
2704        // Take the second generator offline
2705        net.generators[1].in_service = false;
2706        assert_eq!(net.n_generators(), 2, "n_generators returns total count");
2707        assert_eq!(
2708            net.n_generators_in_service(),
2709            1,
2710            "n_generators_in_service should only count in-service generators"
2711        );
2712    }
2713
2714    // -----------------------------------------------------------------------
2715    // bus_index_map
2716    // -----------------------------------------------------------------------
2717
2718    #[test]
2719    fn test_bus_index_map_correctness() {
2720        let net = make_3bus_network();
2721        let map = net.bus_index_map();
2722        assert_eq!(map.len(), 3);
2723        assert_eq!(map[&1], 0);
2724        assert_eq!(map[&2], 1);
2725        assert_eq!(map[&3], 2);
2726    }
2727
2728    #[test]
2729    fn test_bus_index_map_non_contiguous_bus_numbers() {
2730        let mut net = Network::new("non-contiguous");
2731        net.buses.push(Bus::new(10, BusType::Slack, 138.0));
2732        net.buses.push(Bus::new(50, BusType::PQ, 138.0));
2733        net.buses.push(Bus::new(99, BusType::PQ, 138.0));
2734
2735        let map = net.bus_index_map();
2736        assert_eq!(map.len(), 3);
2737        assert_eq!(map[&10], 0);
2738        assert_eq!(map[&50], 1);
2739        assert_eq!(map[&99], 2);
2740    }
2741
2742    // -----------------------------------------------------------------------
2743    // slack_bus_index
2744    // -----------------------------------------------------------------------
2745
2746    #[test]
2747    fn test_slack_bus_index() {
2748        let net = make_3bus_network();
2749        assert_eq!(
2750            net.slack_bus_index(),
2751            Some(0),
2752            "Bus 1 is Slack and at index 0"
2753        );
2754    }
2755
2756    #[test]
2757    fn test_slack_bus_index_no_slack() {
2758        let mut net = Network::new("no-slack");
2759        net.buses.push(Bus::new(1, BusType::PQ, 138.0));
2760        net.buses.push(Bus::new(2, BusType::PV, 138.0));
2761        assert_eq!(
2762            net.slack_bus_index(),
2763            None,
2764            "No slack bus should return None"
2765        );
2766    }
2767
2768    #[test]
2769    fn test_slack_bus_index_slack_not_first() {
2770        let mut net = Network::new("slack-last");
2771        net.buses.push(Bus::new(1, BusType::PQ, 138.0));
2772        net.buses.push(Bus::new(2, BusType::PQ, 138.0));
2773        net.buses.push(Bus::new(3, BusType::Slack, 138.0));
2774        assert_eq!(
2775            net.slack_bus_index(),
2776            Some(2),
2777            "Slack bus at the end should return index 2"
2778        );
2779    }
2780
2781    // -----------------------------------------------------------------------
2782    // total_generation_mw / total_load_mw
2783    // -----------------------------------------------------------------------
2784
2785    #[test]
2786    fn test_total_generation_mw() {
2787        let net = make_3bus_network();
2788        // Gen 1: 90 MW + Gen 2: 20 MW = 110 MW
2789        assert!(
2790            (net.total_generation_mw() - 110.0).abs() < 1e-10,
2791            "total generation should be 110 MW; got {}",
2792            net.total_generation_mw()
2793        );
2794    }
2795
2796    #[test]
2797    fn test_total_generation_mw_excludes_offline() {
2798        let mut net = make_3bus_network();
2799        net.generators[0].in_service = false;
2800        assert!(
2801            (net.total_generation_mw() - 20.0).abs() < 1e-10,
2802            "offline gen should not count; got {}",
2803            net.total_generation_mw()
2804        );
2805    }
2806
2807    #[test]
2808    fn test_total_load_mw() {
2809        let net = make_3bus_network();
2810        // Bus 1: Pd=0, Bus 2: Pd=50, Bus 3: Pd=30 => total = 80 MW
2811        assert!(
2812            (net.total_load_mw() - 80.0).abs() < 1e-10,
2813            "total load should be 80 MW; got {}",
2814            net.total_load_mw()
2815        );
2816    }
2817
2818    // -----------------------------------------------------------------------
2819    // Empty network edge cases
2820    // -----------------------------------------------------------------------
2821
2822    #[test]
2823    fn test_empty_network() {
2824        let net = Network::new("empty");
2825        assert_eq!(net.n_buses(), 0);
2826        assert_eq!(net.n_branches(), 0);
2827        assert_eq!(net.n_generators(), 0);
2828        assert_eq!(net.slack_bus_index(), None);
2829        assert!((net.total_generation_mw()).abs() < 1e-10);
2830        assert!((net.total_load_mw()).abs() < 1e-10);
2831        assert!(net.bus_index_map().is_empty());
2832    }
2833
2834    #[test]
2835    fn test_empty_network_bus_p_injection() {
2836        let net = Network::new("empty");
2837        let p_inj = net.bus_p_injection_pu();
2838        assert!(
2839            p_inj.is_empty(),
2840            "empty network should have empty injection vector"
2841        );
2842    }
2843
2844    #[test]
2845    fn test_validate_rejects_invalid_storage_parameters() {
2846        let mut net = make_3bus_network();
2847        net.generators[0].storage = Some(StorageParams {
2848            charge_efficiency: 1.2,
2849            ..StorageParams::with_energy_capacity_mwh(50.0)
2850        });
2851
2852        let err = net.validate().unwrap_err();
2853        assert!(matches!(
2854            err,
2855            NetworkError::InvalidStorageParameters { bus: 1, .. }
2856        ));
2857    }
2858
2859    #[test]
2860    fn test_validate_rejects_load_on_missing_bus() {
2861        let mut net = make_3bus_network();
2862        net.loads.push(Load::new(99, 10.0, 2.0));
2863
2864        let err = net.validate().unwrap_err();
2865        assert!(matches!(err, NetworkError::InvalidLoadBus(99)));
2866    }
2867
2868    #[test]
2869    fn test_validate_rejects_power_injection_on_missing_bus() {
2870        let mut net = make_3bus_network();
2871        net.power_injections.push(PowerInjection {
2872            bus: 99,
2873            id: "inj_missing".into(),
2874            kind: crate::network::power_injection::PowerInjectionKind::Other,
2875            active_power_injection_mw: 5.0,
2876            reactive_power_injection_mvar: 1.0,
2877            in_service: true,
2878        });
2879
2880        let err = net.validate().unwrap_err();
2881        assert!(matches!(err, NetworkError::InvalidPowerInjectionBus(99)));
2882    }
2883
2884    #[test]
2885    fn test_validate_rejects_fixed_shunt_on_missing_bus() {
2886        let mut net = make_3bus_network();
2887        net.fixed_shunts.push(FixedShunt {
2888            bus: 99,
2889            id: "sh_missing".into(),
2890            shunt_type: crate::network::ShuntType::Capacitor,
2891            g_mw: 0.0,
2892            b_mvar: 1.0,
2893            in_service: true,
2894            rated_kv: None,
2895            rated_mvar: None,
2896        });
2897
2898        let err = net.validate().unwrap_err();
2899        assert!(matches!(err, NetworkError::InvalidFixedShuntBus(99)));
2900    }
2901
2902    #[test]
2903    fn test_validate_rejects_dispatchable_load_on_missing_bus() {
2904        let mut net = make_3bus_network();
2905        net.market_data
2906            .dispatchable_loads
2907            .push(DispatchableLoad::curtailable(
2908                99,
2909                10.0,
2910                2.0,
2911                0.0,
2912                100.0,
2913                net.base_mva,
2914            ));
2915
2916        let err = net.validate().unwrap_err();
2917        assert!(matches!(err, NetworkError::InvalidDispatchableLoadBus(99)));
2918    }
2919
2920    // -----------------------------------------------------------------------
2921    // bus_p_injection_pu
2922    // -----------------------------------------------------------------------
2923
2924    #[test]
2925    fn test_bus_p_injection_pu() {
2926        let net = make_3bus_network();
2927        let p_inj = net.bus_p_injection_pu();
2928        // Bus 1 (idx 0): Gen=90, Pd=0  =>  90/100 = 0.9
2929        // Bus 2 (idx 1): Gen=0,  Pd=50 => -50/100 = -0.5
2930        // Bus 3 (idx 2): Gen=20, Pd=30 => (20-30)/100 = -0.1
2931        assert_eq!(p_inj.len(), 3);
2932        assert!(
2933            (p_inj[0] - 0.9).abs() < 1e-10,
2934            "bus 1 p_inj: expected 0.9, got {}",
2935            p_inj[0]
2936        );
2937        assert!(
2938            (p_inj[1] - (-0.5)).abs() < 1e-10,
2939            "bus 2 p_inj: expected -0.5, got {}",
2940            p_inj[1]
2941        );
2942        assert!(
2943            (p_inj[2] - (-0.1)).abs() < 1e-10,
2944            "bus 3 p_inj: expected -0.1, got {}",
2945            p_inj[2]
2946        );
2947    }
2948
2949    // -----------------------------------------------------------------------
2950    // Network::new defaults
2951    // -----------------------------------------------------------------------
2952
2953    #[test]
2954    fn test_network_new_defaults() {
2955        let net = Network::new("test-defaults");
2956        assert_eq!(net.name, "test-defaults");
2957        assert!(
2958            (net.base_mva - 100.0).abs() < 1e-10,
2959            "default base_mva should be 100"
2960        );
2961        assert!(net.buses.is_empty());
2962        assert!(net.branches.is_empty());
2963        assert!(net.generators.is_empty());
2964        assert!(net.loads.is_empty());
2965    }
2966
2967    // -----------------------------------------------------------------------
2968    // scale_loads / scale_generation
2969    // -----------------------------------------------------------------------
2970
2971    #[test]
2972    fn test_scale_loads_all() {
2973        let mut net = make_3bus_network();
2974        // Add more explicit Load records
2975        net.loads.push(Load::new(2, 10.0, 5.0));
2976        net.loads.push(Load::new(3, 20.0, 10.0));
2977
2978        let old_total: f64 = net
2979            .loads
2980            .iter()
2981            .map(|l| l.active_power_demand_mw)
2982            .sum::<f64>();
2983
2984        net.scale_loads(1.5, None);
2985
2986        let new_total: f64 = net
2987            .loads
2988            .iter()
2989            .map(|l| l.active_power_demand_mw)
2990            .sum::<f64>();
2991
2992        assert!(
2993            (new_total - old_total * 1.5).abs() < 1e-10,
2994            "total Pd should scale by 1.5: old={} new={} expected={}",
2995            old_total,
2996            new_total,
2997            old_total * 1.5
2998        );
2999    }
3000
3001    #[test]
3002    fn test_scale_generation_clamped() {
3003        let mut net = make_3bus_network();
3004        // Gen 0: Pg=90, Pmax=100 (default from Generator::new)
3005        // Gen 1: Pg=20, Pmax=100
3006
3007        // Scale by 10x — should clamp to Pmax
3008        net.scale_generation(10.0, None);
3009
3010        for g in &net.generators {
3011            assert!(
3012                g.p <= g.pmax,
3013                "Pg={} should be <= Pmax={} after 10x scaling",
3014                g.p,
3015                g.pmax
3016            );
3017        }
3018    }
3019
3020    // -----------------------------------------------------------------------
3021    // set/get_branch_sequence
3022    // -----------------------------------------------------------------------
3023
3024    #[test]
3025    fn test_set_branch_sequence_roundtrip() {
3026        let mut net = make_3bus_network();
3027        // Branch 0: from_bus=1, to_bus=2, circuit=1
3028        let (from, to, ckt) = {
3029            let br = &net.branches[0];
3030            (br.from_bus, br.to_bus, br.circuit.clone())
3031        };
3032        assert!(net.get_branch_sequence(from, to, &ckt).is_none());
3033
3034        assert!(net.set_branch_sequence(from, to, &ckt, 0.15, 0.45, 0.02));
3035        let (r0, x0, b0) = net.get_branch_sequence(from, to, &ckt).unwrap();
3036        assert!((r0 - 0.15).abs() < 1e-10);
3037        assert!((x0 - 0.45).abs() < 1e-10);
3038        assert!((b0 - 0.02).abs() < 1e-10);
3039
3040        // Reversed direction should also work
3041        let (r0r, x0r, b0r) = net.get_branch_sequence(to, from, &ckt).unwrap();
3042        assert!((r0r - 0.15).abs() < 1e-10);
3043        assert!((x0r - 0.45).abs() < 1e-10);
3044        assert!((b0r - 0.02).abs() < 1e-10);
3045    }
3046
3047    #[test]
3048    fn test_set_branch_sequence_not_found() {
3049        let mut net = make_3bus_network();
3050        assert!(!net.set_branch_sequence(99, 100, "1", 0.1, 0.2, 0.0));
3051    }
3052
3053    // -----------------------------------------------------------------------
3054    // Negative tap ratio solve-readiness check.
3055    // -----------------------------------------------------------------------
3056
3057    #[test]
3058    fn test_negative_tap_ratio_rejected_by_validate_for_solve() {
3059        let mut net = make_3bus_network();
3060        net.branches[0].tap = -0.95;
3061        let err = net.validate_for_solve().unwrap_err();
3062        assert!(matches!(
3063            err,
3064            NetworkError::InvalidBranchField { field: "tap", .. }
3065        ));
3066    }
3067
3068    #[test]
3069    fn validate_for_solve_rejects_missing_slack_in_island() {
3070        let mut net = Network::new("component-slack");
3071        net.buses = vec![
3072            Bus::new(1, BusType::Slack, 138.0),
3073            Bus::new(2, BusType::PQ, 138.0),
3074            Bus::new(3, BusType::PQ, 138.0),
3075        ];
3076        net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.02));
3077        net.generators.push(Generator::new(1, 50.0, 1.0));
3078        net.canonicalize_generator_ids();
3079
3080        let err = net.validate_for_solve().unwrap_err();
3081        assert!(matches!(
3082            err,
3083            NetworkError::InvalidSlackPlacement { buses, slack_buses }
3084            if buses.contains(&3) && slack_buses.is_empty()
3085        ));
3086    }
3087
3088    #[test]
3089    fn validate_for_solve_rejects_isolated_bus_with_active_equipment() {
3090        let mut net = Network::new("isolated-bus");
3091        net.buses = vec![Bus::new(1, BusType::Isolated, 138.0)];
3092        net.loads.push(Load::new(1, 5.0, 1.0));
3093
3094        let err = net.validate_for_solve().unwrap_err();
3095        assert!(matches!(
3096            err,
3097            NetworkError::InvalidIsolatedBusConnectivity { bus: 1 }
3098        ));
3099    }
3100
3101    #[test]
3102    fn validate_for_solve_rejects_isolated_bus_with_bus_shunt() {
3103        let mut net = Network::new("isolated-bus-shunt");
3104        let mut bus = Bus::new(1, BusType::Isolated, 138.0);
3105        bus.shunt_susceptance_mvar = 25.0;
3106        net.buses = vec![bus];
3107
3108        let err = net.validate_for_solve().unwrap_err();
3109        assert!(matches!(
3110            err,
3111            NetworkError::InvalidIsolatedBusConnectivity { bus: 1 }
3112        ));
3113    }
3114
3115    #[test]
3116    fn validate_for_solve_rejects_duplicate_area_schedule_numbers() {
3117        let mut net = make_3bus_network();
3118        net.area_schedules.push(AreaSchedule {
3119            number: 1,
3120            slack_bus: 1,
3121            p_desired_mw: 10.0,
3122            p_tolerance_mw: 5.0,
3123            name: "A".to_string(),
3124        });
3125        net.area_schedules.push(AreaSchedule {
3126            number: 1,
3127            slack_bus: 1,
3128            p_desired_mw: 20.0,
3129            p_tolerance_mw: 5.0,
3130            name: "B".to_string(),
3131        });
3132
3133        let err = net.validate_for_solve().unwrap_err();
3134        assert!(matches!(err, NetworkError::DuplicateAreaScheduleNumber(1)));
3135    }
3136
3137    #[test]
3138    fn validate_for_solve_rejects_invalid_area_schedule_slack_bus() {
3139        let mut net = make_3bus_network();
3140        net.area_schedules.push(AreaSchedule {
3141            number: 7,
3142            slack_bus: 99,
3143            p_desired_mw: 10.0,
3144            p_tolerance_mw: 5.0,
3145            name: "bad".to_string(),
3146        });
3147
3148        let err = net.validate_for_solve().unwrap_err();
3149        assert!(matches!(
3150            err,
3151            NetworkError::InvalidAreaScheduleSlackBus {
3152                area: 7,
3153                slack_bus: 99
3154            }
3155        ));
3156    }
3157
3158    #[test]
3159    fn validate_for_solve_allows_explicitly_unbounded_generator_limits() {
3160        let mut net = make_3bus_network();
3161        net.generators[0].qmin = f64::NEG_INFINITY;
3162        net.generators[0].qmax = f64::INFINITY;
3163        net.generators[0].pmax = f64::INFINITY;
3164
3165        net.validate_for_solve()
3166            .expect("unbounded OPF-style generator limits should be allowed");
3167    }
3168
3169    #[test]
3170    fn validate_for_solve_allows_explicitly_unbounded_branch_limits() {
3171        let mut net = make_3bus_network();
3172        net.branches[0].rating_a_mva = f64::INFINITY;
3173        net.branches[0].angle_diff_min_rad = Some(f64::NEG_INFINITY);
3174        net.branches[0].angle_diff_max_rad = Some(f64::INFINITY);
3175
3176        net.validate_for_solve()
3177            .expect("unbounded thermal and angle limits should be allowed");
3178    }
3179
3180    #[test]
3181    fn validate_for_solve_rejects_wrong_sided_infinite_limits() {
3182        let mut net = make_3bus_network();
3183        net.generators[0].qmax = f64::NEG_INFINITY;
3184        let err = net.validate_for_solve().unwrap_err();
3185        assert!(matches!(
3186            err,
3187            NetworkError::InvalidGeneratorField {
3188                field: "qmax",
3189                value,
3190                ..
3191            } if value == f64::NEG_INFINITY
3192        ));
3193    }
3194
3195    #[test]
3196    fn validate_for_solve_allows_raw_agc_weights_above_one() {
3197        let mut net = make_3bus_network();
3198        net.generators[0].agc_participation_factor = Some(2.0);
3199
3200        net.validate_for_solve()
3201            .expect("AGC participation factors are raw weights and may exceed 1.0");
3202    }
3203
3204    #[test]
3205    fn validate_accepts_network_after_generator_ids_are_canonicalized() {
3206        let mut net = make_3bus_network();
3207        net.generators[0].id.clear();
3208
3209        net.canonicalize_generator_ids();
3210        net.validate()
3211            .expect("validate should succeed after canonicalization");
3212        assert!(
3213            !net.generators[0].id.is_empty(),
3214            "canonicalization should have auto-assigned a canonical id"
3215        );
3216        assert!(
3217            net.generators[0].id.starts_with("gen_"),
3218            "canonical id should follow gen_{{bus}}_{{ordinal}} format, got: {}",
3219            net.generators[0].id
3220        );
3221    }
3222
3223    #[test]
3224    fn canonicalize_runtime_identities_demotes_pv_bus_without_active_regulator() {
3225        let mut net = Network::new("runtime-bus-type-normalization");
3226        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3227        net.buses.push(Bus::new(2, BusType::PV, 230.0));
3228        net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.0));
3229
3230        let mut slack_gen = Generator::new(1, 50.0, 1.0);
3231        slack_gen.id = "g1".into();
3232        net.generators.push(slack_gen);
3233
3234        let mut offline_pv_gen = Generator::new(2, 10.0, 1.02);
3235        offline_pv_gen.id = "g2".into();
3236        offline_pv_gen.in_service = false;
3237        net.generators.push(offline_pv_gen);
3238
3239        net.canonicalize_runtime_identities();
3240
3241        assert_eq!(net.buses[1].bus_type, BusType::PQ);
3242        net.validate()
3243            .expect("runtime canonicalization should produce a solve-ready network");
3244    }
3245
3246    #[test]
3247    fn validate_rejects_duplicate_generator_id() {
3248        let mut net = make_3bus_network();
3249        let duplicate_id = net.generators[0].id.clone();
3250        net.generators[1].id = duplicate_id.clone();
3251
3252        let err = net.validate().unwrap_err();
3253        assert!(matches!(
3254            err,
3255            NetworkError::DuplicateGeneratorId { id } if id == duplicate_id
3256        ));
3257    }
3258
3259    #[test]
3260    fn canonicalize_generator_ids_fills_missing_ids_deterministically() {
3261        let mut net = Network::new("canonicalize-generator-ids");
3262        net.generators
3263            .push(Generator::with_id(" explicit-a ", 10, 0.0, 1.0));
3264        net.generators.push(Generator::new(10, 0.0, 1.0));
3265        net.generators.push(Generator::new(10, 0.0, 1.0));
3266        net.generators
3267            .push(Generator::with_id("gen_10_2", 10, 0.0, 1.0));
3268        net.generators.push(Generator::new(20, 0.0, 1.0));
3269
3270        let mut clone = net.clone();
3271        net.canonicalize_generator_ids();
3272        clone.canonicalize_generator_ids();
3273
3274        let ids: Vec<&str> = net
3275            .generators
3276            .iter()
3277            .map(|generator| generator.id.as_str())
3278            .collect();
3279        assert_eq!(
3280            ids,
3281            vec![
3282                "explicit-a",
3283                "gen_10_2_2",
3284                "gen_10_3",
3285                "gen_10_2",
3286                "gen_20_1"
3287            ]
3288        );
3289        assert_eq!(
3290            ids,
3291            clone
3292                .generators
3293                .iter()
3294                .map(|generator| generator.id.as_str())
3295                .collect::<Vec<_>>()
3296        );
3297        let unique_ids: HashSet<&str> = ids.iter().copied().collect();
3298        assert_eq!(unique_ids.len(), ids.len());
3299    }
3300
3301    #[test]
3302    fn canonicalize_branch_circuit_ids_disambiguates_reverse_duplicates() {
3303        let mut net = Network::new("canonicalize-branch-circuits");
3304        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3305        net.buses.push(Bus::new(2, BusType::PQ, 230.0));
3306        net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
3307        net.branches.push(Branch::new_line(2, 1, 0.0, 0.1, 0.0));
3308        net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
3309
3310        net.canonicalize_branch_circuit_ids();
3311
3312        let circuits: Vec<&str> = net
3313            .branches
3314            .iter()
3315            .map(|branch| branch.circuit.as_str())
3316            .collect();
3317        assert_eq!(circuits, vec!["1", "1#2", "1#3"]);
3318        net.validate_structure()
3319            .expect("canonicalized branch circuits should be structurally valid");
3320    }
3321
3322    #[test]
3323    fn canonicalize_load_and_shunt_ids_fills_missing_ids_deterministically() {
3324        let mut net = Network::new("canonicalize-load-shunt-ids");
3325        net.loads.push(Load {
3326            bus: 3,
3327            id: String::new(),
3328            ..Default::default()
3329        });
3330        net.loads.push(Load {
3331            bus: 3,
3332            id: " load_existing ".into(),
3333            ..Default::default()
3334        });
3335        net.fixed_shunts.push(FixedShunt {
3336            bus: 7,
3337            id: String::new(),
3338            shunt_type: crate::network::ShuntType::Capacitor,
3339            g_mw: 0.0,
3340            b_mvar: 0.0,
3341            in_service: true,
3342            rated_kv: None,
3343            rated_mvar: None,
3344        });
3345        net.fixed_shunts.push(FixedShunt {
3346            bus: 7,
3347            id: " sh_existing ".into(),
3348            shunt_type: crate::network::ShuntType::Reactor,
3349            g_mw: 0.0,
3350            b_mvar: 0.0,
3351            in_service: true,
3352            rated_kv: None,
3353            rated_mvar: None,
3354        });
3355
3356        net.canonicalize_load_ids();
3357        net.canonicalize_shunt_ids();
3358
3359        assert_eq!(net.loads[0].id, "load_3_1");
3360        assert_eq!(net.loads[1].id, "load_existing");
3361        assert_eq!(net.fixed_shunts[0].id, "shunt_7_1");
3362        assert_eq!(net.fixed_shunts[1].id, "sh_existing");
3363    }
3364
3365    #[test]
3366    fn canonicalize_dispatchable_load_ids_fills_missing_ids_deterministically() {
3367        let mut net = make_3bus_network();
3368        net.market_data
3369            .dispatchable_loads
3370            .push(DispatchableLoad::curtailable(
3371                2, 150.0, 30.0, 50.0, 40.0, 100.0,
3372            ));
3373        net.market_data
3374            .dispatchable_loads
3375            .push(DispatchableLoad::curtailable(
3376                2, 100.0, 20.0, 0.0, 50.0, 100.0,
3377            ));
3378        net.market_data.dispatchable_loads[1].resource_id = " dr_existing ".into();
3379
3380        net.canonicalize_dispatchable_load_ids();
3381
3382        assert_eq!(
3383            net.market_data.dispatchable_loads[0].resource_id,
3384            "dispatchable_load_2_1"
3385        );
3386        assert_eq!(
3387            net.market_data.dispatchable_loads[1].resource_id,
3388            "dr_existing"
3389        );
3390    }
3391
3392    #[test]
3393    fn find_stable_asset_indices_by_identity() {
3394        let mut net = make_3bus_network();
3395        net.loads.push(Load {
3396            bus: 2,
3397            id: "load_2_a".into(),
3398            ..Default::default()
3399        });
3400        net.fixed_shunts.push(FixedShunt {
3401            bus: 2,
3402            id: "shunt_2_a".into(),
3403            shunt_type: crate::network::ShuntType::Capacitor,
3404            g_mw: 1.0,
3405            b_mvar: 2.0,
3406            in_service: true,
3407            rated_kv: None,
3408            rated_mvar: None,
3409        });
3410        net.hvdc
3411            .links
3412            .push(crate::network::HvdcLink::Vsc(crate::network::VscHvdcLink {
3413                name: "HVDC_A".into(),
3414                converter1: crate::network::VscConverterTerminal {
3415                    bus: 1,
3416                    ..Default::default()
3417                },
3418                converter2: crate::network::VscConverterTerminal {
3419                    bus: 3,
3420                    ..Default::default()
3421                },
3422                ..Default::default()
3423            }));
3424        net.market_data
3425            .pumped_hydro_units
3426            .push(PumpedHydroUnit::new(
3427                "PH_A".into(),
3428                GeneratorRef {
3429                    bus: 1,
3430                    id: "GEN_A".into(),
3431                },
3432                100.0,
3433            ));
3434        net.market_data
3435            .dispatchable_loads
3436            .push(DispatchableLoad::curtailable(
3437                2, 20.0, 5.0, 0.0, 100.0, 100.0,
3438            ));
3439        net.market_data.dispatchable_loads[0].resource_id = "dr_a".into();
3440        net.market_data
3441            .combined_cycle_plants
3442            .push(CombinedCyclePlant {
3443                id: String::new(),
3444                name: "CC_A".into(),
3445                configs: Vec::new(),
3446                transitions: Vec::new(),
3447                active_config: None,
3448                hours_in_config: 0.0,
3449                duct_firing_capable: false,
3450            });
3451
3452        assert_eq!(net.find_branch_index(1, 2, "1"), Some(0));
3453        assert_eq!(net.find_branch_index(2, 1, "1"), Some(0));
3454        assert_eq!(net.find_load_index_by_id("load_2_a"), Some(2));
3455        assert_eq!(net.find_load_index(2, Some("load_2_a")), Some(2));
3456        assert_eq!(net.find_shunt_index_by_id("shunt_2_a"), Some(0));
3457        assert_eq!(net.find_shunt_index(2, Some("shunt_2_a")), Some(0));
3458        assert_eq!(net.find_hvdc_link_index_by_name("HVDC_A"), Some(0));
3459        assert_eq!(net.find_pumped_hydro_index_by_name("PH_A"), Some(0));
3460        assert_eq!(net.find_dispatchable_load_index("dr_a", Some(2)), Some(0));
3461        assert_eq!(net.find_combined_cycle_index_by_name("CC_A"), Some(0));
3462    }
3463
3464    #[test]
3465    fn conditional_limits_apply_and_reset() {
3466        let mut net = Network::new("test");
3467        // Add two branches with known ratings.
3468        let mut br0 = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
3469        br0.rating_a_mva = 100.0;
3470        br0.rating_c_mva = 150.0;
3471        let mut br1 = Branch::new_line(2, 3, 0.01, 0.1, 0.0);
3472        br1.rating_a_mva = 200.0;
3473        br1.rating_c_mva = 250.0;
3474        net.branches.push(br0);
3475        net.branches.push(br1);
3476
3477        // Register conditional limits: branch 0 has summer + winter conditions.
3478        net.conditional_limits.insert_for_branch(
3479            &net.branches[0],
3480            vec![
3481                ConditionalRating {
3482                    condition_id: "summer".to_string(),
3483                    rating_a_mva: 80.0,
3484                    rating_c_mva: 0.0,
3485                },
3486                ConditionalRating {
3487                    condition_id: "winter".to_string(),
3488                    rating_a_mva: 120.0,
3489                    rating_c_mva: 0.0,
3490                },
3491            ],
3492        );
3493
3494        // Apply summer: branch 0 rate_a should become 80 (more restrictive).
3495        net.apply_conditional_limits(&["summer".to_string()]);
3496        assert!(
3497            (net.branches[0].rating_a_mva - 80.0).abs() < 1e-6,
3498            "Branch 0 rate_a should be 80 after summer, got {}",
3499            net.branches[0].rating_a_mva
3500        );
3501        assert!(
3502            (net.branches[1].rating_a_mva - 200.0).abs() < 1e-6,
3503            "Branch 1 should be unchanged"
3504        );
3505
3506        // Apply winter (without reset): branch 0 rate_a should become 120.
3507        net.apply_conditional_limits(&["winter".to_string()]);
3508        assert!(
3509            (net.branches[0].rating_a_mva - 120.0).abs() < 1e-6,
3510            "Branch 0 rate_a should be 120 after winter, got {}",
3511            net.branches[0].rating_a_mva
3512        );
3513
3514        // Reset: branch 0 should go back to original 100.
3515        net.reset_conditional_limits();
3516        assert!(
3517            (net.branches[0].rating_a_mva - 100.0).abs() < 1e-6,
3518            "Branch 0 rate_a should be 100 after reset, got {}",
3519            net.branches[0].rating_a_mva
3520        );
3521    }
3522
3523    #[test]
3524    fn conditional_limits_clear_preserves_reset_state() {
3525        let mut net = Network::new("test");
3526        let mut branch = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
3527        branch.rating_a_mva = 100.0;
3528        branch.rating_c_mva = 150.0;
3529        net.branches.push(branch);
3530        net.conditional_limits.insert_for_branch(
3531            &net.branches[0],
3532            vec![ConditionalRating {
3533                condition_id: "summer".to_string(),
3534                rating_a_mva: 80.0,
3535                rating_c_mva: 120.0,
3536            }],
3537        );
3538
3539        net.apply_conditional_limits(&["summer".to_string()]);
3540        assert!((net.branches[0].rating_a_mva - 80.0).abs() < 1e-6);
3541
3542        net.conditional_limits.clear();
3543        net.reset_conditional_limits();
3544
3545        assert!((net.branches[0].rating_a_mva - 100.0).abs() < 1e-6);
3546        assert!((net.branches[0].rating_c_mva - 150.0).abs() < 1e-6);
3547    }
3548
3549    #[test]
3550    fn conditional_limits_empty_conditions_reset_to_base() {
3551        let mut net = Network::new("conditional-empty");
3552        let mut branch = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
3553        branch.rating_a_mva = 100.0;
3554        branch.rating_c_mva = 150.0;
3555        net.branches.push(branch);
3556        net.conditional_limits.insert_for_branch(
3557            &net.branches[0],
3558            vec![ConditionalRating {
3559                condition_id: "summer".to_string(),
3560                rating_a_mva: 80.0,
3561                rating_c_mva: 120.0,
3562            }],
3563        );
3564
3565        net.apply_conditional_limits(&["summer".to_string()]);
3566        assert!((net.branches[0].rating_a_mva - 80.0).abs() < 1e-6);
3567
3568        net.apply_conditional_limits(&[]);
3569        assert!((net.branches[0].rating_a_mva - 100.0).abs() < 1e-6);
3570        assert!((net.branches[0].rating_c_mva - 150.0).abs() < 1e-6);
3571    }
3572
3573    #[test]
3574    fn conditional_limits_serde_roundtrip_preserves_reset_state() {
3575        let mut net = Network::new("test");
3576        let mut branch = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
3577        branch.rating_a_mva = 100.0;
3578        branch.rating_c_mva = 150.0;
3579        net.branches.push(branch);
3580        net.conditional_limits.insert_for_branch(
3581            &net.branches[0],
3582            vec![ConditionalRating {
3583                condition_id: "summer".to_string(),
3584                rating_a_mva: 80.0,
3585                rating_c_mva: 120.0,
3586            }],
3587        );
3588
3589        net.apply_conditional_limits(&["summer".to_string()]);
3590        let json = serde_json::to_string(&net).unwrap();
3591        let mut roundtripped: Network = serde_json::from_str(&json).unwrap();
3592
3593        roundtripped.reset_conditional_limits();
3594
3595        assert!((roundtripped.branches[0].rating_a_mva - 100.0).abs() < 1e-6);
3596        assert!((roundtripped.branches[0].rating_c_mva - 150.0).abs() < 1e-6);
3597    }
3598
3599    #[test]
3600    fn validate_structure_rejects_reverse_direction_duplicate_branch() {
3601        let mut net = Network::new("dup-branch");
3602        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3603        net.buses.push(Bus::new(2, BusType::PQ, 230.0));
3604        net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.0));
3605        let mut reverse = Branch::new_line(2, 1, 0.01, 0.1, 0.0);
3606        reverse.circuit = "1".to_string();
3607        net.branches.push(reverse);
3608
3609        let err = net.validate_structure().unwrap_err();
3610        assert!(matches!(
3611            err,
3612            NetworkError::DuplicateBranchKey {
3613                from_bus: 1,
3614                to_bus: 2,
3615                ..
3616            }
3617        ));
3618    }
3619
3620    #[test]
3621    fn generator_lookup_uses_trimmed_canonical_id() {
3622        let mut net = Network::new("trimmed-generator-id");
3623        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3624        let mut generator = Generator::new(1, 50.0, 1.0);
3625        generator.id = "  GEN_A  ".to_string();
3626        net.generators.push(generator);
3627
3628        assert_eq!(net.find_gen_index_by_id("GEN_A"), Some(0));
3629        assert_eq!(net.find_gen_index_by_id("  GEN_A "), Some(0));
3630        assert_eq!(net.gen_index_by_id().get("GEN_A"), Some(&0));
3631    }
3632
3633    #[test]
3634    fn validate_for_solve_rejects_invalid_switched_shunt_indices() {
3635        let mut net = Network::new("switched-shunt-index");
3636        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3637        net.controls.switched_shunts.push(SwitchedShunt {
3638            id: "ssh_1".into(),
3639            bus: 1,
3640            bus_regulated: 0,
3641            b_step: 0.01,
3642            n_steps_cap: 1,
3643            n_steps_react: 0,
3644            v_target: 1.0,
3645            v_band: 0.02,
3646            n_active_steps: 0,
3647        });
3648
3649        let err = net.validate_for_solve().unwrap_err();
3650        assert!(matches!(
3651            err,
3652            NetworkError::InvalidSwitchedShuntRegulatedBus { id, bus }
3653                if id == "ssh_1" && bus == 0
3654        ));
3655    }
3656
3657    #[test]
3658    fn validate_for_solve_rejects_slack_without_regulating_generator() {
3659        let mut net = Network::new("slack-no-reg");
3660        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3661        let mut generator = Generator::new(1, 50.0, 1.0);
3662        generator.voltage_regulated = false;
3663        net.generators.push(generator);
3664
3665        let err = net.validate_for_solve().unwrap_err();
3666        assert!(matches!(
3667            err,
3668            NetworkError::InvalidSlackPlacement { slack_buses, .. } if slack_buses == vec![1]
3669        ));
3670    }
3671
3672    #[test]
3673    fn validate_for_solve_rejects_pv_without_regulating_generator() {
3674        let mut net = Network::new("pv-no-reg");
3675        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3676        net.buses.push(Bus::new(2, BusType::PV, 230.0));
3677        net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.0));
3678        net.generators.push(Generator::new(1, 60.0, 1.0));
3679        let mut generator = Generator::new(2, 40.0, 1.0);
3680        generator.voltage_regulated = false;
3681        net.generators.push(generator);
3682
3683        let err = net.validate_for_solve().unwrap_err();
3684        assert!(matches!(
3685            err,
3686            NetworkError::InvalidGeneratorField {
3687                bus,
3688                field: "voltage_regulated",
3689                value
3690            } if bus == 2 && value == 0.0
3691        ));
3692    }
3693
3694    #[test]
3695    fn validate_for_solve_accepts_remote_regulated_pv_bus() {
3696        let mut net = Network::new("pv-remote-reg");
3697        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3698        net.buses.push(Bus::new(2, BusType::PV, 230.0));
3699        net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.0));
3700
3701        let mut slack_generator = Generator::new(1, 60.0, 1.0);
3702        slack_generator.reg_bus = Some(1);
3703        net.generators.push(slack_generator);
3704
3705        let mut remote_generator = Generator::new(1, 20.0, 1.0);
3706        remote_generator.reg_bus = Some(2);
3707        net.generators.push(remote_generator);
3708
3709        assert!(net.validate_for_solve().is_ok());
3710    }
3711
3712    #[test]
3713    fn validate_structure_rejects_missing_remote_regulated_bus() {
3714        let mut net = Network::new("missing-reg-bus");
3715        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3716        let mut generator = Generator::new(1, 50.0, 1.0);
3717        generator.reg_bus = Some(2);
3718        net.generators.push(generator);
3719
3720        let err = net.validate_structure().unwrap_err();
3721        assert!(matches!(
3722            err,
3723            NetworkError::InvalidGeneratorRegulatedBus { bus, reg_bus }
3724                if bus == 1 && reg_bus == 2
3725        ));
3726    }
3727
3728    #[test]
3729    fn validate_structure_rejects_empty_interface_members() {
3730        let mut net = Network::new("interface-mismatch");
3731        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3732        net.buses.push(Bus::new(2, BusType::PQ, 230.0));
3733        net.interfaces.push(Interface {
3734            name: "IF_A".to_string(),
3735            members: vec![],
3736            limit_forward_mw: 100.0,
3737            limit_reverse_mw: 100.0,
3738            in_service: true,
3739            limit_forward_mw_schedule: vec![],
3740            limit_reverse_mw_schedule: vec![],
3741        });
3742
3743        let err = net.validate_structure().unwrap_err();
3744        assert!(matches!(
3745            err,
3746            NetworkError::InvalidInterfaceDefinition { name, .. } if name == "IF_A"
3747        ));
3748    }
3749
3750    #[test]
3751    fn validate_structure_rejects_missing_interface_branch_reference() {
3752        let mut net = Network::new("interface-missing-branch");
3753        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3754        net.buses.push(Bus::new(2, BusType::PQ, 230.0));
3755        net.interfaces.push(Interface {
3756            name: "IF_A".to_string(),
3757            members: vec![crate::network::WeightedBranchRef::new(1, 2, "1", 1.0)],
3758            limit_forward_mw: 100.0,
3759            limit_reverse_mw: 100.0,
3760            in_service: true,
3761            limit_forward_mw_schedule: vec![],
3762            limit_reverse_mw_schedule: vec![],
3763        });
3764
3765        let err = net.validate_structure().unwrap_err();
3766        assert!(matches!(
3767            err,
3768            NetworkError::InvalidInterfaceDefinition { name, .. } if name == "IF_A"
3769        ));
3770    }
3771
3772    #[test]
3773    fn validate_structure_rejects_non_finite_flowgate_coefficients() {
3774        let mut net = Network::new("flowgate-nan");
3775        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3776        net.buses.push(Bus::new(2, BusType::PQ, 230.0));
3777        net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.0));
3778        net.flowgates.push(Flowgate {
3779            name: "FG_A".to_string(),
3780            monitored: vec![crate::network::WeightedBranchRef::new(1, 2, "1", f64::NAN)],
3781            contingency_branch: None,
3782            limit_mw: 100.0,
3783            limit_reverse_mw: 100.0,
3784            in_service: true,
3785            limit_mw_schedule: vec![],
3786            limit_reverse_mw_schedule: vec![],
3787            hvdc_coefficients: vec![],
3788            hvdc_band_coefficients: vec![],
3789            ptdf_per_bus: vec![],
3790            limit_mw_active_period: None,
3791            breach_sides: crate::network::FlowgateBreachSides::Both,
3792        });
3793
3794        let err = net.validate_structure().unwrap_err();
3795        assert!(matches!(
3796            err,
3797            NetworkError::InvalidFlowgateDefinition { name, .. } if name == "FG_A"
3798        ));
3799    }
3800
3801    #[test]
3802    fn validate_structure_rejects_missing_flowgate_contingency_branch() {
3803        let mut net = Network::new("flowgate-missing-ctg");
3804        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3805        net.buses.push(Bus::new(2, BusType::PQ, 230.0));
3806        net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.0));
3807        net.flowgates.push(Flowgate {
3808            name: "FG_A".to_string(),
3809            monitored: vec![crate::network::WeightedBranchRef::new(1, 2, "1", 1.0)],
3810            contingency_branch: Some(crate::network::BranchRef::new(2, 1, "1")),
3811            limit_mw: 100.0,
3812            limit_reverse_mw: 100.0,
3813            in_service: true,
3814            limit_mw_schedule: vec![],
3815            limit_reverse_mw_schedule: vec![],
3816            hvdc_coefficients: vec![],
3817            hvdc_band_coefficients: vec![],
3818            ptdf_per_bus: vec![],
3819            limit_mw_active_period: None,
3820            breach_sides: crate::network::FlowgateBreachSides::Both,
3821        });
3822
3823        let err = net.validate_structure().unwrap_err();
3824        assert!(matches!(
3825            err,
3826            NetworkError::InvalidFlowgateDefinition { name, .. } if name == "FG_A"
3827        ));
3828    }
3829
3830    #[test]
3831    fn validate_structure_rejects_duplicate_hvdc_link_names() {
3832        let mut net = Network::new("dup-hvdc-name");
3833        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3834        net.buses.push(Bus::new(2, BusType::PQ, 230.0));
3835
3836        let mut link_a = crate::network::LccHvdcLink {
3837            name: "HVDC_A".to_string(),
3838            ..Default::default()
3839        };
3840        link_a.rectifier.bus = 1;
3841        link_a.inverter.bus = 2;
3842        let mut link_b = crate::network::LccHvdcLink {
3843            name: "HVDC_A".to_string(),
3844            ..Default::default()
3845        };
3846        link_b.rectifier.bus = 1;
3847        link_b.inverter.bus = 2;
3848        net.hvdc.push_lcc_link(link_a);
3849        net.hvdc.push_lcc_link(link_b);
3850
3851        let err = net.validate_structure().unwrap_err();
3852        assert!(matches!(
3853            err,
3854            NetworkError::DuplicateHvdcLinkName { name } if name == "HVDC_A"
3855        ));
3856    }
3857
3858    #[test]
3859    fn validate_for_solve_rejects_mixed_hvdc_representations() {
3860        let mut net = Network::new("mixed-hvdc");
3861        net.buses.push(Bus::new(1, BusType::Slack, 230.0));
3862        net.buses.push(Bus::new(2, BusType::PQ, 230.0));
3863
3864        let mut link = crate::network::LccHvdcLink {
3865            name: "HVDC_A".to_string(),
3866            ..Default::default()
3867        };
3868        link.rectifier.bus = 1;
3869        link.inverter.bus = 2;
3870        net.hvdc.push_lcc_link(link);
3871
3872        let grid = net.hvdc.ensure_dc_grid(1, Some("grid".to_string()));
3873        grid.buses.push(crate::network::DcBus {
3874            bus_id: 101,
3875            p_dc_mw: 0.0,
3876            v_dc_pu: 1.0,
3877            base_kv_dc: 320.0,
3878            v_dc_max: 1.1,
3879            v_dc_min: 0.9,
3880            cost: 0.0,
3881            g_shunt_siemens: 0.0,
3882            r_ground_ohm: 0.0,
3883        });
3884        grid.converters.push(crate::network::DcConverter::Vsc(
3885            crate::network::DcConverterStation {
3886                id: String::new(),
3887                dc_bus: 101,
3888                ac_bus: 1,
3889                control_type_dc: 2,
3890                control_type_ac: 1,
3891                active_power_mw: 0.0,
3892                reactive_power_mvar: 0.0,
3893                is_lcc: false,
3894                voltage_setpoint_pu: 1.0,
3895                transformer_r_pu: 0.0,
3896                transformer_x_pu: 0.0,
3897                transformer: false,
3898                tap_ratio: 1.0,
3899                filter_susceptance_pu: 0.0,
3900                filter: false,
3901                reactor_r_pu: 0.0,
3902                reactor_x_pu: 0.0,
3903                reactor: false,
3904                base_kv_ac: 230.0,
3905                voltage_max_pu: 1.1,
3906                voltage_min_pu: 0.9,
3907                current_max_pu: 2.0,
3908                status: true,
3909                loss_constant_mw: 0.0,
3910                loss_linear: 0.0,
3911                loss_quadratic_rectifier: 0.0,
3912                loss_quadratic_inverter: 0.0,
3913                droop: 0.0,
3914                power_dc_setpoint_mw: 0.0,
3915                voltage_dc_setpoint_pu: 1.0,
3916                active_power_ac_max_mw: 10.0,
3917                active_power_ac_min_mw: -10.0,
3918                reactive_power_ac_max_mvar: 10.0,
3919                reactive_power_ac_min_mvar: -10.0,
3920            },
3921        ));
3922
3923        let err = net.validate_for_solve().unwrap_err();
3924        assert!(matches!(err, NetworkError::MixedHvdcRepresentation));
3925    }
3926}