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