Skip to main content

powerio/format/
surge.rs

1//! Read and write Surge native `surge-json` network documents.
2//!
3//! Surge JSON is a versioned wrapper around a richer network body. The reader
4//! maps the electrical core into `BalancedNetwork`, retains the original source for byte
5//! exact same format writes, and reports source sections that stay only in the
6//! retained document.
7
8use std::collections::BTreeMap;
9use std::sync::Arc;
10
11use serde_json::{Map, Value};
12
13use super::{Conversion, Parsed, finish, jnum, warn_extra_branch_rating_sets};
14use crate::network::{
15    BalancedNetwork, Branch, BranchCharging, BranchCurrentRatings, BranchSolution, Bus, BusId,
16    BusType, Extras, GEN_EXTRA_KEYS, GenCaps, GenCost, Generator, Hvdc, Load, LoadVoltageModel,
17    Shunt, SourceFormat, Storage,
18};
19use crate::normalize;
20use crate::{Error, Result};
21
22const FMT: &str = "Surge JSON";
23const FORMAT_VALUE: &str = "surge-json";
24const SCHEMA_VERSION: &str = "0.1.0";
25const EPS: f64 = 1e-12;
26
27#[must_use]
28pub fn write_surge_json(net: &BalancedNetwork) -> Conversion {
29    let mut warnings = Vec::new();
30    let mut network = Map::new();
31
32    network.insert("name".into(), Value::String(net.name.clone()));
33    network.insert("base_mva".into(), jnum(net.base_mva));
34    network.insert("freq_hz".into(), jnum(net.base_frequency));
35
36    network.insert(
37        "buses".into(),
38        Value::Array(net.buses.iter().map(bus_obj).collect()),
39    );
40    network.insert(
41        "loads".into(),
42        Value::Array(net.loads.iter().enumerate().map(load_obj).collect()),
43    );
44    network.insert(
45        "fixed_shunts".into(),
46        Value::Array(net.shunts.iter().enumerate().map(shunt_obj).collect()),
47    );
48    network.insert(
49        "branches".into(),
50        Value::Array(net.branches.iter().enumerate().map(branch_obj).collect()),
51    );
52
53    let mut gen_counts: BTreeMap<BusId, usize> = BTreeMap::new();
54    let mut generators = Vec::new();
55    for generator in &net.generators {
56        generators.push(gen_obj(generator, &mut gen_counts, &mut warnings));
57    }
58    for storage in &net.storage {
59        generators.push(storage_gen_obj(storage, &mut gen_counts));
60    }
61    network.insert("generators".into(), Value::Array(generators));
62
63    if !net.hvdc.is_empty() {
64        let links = net
65            .hvdc
66            .iter()
67            .enumerate()
68            .map(|(i, dc)| hvdc_link_obj(dc, i, &mut warnings))
69            .collect();
70        let mut hvdc = Map::new();
71        hvdc.insert("links".into(), Value::Array(links));
72        network.insert("hvdc".into(), Value::Object(hvdc));
73    }
74
75    network.insert("metadata".into(), Value::Object(Map::new()));
76    network.insert("market_data".into(), Value::Object(Map::new()));
77    network.insert("controls".into(), Value::Object(Map::new()));
78    network.insert("cim".into(), Value::Object(Map::new()));
79
80    let mut meta = Map::new();
81    meta.insert("producer".into(), Value::String("surge".into()));
82    meta.insert("profile".into(), Value::String("network".into()));
83
84    let mut root = Map::new();
85    root.insert("format".into(), Value::String(FORMAT_VALUE.into()));
86    root.insert(
87        "schema_version".into(),
88        Value::String(SCHEMA_VERSION.into()),
89    );
90    root.insert("meta".into(), Value::Object(meta));
91    root.insert("network".into(), Value::Object(network));
92
93    warn_extra_branch_rating_sets(FMT, net, &mut warnings);
94    finish(root, warnings)
95}
96
97fn bus_type(kind: BusType) -> &'static str {
98    match kind {
99        BusType::Pq => "PQ",
100        BusType::Pv => "PV",
101        BusType::Ref => "Slack",
102        BusType::Isolated => "Isolated",
103    }
104}
105
106fn bus_obj(bus: &Bus) -> Value {
107    let mut obj = Map::new();
108    obj.insert("number".into(), Value::from(bus.id.0 as u64));
109    obj.insert(
110        "name".into(),
111        Value::String(bus.name.clone().unwrap_or_default()),
112    );
113    obj.insert("bus_type".into(), Value::String(bus_type(bus.kind).into()));
114    obj.insert("base_kv".into(), jnum(bus.base_kv));
115    obj.insert("voltage_magnitude_pu".into(), jnum(bus.vm));
116    obj.insert("voltage_angle_rad".into(), jnum(bus.va.to_radians()));
117    obj.insert("voltage_min_pu".into(), jnum(bus.vmin));
118    obj.insert("voltage_max_pu".into(), jnum(bus.vmax));
119    obj.insert("shunt_conductance_mw".into(), jnum(0.0));
120    obj.insert("shunt_susceptance_mvar".into(), jnum(0.0));
121    obj.insert("area".into(), Value::from(bus.area as u64));
122    obj.insert("zone".into(), Value::from(bus.zone as u64));
123    obj.insert("island_id".into(), Value::from(0_u64));
124    Value::Object(obj)
125}
126
127fn frac(value: f64, total: f64, default: f64) -> f64 {
128    if total.abs() > EPS {
129        value / total
130    } else {
131        default
132    }
133}
134
135fn load_obj((i, load): (usize, &Load)) -> Value {
136    let mut obj = Map::new();
137    obj.insert("id".into(), Value::String(format!("load_{}", i + 1)));
138    obj.insert("bus".into(), Value::from(load.bus.0 as u64));
139    obj.insert("active_power_demand_mw".into(), jnum(load.p));
140    obj.insert("reactive_power_demand_mvar".into(), jnum(load.q));
141    obj.insert("in_service".into(), Value::Bool(load.in_service));
142    obj.insert("conforming".into(), Value::Bool(true));
143    obj.insert("connection".into(), Value::String("WyeGrounded".into()));
144
145    let (pz, pi, pp, qz, qi, qp) = match &load.voltage_model {
146        Some(LoadVoltageModel::Zip {
147            p_constant_power,
148            q_constant_power,
149            p_constant_current,
150            q_constant_current,
151            p_constant_impedance,
152            q_constant_impedance,
153            ..
154        }) => (
155            frac(*p_constant_impedance, load.p, 0.0),
156            frac(*p_constant_current, load.p, 0.0),
157            frac(*p_constant_power, load.p, 1.0),
158            frac(*q_constant_impedance, load.q, 0.0),
159            frac(*q_constant_current, load.q, 0.0),
160            frac(*q_constant_power, load.q, 1.0),
161        ),
162        _ => (0.0, 0.0, 1.0, 0.0, 0.0, 1.0),
163    };
164    obj.insert("zip_p_impedance_frac".into(), jnum(pz));
165    obj.insert("zip_p_current_frac".into(), jnum(pi));
166    obj.insert("zip_p_power_frac".into(), jnum(pp));
167    obj.insert("zip_q_impedance_frac".into(), jnum(qz));
168    obj.insert("zip_q_current_frac".into(), jnum(qi));
169    obj.insert("zip_q_power_frac".into(), jnum(qp));
170    Value::Object(obj)
171}
172
173fn shunt_obj((i, shunt): (usize, &Shunt)) -> Value {
174    let mut obj = Map::new();
175    obj.insert("id".into(), Value::String(format!("shunt_{}", i + 1)));
176    obj.insert("bus".into(), Value::from(shunt.bus.0 as u64));
177    obj.insert("g_mw".into(), jnum(shunt.g));
178    obj.insert("b_mvar".into(), jnum(shunt.b));
179    obj.insert("in_service".into(), Value::Bool(shunt.in_service));
180    obj.insert(
181        "shunt_type".into(),
182        Value::String(
183            if shunt.b < 0.0 {
184                "Reactor"
185            } else {
186                "Capacitor"
187            }
188            .into(),
189        ),
190    );
191    Value::Object(obj)
192}
193
194fn branch_obj((_i, branch): (usize, &Branch)) -> Value {
195    let charging = branch.terminal_charging();
196    let mut obj = Map::new();
197    obj.insert("from_bus".into(), Value::from(branch.from.0 as u64));
198    obj.insert("to_bus".into(), Value::from(branch.to.0 as u64));
199    obj.insert("circuit".into(), Value::String("1".into()));
200    obj.insert("r".into(), jnum(branch.r));
201    obj.insert("x".into(), jnum(branch.x));
202    obj.insert("b".into(), jnum(branch.total_charging_b()));
203    obj.insert("g_shunt_from".into(), jnum(charging.g_fr));
204    obj.insert("b_shunt_from".into(), jnum(charging.b_fr));
205    obj.insert("g_shunt_to".into(), jnum(charging.g_to));
206    obj.insert("b_shunt_to".into(), jnum(charging.b_to));
207    obj.insert("tap".into(), jnum(branch.effective_tap()));
208    obj.insert("phase_shift_rad".into(), jnum(branch.shift.to_radians()));
209    obj.insert("rating_a_mva".into(), jnum(branch.rate_a));
210    obj.insert("rating_b_mva".into(), jnum(branch.rate_b));
211    obj.insert("rating_c_mva".into(), jnum(branch.rate_c));
212    if let Some(ratings) = branch.current_ratings {
213        obj.insert("current_rating_a".into(), jnum(ratings.c_rating_a));
214        obj.insert("current_rating_b".into(), jnum(ratings.c_rating_b));
215        obj.insert("current_rating_c".into(), jnum(ratings.c_rating_c));
216    }
217    obj.insert("in_service".into(), Value::Bool(branch.in_service));
218    obj.insert(
219        "branch_type".into(),
220        Value::String(
221            if branch.is_transformer() {
222                "Transformer"
223            } else {
224                "Line"
225            }
226            .into(),
227        ),
228    );
229    obj.insert(
230        "angle_diff_min_rad".into(),
231        jnum(branch.angmin.to_radians()),
232    );
233    obj.insert(
234        "angle_diff_max_rad".into(),
235        jnum(branch.angmax.to_radians()),
236    );
237    if let Some(solution) = branch.solution {
238        obj.insert("pf_mw".into(), jnum(solution.pf));
239        obj.insert("qf_mvar".into(), jnum(solution.qf));
240        obj.insert("pt_mw".into(), jnum(solution.pt));
241        obj.insert("qt_mvar".into(), jnum(solution.qt));
242    }
243    obj.insert("g_pi".into(), jnum(0.0));
244    obj.insert("g_mag".into(), jnum(0.0));
245    obj.insert("b_mag".into(), jnum(0.0));
246    Value::Object(obj)
247}
248
249fn next_id(prefix: &str, counts: &mut BTreeMap<BusId, usize>, bus: BusId) -> String {
250    let count = counts.entry(bus).or_insert(0);
251    *count += 1;
252    format!("{prefix}_{}_{}", bus.0, *count)
253}
254
255fn gen_obj(
256    generator: &Generator,
257    counts: &mut BTreeMap<BusId, usize>,
258    warnings: &mut Vec<String>,
259) -> Value {
260    let mut obj = Map::new();
261    obj.insert(
262        "id".into(),
263        Value::String(next_id("gen", counts, generator.bus)),
264    );
265    obj.insert("bus".into(), Value::from(generator.bus.0 as u64));
266    if let Some(regulated_bus) = generator.regulated_bus {
267        obj.insert("reg_bus".into(), Value::from(regulated_bus.0 as u64));
268    }
269    obj.insert("p".into(), jnum(generator.pg));
270    obj.insert("q".into(), jnum(generator.qg));
271    obj.insert("pmax".into(), jnum(generator.pmax));
272    obj.insert("pmin".into(), jnum(generator.pmin));
273    obj.insert("qmax".into(), jnum(generator.qmax));
274    obj.insert("qmin".into(), jnum(generator.qmin));
275    obj.insert("voltage_setpoint_pu".into(), jnum(generator.vg));
276    obj.insert("machine_base_mva".into(), jnum(generator.mbase));
277    obj.insert("in_service".into(), Value::Bool(generator.in_service));
278    obj.insert("gen_type".into(), Value::String("Synchronous".into()));
279    obj.insert("pfr_eligible".into(), Value::Bool(true));
280    obj.insert("quick_start".into(), Value::Bool(false));
281    obj.insert("voltage_regulated".into(), Value::Bool(true));
282    if let Some(cost) = &generator.cost {
283        if let Some(cost) = cost_obj(cost, warnings) {
284            obj.insert("cost".into(), cost);
285        }
286    }
287    if generator.has_caps() {
288        warnings.push(format!(
289            "generator at bus {} has MATPOWER capability or ramp columns not represented in Surge JSON",
290            generator.bus
291        ));
292    }
293    Value::Object(obj)
294}
295
296fn cost_obj(cost: &GenCost, warnings: &mut Vec<String>) -> Option<Value> {
297    match cost.model {
298        2 => {
299            let count = cost.ncost.min(cost.coeffs.len());
300            let coeffs = cost.coeffs[..count].iter().copied().map(jnum).collect();
301            let mut curve = Map::new();
302            curve.insert("coeffs".into(), Value::Array(coeffs));
303            curve.insert("startup".into(), jnum(cost.startup));
304            curve.insert("shutdown".into(), jnum(cost.shutdown));
305
306            let mut wrapper = Map::new();
307            wrapper.insert("Polynomial".into(), Value::Object(curve));
308            Some(Value::Object(wrapper))
309        }
310        1 => {
311            // saturating_mul: `ncost` comes from input, so an oversized count
312            // must clamp to the coefficient length instead of overflowing.
313            let count = cost.ncost.saturating_mul(2).min(cost.coeffs.len());
314            if count % 2 != 0 {
315                warnings.push(
316                    "piecewise generator cost has an odd coefficient count; cost dropped".into(),
317                );
318                return None;
319            }
320            let mut points = Vec::new();
321            for pair in cost.coeffs[..count].chunks(2) {
322                points.push(Value::Array(vec![jnum(pair[0]), jnum(pair[1])]));
323            }
324            let mut curve = Map::new();
325            curve.insert("points".into(), Value::Array(points));
326            curve.insert("startup".into(), jnum(cost.startup));
327            curve.insert("shutdown".into(), jnum(cost.shutdown));
328
329            let mut wrapper = Map::new();
330            wrapper.insert("PiecewiseLinear".into(), Value::Object(curve));
331            Some(Value::Object(wrapper))
332        }
333        _ => {
334            warnings.push(format!(
335                "unsupported generator cost model {} dropped in Surge JSON",
336                cost.model
337            ));
338            None
339        }
340    }
341}
342
343fn storage_gen_obj(storage: &Storage, counts: &mut BTreeMap<BusId, usize>) -> Value {
344    let mut obj = storage
345        .extras
346        .get("surge_generator")
347        .and_then(Value::as_object)
348        .cloned()
349        .unwrap_or_default();
350    if !obj.contains_key("id") {
351        obj.insert(
352            "id".into(),
353            Value::String(next_id("storage", counts, storage.bus)),
354        );
355    }
356    obj.insert("bus".into(), Value::from(storage.bus.0 as u64));
357    obj.insert("p".into(), jnum(storage.ps));
358    obj.insert("q".into(), jnum(storage.qs));
359    obj.insert("pmax".into(), jnum(storage.discharge_rating));
360    obj.insert("pmin".into(), jnum(-storage.charge_rating));
361    obj.insert("qmax".into(), jnum(storage.qmax));
362    obj.insert("qmin".into(), jnum(storage.qmin));
363    obj.insert("voltage_setpoint_pu".into(), jnum(1.0));
364    obj.insert(
365        "machine_base_mva".into(),
366        jnum(storage.thermal_rating.max(1.0)),
367    );
368    obj.insert("in_service".into(), Value::Bool(storage.in_service));
369    obj.entry("gen_type")
370        .or_insert_with(|| Value::String("Synchronous".into()));
371    obj.entry("pfr_eligible").or_insert(Value::Bool(true));
372    obj.entry("quick_start").or_insert(Value::Bool(false));
373    obj.entry("voltage_regulated").or_insert(Value::Bool(false));
374
375    let mut storage_obj = storage
376        .extras
377        .get("surge_storage")
378        .and_then(Value::as_object)
379        .cloned()
380        .unwrap_or_default();
381    storage_obj.insert("energy_capacity_mwh".into(), jnum(storage.energy_rating));
382    storage_obj.insert("soc_initial_mwh".into(), jnum(storage.energy));
383    storage_obj.insert("soc_min_mwh".into(), jnum(0.0));
384    storage_obj.insert("soc_max_mwh".into(), jnum(storage.energy_rating));
385    storage_obj.insert("charge_efficiency".into(), jnum(storage.charge_efficiency));
386    storage_obj.insert(
387        "discharge_efficiency".into(),
388        jnum(storage.discharge_efficiency),
389    );
390    storage_obj
391        .entry("variable_cost_per_mwh")
392        .or_insert_with(|| jnum(0.0));
393    storage_obj
394        .entry("degradation_cost_per_mwh")
395        .or_insert_with(|| jnum(0.0));
396    storage_obj
397        .entry("dispatch_mode")
398        .or_insert_with(|| Value::String("CostMinimization".into()));
399    obj.insert("storage".into(), Value::Object(storage_obj));
400
401    Value::Object(obj)
402}
403
404fn hvdc_link_obj(dc: &Hvdc, i: usize, warnings: &mut Vec<String>) -> Value {
405    if dc.qf != 0.0
406        || dc.qt != 0.0
407        || dc.qminf != 0.0
408        || dc.qmaxf != 0.0
409        || dc.qmint != 0.0
410        || dc.qmaxt != 0.0
411        || dc.loss0 != 0.0
412        || dc.loss1 != 0.0
413        || dc.cost.is_some()
414    {
415        warnings.push(format!(
416            "dcline {} reactive limits, loss model, or cost mapped best effort in Surge JSON",
417            i + 1
418        ));
419    }
420
421    let mut obj = Map::new();
422    obj.insert("technology".into(), Value::String("lcc".into()));
423    obj.insert("name".into(), Value::String(format!("dcl_{}", i + 1)));
424    obj.insert(
425        "mode".into(),
426        Value::String(
427            if dc.in_service {
428                "PowerControl"
429            } else {
430                "Blocked"
431            }
432            .into(),
433        ),
434    );
435    obj.insert("rectifier".into(), lcc_terminal_obj(dc.from, dc.in_service));
436    obj.insert("inverter".into(), lcc_terminal_obj(dc.to, dc.in_service));
437    obj.insert("scheduled_setpoint".into(), jnum(dc.pf));
438    obj.insert("p_dc_min_mw".into(), jnum(dc.pmin));
439    obj.insert("p_dc_max_mw".into(), jnum(dc.pmax));
440    obj.insert("scheduled_voltage_kv".into(), jnum(0.0));
441    obj.insert("resistance_ohm".into(), jnum(0.0));
442    Value::Object(obj)
443}
444
445fn lcc_terminal_obj(bus: BusId, in_service: bool) -> Value {
446    let mut obj = Map::new();
447    obj.insert("bus".into(), Value::from(bus.0 as u64));
448    obj.insert("in_service".into(), Value::Bool(in_service));
449    obj.insert("n_bridges".into(), Value::from(1_u64));
450    obj.insert("alpha_min".into(), jnum(5.0));
451    obj.insert("alpha_max".into(), jnum(90.0));
452    obj.insert("base_voltage_kv".into(), jnum(0.0));
453    obj.insert("commutation_reactance_ohm".into(), jnum(0.0));
454    obj.insert("commutation_resistance_ohm".into(), jnum(0.0));
455    obj.insert("tap".into(), jnum(1.0));
456    obj.insert("tap_min".into(), jnum(0.9));
457    obj.insert("tap_max".into(), jnum(1.1));
458    obj.insert("tap_step".into(), jnum(0.00625));
459    obj.insert("turns_ratio".into(), jnum(1.0));
460    Value::Object(obj)
461}
462
463pub fn parse_surge_json(content: &str) -> Result<Parsed> {
464    let mut warnings = Vec::new();
465    let network = parse_surge_source(Arc::new(content.to_owned()), None, &mut warnings)?;
466    Ok(Parsed::without_document(network, warnings))
467}
468
469pub(crate) fn parse_surge_source(
470    source: Arc<String>,
471    name_hint: Option<&str>,
472    warnings: &mut Vec<String>,
473) -> Result<BalancedNetwork> {
474    let root_value: Value = serde_json::from_str(&source).map_err(|e| Error::FormatRead {
475        format: FMT,
476        message: e.to_string(),
477    })?;
478    let root = object(&root_value, "top level")?;
479    validate_wrapper(root)?;
480    let network = object_field(root, "network")?;
481
482    warnings.extend(source_loss_warnings_from_root(root, network));
483
484    let mut buses = Vec::new();
485    let mut shunts = Vec::new();
486    for value in array_field(network, "buses", true)? {
487        let (bus, bus_shunt) = read_bus(value)?;
488        buses.push(bus);
489        if let Some(shunt) = bus_shunt {
490            shunts.push(shunt);
491        }
492    }
493
494    shunts.extend(
495        array_field(network, "fixed_shunts", false)?
496            .into_iter()
497            .map(read_fixed_shunt)
498            .collect::<Result<Vec<_>>>()?,
499    );
500
501    let mut generators = Vec::new();
502    let mut storage = Vec::new();
503    for value in array_field(network, "generators", false)? {
504        let (generator, storage_record) = read_generator(value)?;
505        if let Some(generator) = generator {
506            generators.push(generator);
507        }
508        if let Some(storage_record) = storage_record {
509            storage.push(storage_record);
510        }
511    }
512
513    let name = string_map(network, "name")
514        .filter(|name| !name.is_empty())
515        .or(name_hint)
516        .unwrap_or("case")
517        .to_string();
518
519    let net = BalancedNetwork {
520        name,
521        base_mva: f_map_or(network, "base_mva", 100.0)?,
522        base_frequency: f_map_or(network, "freq_hz", crate::network::DEFAULT_BASE_FREQUENCY)?,
523        geo: None,
524        buses,
525        loads: array_field(network, "loads", false)?
526            .into_iter()
527            .map(read_load)
528            .collect::<Result<Vec<_>>>()?,
529        shunts,
530        branches: array_field(network, "branches", false)?
531            .into_iter()
532            .map(read_branch)
533            .collect::<Result<Vec<_>>>()?,
534        switches: Vec::new(),
535        generators,
536        storage,
537        hvdc: read_hvdc(network)?,
538        transformers_3w: Vec::new(),
539        areas: Vec::new(),
540        solver: None,
541        source_format: SourceFormat::SurgeJson,
542        source: Some(source),
543    };
544    net.check_references(FMT)?;
545    Ok(net)
546}
547
548fn validate_wrapper(root: &Map<String, Value>) -> Result<()> {
549    let format = required_string_map(root, "format")?;
550    if format != FORMAT_VALUE {
551        return Err(format_error(format!(
552            "unsupported `format` value `{format}`; expected `{FORMAT_VALUE}`"
553        )));
554    }
555    let schema_version = required_string_map(root, "schema_version")?;
556    if schema_version != SCHEMA_VERSION {
557        return Err(format_error(format!(
558            "unsupported `schema_version` value `{schema_version}`; expected `{SCHEMA_VERSION}`"
559        )));
560    }
561    let meta = object_field(root, "meta")?;
562    if let Some(producer) = string_map(meta, "producer")
563        && producer != "surge"
564    {
565        return Err(format_error(format!(
566            "unsupported `meta.producer` value `{producer}`"
567        )));
568    }
569    if let Some(profile) = string_map(meta, "profile")
570        && !matches!(profile, "network" | "dispatch" | "results")
571    {
572        return Err(format_error(format!(
573            "unsupported `meta.profile` value `{profile}`"
574        )));
575    }
576    if !root.contains_key("network") {
577        return Err(format_error("missing object `network`"));
578    }
579    Ok(())
580}
581
582fn read_bus(value: &Value) -> Result<(Bus, Option<Shunt>)> {
583    let obj = object(value, "bus record")?;
584    let id = BusId(required_usize(obj, "number")?);
585    let g = f_map_or(obj, "shunt_conductance_mw", 0.0)?;
586    let b = f_map_or(obj, "shunt_susceptance_mvar", 0.0)?;
587    let shunt = if g != 0.0 || b != 0.0 {
588        Some(Shunt {
589            bus: id,
590            g,
591            b,
592            in_service: true,
593            control: None,
594            uid: None,
595            extras: Extras::new(),
596        })
597    } else {
598        None
599    };
600    let bus = Bus {
601        id,
602        kind: read_bus_type(string_map(obj, "bus_type").unwrap_or("PQ"))?,
603        vm: f_map_or(obj, "voltage_magnitude_pu", 1.0)?,
604        va: f_map_or(obj, "voltage_angle_rad", 0.0)? * normalize::RAD_TO_DEG,
605        base_kv: f_map_or(obj, "base_kv", 0.0)?,
606        vmax: f_map_or(obj, "voltage_max_pu", 1.1)?,
607        vmin: f_map_or(obj, "voltage_min_pu", 0.9)?,
608        evhi: None,
609        evlo: None,
610        area: usize_map_or(obj, "area", 1)?,
611        zone: usize_map_or(obj, "zone", 1)?,
612        name: string_map(obj, "name")
613            .filter(|name| !name.is_empty())
614            .map(str::to_string),
615        uid: None,
616        location: None,
617        extras: Extras::new(),
618    };
619    Ok((bus, shunt))
620}
621
622fn read_bus_type(value: &str) -> Result<BusType> {
623    match value {
624        "PQ" => Ok(BusType::Pq),
625        "PV" => Ok(BusType::Pv),
626        "Slack" | "REF" | "Ref" => Ok(BusType::Ref),
627        "Isolated" => Ok(BusType::Isolated),
628        other => Err(format_error(format!("unknown bus_type `{other}`"))),
629    }
630}
631
632fn read_load(value: &Value) -> Result<Load> {
633    let obj = object(value, "load record")?;
634    let p = f_map_or(obj, "active_power_demand_mw", 0.0)?;
635    let q = f_map_or(obj, "reactive_power_demand_mvar", 0.0)?;
636    Ok(Load {
637        bus: BusId(required_usize(obj, "bus")?),
638        p,
639        q,
640        voltage_model: read_load_voltage_model(obj, p, q)?,
641        in_service: bool_map_or(obj, "in_service", true)?,
642        uid: None,
643        extras: Extras::new(),
644    })
645}
646
647fn read_load_voltage_model(
648    obj: &Map<String, Value>,
649    p: f64,
650    q: f64,
651) -> Result<Option<LoadVoltageModel>> {
652    let pz = f_map_or(obj, "zip_p_impedance_frac", 0.0)?;
653    let pi = f_map_or(obj, "zip_p_current_frac", 0.0)?;
654    let pp = f_map_or(obj, "zip_p_power_frac", 1.0)?;
655    let qz = f_map_or(obj, "zip_q_impedance_frac", 0.0)?;
656    let qi = f_map_or(obj, "zip_q_current_frac", 0.0)?;
657    let qp = f_map_or(obj, "zip_q_power_frac", 1.0)?;
658    let is_default = (pz.abs() <= EPS)
659        && (pi.abs() <= EPS)
660        && ((pp - 1.0).abs() <= EPS)
661        && (qz.abs() <= EPS)
662        && (qi.abs() <= EPS)
663        && ((qp - 1.0).abs() <= EPS);
664    if is_default {
665        Ok(None)
666    } else {
667        Ok(Some(LoadVoltageModel::Zip {
668            p_constant_power: p * pp,
669            q_constant_power: q * qp,
670            p_constant_current: p * pi,
671            q_constant_current: q * qi,
672            p_constant_impedance: p * pz,
673            q_constant_impedance: q * qz,
674            v_nom: None,
675            load_type: None,
676            scaling: None,
677        }))
678    }
679}
680
681fn read_fixed_shunt(value: &Value) -> Result<Shunt> {
682    let obj = object(value, "fixed_shunt record")?;
683    Ok(Shunt {
684        bus: BusId(required_usize(obj, "bus")?),
685        g: f_map_alias_or(obj, &["g_mw", "conductance_mw"], 0.0)?,
686        b: f_map_alias_or(obj, &["b_mvar", "susceptance_mvar"], 0.0)?,
687        in_service: bool_map_or(obj, "in_service", true)?,
688        control: None,
689        uid: None,
690        extras: Extras::new(),
691    })
692}
693
694fn read_branch(value: &Value) -> Result<Branch> {
695    let obj = object(value, "branch record")?;
696    let branch_type = string_map(obj, "branch_type").unwrap_or("Line");
697    let tap_value = f_map_or(obj, "tap", 1.0)?;
698    let shift = f_map_or(obj, "phase_shift_rad", 0.0)? * normalize::RAD_TO_DEG;
699    let tap = if branch_type == "Line" && (tap_value - 1.0).abs() < EPS {
700        0.0
701    } else {
702        tap_value
703    };
704    let b = f_map_or(obj, "b", 0.0)?;
705    Ok(Branch {
706        from: BusId(required_usize(obj, "from_bus")?),
707        to: BusId(required_usize(obj, "to_bus")?),
708        r: f_map_or(obj, "r", 0.0)?,
709        x: f_map_or(obj, "x", 0.0)?,
710        b,
711        charging: read_branch_charging(obj, b)?,
712        rate_a: f_map_or(obj, "rating_a_mva", 0.0)?,
713        rate_b: f_map_or(obj, "rating_b_mva", 0.0)?,
714        rate_c: f_map_or(obj, "rating_c_mva", 0.0)?,
715        rating_sets: Vec::new(),
716        current_ratings: read_current_ratings(obj)?,
717        tap,
718        shift,
719        in_service: bool_map_or(obj, "in_service", true)?,
720        angmin: f_map_or(obj, "angle_diff_min_rad", -std::f64::consts::TAU)?
721            * normalize::RAD_TO_DEG,
722        angmax: f_map_or(obj, "angle_diff_max_rad", std::f64::consts::TAU)? * normalize::RAD_TO_DEG,
723        control: None,
724        solution: read_branch_solution(obj)?,
725        uid: None,
726        route: None,
727        extras: Extras::new(),
728    })
729}
730
731fn read_branch_charging(obj: &Map<String, Value>, b: f64) -> Result<Option<BranchCharging>> {
732    let has_terminal = [
733        "g_shunt_from",
734        "b_shunt_from",
735        "g_shunt_to",
736        "b_shunt_to",
737        "g_fr",
738        "b_fr",
739        "g_to",
740        "b_to",
741    ]
742    .iter()
743    .any(|key| obj.contains_key(*key));
744    if !has_terminal {
745        return Ok(None);
746    }
747    Ok(Some(BranchCharging {
748        g_fr: f_map_alias_or(obj, &["g_shunt_from", "g_fr"], 0.0)?,
749        b_fr: f_map_alias_or(obj, &["b_shunt_from", "b_fr"], b / 2.0)?,
750        g_to: f_map_alias_or(obj, &["g_shunt_to", "g_to"], 0.0)?,
751        b_to: f_map_alias_or(obj, &["b_shunt_to", "b_to"], b / 2.0)?,
752    }))
753}
754
755fn read_current_ratings(obj: &Map<String, Value>) -> Result<Option<BranchCurrentRatings>> {
756    let has_rating = [
757        "current_rating_a",
758        "current_rating_b",
759        "current_rating_c",
760        "c_rating_a",
761        "c_rating_b",
762        "c_rating_c",
763    ]
764    .iter()
765    .any(|key| obj.contains_key(*key));
766    if !has_rating {
767        return Ok(None);
768    }
769    Ok(Some(BranchCurrentRatings {
770        c_rating_a: f_map_alias_or(obj, &["current_rating_a", "c_rating_a"], 0.0)?,
771        c_rating_b: f_map_alias_or(obj, &["current_rating_b", "c_rating_b"], 0.0)?,
772        c_rating_c: f_map_alias_or(obj, &["current_rating_c", "c_rating_c"], 0.0)?,
773    }))
774}
775
776fn read_branch_solution(obj: &Map<String, Value>) -> Result<Option<BranchSolution>> {
777    let has_solution = [
778        "pf_mw", "qf_mvar", "pt_mw", "qt_mvar", "pf", "qf", "pt", "qt",
779    ]
780    .iter()
781    .any(|key| obj.contains_key(*key));
782    if !has_solution {
783        return Ok(None);
784    }
785    Ok(Some(BranchSolution {
786        pf: f_map_alias_or(obj, &["pf_mw", "pf"], 0.0)?,
787        qf: f_map_alias_or(obj, &["qf_mvar", "qf"], 0.0)?,
788        pt: f_map_alias_or(obj, &["pt_mw", "pt"], 0.0)?,
789        qt: f_map_alias_or(obj, &["qt_mvar", "qt"], 0.0)?,
790    }))
791}
792
793fn read_generator(value: &Value) -> Result<(Option<Generator>, Option<Storage>)> {
794    let obj = object(value, "generator record")?;
795    let mut caps: GenCaps = [None; GEN_EXTRA_KEYS.len()];
796    if let Some(apf) = obj.get("agc_participation_factor").and_then(Value::as_f64)
797        && let Some(slot) = GEN_EXTRA_KEYS.iter().position(|key| *key == "apf")
798    {
799        caps[slot] = Some(apf);
800    }
801
802    let bus = BusId(required_usize(obj, "bus")?);
803    let pg = f_map_alias_or(obj, &["p", "pg"], 0.0)?;
804    let qg = f_map_alias_or(obj, &["q", "qg"], 0.0)?;
805    let pmax = f_map_or(obj, "pmax", 0.0)?;
806    let pmin = f_map_or(obj, "pmin", 0.0)?;
807    let qmax = f_map_or(obj, "qmax", 0.0)?;
808    let qmin = f_map_or(obj, "qmin", 0.0)?;
809    let in_service = bool_map_or(obj, "in_service", true)?;
810
811    let generator = Generator {
812        bus,
813        pg,
814        qg,
815        pmax,
816        pmin,
817        qmax,
818        qmin,
819        vg: f_map_or(obj, "voltage_setpoint_pu", 1.0)?,
820        mbase: f_map_or(obj, "machine_base_mva", 0.0)?,
821        in_service,
822        cost: match obj.get("cost") {
823            Some(Value::Null) | None => None,
824            Some(value) => Some(read_cost(value)?),
825        },
826        caps,
827        regulated_bus: optional_usize(obj, "reg_bus")?.map(BusId),
828        uid: None,
829    };
830
831    let storage = match obj.get("storage") {
832        Some(Value::Null) | None => None,
833        Some(value) => {
834            let mut storage = read_storage(value, bus, pg, qg, pmax, pmin, qmax, qmin, in_service)?;
835            retain_storage_generator_metadata(&mut storage, obj);
836            Some(storage)
837        }
838    };
839
840    if storage.is_some() {
841        Ok((None, storage))
842    } else {
843        Ok((Some(generator), None))
844    }
845}
846
847fn retain_storage_generator_metadata(storage: &mut Storage, generator: &Map<String, Value>) {
848    let mut metadata = generator.clone();
849    metadata.remove("storage");
850    if !metadata.is_empty() {
851        storage
852            .extras
853            .insert("surge_generator".to_owned(), Value::Object(metadata));
854    }
855}
856
857fn read_cost(value: &Value) -> Result<GenCost> {
858    let obj = object(value, "generator cost")?;
859    if let Some(poly) = obj.get("Polynomial") {
860        let poly = object(poly, "Polynomial cost")?;
861        let coeffs = number_array(poly, "coeffs")?;
862        return Ok(GenCost {
863            model: 2,
864            startup: f_map_or(poly, "startup", 0.0)?,
865            shutdown: f_map_or(poly, "shutdown", 0.0)?,
866            ncost: coeffs.len(),
867            coeffs,
868        });
869    }
870    if let Some(piecewise) = obj.get("PiecewiseLinear").or_else(|| obj.get("Piecewise")) {
871        let piecewise = object(piecewise, "PiecewiseLinear cost")?;
872        let points = array_field(piecewise, "points", true)?;
873        let ncost = points.len();
874        let mut coeffs = Vec::with_capacity(points.len() * 2);
875        for point in &points {
876            let pair = point
877                .as_array()
878                .ok_or_else(|| format_error("piecewise cost point must be a two element array"))?;
879            if pair.len() != 2 {
880                return Err(format_error("piecewise cost point must have two elements"));
881            }
882            coeffs.push(value_to_f64(&pair[0], "piecewise cost MW")?);
883            coeffs.push(value_to_f64(&pair[1], "piecewise cost value")?);
884        }
885        return Ok(GenCost {
886            model: 1,
887            startup: f_map_or(piecewise, "startup", 0.0)?,
888            shutdown: f_map_or(piecewise, "shutdown", 0.0)?,
889            ncost,
890            coeffs,
891        });
892    }
893    Err(format_error("unsupported generator cost curve"))
894}
895
896#[allow(clippy::too_many_arguments)]
897fn read_storage(
898    storage: &Value,
899    bus: BusId,
900    pg: f64,
901    qg: f64,
902    pmax: f64,
903    pmin: f64,
904    qmax: f64,
905    qmin: f64,
906    in_service: bool,
907) -> Result<Storage> {
908    let obj = object(storage, "storage params")?;
909    let efficiency = f_map_or(obj, "efficiency", 1.0)?;
910    let split_efficiency = if efficiency >= 0.0 {
911        efficiency.sqrt()
912    } else {
913        1.0
914    };
915    let energy_rating = f_map_alias_or(obj, &["energy_capacity_mwh", "soc_max_mwh"], 0.0)?;
916    let mut out = Storage {
917        bus,
918        ps: pg,
919        qs: qg,
920        energy: f_map_or(obj, "soc_initial_mwh", 0.0)?,
921        energy_rating,
922        charge_rating: if pmin < 0.0 { -pmin } else { 0.0 },
923        discharge_rating: pmax.max(0.0),
924        charge_efficiency: f_map_or(obj, "charge_efficiency", split_efficiency)?,
925        discharge_efficiency: f_map_or(obj, "discharge_efficiency", split_efficiency)?,
926        thermal_rating: pmax.abs().max(pmin.abs()),
927        current_rating: f_map_opt(obj, "current_rating")?,
928        qmin,
929        qmax,
930        r: 0.0,
931        x: 0.0,
932        p_loss: 0.0,
933        q_loss: 0.0,
934        in_service,
935        uid: None,
936        extras: Extras::new(),
937    };
938    out.extras
939        .insert("surge_storage".to_owned(), Value::Object(obj.clone()));
940    Ok(out)
941}
942
943fn read_hvdc(network: &Map<String, Value>) -> Result<Vec<Hvdc>> {
944    let Some(hvdc) = network.get("hvdc") else {
945        return Ok(Vec::new());
946    };
947    if hvdc.is_null() {
948        return Ok(Vec::new());
949    }
950    let hvdc = object(hvdc, "hvdc")?;
951    let mut out = Vec::new();
952    for link in array_field(hvdc, "links", false)? {
953        out.push(read_hvdc_link(link)?);
954    }
955    Ok(out)
956}
957
958fn read_hvdc_link(value: &Value) -> Result<Hvdc> {
959    let obj = object(value, "hvdc link")?;
960    let tech = string_map(obj, "technology").unwrap_or("lcc");
961    let (from_terminal, to_terminal) = match tech {
962        "lcc" | "Lcc" | "LCC" => (
963            object_field(obj, "rectifier")?,
964            object_field(obj, "inverter")?,
965        ),
966        "vsc" | "Vsc" | "VSC" => (
967            object_field(obj, "converter1")?,
968            object_field(obj, "converter2")?,
969        ),
970        other => {
971            return Err(format_error(format!(
972                "unsupported hvdc technology `{other}`"
973            )));
974        }
975    };
976    let from = BusId(required_usize(from_terminal, "bus")?);
977    let to = BusId(required_usize(to_terminal, "bus")?);
978    let setpoint = f_map_alias_or(
979        obj,
980        &["scheduled_setpoint", "scheduled_setpoint_mw"],
981        f_map_or(from_terminal, "dc_setpoint", 0.0)?,
982    )?;
983    let pmin = f_map_or(obj, "p_dc_min_mw", setpoint.min(0.0))?;
984    let pmax = f_map_or(obj, "p_dc_max_mw", setpoint.max(0.0))?;
985    let in_service = string_map(obj, "mode").unwrap_or("PowerControl") != "Blocked"
986        && bool_map_or(from_terminal, "in_service", true)?
987        && bool_map_or(to_terminal, "in_service", true)?;
988
989    Ok(Hvdc {
990        from,
991        to,
992        in_service,
993        pf: setpoint,
994        pt: -setpoint,
995        qf: 0.0,
996        qt: 0.0,
997        vf: f_map_or(from_terminal, "ac_setpoint", 1.0)?,
998        vt: f_map_or(to_terminal, "ac_setpoint", 1.0)?,
999        pmin,
1000        pmax,
1001        qminf: f_map_or(from_terminal, "q_min_mvar", 0.0)?,
1002        qmaxf: f_map_or(from_terminal, "q_max_mvar", 0.0)?,
1003        qmint: f_map_or(to_terminal, "q_min_mvar", 0.0)?,
1004        qmaxt: f_map_or(to_terminal, "q_max_mvar", 0.0)?,
1005        loss0: f_map_or(from_terminal, "loss_constant_mw", 0.0)?
1006            + f_map_or(to_terminal, "loss_constant_mw", 0.0)?,
1007        loss1: f_map_or(from_terminal, "loss_linear", 0.0)?
1008            + f_map_or(to_terminal, "loss_linear", 0.0)?,
1009        cost: None,
1010        uid: None,
1011        extras: Extras::new(),
1012    })
1013}
1014
1015fn source_loss_warnings_from_root(
1016    root: &Map<String, Value>,
1017    network: &Map<String, Value>,
1018) -> Vec<String> {
1019    let mut warnings = Vec::new();
1020
1021    let profile = root
1022        .get("meta")
1023        .and_then(Value::as_object)
1024        .and_then(|meta| string_map(meta, "profile"));
1025    if matches!(profile, Some("dispatch" | "results")) || has_nonempty(root, "dispatch") {
1026        warnings.push("Surge dispatch profile data retained only in source text".into());
1027    }
1028    if matches!(profile, Some("results")) || has_nonempty(root, "solution") {
1029        warnings.push("Surge solution profile data retained only in source text".into());
1030    }
1031
1032    let top = [
1033        "facts_devices",
1034        "topology",
1035        "controls",
1036        "area_schedules",
1037        "interfaces",
1038        "flowgates",
1039        "market_data",
1040        "pumped_hydro_units",
1041        "combined_cycle_plants",
1042        "dispatchable_loads",
1043        "induction_machines",
1044        "power_injections",
1045        "breaker_ratings",
1046        "conditional_limits",
1047        "nomograms",
1048        "cim",
1049        "metadata",
1050    ];
1051    let retained_top: Vec<&str> = top
1052        .into_iter()
1053        .filter(|key| has_nonempty(network, key))
1054        .collect();
1055    if !retained_top.is_empty() {
1056        warnings.push(format!(
1057            "Surge network sections retained only in source text: {}",
1058            retained_top.join(", ")
1059        ));
1060    }
1061
1062    warn_count(
1063        &mut warnings,
1064        network,
1065        "loads",
1066        "load composition, frequency, classification, or ownership fields retained only in source text",
1067        load_has_source_only_fields,
1068    );
1069    warn_count(
1070        &mut warnings,
1071        network,
1072        "branches",
1073        "branch control, phase shifter bounds, sequence, thermal, cost, or circuit metadata retained only in source text",
1074        branch_has_source_only_fields,
1075    );
1076    warn_count(
1077        &mut warnings,
1078        network,
1079        "generators",
1080        "generator commitment, ramping, fuel, market, reserve, emission, classification, or richer storage fields retained only in source text",
1081        generator_has_source_only_fields,
1082    );
1083    if has_nonempty(network, "hvdc") {
1084        warnings.push(
1085            "Surge HVDC converter, reactive, loss, and control details mapped best effort".into(),
1086        );
1087    }
1088
1089    warnings
1090}
1091
1092fn warn_count(
1093    warnings: &mut Vec<String>,
1094    network: &Map<String, Value>,
1095    section: &str,
1096    message: &str,
1097    predicate: fn(&Map<String, Value>) -> bool,
1098) {
1099    let count = network
1100        .get(section)
1101        .and_then(Value::as_array)
1102        .map_or(0, |items| {
1103            items
1104                .iter()
1105                .filter_map(Value::as_object)
1106                .filter(|item| predicate(item))
1107                .count()
1108        });
1109    if count > 0 {
1110        warnings.push(format!("{count} Surge {message}"));
1111    }
1112}
1113
1114fn load_has_source_only_fields(load: &Map<String, Value>) -> bool {
1115    num_not_default(load, "freq_sensitivity_p_pct_per_hz", 0.0)
1116        || num_not_default(load, "freq_sensitivity_q_pct_per_hz", 0.0)
1117        || num_not_default(load, "frac_static", 1.0)
1118        || num_not_default(load, "frac_motor_a", 0.0)
1119        || num_not_default(load, "frac_motor_b", 0.0)
1120        || num_not_default(load, "frac_motor_c", 0.0)
1121        || num_not_default(load, "frac_motor_d", 0.0)
1122        || num_not_default(load, "frac_electronic", 0.0)
1123        || bool_not_default(load, "conforming", true)
1124        || string_not_default(load, "connection", "WyeGrounded")
1125        || has_nonempty(load, "owners")
1126        || has_nonempty(load, "load_class")
1127        || has_nonempty(load, "classification")
1128}
1129
1130fn branch_has_source_only_fields(branch: &Map<String, Value>) -> bool {
1131    [
1132        "g_pi",
1133        "g_mag",
1134        "b_mag",
1135        "bi0",
1136        "bj0",
1137        "gi0",
1138        "gj0",
1139        "r_temp_coeff",
1140        "skin_effect_alpha",
1141        "cost_startup",
1142        "cost_shutdown",
1143        "tap_step",
1144        "phase_step_rad",
1145    ]
1146    .into_iter()
1147    .any(|key| num_not_default(branch, key, 0.0))
1148        || has_nonempty(branch, "phase_min_rad")
1149        || has_nonempty(branch, "phase_max_rad")
1150        || num_not_default(branch, "tap_min", 1.0)
1151        || num_not_default(branch, "tap_max", 1.0)
1152        || bool_not_default(branch, "bypassed", false)
1153        || bool_not_default(branch, "delta_connected", false)
1154        || string_not_default(branch, "phase_mode", "fixed")
1155        || string_not_default(branch, "tap_mode", "fixed")
1156        || string_not_default(branch, "circuit", "1")
1157        || has_nonempty(branch, "opf_control")
1158        || has_nonempty(branch, "owners")
1159        || has_nonempty(branch, "zero_sequence")
1160}
1161
1162fn generator_has_source_only_fields(generator: &Map<String, Value>) -> bool {
1163    [
1164        "commitment",
1165        "ramping",
1166        "market",
1167        "reserve_offers",
1168        "qualifications",
1169        "emission_rates",
1170        "fuel_type",
1171        "machine_id",
1172        "commitment_status",
1173        "ramp_down_curve",
1174        "ramp_up_curve",
1175        "min_down_time_hr",
1176        "min_up_time_hr",
1177        "hours_offline",
1178        "hours_online",
1179    ]
1180    .into_iter()
1181    .any(|key| has_nonempty(generator, key))
1182        || bool_not_default(generator, "quick_start", false)
1183        || bool_not_default(generator, "grid_forming", false)
1184        || bool_not_default(generator, "curtailable", false)
1185        || bool_not_default(generator, "voltage_regulated", true)
1186        || generator
1187            .get("gen_type")
1188            .and_then(Value::as_str)
1189            .is_some_and(|kind| kind != "Synchronous")
1190        || generator
1191            .get("storage")
1192            .and_then(Value::as_object)
1193            .is_some_and(storage_has_source_only_fields)
1194}
1195
1196fn storage_has_source_only_fields(storage: &Map<String, Value>) -> bool {
1197    num_not_default(storage, "variable_cost_per_mwh", 0.0)
1198        || num_not_default(storage, "degradation_cost_per_mwh", 0.0)
1199        || num_not_default(storage, "self_schedule_mw", 0.0)
1200        || has_nonempty(storage, "chemistry")
1201        || string_not_default(storage, "dispatch_mode", "CostMinimization")
1202}
1203
1204fn format_error(message: impl Into<String>) -> Error {
1205    Error::FormatRead {
1206        format: FMT,
1207        message: message.into(),
1208    }
1209}
1210
1211fn object<'a>(value: &'a Value, context: &str) -> Result<&'a Map<String, Value>> {
1212    value
1213        .as_object()
1214        .ok_or_else(|| format_error(format!("{context} is not a JSON object")))
1215}
1216
1217fn object_field<'a>(obj: &'a Map<String, Value>, key: &str) -> Result<&'a Map<String, Value>> {
1218    let value = obj
1219        .get(key)
1220        .ok_or_else(|| format_error(format!("missing object `{key}`")))?;
1221    object(value, key)
1222}
1223
1224fn array_field<'a>(
1225    obj: &'a Map<String, Value>,
1226    key: &str,
1227    required: bool,
1228) -> Result<Vec<&'a Value>> {
1229    match obj.get(key) {
1230        Some(Value::Array(items)) => Ok(items.iter().collect()),
1231        Some(Value::Null) | None if !required => Ok(Vec::new()),
1232        None => Err(format_error(format!("missing array `{key}`"))),
1233        Some(_) => Err(format_error(format!("`{key}` must be an array"))),
1234    }
1235}
1236
1237fn required_string_map<'a>(obj: &'a Map<String, Value>, key: &str) -> Result<&'a str> {
1238    string_map(obj, key).ok_or_else(|| format_error(format!("missing string `{key}`")))
1239}
1240
1241fn string_map<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
1242    obj.get(key).and_then(Value::as_str)
1243}
1244
1245fn required_usize(obj: &Map<String, Value>, key: &str) -> Result<usize> {
1246    let value = obj
1247        .get(key)
1248        .ok_or_else(|| format_error(format!("missing integer `{key}`")))?;
1249    value_to_usize(value, key)
1250}
1251
1252fn optional_usize(obj: &Map<String, Value>, key: &str) -> Result<Option<usize>> {
1253    match obj.get(key) {
1254        Some(Value::Null) | None => Ok(None),
1255        Some(value) => value_to_usize(value, key).map(Some),
1256    }
1257}
1258
1259fn usize_map_or(obj: &Map<String, Value>, key: &str, default: usize) -> Result<usize> {
1260    match obj.get(key) {
1261        Some(Value::Null) | None => Ok(default),
1262        Some(value) => value_to_usize(value, key),
1263    }
1264}
1265
1266fn f_map_or(obj: &Map<String, Value>, key: &str, default: f64) -> Result<f64> {
1267    match obj.get(key) {
1268        Some(Value::Null) | None => Ok(default),
1269        Some(value) => value_to_f64(value, key),
1270    }
1271}
1272
1273fn f_map_opt(obj: &Map<String, Value>, key: &str) -> Result<Option<f64>> {
1274    match obj.get(key) {
1275        Some(Value::Null) | None => Ok(None),
1276        Some(value) => value_to_f64(value, key).map(Some),
1277    }
1278}
1279
1280fn f_map_alias_or(obj: &Map<String, Value>, keys: &[&str], default: f64) -> Result<f64> {
1281    for key in keys {
1282        if let Some(value) = obj.get(*key) {
1283            return if value.is_null() {
1284                Ok(default)
1285            } else {
1286                value_to_f64(value, key)
1287            };
1288        }
1289    }
1290    Ok(default)
1291}
1292
1293fn bool_map_or(obj: &Map<String, Value>, key: &str, default: bool) -> Result<bool> {
1294    match obj.get(key) {
1295        Some(Value::Null) | None => Ok(default),
1296        Some(Value::Bool(value)) => Ok(*value),
1297        Some(Value::Number(value)) => value
1298            .as_f64()
1299            .map(|value| value != 0.0)
1300            .ok_or_else(|| format_error(format!("`{key}` is not a finite bool-like number"))),
1301        Some(Value::String(value)) => match value.as_str() {
1302            "true" | "True" | "1" => Ok(true),
1303            "false" | "False" | "0" => Ok(false),
1304            _ => Err(format_error(format!("`{key}` is not a bool"))),
1305        },
1306        Some(_) => Err(format_error(format!("`{key}` is not a bool"))),
1307    }
1308}
1309
1310fn number_array(obj: &Map<String, Value>, key: &str) -> Result<Vec<f64>> {
1311    let values = array_field(obj, key, true)?;
1312    values
1313        .iter()
1314        .enumerate()
1315        .map(|(i, value)| value_to_f64(value, &format!("{key}[{i}]")))
1316        .collect()
1317}
1318
1319fn value_to_f64(value: &Value, key: &str) -> Result<f64> {
1320    match value {
1321        Value::Number(number) => number
1322            .as_f64()
1323            .filter(|value| value.is_finite())
1324            .ok_or_else(|| format_error(format!("`{key}` is not a finite f64"))),
1325        Value::String(value) => {
1326            let parsed = value
1327                .parse::<f64>()
1328                .map_err(|_| format_error(format!("`{key}` string is not a f64")))?;
1329            if parsed.is_finite() {
1330                Ok(parsed)
1331            } else {
1332                Err(format_error(format!("`{key}` string is not a finite f64")))
1333            }
1334        }
1335        Value::Object(obj) if obj.contains_key("$surge_float") => Err(format_error(format!(
1336            "`{key}` uses Surge tagged non-finite float values, which powerio does not support"
1337        ))),
1338        _ => Err(format_error(format!("`{key}` is not a number"))),
1339    }
1340}
1341
1342fn value_to_usize(value: &Value, key: &str) -> Result<usize> {
1343    match value {
1344        Value::Number(number) => {
1345            if let Some(value) = number.as_u64() {
1346                usize::try_from(value)
1347                    .map_err(|_| format_error(format!("`{key}` integer is too large")))
1348            } else if let Some(value) = number.as_i64() {
1349                if value >= 0 {
1350                    usize::try_from(value as u64)
1351                        .map_err(|_| format_error(format!("`{key}` integer is too large")))
1352                } else {
1353                    Err(format_error(format!("`{key}` must be nonnegative")))
1354                }
1355            } else if let Some(value) = number.as_f64() {
1356                // Mirror the integer branches' "too large" rejection: a float
1357                // beyond usize would otherwise saturate to usize::MAX and read
1358                // as a confusing unknown-bus reference later.
1359                if value >= 0.0 && value.fract() == 0.0 && value < usize::MAX as f64 {
1360                    Ok(value as usize)
1361                } else if value >= usize::MAX as f64 {
1362                    Err(format_error(format!("`{key}` integer is too large")))
1363                } else {
1364                    Err(format_error(format!("`{key}` must be an integer")))
1365                }
1366            } else {
1367                Err(format_error(format!("`{key}` is not an integer")))
1368            }
1369        }
1370        Value::String(value) => value
1371            .parse::<usize>()
1372            .map_err(|_| format_error(format!("`{key}` string is not an integer"))),
1373        _ => Err(format_error(format!("`{key}` is not an integer"))),
1374    }
1375}
1376
1377fn has_nonempty(obj: &Map<String, Value>, key: &str) -> bool {
1378    obj.get(key).is_some_and(value_nonempty)
1379}
1380
1381fn value_nonempty(value: &Value) -> bool {
1382    match value {
1383        Value::Null => false,
1384        Value::Bool(value) => *value,
1385        Value::Number(number) => number.as_f64().is_some_and(|value| value != 0.0),
1386        Value::String(value) => !value.is_empty(),
1387        Value::Array(values) => !values.is_empty(),
1388        Value::Object(values) => !values.is_empty(),
1389    }
1390}
1391
1392fn num_not_default(obj: &Map<String, Value>, key: &str, default: f64) -> bool {
1393    obj.get(key)
1394        .and_then(|value| value_to_f64(value, key).ok())
1395        .is_some_and(|value| (value - default).abs() > EPS)
1396}
1397
1398fn bool_not_default(obj: &Map<String, Value>, key: &str, default: bool) -> bool {
1399    obj.get(key)
1400        .and_then(|value| match value {
1401            Value::Bool(value) => Some(*value),
1402            Value::Number(number) => number.as_f64().map(|value| value != 0.0),
1403            _ => None,
1404        })
1405        .is_some_and(|value| value != default)
1406}
1407
1408fn string_not_default(obj: &Map<String, Value>, key: &str, default: &str) -> bool {
1409    obj.get(key)
1410        .and_then(Value::as_str)
1411        .is_some_and(|value| value != default)
1412}
1413
1414#[cfg(test)]
1415mod tests {
1416    use super::*;
1417
1418    #[test]
1419    fn float_index_beyond_usize_is_rejected() {
1420        // 1e20 backs into the f64 branch (too large for as_u64) and would
1421        // saturate to usize::MAX under `as usize`; it must get the same "too
1422        // large" rejection the integer branches give.
1423        let err = value_to_usize(&serde_json::json!(1e20), "from_bus").unwrap_err();
1424        assert!(err.to_string().contains("too large"), "got: {err}");
1425        assert_eq!(
1426            value_to_usize(&serde_json::json!(3.0), "from_bus").unwrap(),
1427            3
1428        );
1429    }
1430
1431    #[test]
1432    fn rejects_bad_wrapper() {
1433        let err = parse_surge_json(
1434            r#"{"format":"surge-json","schema_version":"9","meta":{},"network":{}}"#,
1435        )
1436        .unwrap_err();
1437        assert!(matches!(err, Error::FormatRead { .. }));
1438    }
1439
1440    #[test]
1441    fn bus_type_mapping() {
1442        assert_eq!(read_bus_type("PQ").unwrap(), BusType::Pq);
1443        assert_eq!(read_bus_type("PV").unwrap(), BusType::Pv);
1444        assert_eq!(read_bus_type("Slack").unwrap(), BusType::Ref);
1445        assert_eq!(read_bus_type("Isolated").unwrap(), BusType::Isolated);
1446    }
1447
1448    #[test]
1449    fn cost_mapping() {
1450        let cost = read_cost(&serde_json::json!({
1451            "Polynomial": {"coeffs": [1.0, 2.0, 3.0], "startup": 4.0, "shutdown": 5.0}
1452        }))
1453        .unwrap();
1454        assert_eq!(cost.model, 2);
1455        assert_eq!(cost.coeffs, vec![1.0, 2.0, 3.0]);
1456
1457        let cost = read_cost(&serde_json::json!({
1458            "PiecewiseLinear": {"points": [[0.0, 0.0], [10.0, 20.0]]}
1459        }))
1460        .unwrap();
1461        assert_eq!(cost.model, 1);
1462        assert_eq!(cost.coeffs, vec![0.0, 0.0, 10.0, 20.0]);
1463    }
1464
1465    #[test]
1466    fn branch_tap_convention() {
1467        let branch = read_branch(&serde_json::json!({
1468            "from_bus": 1,
1469            "to_bus": 2,
1470            "branch_type": "Line",
1471            "tap": 1.0
1472        }))
1473        .unwrap();
1474        assert!(branch.tap.abs() < EPS);
1475
1476        let branch = read_branch(&serde_json::json!({
1477            "from_bus": 1,
1478            "to_bus": 2,
1479            "branch_type": "Line",
1480            "tap": 1.0,
1481            "phase_shift_rad": 0.1
1482        }))
1483        .unwrap();
1484        assert!(branch.tap.abs() < EPS);
1485        assert!((branch.shift - 0.1 * normalize::RAD_TO_DEG).abs() < EPS);
1486
1487        let branch = read_branch(&serde_json::json!({
1488            "from_bus": 1,
1489            "to_bus": 2,
1490            "branch_type": "Transformer",
1491            "tap": 1.0
1492        }))
1493        .unwrap();
1494        assert!((branch.tap - 1.0).abs() < EPS);
1495    }
1496
1497    #[test]
1498    fn preserves_branch_terminal_charging() {
1499        let branch = read_branch(&serde_json::json!({
1500            "from_bus": 1,
1501            "to_bus": 2,
1502            "g_shunt_from": 0.1,
1503            "b_shunt_from": 0.2,
1504            "g_shunt_to": 0.3,
1505            "b_shunt_to": 0.4
1506        }))
1507        .unwrap();
1508        let charging = branch.charging.unwrap();
1509        assert!((charging.g_fr - 0.1).abs() < EPS);
1510        assert!((charging.b_fr - 0.2).abs() < EPS);
1511        assert!((charging.g_to - 0.3).abs() < EPS);
1512        assert!((charging.b_to - 0.4).abs() < EPS);
1513    }
1514
1515    #[test]
1516    fn rejects_nonfinite_numeric_strings() {
1517        let err = parse_surge_json(
1518            r#"{
1519              "format": "surge-json",
1520              "schema_version": "0.1.0",
1521              "meta": {},
1522              "network": {
1523                "buses": [
1524                  {"number": 1, "voltage_angle_rad": "NaN"}
1525                ]
1526              }
1527            }"#,
1528        )
1529        .unwrap_err();
1530        assert!(matches!(err, Error::FormatRead { .. }));
1531    }
1532}