Skip to main content

surge_io/
bin.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Native binary format for Surge network serialization.
3//!
4//! The binary container is intentionally optimized for full-model native IO:
5//!
6//! - a compact fixed header and framed sections
7//! - direct packed sections for the highest-volume entities
8//! - typed extra sections for the rest of the Surge model
9//!
10//! This keeps the format whole-model for PF, OPF, topology, market, and
11//! dynamics-adjacent workflows while avoiding the JSON-like object tree that
12//! previously dominated `surge-bin` load/save costs.
13
14use std::collections::HashMap;
15use std::io::{BufReader, Read, Write};
16use std::path::Path;
17
18use serde::{Deserialize, Serialize, de::DeserializeOwned};
19use surge_network::dynamics::{CoreLossModel, CoreType, TransformerSaturation};
20use surge_network::market::{
21    AmbientConditions, CombinedCyclePlant, CostCurve, DispatchableLoad, EmissionPolicy,
22    EmissionRates, EnergyOffer, MarketRules, OutageEntry, PumpedHydroUnit, QualificationMap,
23    ReserveOffer, ReserveZone,
24};
25use surge_network::network::asset::AssetCatalog;
26use surge_network::network::boundary::BoundaryData;
27use surge_network::network::breaker::BreakerRating;
28use surge_network::network::flowgate::OperatingNomogram;
29use surge_network::network::grounding::GroundingEntry;
30use surge_network::network::impedance_correction::ImpedanceCorrectionTable;
31use surge_network::network::induction_machine::InductionMachine;
32use surge_network::network::market_data::MarketData;
33use surge_network::network::measurement::CimMeasurement;
34use surge_network::network::model::{GeoPoint, MutualCoupling, PhaseImpedanceEntry};
35use surge_network::network::multi_section_line::MultiSectionLineGroup;
36use surge_network::network::net_ops::NetworkOperationsData;
37use surge_network::network::op_limits::OperationalLimits;
38use surge_network::network::protection::ProtectionData;
39use surge_network::network::scheduled_area_transfer::ScheduledAreaTransfer;
40use surge_network::network::{
41    AreaSchedule, Branch, BranchConditionalRatings, BranchOpfControl, BranchType, Bus, BusType,
42    FactsDevice, FixedShunt, Flowgate, FuelSupply, GenType, Generator, GeneratorTechnology,
43    HarmonicData, HvdcModel, Interface, LineData, Load, LoadClass, LoadConnection, Network,
44    NodeBreakerTopology, OltcSpec, Owner, OwnershipEntry, ParSpec, PhaseMode, PowerInjection,
45    Region, SeriesCompData, StorageParams, SwitchedShunt, SwitchedShuntOpf, TapMode,
46    TransformerConnection, TransformerData, WindingConnection, ZeroSeqData,
47};
48use thiserror::Error;
49
50pub const SURGE_BIN_FORMAT: &str = "surge-bin";
51pub const SURGE_BIN_SCHEMA_VERSION: &str = "0.1.0";
52
53const MAGIC: &[u8; 8] = b"SRGBIN02";
54const BINARY_REVISION: u16 = 2;
55const HEADER_LEN: usize = 8 + 2 + 2 + 4 + 8 + 4;
56const SECTION_HEADER_LEN: usize = 2 + 2 + 4 + 8;
57
58const SECTION_NETWORK_HEADER: u16 = 1;
59const SECTION_STRINGS: u16 = 2;
60const SECTION_BUSES: u16 = 3;
61const SECTION_BUS_EXTRAS: u16 = 4;
62const SECTION_BRANCHES: u16 = 5;
63const SECTION_BRANCH_EXTRAS: u16 = 6;
64const SECTION_GENERATORS: u16 = 7;
65const SECTION_GENERATOR_EXTRAS: u16 = 8;
66const SECTION_LOADS: u16 = 9;
67const SECTION_NETWORK_EXTRAS: u16 = 10;
68const SECTION_LOAD_EXTRAS: u16 = 11;
69
70#[derive(Error, Debug)]
71pub enum Error {
72    #[error("I/O error: {0}")]
73    Io(#[from] std::io::Error),
74
75    #[error("CBOR encode error: {0}")]
76    Encode(#[from] ciborium::ser::Error<std::io::Error>),
77
78    #[error("CBOR decode error: {0}")]
79    Decode(#[from] ciborium::de::Error<std::io::Error>),
80
81    #[error("invalid binary document: {0}")]
82    InvalidDocument(String),
83}
84
85#[derive(Clone)]
86struct Section {
87    id: u16,
88    count: u32,
89    payload: Vec<u8>,
90}
91
92#[derive(Clone, Copy)]
93struct SectionView<'a> {
94    count: u32,
95    payload: &'a [u8],
96}
97
98#[derive(Default)]
99struct StringTableBuilder {
100    ids: HashMap<String, u32>,
101    values: Vec<String>,
102}
103
104impl StringTableBuilder {
105    fn new() -> Self {
106        let mut ids = HashMap::new();
107        ids.insert(String::new(), 0);
108        Self {
109            ids,
110            values: vec![String::new()],
111        }
112    }
113
114    fn intern(&mut self, value: &str) -> u32 {
115        if let Some(id) = self.ids.get(value) {
116            return *id;
117        }
118        let id = self.values.len() as u32;
119        let owned = value.to_string();
120        self.ids.insert(owned.clone(), id);
121        self.values.push(owned);
122        id
123    }
124
125    fn freeze(self) -> StringTable {
126        StringTable {
127            values: self.values,
128        }
129    }
130}
131
132struct StringTable {
133    values: Vec<String>,
134}
135
136impl StringTable {
137    fn resolve(&self, id: u32) -> Result<&str, Error> {
138        self.values
139            .get(id as usize)
140            .map(String::as_str)
141            .ok_or_else(|| Error::InvalidDocument(format!("invalid string table index {id}")))
142    }
143}
144
145struct Reader<'a> {
146    bytes: &'a [u8],
147    pos: usize,
148}
149
150impl<'a> Reader<'a> {
151    fn new(bytes: &'a [u8]) -> Self {
152        Self { bytes, pos: 0 }
153    }
154
155    fn remaining(&self) -> usize {
156        self.bytes.len().saturating_sub(self.pos)
157    }
158
159    fn is_empty(&self) -> bool {
160        self.pos == self.bytes.len()
161    }
162
163    fn take(&mut self, len: usize) -> Result<&'a [u8], Error> {
164        let end = self
165            .pos
166            .checked_add(len)
167            .ok_or_else(|| Error::InvalidDocument("binary document length overflow".to_string()))?;
168        if end > self.bytes.len() {
169            return Err(Error::InvalidDocument(format!(
170                "truncated binary payload: need {len} bytes, have {}",
171                self.remaining()
172            )));
173        }
174        let slice = &self.bytes[self.pos..end];
175        self.pos = end;
176        Ok(slice)
177    }
178
179    fn u8(&mut self) -> Result<u8, Error> {
180        Ok(self.take(1)?[0])
181    }
182
183    fn u16(&mut self) -> Result<u16, Error> {
184        let mut bytes = [0u8; 2];
185        bytes.copy_from_slice(self.take(2)?);
186        Ok(u16::from_le_bytes(bytes))
187    }
188
189    fn u32(&mut self) -> Result<u32, Error> {
190        let mut bytes = [0u8; 4];
191        bytes.copy_from_slice(self.take(4)?);
192        Ok(u32::from_le_bytes(bytes))
193    }
194
195    fn u64(&mut self) -> Result<u64, Error> {
196        let mut bytes = [0u8; 8];
197        bytes.copy_from_slice(self.take(8)?);
198        Ok(u64::from_le_bytes(bytes))
199    }
200
201    fn f64(&mut self) -> Result<f64, Error> {
202        let mut bytes = [0u8; 8];
203        bytes.copy_from_slice(self.take(8)?);
204        Ok(f64::from_le_bytes(bytes))
205    }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209struct IndexedBusExtra {
210    index: u32,
211    extra: BusExtra,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215struct BusExtra {
216    latitude: Option<f64>,
217    longitude: Option<f64>,
218    freq_hz: Option<f64>,
219    ambient: Option<AmbientConditions>,
220    reserve_zone: Option<String>,
221    owners: Vec<OwnershipEntry>,
222}
223
224impl BusExtra {
225    fn from_bus(bus: &Bus) -> Option<Self> {
226        let extra = Self {
227            latitude: bus.latitude,
228            longitude: bus.longitude,
229            freq_hz: bus.freq_hz,
230            ambient: bus.ambient.clone(),
231            reserve_zone: bus.reserve_zone.clone(),
232            owners: bus.owners.clone(),
233        };
234        (!extra.is_empty()).then_some(extra)
235    }
236
237    fn is_empty(&self) -> bool {
238        self.latitude.is_none()
239            && self.longitude.is_none()
240            && self.freq_hz.is_none()
241            && self.ambient.is_none()
242            && self.reserve_zone.is_none()
243            && self.owners.is_empty()
244    }
245
246    fn apply(self, bus: &mut Bus) {
247        bus.latitude = self.latitude;
248        bus.longitude = self.longitude;
249        bus.freq_hz = self.freq_hz;
250        bus.ambient = self.ambient;
251        bus.reserve_zone = self.reserve_zone;
252        bus.owners = self.owners;
253    }
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257struct IndexedBranchExtra {
258    index: u32,
259    extra: BranchExtra,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
263struct BranchExtra {
264    r0: Option<f64>,
265    x0: Option<f64>,
266    b0: Option<f64>,
267    zn: Option<(f64, f64)>,
268    tab: Option<u32>,
269    oil_temp_limit_c: Option<f64>,
270    winding_temp_limit_c: Option<f64>,
271    impedance_limit_ohm: Option<f64>,
272    saturation: Option<TransformerSaturation>,
273    core_type: Option<CoreType>,
274    core_loss_model: Option<CoreLossModel>,
275    length_km: Option<f64>,
276    line_type: Option<surge_network::network::LineType>,
277    conductor: Option<String>,
278    n_bundles: Option<u32>,
279    winding_rated_kv: Option<f64>,
280    winding_rated_mva: Option<f64>,
281    parent_transformer_id: Option<String>,
282    winding_number: Option<u8>,
283    winding_connection: Option<WindingConnection>,
284    zn_winding: Option<(f64, f64)>,
285    bypass_current_ka: Option<f64>,
286    rated_mvar_series: Option<f64>,
287    ambient: Option<AmbientConditions>,
288    owners: Vec<OwnershipEntry>,
289}
290
291impl BranchExtra {
292    fn from_branch(branch: &Branch) -> Option<Self> {
293        let zs = branch.zero_seq.as_ref();
294        let td = branch.transformer_data.as_ref();
295        let hd = branch.harmonic.as_ref();
296        let ld = branch.line_data.as_ref();
297        let sc = branch.series_comp.as_ref();
298        let extra = Self {
299            r0: zs.map(|z| z.r0),
300            x0: zs.map(|z| z.x0),
301            b0: zs.map(|z| z.b0),
302            zn: zs.and_then(|z| z.zn).map(|value| (value.re, value.im)),
303            tab: branch.tab,
304            oil_temp_limit_c: td.and_then(|t| t.oil_temp_limit_c),
305            winding_temp_limit_c: td.and_then(|t| t.winding_temp_limit_c),
306            impedance_limit_ohm: td.and_then(|t| t.impedance_limit_ohm),
307            saturation: hd.and_then(|h| h.saturation.clone()),
308            core_type: hd.and_then(|h| h.core_type),
309            core_loss_model: hd.and_then(|h| h.core_loss_model),
310            length_km: ld.and_then(|l| l.length_km),
311            line_type: ld.and_then(|l| l.line_type),
312            conductor: ld.and_then(|l| l.conductor.clone()),
313            n_bundles: ld.and_then(|l| l.n_bundles),
314            winding_rated_kv: td.and_then(|t| t.winding_rated_kv),
315            winding_rated_mva: td.and_then(|t| t.winding_rated_mva),
316            parent_transformer_id: td.and_then(|t| t.parent_transformer_id.clone()),
317            winding_number: td.and_then(|t| t.winding_number),
318            winding_connection: td.and_then(|t| t.winding_connection),
319            zn_winding: td
320                .and_then(|t| t.zn_winding)
321                .map(|value| (value.re, value.im)),
322            bypass_current_ka: sc.and_then(|s| s.bypass_current_ka),
323            rated_mvar_series: sc.and_then(|s| s.rated_mvar_series),
324            ambient: branch.ambient.clone(),
325            owners: branch.owners.clone(),
326        };
327        (!extra.is_empty()).then_some(extra)
328    }
329
330    fn is_empty(&self) -> bool {
331        self.r0.is_none()
332            && self.x0.is_none()
333            && self.b0.is_none()
334            && self.zn.is_none()
335            && self.tab.is_none()
336            && self.oil_temp_limit_c.is_none()
337            && self.winding_temp_limit_c.is_none()
338            && self.impedance_limit_ohm.is_none()
339            && self.saturation.is_none()
340            && self.core_type.is_none()
341            && self.core_loss_model.is_none()
342            && self.length_km.is_none()
343            && self.line_type.is_none()
344            && self.conductor.is_none()
345            && self.n_bundles.is_none()
346            && self.winding_rated_kv.is_none()
347            && self.winding_rated_mva.is_none()
348            && self.parent_transformer_id.is_none()
349            && self.winding_number.is_none()
350            && self.winding_connection.is_none()
351            && self.zn_winding.is_none()
352            && self.bypass_current_ka.is_none()
353            && self.rated_mvar_series.is_none()
354            && self.ambient.is_none()
355            && self.owners.is_empty()
356    }
357
358    fn apply(self, branch: &mut Branch) {
359        // Zero-sequence data
360        if self.r0.is_some() || self.x0.is_some() || self.b0.is_some() || self.zn.is_some() {
361            let zs = branch.zero_seq.get_or_insert_with(ZeroSeqData::default);
362            if let Some(r0) = self.r0 {
363                zs.r0 = r0;
364            }
365            if let Some(x0) = self.x0 {
366                zs.x0 = x0;
367            }
368            if let Some(b0) = self.b0 {
369                zs.b0 = b0;
370            }
371            zs.zn = self.zn.map(|(re, im)| num_complex::Complex64::new(re, im));
372        }
373        branch.tab = self.tab;
374        // Transformer data
375        if self.oil_temp_limit_c.is_some()
376            || self.winding_temp_limit_c.is_some()
377            || self.impedance_limit_ohm.is_some()
378            || self.winding_rated_kv.is_some()
379            || self.winding_rated_mva.is_some()
380            || self.parent_transformer_id.is_some()
381            || self.winding_number.is_some()
382            || self.winding_connection.is_some()
383            || self.zn_winding.is_some()
384        {
385            let td = branch
386                .transformer_data
387                .get_or_insert_with(TransformerData::default);
388            td.oil_temp_limit_c = self.oil_temp_limit_c;
389            td.winding_temp_limit_c = self.winding_temp_limit_c;
390            td.impedance_limit_ohm = self.impedance_limit_ohm;
391            td.winding_rated_kv = self.winding_rated_kv;
392            td.winding_rated_mva = self.winding_rated_mva;
393            td.parent_transformer_id = self.parent_transformer_id;
394            td.winding_number = self.winding_number;
395            td.winding_connection = self.winding_connection;
396            td.zn_winding = self
397                .zn_winding
398                .map(|(re, im)| num_complex::Complex64::new(re, im));
399        }
400        // Harmonic data
401        if self.saturation.is_some() || self.core_type.is_some() || self.core_loss_model.is_some() {
402            let hd = branch.harmonic.get_or_insert_with(HarmonicData::default);
403            hd.saturation = self.saturation;
404            hd.core_type = self.core_type;
405            hd.core_loss_model = self.core_loss_model;
406        }
407        // Line data
408        if self.length_km.is_some()
409            || self.line_type.is_some()
410            || self.conductor.is_some()
411            || self.n_bundles.is_some()
412        {
413            let ld = branch.line_data.get_or_insert_with(LineData::default);
414            ld.length_km = self.length_km;
415            ld.line_type = self.line_type;
416            ld.conductor = self.conductor;
417            ld.n_bundles = self.n_bundles;
418        }
419        // Series comp data
420        if self.bypass_current_ka.is_some() || self.rated_mvar_series.is_some() {
421            let sc = branch
422                .series_comp
423                .get_or_insert_with(SeriesCompData::default);
424            sc.bypass_current_ka = self.bypass_current_ka;
425            sc.rated_mvar_series = self.rated_mvar_series;
426        }
427        branch.ambient = self.ambient;
428        branch.owners = self.owners;
429    }
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
433struct IndexedGeneratorExtra {
434    index: u32,
435    extra: GeneratorExtra,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
439struct GeneratorExtra {
440    technology: Option<GeneratorTechnology>,
441    source_technology_code: Option<String>,
442    xs: Option<f64>,
443    x2_pu: Option<f64>,
444    r2_pu: Option<f64>,
445    x0_pu: Option<f64>,
446    r0_pu: Option<f64>,
447    zn: Option<(f64, f64)>,
448    s_rated_mva: Option<f64>,
449    p_available_mw: Option<f64>,
450    p_ecomin: Option<f64>,
451    p_ecomax: Option<f64>,
452    p_emergency_min: Option<f64>,
453    p_emergency_max: Option<f64>,
454    p_reg_min: Option<f64>,
455    p_reg_max: Option<f64>,
456    min_up_time_hr: Option<f64>,
457    min_down_time_hr: Option<f64>,
458    max_up_time_hr: Option<f64>,
459    min_run_at_pmin_hr: Option<f64>,
460    max_starts_per_day: Option<u32>,
461    max_starts_per_week: Option<u32>,
462    max_energy_mwh_per_day: Option<f64>,
463    shutdown_ramp_mw_per_min: Option<f64>,
464    startup_ramp_mw_per_min: Option<f64>,
465    forbidden_zones: Vec<(f64, f64)>,
466    ramp_up_curve: Vec<(f64, f64)>,
467    ramp_down_curve: Vec<(f64, f64)>,
468    emergency_ramp_up_curve: Vec<(f64, f64)>,
469    emergency_ramp_down_curve: Vec<(f64, f64)>,
470    reg_ramp_up_curve: Vec<(f64, f64)>,
471    reg_ramp_down_curve: Vec<(f64, f64)>,
472    primary_fuel: Option<FuelSupply>,
473    backup_fuel: Option<FuelSupply>,
474    fuel_switch_time_min: Option<f64>,
475    fuel_type: Option<String>,
476    heat_rate_btu_mwh: Option<f64>,
477    energy_offer: Option<EnergyOffer>,
478    reserve_offers: Vec<ReserveOffer>,
479    qualifications: QualificationMap,
480    forced_outage_rate: Option<f64>,
481    pq_curve: Vec<(f64, f64, f64)>,
482    storage: Option<StorageParams>,
483    owners: Vec<OwnershipEntry>,
484}
485
486impl GeneratorExtra {
487    fn from_generator(generator: &Generator) -> Option<Self> {
488        let fd = generator.fault_data.as_ref();
489        let inv = generator.inverter.as_ref();
490        let cm = generator.commitment.as_ref();
491        let rp = generator.ramping.as_ref();
492        let fl = generator.fuel.as_ref();
493        let mk = generator.market.as_ref();
494        let rc = generator.reactive_capability.as_ref();
495        let extra = Self {
496            technology: generator.technology,
497            source_technology_code: generator.source_technology_code.clone(),
498            xs: fd.and_then(|f| f.xs),
499            x2_pu: fd.and_then(|f| f.x2_pu),
500            r2_pu: fd.and_then(|f| f.r2_pu),
501            x0_pu: fd.and_then(|f| f.x0_pu),
502            r0_pu: fd.and_then(|f| f.r0_pu),
503            zn: fd.and_then(|f| f.zn).map(|value| (value.re, value.im)),
504            s_rated_mva: inv.and_then(|i| i.s_rated_mva),
505            p_available_mw: inv.and_then(|i| i.p_available_mw),
506            p_ecomin: cm.and_then(|c| c.p_ecomin),
507            p_ecomax: cm.and_then(|c| c.p_ecomax),
508            p_emergency_min: cm.and_then(|c| c.p_emergency_min),
509            p_emergency_max: cm.and_then(|c| c.p_emergency_max),
510            p_reg_min: cm.and_then(|c| c.p_reg_min),
511            p_reg_max: cm.and_then(|c| c.p_reg_max),
512            min_up_time_hr: cm.and_then(|c| c.min_up_time_hr),
513            min_down_time_hr: cm.and_then(|c| c.min_down_time_hr),
514            max_up_time_hr: cm.and_then(|c| c.max_up_time_hr),
515            min_run_at_pmin_hr: cm.and_then(|c| c.min_run_at_pmin_hr),
516            max_starts_per_day: cm.and_then(|c| c.max_starts_per_day),
517            max_starts_per_week: cm.and_then(|c| c.max_starts_per_week),
518            max_energy_mwh_per_day: cm.and_then(|c| c.max_energy_mwh_per_day),
519            shutdown_ramp_mw_per_min: cm.and_then(|c| c.shutdown_ramp_mw_per_min),
520            startup_ramp_mw_per_min: cm.and_then(|c| c.startup_ramp_mw_per_min),
521            forbidden_zones: cm.map(|c| c.forbidden_zones.clone()).unwrap_or_default(),
522            ramp_up_curve: rp.map(|r| r.ramp_up_curve.clone()).unwrap_or_default(),
523            ramp_down_curve: rp.map(|r| r.ramp_down_curve.clone()).unwrap_or_default(),
524            emergency_ramp_up_curve: rp
525                .map(|r| r.emergency_ramp_up_curve.clone())
526                .unwrap_or_default(),
527            emergency_ramp_down_curve: rp
528                .map(|r| r.emergency_ramp_down_curve.clone())
529                .unwrap_or_default(),
530            reg_ramp_up_curve: rp.map(|r| r.reg_ramp_up_curve.clone()).unwrap_or_default(),
531            reg_ramp_down_curve: rp
532                .map(|r| r.reg_ramp_down_curve.clone())
533                .unwrap_or_default(),
534            primary_fuel: fl.and_then(|f| f.primary_fuel.clone()),
535            backup_fuel: fl.and_then(|f| f.backup_fuel.clone()),
536            fuel_switch_time_min: fl.and_then(|f| f.fuel_switch_time_min),
537            fuel_type: fl.and_then(|f| f.fuel_type.clone()),
538            heat_rate_btu_mwh: fl.and_then(|f| f.heat_rate_btu_mwh),
539            energy_offer: mk.and_then(|m| m.energy_offer.clone()),
540            reserve_offers: mk.map(|m| m.reserve_offers.clone()).unwrap_or_default(),
541            qualifications: mk.map(|m| m.qualifications.clone()).unwrap_or_default(),
542            forced_outage_rate: generator.forced_outage_rate,
543            pq_curve: rc.map(|r| r.pq_curve.clone()).unwrap_or_default(),
544            storage: generator.storage.clone(),
545            owners: generator.owners.clone(),
546        };
547        (!extra.is_empty()).then_some(extra)
548    }
549
550    fn is_empty(&self) -> bool {
551        self.xs.is_none()
552            && self.technology.is_none()
553            && self.source_technology_code.is_none()
554            && self.x2_pu.is_none()
555            && self.r2_pu.is_none()
556            && self.x0_pu.is_none()
557            && self.r0_pu.is_none()
558            && self.zn.is_none()
559            && self.s_rated_mva.is_none()
560            && self.p_available_mw.is_none()
561            && self.p_ecomin.is_none()
562            && self.p_ecomax.is_none()
563            && self.p_emergency_min.is_none()
564            && self.p_emergency_max.is_none()
565            && self.p_reg_min.is_none()
566            && self.p_reg_max.is_none()
567            && self.min_up_time_hr.is_none()
568            && self.min_down_time_hr.is_none()
569            && self.max_up_time_hr.is_none()
570            && self.min_run_at_pmin_hr.is_none()
571            && self.max_starts_per_day.is_none()
572            && self.max_starts_per_week.is_none()
573            && self.max_energy_mwh_per_day.is_none()
574            && self.shutdown_ramp_mw_per_min.is_none()
575            && self.startup_ramp_mw_per_min.is_none()
576            && self.forbidden_zones.is_empty()
577            && self.ramp_up_curve.is_empty()
578            && self.ramp_down_curve.is_empty()
579            && self.emergency_ramp_up_curve.is_empty()
580            && self.emergency_ramp_down_curve.is_empty()
581            && self.reg_ramp_up_curve.is_empty()
582            && self.reg_ramp_down_curve.is_empty()
583            && self.primary_fuel.is_none()
584            && self.backup_fuel.is_none()
585            && self.fuel_switch_time_min.is_none()
586            && self.fuel_type.is_none()
587            && self.heat_rate_btu_mwh.is_none()
588            && self.energy_offer.is_none()
589            && self.reserve_offers.is_empty()
590            && self.qualifications.is_empty()
591            && self.forced_outage_rate.is_none()
592            && self.pq_curve.is_empty()
593            && self.storage.is_none()
594            && self.owners.is_empty()
595    }
596
597    fn apply(self, generator: &mut Generator) {
598        generator.technology = self.technology;
599        generator.source_technology_code = self.source_technology_code;
600        // Fault data
601        if self.xs.is_some()
602            || self.x2_pu.is_some()
603            || self.r2_pu.is_some()
604            || self.x0_pu.is_some()
605            || self.r0_pu.is_some()
606            || self.zn.is_some()
607        {
608            let fd = generator.fault_data.get_or_insert_with(Default::default);
609            fd.xs = self.xs;
610            fd.x2_pu = self.x2_pu;
611            fd.r2_pu = self.r2_pu;
612            fd.x0_pu = self.x0_pu;
613            fd.r0_pu = self.r0_pu;
614            fd.zn = self.zn.map(|(re, im)| num_complex::Complex64::new(re, im));
615        }
616        // Inverter
617        if self.s_rated_mva.is_some() || self.p_available_mw.is_some() {
618            let inv = generator.inverter.get_or_insert_with(Default::default);
619            inv.s_rated_mva = self.s_rated_mva;
620            inv.p_available_mw = self.p_available_mw;
621        }
622        // Commitment
623        if self.p_ecomin.is_some()
624            || self.p_ecomax.is_some()
625            || self.p_emergency_min.is_some()
626            || self.p_emergency_max.is_some()
627            || self.p_reg_min.is_some()
628            || self.p_reg_max.is_some()
629            || self.min_up_time_hr.is_some()
630            || self.min_down_time_hr.is_some()
631            || self.max_up_time_hr.is_some()
632            || self.min_run_at_pmin_hr.is_some()
633            || self.max_starts_per_day.is_some()
634            || self.max_starts_per_week.is_some()
635            || self.max_energy_mwh_per_day.is_some()
636            || self.shutdown_ramp_mw_per_min.is_some()
637            || self.startup_ramp_mw_per_min.is_some()
638            || !self.forbidden_zones.is_empty()
639        {
640            let cm = generator.commitment.get_or_insert_with(Default::default);
641            cm.p_ecomin = self.p_ecomin;
642            cm.p_ecomax = self.p_ecomax;
643            cm.p_emergency_min = self.p_emergency_min;
644            cm.p_emergency_max = self.p_emergency_max;
645            cm.p_reg_min = self.p_reg_min;
646            cm.p_reg_max = self.p_reg_max;
647            cm.min_up_time_hr = self.min_up_time_hr;
648            cm.min_down_time_hr = self.min_down_time_hr;
649            cm.max_up_time_hr = self.max_up_time_hr;
650            cm.min_run_at_pmin_hr = self.min_run_at_pmin_hr;
651            cm.max_starts_per_day = self.max_starts_per_day;
652            cm.max_starts_per_week = self.max_starts_per_week;
653            cm.max_energy_mwh_per_day = self.max_energy_mwh_per_day;
654            cm.shutdown_ramp_mw_per_min = self.shutdown_ramp_mw_per_min;
655            cm.startup_ramp_mw_per_min = self.startup_ramp_mw_per_min;
656            cm.forbidden_zones = self.forbidden_zones;
657        }
658        // Ramping
659        if !self.ramp_up_curve.is_empty()
660            || !self.ramp_down_curve.is_empty()
661            || !self.emergency_ramp_up_curve.is_empty()
662            || !self.emergency_ramp_down_curve.is_empty()
663            || !self.reg_ramp_up_curve.is_empty()
664            || !self.reg_ramp_down_curve.is_empty()
665        {
666            let rp = generator.ramping.get_or_insert_with(Default::default);
667            rp.ramp_up_curve = self.ramp_up_curve;
668            rp.ramp_down_curve = self.ramp_down_curve;
669            rp.emergency_ramp_up_curve = self.emergency_ramp_up_curve;
670            rp.emergency_ramp_down_curve = self.emergency_ramp_down_curve;
671            rp.reg_ramp_up_curve = self.reg_ramp_up_curve;
672            rp.reg_ramp_down_curve = self.reg_ramp_down_curve;
673        }
674        // Fuel
675        if self.primary_fuel.is_some()
676            || self.backup_fuel.is_some()
677            || self.fuel_switch_time_min.is_some()
678            || self.fuel_type.is_some()
679            || self.heat_rate_btu_mwh.is_some()
680        {
681            let fl = generator.fuel.get_or_insert_with(Default::default);
682            fl.primary_fuel = self.primary_fuel;
683            fl.backup_fuel = self.backup_fuel;
684            fl.fuel_switch_time_min = self.fuel_switch_time_min;
685            fl.fuel_type = self.fuel_type;
686            fl.heat_rate_btu_mwh = self.heat_rate_btu_mwh;
687        }
688        // Market
689        if self.energy_offer.is_some()
690            || !self.reserve_offers.is_empty()
691            || !self.qualifications.is_empty()
692        {
693            let mk = generator.market.get_or_insert_with(Default::default);
694            mk.energy_offer = self.energy_offer;
695            mk.reserve_offers = self.reserve_offers;
696            mk.qualifications = self.qualifications;
697        }
698        // Reactive capability (pq_curve)
699        if !self.pq_curve.is_empty() {
700            generator
701                .reactive_capability
702                .get_or_insert_with(Default::default)
703                .pq_curve = self.pq_curve;
704        }
705        generator.forced_outage_rate = self.forced_outage_rate;
706        generator.storage = self.storage;
707        generator.owners = self.owners;
708    }
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
712struct IndexedLoadExtra {
713    index: u32,
714    extra: LoadExtra,
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize)]
718struct LoadExtra {
719    owners: Vec<OwnershipEntry>,
720}
721
722impl LoadExtra {
723    fn from_load(load: &Load) -> Option<Self> {
724        let extra = Self {
725            owners: load.owners.clone(),
726        };
727        (!extra.is_empty()).then_some(extra)
728    }
729
730    fn is_empty(&self) -> bool {
731        self.owners.is_empty()
732    }
733
734    fn apply(self, load: &mut Load) {
735        load.owners = self.owners;
736    }
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize)]
740#[allow(clippy::type_complexity)]
741struct NetworkExtras {
742    dispatchable_loads: Vec<DispatchableLoad>,
743    switched_shunts: Vec<SwitchedShunt>,
744    switched_shunts_opf: Vec<SwitchedShuntOpf>,
745    oltc_specs: Vec<OltcSpec>,
746    par_specs: Vec<ParSpec>,
747    hvdc: HvdcModel,
748    area_schedules: Vec<AreaSchedule>,
749    facts_devices: Vec<FactsDevice>,
750    regions: Vec<Region>,
751    owners: Vec<Owner>,
752    scheduled_area_transfers: Vec<ScheduledAreaTransfer>,
753    impedance_corrections: Vec<ImpedanceCorrectionTable>,
754    multi_section_line_groups: Vec<MultiSectionLineGroup>,
755    per_length_phase_impedances: HashMap<String, Vec<PhaseImpedanceEntry>>,
756    mutual_couplings: Vec<MutualCoupling>,
757    grounding_impedances: Vec<GroundingEntry>,
758    geo_locations: HashMap<String, Vec<GeoPoint>>,
759    interfaces: Vec<Interface>,
760    flowgates: Vec<Flowgate>,
761    nomograms: Vec<OperatingNomogram>,
762    topology: Option<NodeBreakerTopology>,
763    induction_machines: Vec<InductionMachine>,
764    conditional_limits: BranchConditionalRatings,
765    pumped_hydro_units: Vec<PumpedHydroUnit>,
766    breaker_ratings: Vec<BreakerRating>,
767    fixed_shunts: Vec<FixedShunt>,
768    power_injections: Vec<PowerInjection>,
769    combined_cycle_plants: Vec<CombinedCyclePlant>,
770    outage_schedule: Vec<OutageEntry>,
771    reserve_zones: Vec<ReserveZone>,
772    ambient: Option<AmbientConditions>,
773    emission_policy: Option<EmissionPolicy>,
774    measurements: Vec<CimMeasurement>,
775    asset_catalog: AssetCatalog,
776    operational_limits: OperationalLimits,
777    market_rules: Option<MarketRules>,
778    boundary_data: BoundaryData,
779    protection_data: ProtectionData,
780    market_data: MarketData,
781    network_operations: NetworkOperationsData,
782}
783
784impl NetworkExtras {
785    fn from_network(network: &Network) -> Self {
786        Self {
787            dispatchable_loads: network.market_data.dispatchable_loads.clone(),
788            switched_shunts: network.controls.switched_shunts.clone(),
789            switched_shunts_opf: network.controls.switched_shunts_opf.clone(),
790            oltc_specs: network.controls.oltc_specs.clone(),
791            par_specs: network.controls.par_specs.clone(),
792            hvdc: network.hvdc.clone(),
793            area_schedules: network.area_schedules.clone(),
794            facts_devices: network.facts_devices.clone(),
795            regions: network.metadata.regions.clone(),
796            owners: network.metadata.owners.clone(),
797            scheduled_area_transfers: network.metadata.scheduled_area_transfers.clone(),
798            impedance_corrections: network.metadata.impedance_corrections.clone(),
799            multi_section_line_groups: network.metadata.multi_section_line_groups.clone(),
800            per_length_phase_impedances: network.cim.per_length_phase_impedances.clone(),
801            mutual_couplings: network.cim.mutual_couplings.clone(),
802            grounding_impedances: network.cim.grounding_impedances.clone(),
803            geo_locations: network.cim.geo_locations.clone(),
804            interfaces: network.interfaces.clone(),
805            flowgates: network.flowgates.clone(),
806            nomograms: network.nomograms.clone(),
807            topology: network.topology.clone(),
808            induction_machines: network.induction_machines.clone(),
809            conditional_limits: network.conditional_limits.clone(),
810            pumped_hydro_units: network.market_data.pumped_hydro_units.clone(),
811            breaker_ratings: network.breaker_ratings.clone(),
812            fixed_shunts: network.fixed_shunts.clone(),
813            power_injections: network.power_injections.clone(),
814            combined_cycle_plants: network.market_data.combined_cycle_plants.clone(),
815            outage_schedule: network.market_data.outage_schedule.clone(),
816            reserve_zones: network.market_data.reserve_zones.clone(),
817            ambient: network.market_data.ambient.clone(),
818            emission_policy: network.market_data.emission_policy.clone(),
819            measurements: network.cim.measurements.clone(),
820            asset_catalog: network.cim.asset_catalog.clone(),
821            operational_limits: network.cim.operational_limits.clone(),
822            market_rules: network.market_data.market_rules.clone(),
823            boundary_data: network.cim.boundary_data.clone(),
824            protection_data: network.cim.protection_data.clone(),
825            market_data: network.cim.market_data.clone(),
826            network_operations: network.cim.network_operations.clone(),
827        }
828    }
829
830    fn is_empty(&self) -> bool {
831        self.dispatchable_loads.is_empty()
832            && self.switched_shunts.is_empty()
833            && self.switched_shunts_opf.is_empty()
834            && self.oltc_specs.is_empty()
835            && self.par_specs.is_empty()
836            && self.hvdc.is_empty()
837            && self.area_schedules.is_empty()
838            && self.facts_devices.is_empty()
839            && self.regions.is_empty()
840            && self.owners.is_empty()
841            && self.scheduled_area_transfers.is_empty()
842            && self.impedance_corrections.is_empty()
843            && self.multi_section_line_groups.is_empty()
844            && self.per_length_phase_impedances.is_empty()
845            && self.mutual_couplings.is_empty()
846            && self.grounding_impedances.is_empty()
847            && self.geo_locations.is_empty()
848            && self.interfaces.is_empty()
849            && self.flowgates.is_empty()
850            && self.nomograms.is_empty()
851            && self.topology.is_none()
852            && self.induction_machines.is_empty()
853            && self.conditional_limits.is_empty()
854            && self.pumped_hydro_units.is_empty()
855            && self.breaker_ratings.is_empty()
856            && self.fixed_shunts.is_empty()
857            && self.power_injections.is_empty()
858            && self.combined_cycle_plants.is_empty()
859            && self.outage_schedule.is_empty()
860            && self.reserve_zones.is_empty()
861            && self.ambient.is_none()
862            && self.emission_policy.is_none()
863            && self.measurements.is_empty()
864            && self.asset_catalog.is_empty()
865            && self.operational_limits.is_empty()
866            && self.market_rules.is_none()
867            && self.boundary_data.is_empty()
868            && self.protection_data.is_empty()
869            && self.market_data.is_empty()
870            && self.network_operations.is_empty()
871    }
872
873    fn apply(self, network: &mut Network) {
874        network.market_data.dispatchable_loads = self.dispatchable_loads;
875        network.controls.switched_shunts = self.switched_shunts;
876        network.controls.switched_shunts_opf = self.switched_shunts_opf;
877        network.controls.oltc_specs = self.oltc_specs;
878        network.controls.par_specs = self.par_specs;
879        network.hvdc = self.hvdc;
880        network.area_schedules = self.area_schedules;
881        network.facts_devices = self.facts_devices;
882        network.metadata.regions = self.regions;
883        network.metadata.owners = self.owners;
884        network.metadata.scheduled_area_transfers = self.scheduled_area_transfers;
885        network.metadata.impedance_corrections = self.impedance_corrections;
886        network.metadata.multi_section_line_groups = self.multi_section_line_groups;
887        network.cim.per_length_phase_impedances = self.per_length_phase_impedances;
888        network.cim.mutual_couplings = self.mutual_couplings;
889        network.cim.grounding_impedances = self.grounding_impedances;
890        network.cim.geo_locations = self.geo_locations;
891        network.interfaces = self.interfaces;
892        network.flowgates = self.flowgates;
893        network.nomograms = self.nomograms;
894        network.topology = self.topology;
895        network.induction_machines = self.induction_machines;
896        network.conditional_limits = self.conditional_limits;
897        network.market_data.pumped_hydro_units = self.pumped_hydro_units;
898        network.breaker_ratings = self.breaker_ratings;
899        network.fixed_shunts = self.fixed_shunts;
900        network.power_injections = self.power_injections;
901        network.market_data.combined_cycle_plants = self.combined_cycle_plants;
902        network.market_data.outage_schedule = self.outage_schedule;
903        network.market_data.reserve_zones = self.reserve_zones;
904        network.market_data.ambient = self.ambient;
905        network.market_data.emission_policy = self.emission_policy;
906        network.cim.measurements = self.measurements;
907        network.cim.asset_catalog = self.asset_catalog;
908        network.cim.operational_limits = self.operational_limits;
909        network.market_data.market_rules = self.market_rules;
910        network.cim.boundary_data = self.boundary_data;
911        network.cim.protection_data = self.protection_data;
912        network.cim.market_data = self.market_data;
913        network.cim.network_operations = self.network_operations;
914    }
915}
916
917/// Load a binary network file from disk.
918pub fn load(path: impl AsRef<Path>) -> Result<Network, Error> {
919    let file = std::fs::File::open(path)?;
920    let mut reader = BufReader::new(file);
921    let mut bytes = Vec::new();
922    reader.read_to_end(&mut bytes)?;
923    loads(&bytes)
924}
925
926/// Load a binary network from in-memory bytes.
927pub fn loads(bytes: &[u8]) -> Result<Network, Error> {
928    decode_document(bytes)
929}
930
931/// Save a network to a binary file.
932pub fn save(network: &Network, path: impl AsRef<Path>) -> Result<(), Error> {
933    let bytes = dumps(network)?;
934    let mut file = std::fs::File::create(path)?;
935    file.write_all(&bytes)?;
936    Ok(())
937}
938
939/// Serialize a network to binary bytes.
940pub fn dumps(network: &Network) -> Result<Vec<u8>, Error> {
941    encode_document(network)
942}
943
944fn encode_document(network: &Network) -> Result<Vec<u8>, Error> {
945    let mut strings = StringTableBuilder::new();
946
947    let network_header = encode_network_header(network, &mut strings);
948    let bus_section = encode_buses(&network.buses, &mut strings);
949    let bus_extras = encode_bus_extras(&network.buses)?;
950    let branch_section = encode_branches(&network.branches, &mut strings);
951    let branch_extras = encode_branch_extras(&network.branches)?;
952    let generator_section = encode_generators(&network.generators, &mut strings);
953    let generator_extras = encode_generator_extras(&network.generators)?;
954    let load_section = encode_loads(&network.loads, &mut strings);
955    let load_extras = encode_load_extras(&network.loads)?;
956    let string_section = encode_strings(strings.freeze());
957    let network_extras = encode_network_extras(network)?;
958
959    let mut sections = vec![
960        Section {
961            id: SECTION_NETWORK_HEADER,
962            count: 1,
963            payload: network_header,
964        },
965        string_section,
966        Section {
967            id: SECTION_BUSES,
968            count: network.buses.len() as u32,
969            payload: bus_section,
970        },
971        Section {
972            id: SECTION_BRANCHES,
973            count: network.branches.len() as u32,
974            payload: branch_section,
975        },
976        Section {
977            id: SECTION_GENERATORS,
978            count: network.generators.len() as u32,
979            payload: generator_section,
980        },
981        Section {
982            id: SECTION_LOADS,
983            count: network.loads.len() as u32,
984            payload: load_section,
985        },
986    ];
987
988    if let Some(payload) = bus_extras {
989        sections.push(Section {
990            id: SECTION_BUS_EXTRAS,
991            count: payload.1,
992            payload: payload.0,
993        });
994    }
995    if let Some(payload) = branch_extras {
996        sections.push(Section {
997            id: SECTION_BRANCH_EXTRAS,
998            count: payload.1,
999            payload: payload.0,
1000        });
1001    }
1002    if let Some(payload) = generator_extras {
1003        sections.push(Section {
1004            id: SECTION_GENERATOR_EXTRAS,
1005            count: payload.1,
1006            payload: payload.0,
1007        });
1008    }
1009    if let Some(payload) = load_extras {
1010        sections.push(Section {
1011            id: SECTION_LOAD_EXTRAS,
1012            count: payload.1,
1013            payload: payload.0,
1014        });
1015    }
1016    if let Some(payload) = network_extras {
1017        sections.push(Section {
1018            id: SECTION_NETWORK_EXTRAS,
1019            count: 1,
1020            payload,
1021        });
1022    }
1023
1024    sections.sort_by_key(|section| section.id);
1025
1026    let mut body = Vec::new();
1027    for section in &sections {
1028        write_u16(&mut body, section.id);
1029        write_u16(&mut body, 0);
1030        write_u32(&mut body, section.count);
1031        write_u64(&mut body, section.payload.len() as u64);
1032        body.extend_from_slice(&section.payload);
1033    }
1034
1035    let checksum = crc32fast::hash(&body);
1036    let mut output = Vec::with_capacity(HEADER_LEN + body.len());
1037    output.extend_from_slice(MAGIC);
1038    output.extend_from_slice(&BINARY_REVISION.to_le_bytes());
1039    output.extend_from_slice(&0u16.to_le_bytes());
1040    output.extend_from_slice(&(sections.len() as u32).to_le_bytes());
1041    output.extend_from_slice(&(body.len() as u64).to_le_bytes());
1042    output.extend_from_slice(&checksum.to_le_bytes());
1043    output.extend_from_slice(&body);
1044    Ok(output)
1045}
1046
1047fn decode_document(bytes: &[u8]) -> Result<Network, Error> {
1048    if bytes.len() < HEADER_LEN {
1049        return Err(Error::InvalidDocument(
1050            "binary document is shorter than the minimum header".to_string(),
1051        ));
1052    }
1053
1054    if &bytes[..MAGIC.len()] != MAGIC {
1055        return Err(Error::InvalidDocument(
1056            "invalid binary magic header".to_string(),
1057        ));
1058    }
1059
1060    let mut reader = Reader::new(bytes);
1061    let _magic = reader.take(MAGIC.len())?;
1062    let revision = reader.u16()?;
1063    if revision != BINARY_REVISION {
1064        return Err(Error::InvalidDocument(format!(
1065            "unsupported binary revision {revision}"
1066        )));
1067    }
1068    let _flags = reader.u16()?;
1069    let section_count = reader.u32()? as usize;
1070    let body_len = reader.u64()? as usize;
1071    let checksum = reader.u32()?;
1072    let body = reader.take(body_len)?;
1073    if !reader.is_empty() {
1074        return Err(Error::InvalidDocument(
1075            "binary document has trailing bytes after body".to_string(),
1076        ));
1077    }
1078    if crc32fast::hash(body) != checksum {
1079        return Err(Error::InvalidDocument(
1080            "binary payload checksum mismatch".to_string(),
1081        ));
1082    }
1083
1084    let mut body_reader = Reader::new(body);
1085    let mut sections = HashMap::new();
1086    for _ in 0..section_count {
1087        if body_reader.remaining() < SECTION_HEADER_LEN {
1088            return Err(Error::InvalidDocument(
1089                "truncated section header".to_string(),
1090            ));
1091        }
1092        let id = body_reader.u16()?;
1093        let _section_flags = body_reader.u16()?;
1094        let count = body_reader.u32()?;
1095        let len = body_reader.u64()? as usize;
1096        let payload = body_reader.take(len)?;
1097        if sections
1098            .insert(id, SectionView { count, payload })
1099            .is_some()
1100        {
1101            return Err(Error::InvalidDocument(format!("duplicate section id {id}")));
1102        }
1103    }
1104
1105    if !body_reader.is_empty() {
1106        return Err(Error::InvalidDocument(
1107            "binary body contains trailing bytes after declared sections".to_string(),
1108        ));
1109    }
1110
1111    let strings = decode_strings(require_section(&sections, SECTION_STRINGS)?)?;
1112    let mut network = decode_network_header(
1113        require_section(&sections, SECTION_NETWORK_HEADER)?,
1114        &strings,
1115    )?;
1116    network.buses = decode_buses(require_section(&sections, SECTION_BUSES)?, &strings)?;
1117    network.branches = decode_branches(require_section(&sections, SECTION_BRANCHES)?, &strings)?;
1118    network.generators =
1119        decode_generators(require_section(&sections, SECTION_GENERATORS)?, &strings)?;
1120    network.loads = decode_loads(require_section(&sections, SECTION_LOADS)?, &strings)?;
1121
1122    if let Some(section) = sections.get(&SECTION_BUS_EXTRAS) {
1123        apply_bus_extras(section, &mut network)?;
1124    }
1125    if let Some(section) = sections.get(&SECTION_BRANCH_EXTRAS) {
1126        apply_branch_extras(section, &mut network)?;
1127    }
1128    if let Some(section) = sections.get(&SECTION_GENERATOR_EXTRAS) {
1129        apply_generator_extras(section, &mut network)?;
1130    }
1131    if let Some(section) = sections.get(&SECTION_LOAD_EXTRAS) {
1132        apply_load_extras(section, &mut network)?;
1133    }
1134    if let Some(section) = sections.get(&SECTION_NETWORK_EXTRAS) {
1135        decode_cbor::<NetworkExtras>(section.payload)?.apply(&mut network);
1136    }
1137
1138    Ok(network)
1139}
1140
1141fn require_section<'a>(
1142    sections: &'a HashMap<u16, SectionView<'a>>,
1143    id: u16,
1144) -> Result<SectionView<'a>, Error> {
1145    sections
1146        .get(&id)
1147        .copied()
1148        .ok_or_else(|| Error::InvalidDocument(format!("missing required section id {id}")))
1149}
1150
1151fn encode_strings(table: StringTable) -> Section {
1152    let mut payload = Vec::new();
1153    for value in &table.values {
1154        write_bytes(&mut payload, value.as_bytes());
1155    }
1156    Section {
1157        id: SECTION_STRINGS,
1158        count: table.values.len() as u32,
1159        payload,
1160    }
1161}
1162
1163fn decode_strings(section: SectionView<'_>) -> Result<StringTable, Error> {
1164    let mut reader = Reader::new(section.payload);
1165    let mut values = Vec::with_capacity(section.count as usize);
1166    for _ in 0..section.count {
1167        let bytes = read_bytes(&mut reader)?;
1168        let value = String::from_utf8(bytes)
1169            .map_err(|error| Error::InvalidDocument(format!("invalid UTF-8 string: {error}")))?;
1170        values.push(value);
1171    }
1172    if !reader.is_empty() {
1173        return Err(Error::InvalidDocument(
1174            "string table payload has trailing bytes".to_string(),
1175        ));
1176    }
1177    Ok(StringTable { values })
1178}
1179
1180fn encode_network_header(network: &Network, strings: &mut StringTableBuilder) -> Vec<u8> {
1181    let mut payload = Vec::new();
1182    write_u32(&mut payload, strings.intern(&network.name));
1183    write_f64(&mut payload, network.base_mva);
1184    write_f64(&mut payload, network.freq_hz);
1185    payload
1186}
1187
1188fn decode_network_header(
1189    section: SectionView<'_>,
1190    strings: &StringTable,
1191) -> Result<Network, Error> {
1192    let mut reader = Reader::new(section.payload);
1193    let name_id = reader.u32()?;
1194    let base_mva = reader.f64()?;
1195    let freq_hz = reader.f64()?;
1196    if !reader.is_empty() {
1197        return Err(Error::InvalidDocument(
1198            "network header payload has trailing bytes".to_string(),
1199        ));
1200    }
1201    Ok(Network {
1202        name: strings.resolve(name_id)?.to_string(),
1203        base_mva,
1204        freq_hz,
1205        ..Network::default()
1206    })
1207}
1208
1209fn encode_buses(buses: &[Bus], strings: &mut StringTableBuilder) -> Vec<u8> {
1210    let mut payload = Vec::new();
1211    for bus in buses {
1212        write_u32(&mut payload, bus.number);
1213        write_u32(&mut payload, strings.intern(&bus.name));
1214        write_u8(&mut payload, encode_bus_type(bus.bus_type));
1215        write_u32(&mut payload, bus.area);
1216        write_u32(&mut payload, bus.zone);
1217        write_u32(&mut payload, bus.island_id);
1218        write_f64(&mut payload, 0.0); // legacy: active_power_demand_mw (now on Load objects)
1219        write_f64(&mut payload, 0.0); // legacy: reactive_power_demand_mvar (now on Load objects)
1220        write_f64(&mut payload, bus.shunt_conductance_mw);
1221        write_f64(&mut payload, bus.shunt_susceptance_mvar);
1222        write_f64(&mut payload, bus.voltage_magnitude_pu);
1223        write_f64(&mut payload, bus.voltage_angle_rad);
1224        write_f64(&mut payload, bus.base_kv);
1225        write_f64(&mut payload, bus.voltage_max_pu);
1226        write_f64(&mut payload, bus.voltage_min_pu);
1227    }
1228    payload
1229}
1230
1231fn decode_buses(section: SectionView<'_>, strings: &StringTable) -> Result<Vec<Bus>, Error> {
1232    let mut reader = Reader::new(section.payload);
1233    let mut buses = Vec::with_capacity(section.count as usize);
1234    for _ in 0..section.count {
1235        let bus = Bus {
1236            number: reader.u32()?,
1237            name: strings.resolve(reader.u32()?)?.to_string(),
1238            bus_type: decode_bus_type(reader.u8()?)?,
1239            area: reader.u32()?,
1240            zone: reader.u32()?,
1241            island_id: reader.u32()?,
1242            shunt_conductance_mw: {
1243                let _legacy_pd = reader.f64()?; // legacy: active_power_demand_mw
1244                let _legacy_qd = reader.f64()?; // legacy: reactive_power_demand_mvar
1245                reader.f64()?
1246            },
1247            shunt_susceptance_mvar: reader.f64()?,
1248            voltage_magnitude_pu: reader.f64()?,
1249            voltage_angle_rad: reader.f64()?,
1250            base_kv: reader.f64()?,
1251            voltage_max_pu: reader.f64()?,
1252            voltage_min_pu: reader.f64()?,
1253            ..Bus::default()
1254        };
1255        buses.push(bus);
1256    }
1257    if !reader.is_empty() {
1258        return Err(Error::InvalidDocument(
1259            "bus section payload has trailing bytes".to_string(),
1260        ));
1261    }
1262    Ok(buses)
1263}
1264
1265fn encode_bus_extras(buses: &[Bus]) -> Result<Option<(Vec<u8>, u32)>, Error> {
1266    let extras: Vec<_> = buses
1267        .iter()
1268        .enumerate()
1269        .filter_map(|(index, bus)| {
1270            BusExtra::from_bus(bus).map(|extra| IndexedBusExtra {
1271                index: index as u32,
1272                extra,
1273            })
1274        })
1275        .collect();
1276    if extras.is_empty() {
1277        return Ok(None);
1278    }
1279    Ok(Some((encode_cbor(&extras)?, extras.len() as u32)))
1280}
1281
1282fn apply_bus_extras(section: &SectionView<'_>, network: &mut Network) -> Result<(), Error> {
1283    let extras: Vec<IndexedBusExtra> = decode_cbor(section.payload)?;
1284    for extra in extras {
1285        let bus = network.buses.get_mut(extra.index as usize).ok_or_else(|| {
1286            Error::InvalidDocument(format!("bus extra index {} out of range", extra.index))
1287        })?;
1288        extra.extra.apply(bus);
1289    }
1290    Ok(())
1291}
1292
1293fn encode_branches(branches: &[Branch], strings: &mut StringTableBuilder) -> Vec<u8> {
1294    let mut payload = Vec::new();
1295    for branch in branches {
1296        let zs = branch.zero_seq.as_ref();
1297        let oc = branch.opf_control.as_ref();
1298        let ld = branch.line_data.as_ref();
1299        let hd = branch.harmonic.as_ref();
1300        let td = branch.transformer_data.as_ref();
1301        let sc = branch.series_comp.as_ref();
1302
1303        let mut flags = 0u8;
1304        if branch.in_service {
1305            flags |= 1 << 0;
1306        }
1307        if zs.is_some_and(|z| z.delta_connected) {
1308            flags |= 1 << 1;
1309        }
1310        if sc.is_some_and(|s| s.bypassed) {
1311            flags |= 1 << 2;
1312        }
1313        if branch.angle_diff_min_rad.is_some() {
1314            flags |= 1 << 3;
1315        }
1316        if branch.angle_diff_max_rad.is_some() {
1317            flags |= 1 << 4;
1318        }
1319
1320        write_u32(&mut payload, branch.from_bus);
1321        write_u32(&mut payload, branch.to_bus);
1322        write_u32(&mut payload, strings.intern(&branch.circuit));
1323        write_u8(&mut payload, flags);
1324        write_u8(
1325            &mut payload,
1326            encode_transformer_connection(td.map(|t| t.transformer_connection).unwrap_or_default()),
1327        );
1328        write_u8(
1329            &mut payload,
1330            encode_tap_mode(oc.map(|c| c.tap_mode).unwrap_or_default()),
1331        );
1332        write_u8(
1333            &mut payload,
1334            encode_phase_mode(oc.map(|c| c.phase_mode).unwrap_or_default()),
1335        );
1336        write_u8(&mut payload, encode_branch_type(branch.branch_type));
1337        write_f64(&mut payload, branch.r);
1338        write_f64(&mut payload, branch.x);
1339        write_f64(&mut payload, branch.b);
1340        write_f64(&mut payload, branch.rating_a_mva);
1341        write_f64(&mut payload, branch.rating_b_mva);
1342        write_f64(&mut payload, branch.rating_c_mva);
1343        write_f64(&mut payload, branch.tap);
1344        write_f64(&mut payload, branch.phase_shift_rad);
1345        write_f64(&mut payload, hd.map(|h| h.skin_effect_alpha).unwrap_or(0.0));
1346        write_f64(&mut payload, branch.g_pi);
1347        write_f64(&mut payload, branch.g_mag);
1348        write_f64(&mut payload, branch.b_mag);
1349        write_f64(&mut payload, zs.map(|z| z.gi0).unwrap_or(0.0));
1350        write_f64(&mut payload, zs.map(|z| z.bi0).unwrap_or(0.0));
1351        write_f64(&mut payload, zs.map(|z| z.gj0).unwrap_or(0.0));
1352        write_f64(&mut payload, zs.map(|z| z.bj0).unwrap_or(0.0));
1353        write_f64(
1354            &mut payload,
1355            oc.map(|c| c.tap_min)
1356                .unwrap_or(BranchOpfControl::default().tap_min),
1357        );
1358        write_f64(
1359            &mut payload,
1360            oc.map(|c| c.tap_max)
1361                .unwrap_or(BranchOpfControl::default().tap_max),
1362        );
1363        write_f64(&mut payload, oc.map(|c| c.tap_step).unwrap_or(0.0));
1364        write_f64(
1365            &mut payload,
1366            oc.map(|c| c.phase_min_rad)
1367                .unwrap_or(BranchOpfControl::default().phase_min_rad),
1368        );
1369        write_f64(
1370            &mut payload,
1371            oc.map(|c| c.phase_max_rad)
1372                .unwrap_or(BranchOpfControl::default().phase_max_rad),
1373        );
1374        write_f64(&mut payload, oc.map(|c| c.phase_step_rad).unwrap_or(0.0));
1375        write_f64(&mut payload, ld.map(|l| l.r_temp_coeff).unwrap_or(0.0));
1376        write_f64(&mut payload, ld.map(|l| l.r_ref_temp_c).unwrap_or(20.0));
1377        if let Some(value) = branch.angle_diff_min_rad {
1378            write_f64(&mut payload, value);
1379        }
1380        if let Some(value) = branch.angle_diff_max_rad {
1381            write_f64(&mut payload, value);
1382        }
1383    }
1384    payload
1385}
1386
1387fn decode_branches(section: SectionView<'_>, strings: &StringTable) -> Result<Vec<Branch>, Error> {
1388    let mut reader = Reader::new(section.payload);
1389    let mut branches = Vec::with_capacity(section.count as usize);
1390    for _ in 0..section.count {
1391        let from_bus = reader.u32()?;
1392        let to_bus = reader.u32()?;
1393        let circuit = strings.resolve(reader.u32()?)?.to_string();
1394        let flags = reader.u8()?;
1395        let in_service = (flags & (1 << 0)) != 0;
1396        let delta_connected = (flags & (1 << 1)) != 0;
1397        let bypassed = (flags & (1 << 2)) != 0;
1398        let has_angle_min = (flags & (1 << 3)) != 0;
1399        let has_angle_max = (flags & (1 << 4)) != 0;
1400
1401        let transformer_connection = decode_transformer_connection(reader.u8()?)?;
1402        let tap_mode = decode_tap_mode(reader.u8()?)?;
1403        let phase_mode = decode_phase_mode(reader.u8()?)?;
1404        let branch_type = decode_branch_type(reader.u8()?)?;
1405        let r = reader.f64()?;
1406        let x = reader.f64()?;
1407        let b_val = reader.f64()?;
1408        let rating_a_mva = reader.f64()?;
1409        let rating_b_mva = reader.f64()?;
1410        let rating_c_mva = reader.f64()?;
1411        let tap = reader.f64()?;
1412        let phase_shift_rad = reader.f64()?;
1413        let skin_effect_alpha = reader.f64()?;
1414        let g_pi = reader.f64()?;
1415        let g_mag = reader.f64()?;
1416        let b_mag = reader.f64()?;
1417        let gi0 = reader.f64()?;
1418        let bi0 = reader.f64()?;
1419        let gj0 = reader.f64()?;
1420        let bj0 = reader.f64()?;
1421        let tap_min = reader.f64()?;
1422        let tap_max = reader.f64()?;
1423        let tap_step = reader.f64()?;
1424        let phase_min_rad = reader.f64()?;
1425        let phase_max_rad = reader.f64()?;
1426        let phase_step_rad = reader.f64()?;
1427        let r_temp_coeff = reader.f64()?;
1428        let r_ref_temp_c = reader.f64()?;
1429
1430        // Reconstruct optional sub-structs only when they contain non-default data.
1431        let has_zero_seq = delta_connected
1432            || gi0.abs() > 0.0
1433            || bi0.abs() > 0.0
1434            || gj0.abs() > 0.0
1435            || bj0.abs() > 0.0;
1436        let zero_seq = if has_zero_seq {
1437            Some(ZeroSeqData {
1438                r0: 0.0,
1439                x0: 0.0,
1440                b0: 0.0,
1441                zn: None,
1442                gi0,
1443                bi0,
1444                gj0,
1445                bj0,
1446                delta_connected,
1447            })
1448        } else {
1449            None
1450        };
1451
1452        let opf_default = BranchOpfControl::default();
1453        let has_opf = tap_mode != TapMode::Fixed
1454            || phase_mode != PhaseMode::Fixed
1455            || (tap_min - opf_default.tap_min).abs() > 1e-12
1456            || (tap_max - opf_default.tap_max).abs() > 1e-12
1457            || tap_step.abs() > 1e-12
1458            || (phase_min_rad - opf_default.phase_min_rad).abs() > 1e-12
1459            || (phase_max_rad - opf_default.phase_max_rad).abs() > 1e-12
1460            || phase_step_rad.abs() > 1e-12;
1461        let opf_control = if has_opf {
1462            Some(BranchOpfControl {
1463                tap_mode,
1464                tap_min,
1465                tap_max,
1466                tap_step,
1467                phase_mode,
1468                phase_min_rad,
1469                phase_max_rad,
1470                phase_step_rad,
1471            })
1472        } else {
1473            None
1474        };
1475
1476        let has_transformer_data =
1477            !matches!(transformer_connection, TransformerConnection::WyeGWyeG);
1478        let transformer_data = if has_transformer_data {
1479            Some(TransformerData {
1480                transformer_connection,
1481                ..TransformerData::default()
1482            })
1483        } else {
1484            None
1485        };
1486
1487        let has_line_data = r_temp_coeff.abs() > 1e-12 || (r_ref_temp_c - 20.0).abs() > 1e-12;
1488        let line_data = if has_line_data {
1489            Some(LineData {
1490                r_temp_coeff,
1491                r_ref_temp_c,
1492                ..LineData::default()
1493            })
1494        } else {
1495            None
1496        };
1497
1498        let has_harmonic = skin_effect_alpha.abs() > 1e-12;
1499        let harmonic = if has_harmonic {
1500            Some(HarmonicData {
1501                skin_effect_alpha,
1502                ..HarmonicData::default()
1503            })
1504        } else {
1505            None
1506        };
1507
1508        let bypassed_val = bypassed;
1509        let series_comp = if bypassed_val {
1510            Some(SeriesCompData {
1511                bypassed: true,
1512                ..SeriesCompData::default()
1513            })
1514        } else {
1515            None
1516        };
1517
1518        let mut branch = Branch {
1519            from_bus,
1520            to_bus,
1521            circuit,
1522            in_service,
1523            branch_type,
1524            r,
1525            x,
1526            b: b_val,
1527            rating_a_mva,
1528            rating_b_mva,
1529            rating_c_mva,
1530            tap,
1531            phase_shift_rad,
1532            g_pi,
1533            g_mag,
1534            b_mag,
1535            opf_control,
1536            transformer_data,
1537            line_data,
1538            zero_seq,
1539            harmonic,
1540            series_comp,
1541            ..Branch::default()
1542        };
1543        if has_angle_min {
1544            branch.angle_diff_min_rad = Some(reader.f64()?);
1545        }
1546        if has_angle_max {
1547            branch.angle_diff_max_rad = Some(reader.f64()?);
1548        }
1549        branches.push(branch);
1550    }
1551    if !reader.is_empty() {
1552        return Err(Error::InvalidDocument(
1553            "branch section payload has trailing bytes".to_string(),
1554        ));
1555    }
1556    Ok(branches)
1557}
1558
1559fn encode_branch_extras(branches: &[Branch]) -> Result<Option<(Vec<u8>, u32)>, Error> {
1560    let extras: Vec<_> = branches
1561        .iter()
1562        .enumerate()
1563        .filter_map(|(index, branch)| {
1564            BranchExtra::from_branch(branch).map(|extra| IndexedBranchExtra {
1565                index: index as u32,
1566                extra,
1567            })
1568        })
1569        .collect();
1570    if extras.is_empty() {
1571        return Ok(None);
1572    }
1573    Ok(Some((encode_cbor(&extras)?, extras.len() as u32)))
1574}
1575
1576fn apply_branch_extras(section: &SectionView<'_>, network: &mut Network) -> Result<(), Error> {
1577    let extras: Vec<IndexedBranchExtra> = decode_cbor(section.payload)?;
1578    for extra in extras {
1579        let branch = network
1580            .branches
1581            .get_mut(extra.index as usize)
1582            .ok_or_else(|| {
1583                Error::InvalidDocument(format!("branch extra index {} out of range", extra.index))
1584            })?;
1585        extra.extra.apply(branch);
1586    }
1587    Ok(())
1588}
1589
1590fn encode_generators(generators: &[Generator], strings: &mut StringTableBuilder) -> Vec<u8> {
1591    let mut payload = Vec::new();
1592    for generator in generators {
1593        let mut flags = 0u16;
1594        if generator.voltage_regulated {
1595            flags |= 1 << 0;
1596        }
1597        if generator.in_service {
1598            flags |= 1 << 1;
1599        }
1600        if generator.inverter.as_ref().is_some_and(|i| i.curtailable) {
1601            flags |= 1 << 2;
1602        }
1603        if generator.inverter.as_ref().is_some_and(|i| i.grid_forming) {
1604            flags |= 1 << 3;
1605        }
1606        if generator.fuel.as_ref().is_some_and(|f| f.on_backup_fuel) {
1607            flags |= 1 << 4;
1608        }
1609        if generator.quick_start {
1610            flags |= 1 << 5;
1611        }
1612        if generator.pfr_eligible {
1613            flags |= 1 << 6;
1614        }
1615        if generator.reg_bus.is_some() {
1616            flags |= 1 << 7;
1617        }
1618        if generator.agc_participation_factor.is_some() {
1619            flags |= 1 << 8;
1620        }
1621        let rc = generator.reactive_capability.as_ref();
1622        if rc.and_then(|r| r.pc1).is_some() {
1623            flags |= 1 << 9;
1624        }
1625        if rc.and_then(|r| r.pc2).is_some() {
1626            flags |= 1 << 10;
1627        }
1628        if rc.and_then(|r| r.qc1min).is_some() {
1629            flags |= 1 << 11;
1630        }
1631        if rc.and_then(|r| r.qc1max).is_some() {
1632            flags |= 1 << 12;
1633        }
1634        if rc.and_then(|r| r.qc2min).is_some() {
1635            flags |= 1 << 13;
1636        }
1637        if rc.and_then(|r| r.qc2max).is_some() {
1638            flags |= 1 << 14;
1639        }
1640        if generator.h_inertia_s.is_some() {
1641            flags |= 1 << 15;
1642        }
1643
1644        write_u32(&mut payload, strings.intern(&generator.id));
1645        write_u32(&mut payload, generator.bus);
1646        write_bool(&mut payload, generator.machine_id.is_some());
1647        if let Some(machine_id) = &generator.machine_id {
1648            write_u32(&mut payload, strings.intern(machine_id));
1649        }
1650        write_u16(&mut payload, flags);
1651        write_u8(&mut payload, encode_gen_type(generator.gen_type));
1652        write_u8(
1653            &mut payload,
1654            encode_commitment_status(
1655                generator
1656                    .commitment
1657                    .as_ref()
1658                    .map(|c| c.status)
1659                    .unwrap_or(surge_network::network::CommitmentStatus::Market),
1660            ),
1661        );
1662        write_f64(&mut payload, generator.p);
1663        write_f64(&mut payload, generator.q);
1664        write_f64(&mut payload, generator.qmax);
1665        write_f64(&mut payload, generator.qmin);
1666        write_f64(&mut payload, generator.voltage_setpoint_pu);
1667        write_f64(&mut payload, generator.machine_base_mva);
1668        write_f64(&mut payload, generator.pmax);
1669        write_f64(&mut payload, generator.pmin);
1670        write_f64(
1671            &mut payload,
1672            generator
1673                .inverter
1674                .as_ref()
1675                .map_or(0.0, |i| i.inverter_loss_a_mw),
1676        );
1677        write_f64(
1678            &mut payload,
1679            generator
1680                .inverter
1681                .as_ref()
1682                .map_or(0.0, |i| i.inverter_loss_b_pu),
1683        );
1684        write_f64(
1685            &mut payload,
1686            generator
1687                .commitment
1688                .as_ref()
1689                .map_or(0.0, |c| c.hours_online),
1690        );
1691        write_f64(
1692            &mut payload,
1693            generator
1694                .commitment
1695                .as_ref()
1696                .map_or(0.0, |c| c.hours_offline),
1697        );
1698        write_cost_curve(&mut payload, &generator.cost);
1699        {
1700            let default_rates = EmissionRates::default();
1701            let rates = generator
1702                .fuel
1703                .as_ref()
1704                .map_or(&default_rates, |f| &f.emission_rates);
1705            write_emission_rates(&mut payload, rates);
1706        }
1707        if let Some(value) = generator.reg_bus {
1708            write_u32(&mut payload, value);
1709        }
1710        if let Some(value) = generator.agc_participation_factor {
1711            write_f64(&mut payload, value);
1712        }
1713        if let Some(value) = rc.and_then(|r| r.pc1) {
1714            write_f64(&mut payload, value);
1715        }
1716        if let Some(value) = rc.and_then(|r| r.pc2) {
1717            write_f64(&mut payload, value);
1718        }
1719        if let Some(value) = rc.and_then(|r| r.qc1min) {
1720            write_f64(&mut payload, value);
1721        }
1722        if let Some(value) = rc.and_then(|r| r.qc1max) {
1723            write_f64(&mut payload, value);
1724        }
1725        if let Some(value) = rc.and_then(|r| r.qc2min) {
1726            write_f64(&mut payload, value);
1727        }
1728        if let Some(value) = rc.and_then(|r| r.qc2max) {
1729            write_f64(&mut payload, value);
1730        }
1731        if let Some(value) = generator.h_inertia_s {
1732            write_f64(&mut payload, value);
1733        }
1734    }
1735    payload
1736}
1737
1738fn decode_generators(
1739    section: SectionView<'_>,
1740    strings: &StringTable,
1741) -> Result<Vec<Generator>, Error> {
1742    let mut reader = Reader::new(section.payload);
1743    let mut generators = Vec::with_capacity(section.count as usize);
1744    for _ in 0..section.count {
1745        let id = strings.resolve(reader.u32()?)?.to_string();
1746        let bus = reader.u32()?;
1747        let has_machine_id = read_bool(&mut reader)?;
1748        let machine_id = if has_machine_id {
1749            Some(strings.resolve(reader.u32()?)?.to_string())
1750        } else {
1751            None
1752        };
1753        let flags = reader.u16()?;
1754        let gen_type = decode_gen_type(reader.u8()?)?;
1755        let commitment_status = decode_commitment_status(reader.u8()?)?;
1756        let pg = reader.f64()?;
1757        let qg = reader.f64()?;
1758        let qmax = reader.f64()?;
1759        let qmin = reader.f64()?;
1760        let voltage_setpoint_pu = reader.f64()?;
1761        let machine_base_mva = reader.f64()?;
1762        let pmax = reader.f64()?;
1763        let pmin = reader.f64()?;
1764        let inverter_loss_a_mw = reader.f64()?;
1765        let inverter_loss_b_pu = reader.f64()?;
1766        let hours_online = reader.f64()?;
1767        let hours_offline = reader.f64()?;
1768        let cost = read_cost_curve(&mut reader)?;
1769        let emission_rates = read_emission_rates(&mut reader)?;
1770        let reg_bus = (flags & (1 << 7) != 0).then(|| reader.u32()).transpose()?;
1771        let agc_participation_factor = (flags & (1 << 8) != 0).then(|| reader.f64()).transpose()?;
1772        let pc1 = (flags & (1 << 9) != 0).then(|| reader.f64()).transpose()?;
1773        let pc2 = (flags & (1 << 10) != 0).then(|| reader.f64()).transpose()?;
1774        let qc1min = (flags & (1 << 11) != 0).then(|| reader.f64()).transpose()?;
1775        let qc1max = (flags & (1 << 12) != 0).then(|| reader.f64()).transpose()?;
1776        let qc2min = (flags & (1 << 13) != 0).then(|| reader.f64()).transpose()?;
1777        let qc2max = (flags & (1 << 14) != 0).then(|| reader.f64()).transpose()?;
1778        let h_inertia_s = (flags & (1 << 15) != 0).then(|| reader.f64()).transpose()?;
1779
1780        // Build optional sub-structs from decoded binary fields.
1781        let has_inverter = inverter_loss_a_mw.abs() > 1e-20
1782            || inverter_loss_b_pu.abs() > 1e-20
1783            || (flags & (1 << 2)) != 0
1784            || (flags & (1 << 3)) != 0;
1785        let inverter = if has_inverter {
1786            Some(surge_network::network::InverterParams {
1787                curtailable: (flags & (1 << 2)) != 0,
1788                grid_forming: (flags & (1 << 3)) != 0,
1789                inverter_loss_a_mw,
1790                inverter_loss_b_pu,
1791                ..Default::default()
1792            })
1793        } else {
1794            None
1795        };
1796        let has_commitment = commitment_status != surge_network::network::CommitmentStatus::Market
1797            || hours_online.abs() > 1e-20
1798            || hours_offline.abs() > 1e-20;
1799        let commitment = if has_commitment {
1800            Some(surge_network::network::CommitmentParams {
1801                status: commitment_status,
1802                hours_online,
1803                hours_offline,
1804                ..Default::default()
1805            })
1806        } else {
1807            None
1808        };
1809        let has_fuel = (flags & (1 << 4)) != 0
1810            || emission_rates.co2.abs() > 1e-20
1811            || emission_rates.nox.abs() > 1e-20
1812            || emission_rates.so2.abs() > 1e-20
1813            || emission_rates.pm25.abs() > 1e-20;
1814        let fuel = if has_fuel {
1815            Some(surge_network::network::FuelParams {
1816                on_backup_fuel: (flags & (1 << 4)) != 0,
1817                emission_rates,
1818                ..Default::default()
1819            })
1820        } else {
1821            None
1822        };
1823        let has_rc = pc1.is_some()
1824            || pc2.is_some()
1825            || qc1min.is_some()
1826            || qc1max.is_some()
1827            || qc2min.is_some()
1828            || qc2max.is_some();
1829        let reactive_capability = if has_rc {
1830            Some(surge_network::network::ReactiveCapability {
1831                pc1,
1832                pc2,
1833                qc1min,
1834                qc1max,
1835                qc2min,
1836                qc2max,
1837                ..Default::default()
1838            })
1839        } else {
1840            None
1841        };
1842        let generator = Generator {
1843            id,
1844            bus,
1845            machine_id,
1846            voltage_regulated: (flags & (1 << 0)) != 0,
1847            in_service: (flags & (1 << 1)) != 0,
1848            quick_start: (flags & (1 << 5)) != 0,
1849            pfr_eligible: (flags & (1 << 6)) != 0,
1850            gen_type,
1851            commitment,
1852            inverter,
1853            fuel,
1854            reactive_capability,
1855            p: pg,
1856            q: qg,
1857            qmax,
1858            qmin,
1859            voltage_setpoint_pu,
1860            machine_base_mva,
1861            pmax,
1862            pmin,
1863            cost,
1864            reg_bus,
1865            agc_participation_factor,
1866            h_inertia_s,
1867            ..Generator::default()
1868        };
1869        generators.push(generator);
1870    }
1871    if !reader.is_empty() {
1872        return Err(Error::InvalidDocument(
1873            "generator section payload has trailing bytes".to_string(),
1874        ));
1875    }
1876    Ok(generators)
1877}
1878
1879fn encode_generator_extras(generators: &[Generator]) -> Result<Option<(Vec<u8>, u32)>, Error> {
1880    let extras: Vec<_> = generators
1881        .iter()
1882        .enumerate()
1883        .filter_map(|(index, generator)| {
1884            GeneratorExtra::from_generator(generator).map(|extra| IndexedGeneratorExtra {
1885                index: index as u32,
1886                extra,
1887            })
1888        })
1889        .collect();
1890    if extras.is_empty() {
1891        return Ok(None);
1892    }
1893    Ok(Some((encode_cbor(&extras)?, extras.len() as u32)))
1894}
1895
1896fn apply_generator_extras(section: &SectionView<'_>, network: &mut Network) -> Result<(), Error> {
1897    let extras: Vec<IndexedGeneratorExtra> = decode_cbor(section.payload)?;
1898    for extra in extras {
1899        let generator = network
1900            .generators
1901            .get_mut(extra.index as usize)
1902            .ok_or_else(|| {
1903                Error::InvalidDocument(format!(
1904                    "generator extra index {} out of range",
1905                    extra.index
1906                ))
1907            })?;
1908        extra.extra.apply(generator);
1909    }
1910    Ok(())
1911}
1912
1913fn encode_load_extras(loads: &[Load]) -> Result<Option<(Vec<u8>, u32)>, Error> {
1914    let extras: Vec<_> = loads
1915        .iter()
1916        .enumerate()
1917        .filter_map(|(index, load)| {
1918            LoadExtra::from_load(load).map(|extra| IndexedLoadExtra {
1919                index: index as u32,
1920                extra,
1921            })
1922        })
1923        .collect();
1924    if extras.is_empty() {
1925        return Ok(None);
1926    }
1927    Ok(Some((encode_cbor(&extras)?, extras.len() as u32)))
1928}
1929
1930fn apply_load_extras(section: &SectionView<'_>, network: &mut Network) -> Result<(), Error> {
1931    let extras: Vec<IndexedLoadExtra> = decode_cbor(section.payload)?;
1932    for extra in extras {
1933        let load = network.loads.get_mut(extra.index as usize).ok_or_else(|| {
1934            Error::InvalidDocument(format!("load extra index {} out of range", extra.index))
1935        })?;
1936        extra.extra.apply(load);
1937    }
1938    Ok(())
1939}
1940
1941fn encode_loads(loads: &[Load], strings: &mut StringTableBuilder) -> Vec<u8> {
1942    let mut payload = Vec::new();
1943    for load in loads {
1944        let mut flags = 0u8;
1945        if load.in_service {
1946            flags |= 1 << 0;
1947        }
1948        if load.conforming {
1949            flags |= 1 << 1;
1950        }
1951        if load.load_class.is_some() {
1952            flags |= 1 << 2;
1953        }
1954        if load.shedding_priority.is_some() {
1955            flags |= 1 << 3;
1956        }
1957        write_u32(&mut payload, load.bus);
1958        write_u32(&mut payload, strings.intern(&load.id));
1959        write_u8(&mut payload, flags);
1960        write_u8(&mut payload, encode_load_connection(load.connection));
1961        write_f64(&mut payload, load.active_power_demand_mw);
1962        write_f64(&mut payload, load.reactive_power_demand_mvar);
1963        write_f64(&mut payload, load.zip_p_impedance_frac);
1964        write_f64(&mut payload, load.zip_p_current_frac);
1965        write_f64(&mut payload, load.zip_p_power_frac);
1966        write_f64(&mut payload, load.zip_q_impedance_frac);
1967        write_f64(&mut payload, load.zip_q_current_frac);
1968        write_f64(&mut payload, load.zip_q_power_frac);
1969        write_f64(&mut payload, load.freq_sensitivity_p_pct_per_hz);
1970        write_f64(&mut payload, load.freq_sensitivity_q_pct_per_hz);
1971        write_f64(&mut payload, load.frac_motor_a);
1972        write_f64(&mut payload, load.frac_motor_b);
1973        write_f64(&mut payload, load.frac_motor_c);
1974        write_f64(&mut payload, load.frac_motor_d);
1975        write_f64(&mut payload, load.frac_electronic);
1976        write_f64(&mut payload, load.frac_static);
1977        if let Some(value) = load.load_class {
1978            write_u8(&mut payload, encode_load_class(value));
1979        }
1980        if let Some(value) = load.shedding_priority {
1981            write_u32(&mut payload, value);
1982        }
1983    }
1984    payload
1985}
1986
1987fn decode_loads(section: SectionView<'_>, strings: &StringTable) -> Result<Vec<Load>, Error> {
1988    let mut reader = Reader::new(section.payload);
1989    let mut loads = Vec::with_capacity(section.count as usize);
1990    for _ in 0..section.count {
1991        let bus = reader.u32()?;
1992        let id = strings.resolve(reader.u32()?)?.to_string();
1993        let flags = reader.u8()?;
1994        let connection = decode_load_connection(reader.u8()?)?;
1995        let has_load_class = (flags & (1 << 2)) != 0;
1996        let has_shedding_priority = (flags & (1 << 3)) != 0;
1997        let load = Load {
1998            bus,
1999            id,
2000            in_service: (flags & (1 << 0)) != 0,
2001            conforming: (flags & (1 << 1)) != 0,
2002            connection,
2003            active_power_demand_mw: reader.f64()?,
2004            reactive_power_demand_mvar: reader.f64()?,
2005            zip_p_impedance_frac: reader.f64()?,
2006            zip_p_current_frac: reader.f64()?,
2007            zip_p_power_frac: reader.f64()?,
2008            zip_q_impedance_frac: reader.f64()?,
2009            zip_q_current_frac: reader.f64()?,
2010            zip_q_power_frac: reader.f64()?,
2011            freq_sensitivity_p_pct_per_hz: reader.f64()?,
2012            freq_sensitivity_q_pct_per_hz: reader.f64()?,
2013            frac_motor_a: reader.f64()?,
2014            frac_motor_b: reader.f64()?,
2015            frac_motor_c: reader.f64()?,
2016            frac_motor_d: reader.f64()?,
2017            frac_electronic: reader.f64()?,
2018            frac_static: reader.f64()?,
2019            load_class: if has_load_class {
2020                Some(decode_load_class(reader.u8()?)?)
2021            } else {
2022                None
2023            },
2024            shedding_priority: if has_shedding_priority {
2025                Some(reader.u32()?)
2026            } else {
2027                None
2028            },
2029            owners: Vec::new(),
2030        };
2031        loads.push(load);
2032    }
2033    if !reader.is_empty() {
2034        return Err(Error::InvalidDocument(
2035            "load section payload has trailing bytes".to_string(),
2036        ));
2037    }
2038    Ok(loads)
2039}
2040
2041fn encode_network_extras(network: &Network) -> Result<Option<Vec<u8>>, Error> {
2042    let extras = NetworkExtras::from_network(network);
2043    if extras.is_empty() {
2044        return Ok(None);
2045    }
2046    Ok(Some(encode_cbor(&extras)?))
2047}
2048
2049fn encode_cbor<T: Serialize>(value: &T) -> Result<Vec<u8>, Error> {
2050    let mut payload = Vec::new();
2051    ciborium::into_writer(value, &mut payload)?;
2052    Ok(payload)
2053}
2054
2055fn decode_cbor<T: DeserializeOwned>(payload: &[u8]) -> Result<T, Error> {
2056    Ok(ciborium::from_reader(payload)?)
2057}
2058
2059fn write_cost_curve(payload: &mut Vec<u8>, cost: &Option<CostCurve>) {
2060    match cost {
2061        None => write_u8(payload, 0),
2062        Some(CostCurve::Polynomial {
2063            startup,
2064            shutdown,
2065            coeffs,
2066        }) => {
2067            write_u8(payload, 1);
2068            write_f64(payload, *startup);
2069            write_f64(payload, *shutdown);
2070            write_u32(payload, coeffs.len() as u32);
2071            for value in coeffs {
2072                write_f64(payload, *value);
2073            }
2074        }
2075        Some(CostCurve::PiecewiseLinear {
2076            startup,
2077            shutdown,
2078            points,
2079        }) => {
2080            write_u8(payload, 2);
2081            write_f64(payload, *startup);
2082            write_f64(payload, *shutdown);
2083            write_u32(payload, points.len() as u32);
2084            for (x, y) in points {
2085                write_f64(payload, *x);
2086                write_f64(payload, *y);
2087            }
2088        }
2089    }
2090}
2091
2092fn read_cost_curve(reader: &mut Reader<'_>) -> Result<Option<CostCurve>, Error> {
2093    Ok(match reader.u8()? {
2094        0 => None,
2095        1 => {
2096            let startup = reader.f64()?;
2097            let shutdown = reader.f64()?;
2098            let len = reader.u32()? as usize;
2099            let mut coeffs = Vec::with_capacity(len);
2100            for _ in 0..len {
2101                coeffs.push(reader.f64()?);
2102            }
2103            Some(CostCurve::Polynomial {
2104                startup,
2105                shutdown,
2106                coeffs,
2107            })
2108        }
2109        2 => {
2110            let startup = reader.f64()?;
2111            let shutdown = reader.f64()?;
2112            let len = reader.u32()? as usize;
2113            let mut points = Vec::with_capacity(len);
2114            for _ in 0..len {
2115                points.push((reader.f64()?, reader.f64()?));
2116            }
2117            Some(CostCurve::PiecewiseLinear {
2118                startup,
2119                shutdown,
2120                points,
2121            })
2122        }
2123        tag => {
2124            return Err(Error::InvalidDocument(format!(
2125                "unsupported generator cost curve tag {tag}"
2126            )));
2127        }
2128    })
2129}
2130
2131fn write_emission_rates(payload: &mut Vec<u8>, emission_rates: &EmissionRates) {
2132    write_f64(payload, emission_rates.co2);
2133    write_f64(payload, emission_rates.nox);
2134    write_f64(payload, emission_rates.so2);
2135    write_f64(payload, emission_rates.pm25);
2136}
2137
2138fn read_emission_rates(reader: &mut Reader<'_>) -> Result<EmissionRates, Error> {
2139    Ok(EmissionRates {
2140        co2: reader.f64()?,
2141        nox: reader.f64()?,
2142        so2: reader.f64()?,
2143        pm25: reader.f64()?,
2144    })
2145}
2146
2147fn write_u8(payload: &mut Vec<u8>, value: u8) {
2148    payload.push(value);
2149}
2150
2151fn write_u16(payload: &mut Vec<u8>, value: u16) {
2152    payload.extend_from_slice(&value.to_le_bytes());
2153}
2154
2155fn write_u32(payload: &mut Vec<u8>, value: u32) {
2156    payload.extend_from_slice(&value.to_le_bytes());
2157}
2158
2159fn write_u64(payload: &mut Vec<u8>, value: u64) {
2160    payload.extend_from_slice(&value.to_le_bytes());
2161}
2162
2163fn write_f64(payload: &mut Vec<u8>, value: f64) {
2164    payload.extend_from_slice(&value.to_le_bytes());
2165}
2166
2167fn write_bool(payload: &mut Vec<u8>, value: bool) {
2168    write_u8(payload, u8::from(value));
2169}
2170
2171fn read_bool(reader: &mut Reader<'_>) -> Result<bool, Error> {
2172    Ok(match reader.u8()? {
2173        0 => false,
2174        1 => true,
2175        value => {
2176            return Err(Error::InvalidDocument(format!(
2177                "invalid encoded boolean value {value}"
2178            )));
2179        }
2180    })
2181}
2182
2183fn write_bytes(payload: &mut Vec<u8>, bytes: &[u8]) {
2184    write_u32(payload, bytes.len() as u32);
2185    payload.extend_from_slice(bytes);
2186}
2187
2188fn read_bytes(reader: &mut Reader<'_>) -> Result<Vec<u8>, Error> {
2189    let len = reader.u32()? as usize;
2190    Ok(reader.take(len)?.to_vec())
2191}
2192
2193fn encode_bus_type(value: BusType) -> u8 {
2194    match value {
2195        BusType::PQ => 1,
2196        BusType::PV => 2,
2197        BusType::Slack => 3,
2198        BusType::Isolated => 4,
2199    }
2200}
2201
2202fn decode_bus_type(value: u8) -> Result<BusType, Error> {
2203    match value {
2204        1 => Ok(BusType::PQ),
2205        2 => Ok(BusType::PV),
2206        3 => Ok(BusType::Slack),
2207        4 => Ok(BusType::Isolated),
2208        _ => Err(Error::InvalidDocument(format!(
2209            "invalid bus type code {value}"
2210        ))),
2211    }
2212}
2213
2214fn encode_transformer_connection(value: TransformerConnection) -> u8 {
2215    match value {
2216        TransformerConnection::WyeGWyeG => 0,
2217        TransformerConnection::WyeGDelta => 1,
2218        TransformerConnection::DeltaWyeG => 2,
2219        TransformerConnection::DeltaDelta => 3,
2220        TransformerConnection::WyeGWye => 4,
2221    }
2222}
2223
2224fn decode_transformer_connection(value: u8) -> Result<TransformerConnection, Error> {
2225    match value {
2226        0 => Ok(TransformerConnection::WyeGWyeG),
2227        1 => Ok(TransformerConnection::WyeGDelta),
2228        2 => Ok(TransformerConnection::DeltaWyeG),
2229        3 => Ok(TransformerConnection::DeltaDelta),
2230        4 => Ok(TransformerConnection::WyeGWye),
2231        _ => Err(Error::InvalidDocument(format!(
2232            "invalid transformer connection code {value}"
2233        ))),
2234    }
2235}
2236
2237fn encode_tap_mode(value: TapMode) -> u8 {
2238    match value {
2239        TapMode::Fixed => 0,
2240        TapMode::Continuous => 1,
2241    }
2242}
2243
2244fn decode_tap_mode(value: u8) -> Result<TapMode, Error> {
2245    match value {
2246        0 => Ok(TapMode::Fixed),
2247        1 => Ok(TapMode::Continuous),
2248        _ => Err(Error::InvalidDocument(format!(
2249            "invalid tap mode code {value}"
2250        ))),
2251    }
2252}
2253
2254fn encode_phase_mode(value: PhaseMode) -> u8 {
2255    match value {
2256        PhaseMode::Fixed => 0,
2257        PhaseMode::Continuous => 1,
2258    }
2259}
2260
2261fn decode_phase_mode(value: u8) -> Result<PhaseMode, Error> {
2262    match value {
2263        0 => Ok(PhaseMode::Fixed),
2264        1 => Ok(PhaseMode::Continuous),
2265        _ => Err(Error::InvalidDocument(format!(
2266            "invalid phase mode code {value}"
2267        ))),
2268    }
2269}
2270
2271fn encode_branch_type(value: BranchType) -> u8 {
2272    match value {
2273        BranchType::Line => 0,
2274        BranchType::Transformer => 1,
2275        BranchType::Transformer3W => 2,
2276        BranchType::SeriesCapacitor => 3,
2277        BranchType::ZeroImpedanceTie => 4,
2278    }
2279}
2280
2281fn decode_branch_type(value: u8) -> Result<BranchType, Error> {
2282    match value {
2283        0 => Ok(BranchType::Line),
2284        1 => Ok(BranchType::Transformer),
2285        2 => Ok(BranchType::Transformer3W),
2286        3 => Ok(BranchType::SeriesCapacitor),
2287        4 => Ok(BranchType::ZeroImpedanceTie),
2288        _ => Err(Error::InvalidDocument(format!(
2289            "invalid branch type code {value}"
2290        ))),
2291    }
2292}
2293
2294fn encode_gen_type(value: GenType) -> u8 {
2295    match value {
2296        GenType::Synchronous => 0,
2297        GenType::Asynchronous => 1,
2298        GenType::InverterBased => 2,
2299        GenType::Hybrid => 3,
2300        GenType::Unknown => 4,
2301    }
2302}
2303
2304fn decode_gen_type(value: u8) -> Result<GenType, Error> {
2305    match value {
2306        0 => Ok(GenType::Synchronous),
2307        1 => Ok(GenType::Asynchronous),
2308        2 => Ok(GenType::InverterBased),
2309        3 => Ok(GenType::Hybrid),
2310        4 => Ok(GenType::Unknown),
2311        _ => Err(Error::InvalidDocument(format!(
2312            "invalid generator type code {value}"
2313        ))),
2314    }
2315}
2316
2317fn encode_commitment_status(value: surge_network::network::CommitmentStatus) -> u8 {
2318    match value {
2319        surge_network::network::CommitmentStatus::Market => 0,
2320        surge_network::network::CommitmentStatus::SelfCommitted => 1,
2321        surge_network::network::CommitmentStatus::MustRun => 2,
2322        surge_network::network::CommitmentStatus::Unavailable => 3,
2323        surge_network::network::CommitmentStatus::EmergencyOnly => 4,
2324    }
2325}
2326
2327fn decode_commitment_status(value: u8) -> Result<surge_network::network::CommitmentStatus, Error> {
2328    match value {
2329        0 => Ok(surge_network::network::CommitmentStatus::Market),
2330        1 => Ok(surge_network::network::CommitmentStatus::SelfCommitted),
2331        2 => Ok(surge_network::network::CommitmentStatus::MustRun),
2332        3 => Ok(surge_network::network::CommitmentStatus::Unavailable),
2333        4 => Ok(surge_network::network::CommitmentStatus::EmergencyOnly),
2334        _ => Err(Error::InvalidDocument(format!(
2335            "invalid commitment status code {value}"
2336        ))),
2337    }
2338}
2339
2340fn encode_load_connection(value: LoadConnection) -> u8 {
2341    match value {
2342        LoadConnection::WyeGrounded => 0,
2343        LoadConnection::WyeUngrounded => 1,
2344        LoadConnection::Delta => 2,
2345    }
2346}
2347
2348fn decode_load_connection(value: u8) -> Result<LoadConnection, Error> {
2349    match value {
2350        0 => Ok(LoadConnection::WyeGrounded),
2351        1 => Ok(LoadConnection::WyeUngrounded),
2352        2 => Ok(LoadConnection::Delta),
2353        _ => Err(Error::InvalidDocument(format!(
2354            "invalid load connection code {value}"
2355        ))),
2356    }
2357}
2358
2359fn encode_load_class(value: LoadClass) -> u8 {
2360    match value {
2361        LoadClass::Residential => 0,
2362        LoadClass::Commercial => 1,
2363        LoadClass::Industrial => 2,
2364        LoadClass::Agricultural => 3,
2365        LoadClass::DataCenter => 4,
2366        LoadClass::EvCharging => 5,
2367        LoadClass::Other => 6,
2368    }
2369}
2370
2371fn decode_load_class(value: u8) -> Result<LoadClass, Error> {
2372    match value {
2373        0 => Ok(LoadClass::Residential),
2374        1 => Ok(LoadClass::Commercial),
2375        2 => Ok(LoadClass::Industrial),
2376        3 => Ok(LoadClass::Agricultural),
2377        4 => Ok(LoadClass::DataCenter),
2378        5 => Ok(LoadClass::EvCharging),
2379        6 => Ok(LoadClass::Other),
2380        _ => Err(Error::InvalidDocument(format!(
2381            "invalid load class code {value}"
2382        ))),
2383    }
2384}
2385
2386#[cfg(test)]
2387mod tests {
2388    use super::*;
2389    use surge_network::network::{
2390        Branch, Bus, GenType, Generator, GeneratorTechnology, OwnershipEntry,
2391    };
2392
2393    fn mini_network() -> Network {
2394        let mut network = Network::new("test_bin");
2395        network.base_mva = 100.0;
2396        network.buses.push(Bus::new(1, BusType::Slack, 138.0));
2397        network.buses.push(Bus::new(2, BusType::PQ, 138.0));
2398        network.buses[0].latitude = Some(30.0);
2399        network.buses[0].reserve_zone = Some("Reserve Zone 1".to_string());
2400        network.buses[0].owners = vec![OwnershipEntry {
2401            owner: 7,
2402            fraction: 1.0,
2403        }];
2404        let mut generator = Generator::new(1, 100.0, 1.06);
2405        generator.cost = Some(CostCurve::Polynomial {
2406            startup: 10.0,
2407            shutdown: 5.0,
2408            coeffs: vec![0.01, 1.5, 3.0],
2409        });
2410        generator.gen_type = GenType::InverterBased;
2411        generator.technology = Some(GeneratorTechnology::SolarPv);
2412        generator.source_technology_code = Some("PV".to_string());
2413        generator
2414            .fuel
2415            .get_or_insert_with(Default::default)
2416            .fuel_type = Some("solar".to_string());
2417        generator.storage = Some(StorageParams::with_energy_capacity_mwh(50.0));
2418        generator.owners = vec![OwnershipEntry {
2419            owner: 11,
2420            fraction: 0.75,
2421        }];
2422        network.generators.push(generator);
2423        let mut branch = Branch::new_line(1, 2, 0.01, 0.1, 0.02);
2424        {
2425            let zs = branch.zero_seq.get_or_insert_with(ZeroSeqData::default);
2426            zs.r0 = 0.03;
2427        }
2428        {
2429            let td = branch
2430                .transformer_data
2431                .get_or_insert_with(TransformerData::default);
2432            td.parent_transformer_id = Some("tx1".to_string());
2433        }
2434        branch.owners = vec![OwnershipEntry {
2435            owner: 13,
2436            fraction: 0.5,
2437        }];
2438        network.branches.push(branch);
2439        let mut load = Load::new(2, 50.0, 20.0);
2440        load.owners = vec![OwnershipEntry {
2441            owner: 17,
2442            fraction: 1.0,
2443        }];
2444        network.loads.push(load);
2445        network.market_data.reserve_zones.push(ReserveZone {
2446            name: "Reserve Zone 1".to_string(),
2447            zonal_requirements: Vec::new(),
2448        });
2449        network
2450    }
2451
2452    #[test]
2453    fn test_roundtrip_preserves_network_json_shape() {
2454        let network = mini_network();
2455        let before = crate::json::encode_network(&network).expect("encode original");
2456        let bytes = dumps(&network).expect("failed to dump binary");
2457        let parsed = loads(&bytes).expect("failed to parse binary");
2458        let after = crate::json::encode_network(&parsed).expect("encode parsed");
2459        assert_eq!(before, after);
2460    }
2461
2462    #[test]
2463    fn test_file_roundtrip() {
2464        let network = mini_network();
2465        let tmp = std::env::temp_dir().join("surge_test_roundtrip.surge.bin");
2466        save(&network, &tmp).expect("failed to save binary");
2467        let parsed = load(&tmp).expect("failed to load binary");
2468        assert_eq!(parsed.name, network.name);
2469        assert_eq!(parsed.n_buses(), network.n_buses());
2470        assert_eq!(parsed.buses[0].owners, network.buses[0].owners);
2471        assert_eq!(parsed.branches[0].owners, network.branches[0].owners);
2472        assert_eq!(parsed.generators[0].owners, network.generators[0].owners);
2473        assert_eq!(parsed.loads[0].owners, network.loads[0].owners);
2474        let _ = std::fs::remove_file(&tmp);
2475    }
2476
2477    #[test]
2478    fn test_corrupt_checksum_is_rejected() {
2479        let network = mini_network();
2480        let mut bytes = dumps(&network).expect("failed to dump binary");
2481        *bytes.last_mut().expect("binary output is non-empty") ^= 0x01;
2482        let result = loads(&bytes);
2483        assert!(result.is_err(), "corrupt payload should be rejected");
2484    }
2485}