Skip to main content

powerio/format/
powermodels.rs

1//! Write a [`Network`] as PowerModels.jl network data JSON.
2//!
3//! Output is idiomatic PowerModels data with `per_unit = true`, the same form
4//! PowerModels itself exports: powers are divided by `baseMVA`, angles are in
5//! radians, and gen cost coefficients are rescaled to the per-unit basis (a
6//! polynomial term `p^j` by `baseMVA^j`, a piecewise curve's MW breakpoints by
7//! `1/baseMVA`). Because the data already declares per unit, `parse_file(out.json)`
8//! reads it with PowerModels' default `validate = true` without rerunning
9//! `make_per_unit!`, so it lands on the same network as `parse_file(case.m)`.
10//! Loads and shunts are first-class on the `Network`; branch terminal admittance
11//! writes as PowerModels' `g_fr`/`b_fr`/`g_to`/`b_to` fields, with MATPOWER
12//! `BR_B` expanded only when no richer terminal model is present. `transformer`
13//! follows PowerModels' rule (raw tap `≠ 0`). `hvdc`/`storage` are mapped to the
14//! closest PowerModels blocks and emit a warning when present.
15
16use std::sync::Arc;
17
18use serde_json::{Map, Value};
19
20use super::{Conversion, finish, jnum, warn_extra_branch_rating_sets};
21use crate::network::{
22    Branch, BranchCharging, BranchCurrentRatings, BranchSolution, Bus, BusId, BusType,
23    GEN_EXTRA_KEYS, GenCost, Generator, Hvdc, Load, LoadVoltageModel, Network, Shunt, SourceFormat,
24    Storage, Switch,
25};
26use crate::normalize::{self, GEN_PU_KEYS};
27use crate::{Error, Result};
28
29#[must_use]
30#[expect(clippy::too_many_lines)]
31pub fn write_powermodels_json(net: &Network) -> Conversion {
32    let mut warnings = Vec::new();
33
34    // Per-unit write factors, the exact inverse of the reader's pscale/ascale:
35    // powers ÷ baseMVA, angles degrees → radians. Cost rescale needs the base.
36    let base = net.base_mva;
37    let p = 1.0 / base;
38    let a = normalize::DEG_TO_RAD;
39
40    let mut bus = Map::new();
41    for b in &net.buses {
42        bus.insert(b.id.to_string(), bus_obj(b, a));
43    }
44
45    let mut branch = Map::new();
46    for (i, br) in net.branches.iter().enumerate() {
47        let idx = i + 1;
48        branch.insert(idx.to_string(), branch_obj(br, idx, p, a));
49    }
50
51    let mut gen_map = Map::new();
52    for (i, g) in net.generators.iter().enumerate() {
53        let idx = i + 1;
54        gen_map.insert(idx.to_string(), gen_obj(g, idx, p, base));
55    }
56
57    let mut load = Map::new();
58    for (i, l) in net.loads.iter().enumerate() {
59        let idx = i + 1;
60        load.insert(idx.to_string(), load_obj(l, idx, p));
61    }
62    let mut shunt = Map::new();
63    for (i, s) in net.shunts.iter().enumerate() {
64        let idx = i + 1;
65        shunt.insert(idx.to_string(), shunt_obj(s, idx, p));
66    }
67
68    let mut dcline = Map::new();
69    for (i, dc) in net.hvdc.iter().enumerate() {
70        let idx = i + 1;
71        dcline.insert(idx.to_string(), dcline_obj(dc, idx, p));
72    }
73    let mut storage = Map::new();
74    for (i, st) in net.storage.iter().enumerate() {
75        let idx = i + 1;
76        storage.insert(idx.to_string(), storage_obj(st, idx, p));
77    }
78    let mut switch = Map::new();
79    for (i, sw) in net.switches.iter().enumerate() {
80        let idx = i + 1;
81        switch.insert(idx.to_string(), switch_obj(sw, idx, p));
82    }
83    if !dcline.is_empty() {
84        warnings.push(format!(
85            "{} dcline(s) mapped with warnings to the PowerModels dcline schema",
86            dcline.len()
87        ));
88    }
89    if !storage.is_empty() {
90        warnings.push(format!(
91            "{} storage unit(s) mapped with warnings to the PowerModels storage schema",
92            storage.len()
93        ));
94    }
95    if !net.transformers_3w.is_empty() {
96        warnings.push(format!(
97            "{} 3-winding transformer(s) dropped: the PowerModels JSON writer emits no 3-winding record",
98            net.transformers_3w.len()
99        ));
100    }
101    let voltage_loads = net
102        .loads
103        .iter()
104        .filter(|l| {
105            l.voltage_model
106                .as_ref()
107                .is_some_and(LoadVoltageModel::has_non_matpower_fields)
108        })
109        .count();
110    if voltage_loads > 0 {
111        warnings.push(format!(
112            "{voltage_loads} voltage dependent load model(s) dropped: PowerModels load records carry static pd/qd only"
113        ));
114    }
115    warn_extra_branch_rating_sets("PowerModels JSON", net, &mut warnings);
116    if net
117        .buses
118        .iter()
119        .any(|b| b.evhi.is_some() || b.evlo.is_some())
120    {
121        warnings.push(
122            "emergency voltage band(s) (EVHI/EVLO) dropped: this writer carries one voltage band"
123                .into(),
124        );
125    }
126
127    let mut root = Map::new();
128    root.insert("name".into(), Value::String(net.name.clone()));
129    root.insert("baseMVA".into(), jnum(net.base_mva));
130    root.insert("per_unit".into(), Value::Bool(true));
131    root.insert("source_type".into(), Value::String("matpower".into()));
132    root.insert("source_version".into(), Value::String("2".into()));
133    root.insert("bus".into(), Value::Object(bus));
134    root.insert("branch".into(), Value::Object(branch));
135    root.insert("gen".into(), Value::Object(gen_map));
136    root.insert("load".into(), Value::Object(load));
137    root.insert("shunt".into(), Value::Object(shunt));
138    root.insert("dcline".into(), Value::Object(dcline));
139    root.insert("storage".into(), Value::Object(storage));
140    root.insert("switch".into(), Value::Object(switch));
141
142    finish(root, warnings)
143}
144
145/// PowerModels back-reference `["bus"|"branch"|…, index]`.
146fn source_id(kind: &str, idx: usize) -> Value {
147    Value::Array(vec![Value::String(kind.into()), Value::from(idx as u64)])
148}
149
150fn status_int(in_service: bool) -> Value {
151    Value::from(u64::from(in_service))
152}
153
154fn bus_obj(b: &Bus, a: f64) -> Value {
155    let mut m = Map::new();
156    m.insert("bus_i".into(), Value::from(b.id.0 as u64));
157    m.insert("index".into(), Value::from(b.id.0 as u64));
158    m.insert("bus_type".into(), Value::from(u64::from(b.kind as u8)));
159    m.insert("vm".into(), jnum(b.vm));
160    m.insert("va".into(), jnum(b.va * a));
161    m.insert("vmax".into(), jnum(b.vmax));
162    m.insert("vmin".into(), jnum(b.vmin));
163    m.insert("base_kv".into(), jnum(b.base_kv));
164    m.insert("area".into(), Value::from(b.area as u64));
165    m.insert("zone".into(), Value::from(b.zone as u64));
166    if let Some(name) = &b.name {
167        m.insert("name".into(), Value::String(name.clone()));
168    }
169    m.insert("source_id".into(), source_id("bus", b.id.0));
170    Value::Object(m)
171}
172
173fn branch_obj(br: &Branch, idx: usize, p: f64, a: f64) -> Value {
174    let mut m = Map::new();
175    m.insert("index".into(), Value::from(idx as u64));
176    m.insert("f_bus".into(), Value::from(br.from.0 as u64));
177    m.insert("t_bus".into(), Value::from(br.to.0 as u64));
178    m.insert("br_r".into(), jnum(br.r));
179    m.insert("br_x".into(), jnum(br.x));
180    let charging = br.terminal_charging();
181    m.insert("b_fr".into(), jnum(charging.b_fr));
182    m.insert("b_to".into(), jnum(charging.b_to));
183    m.insert("g_fr".into(), jnum(charging.g_fr));
184    m.insert("g_to".into(), jnum(charging.g_to));
185    m.insert("tap".into(), jnum(br.effective_tap()));
186    m.insert("shift".into(), jnum(br.shift * a));
187    m.insert("br_status".into(), status_int(br.in_service));
188    m.insert("angmin".into(), jnum(br.angmin * a));
189    m.insert("angmax".into(), jnum(br.angmax * a));
190    // PowerModels' rule: a transformer is a branch with an off-nominal raw tap.
191    // A pure phase shifter (tap 0, shift ≠ 0) is not flagged, matching matpower.jl.
192    m.insert("transformer".into(), Value::Bool(br.tap != 0.0));
193    // PowerModels omits a rate when it is 0 (unlimited).
194    if br.rate_a != 0.0 {
195        m.insert("rate_a".into(), jnum(br.rate_a * p));
196    }
197    if br.rate_b != 0.0 {
198        m.insert("rate_b".into(), jnum(br.rate_b * p));
199    }
200    if br.rate_c != 0.0 {
201        m.insert("rate_c".into(), jnum(br.rate_c * p));
202    }
203    if let Some(current) = br.current_ratings {
204        if current.c_rating_a != 0.0 {
205            m.insert("c_rating_a".into(), jnum(current.c_rating_a));
206        }
207        if current.c_rating_b != 0.0 {
208            m.insert("c_rating_b".into(), jnum(current.c_rating_b));
209        }
210        if current.c_rating_c != 0.0 {
211            m.insert("c_rating_c".into(), jnum(current.c_rating_c));
212        }
213    }
214    if let Some(solution) = br.solution {
215        m.insert("pf".into(), jnum(solution.pf * p));
216        m.insert("qf".into(), jnum(solution.qf * p));
217        m.insert("pt".into(), jnum(solution.pt * p));
218        m.insert("qt".into(), jnum(solution.qt * p));
219    }
220    m.insert("source_id".into(), source_id("branch", idx));
221    Value::Object(m)
222}
223
224fn gen_obj(g: &Generator, idx: usize, p: f64, base: f64) -> Value {
225    let mut m = Map::new();
226    m.insert("index".into(), Value::from(idx as u64));
227    m.insert("gen_bus".into(), Value::from(g.bus.0 as u64));
228    m.insert("pg".into(), jnum(g.pg * p));
229    m.insert("qg".into(), jnum(g.qg * p));
230    m.insert("qmax".into(), jnum(g.qmax * p));
231    m.insert("qmin".into(), jnum(g.qmin * p));
232    m.insert("vg".into(), jnum(g.vg));
233    m.insert("mbase".into(), jnum(g.mbase));
234    m.insert("gen_status".into(), status_int(g.in_service));
235    m.insert("pmax".into(), jnum(g.pmax * p));
236    m.insert("pmin".into(), jnum(g.pmin * p));
237    // Gen capability columns, in PowerModels' field order, for those present. Only
238    // the ramp rates are per-unitized; the PQ curve points and apf stay raw.
239    for (i, key) in GEN_EXTRA_KEYS.iter().enumerate() {
240        if let Some(v) = g.caps[i] {
241            let scaled = if GEN_PU_KEYS.contains(key) {
242                jnum(v * p)
243            } else {
244                jnum(v)
245            };
246            m.insert((*key).into(), scaled);
247        }
248    }
249    if let Some(cost) = &g.cost {
250        let coeffs: Vec<Value> = normalize::cost_to_pu(cost, base)
251            .into_iter()
252            .map(jnum)
253            .collect();
254        // Emit `ncost` consistent with the coefficients actually written. The reader
255        // un-scales by the array length, so a mismatched `ncost` (from a malformed
256        // row that claimed more coefficients than it carried) would reconstruct the
257        // wrong polynomial degree.
258        let ncost = if cost.model == 1 {
259            coeffs.len() / 2
260        } else {
261            coeffs.len()
262        };
263        m.insert("model".into(), Value::from(u64::from(cost.model)));
264        m.insert("ncost".into(), Value::from(ncost as u64));
265        m.insert("startup".into(), jnum(cost.startup));
266        m.insert("shutdown".into(), jnum(cost.shutdown));
267        m.insert("cost".into(), Value::Array(coeffs));
268    }
269    m.insert("source_id".into(), source_id("gen", idx));
270    Value::Object(m)
271}
272
273fn load_obj(l: &Load, idx: usize, p: f64) -> Value {
274    let mut m = Map::new();
275    m.insert("index".into(), Value::from(idx as u64));
276    m.insert("load_bus".into(), Value::from(l.bus.0 as u64));
277    m.insert("pd".into(), jnum(l.p * p));
278    m.insert("qd".into(), jnum(l.q * p));
279    m.insert("status".into(), status_int(l.in_service));
280    m.insert("source_id".into(), source_id("bus", l.bus.0));
281    Value::Object(m)
282}
283
284fn shunt_obj(s: &Shunt, idx: usize, p: f64) -> Value {
285    let mut m = Map::new();
286    m.insert("index".into(), Value::from(idx as u64));
287    m.insert("shunt_bus".into(), Value::from(s.bus.0 as u64));
288    m.insert("gs".into(), jnum(s.g * p));
289    m.insert("bs".into(), jnum(s.b * p));
290    m.insert("status".into(), status_int(s.in_service));
291    m.insert("source_id".into(), source_id("bus", s.bus.0));
292    Value::Object(m)
293}
294
295fn dcline_obj(dc: &Hvdc, idx: usize, p: f64) -> Value {
296    let mut m = Map::new();
297    m.insert("index".into(), Value::from(idx as u64));
298    m.insert("f_bus".into(), Value::from(dc.from.0 as u64));
299    m.insert("t_bus".into(), Value::from(dc.to.0 as u64));
300    m.insert("br_status".into(), status_int(dc.in_service));
301    m.insert("pf".into(), jnum(dc.pf * p));
302    // MATPOWER uses the opposite sign for Pt/Qf/Qt; PowerModels flips them.
303    m.insert("pt".into(), jnum(-dc.pt * p));
304    m.insert("qf".into(), jnum(-dc.qf * p));
305    m.insert("qt".into(), jnum(-dc.qt * p));
306    m.insert("vf".into(), jnum(dc.vf));
307    m.insert("vt".into(), jnum(dc.vt));
308    // Per-end active-power bounds, derived from the aggregate Pmin/Pmax and the
309    // loss model exactly as PowerModels' matpower loader does (_mp2pm_dcline!), so
310    // the line reads back through PowerModels' own correct_dclines! pass. Derived
311    // in raw MW, then per-unitized like everything else.
312    let (pminf, pmaxf, pmint, pmaxt) = dcline_p_bounds(dc.pmin, dc.pmax, dc.loss0, dc.loss1);
313    m.insert("pminf".into(), jnum(pminf * p));
314    m.insert("pmaxf".into(), jnum(pmaxf * p));
315    m.insert("pmint".into(), jnum(pmint * p));
316    m.insert("pmaxt".into(), jnum(pmaxt * p));
317    // The original aggregate bounds, kept raw, as PowerModels does.
318    m.insert("mp_pmin".into(), jnum(dc.pmin));
319    m.insert("mp_pmax".into(), jnum(dc.pmax));
320    m.insert("qminf".into(), jnum(dc.qminf * p));
321    m.insert("qmaxf".into(), jnum(dc.qmaxf * p));
322    m.insert("qmint".into(), jnum(dc.qmint * p));
323    m.insert("qmaxt".into(), jnum(dc.qmaxt * p));
324    m.insert("loss0".into(), jnum(dc.loss0 * p));
325    m.insert("loss1".into(), jnum(dc.loss1));
326    if let Some(cost) = &dc.cost {
327        let coeffs: Vec<Value> = normalize::cost_to_pu(cost, 1.0 / p)
328            .into_iter()
329            .map(jnum)
330            .collect();
331        let ncost = if cost.model == 1 {
332            coeffs.len() / 2
333        } else {
334            coeffs.len()
335        };
336        m.insert("model".into(), Value::from(u64::from(cost.model)));
337        m.insert("ncost".into(), Value::from(ncost as u64));
338        m.insert("startup".into(), jnum(cost.startup));
339        m.insert("shutdown".into(), jnum(cost.shutdown));
340        m.insert("cost".into(), Value::Array(coeffs));
341    }
342    m.insert("source_id".into(), source_id("dcline", idx));
343    Value::Object(m)
344}
345
346/// Per-end active-power bounds `(pminf, pmaxf, pmint, pmaxt)` for an HVDC line,
347/// from the aggregate Pmin/Pmax and the loss model, branching on the bound signs
348/// exactly as PowerModels' `_mp2pm_dcline!` does. Inputs and outputs are raw MW.
349fn dcline_p_bounds(pmin: f64, pmax: f64, loss0: f64, loss1: f64) -> (f64, f64, f64, f64) {
350    let l = 1.0 - loss1;
351    if pmin >= 0.0 && pmax >= 0.0 {
352        (pmin, pmax, loss0 - pmax * l, loss0 - pmin * l)
353    } else if pmin >= 0.0 {
354        (pmin, (-pmax + loss0) / l, pmax, loss0 - pmin * l)
355    } else if pmax >= 0.0 {
356        ((pmin + loss0) / l, pmax, loss0 - pmax * l, -pmin)
357    } else {
358        ((pmin + loss0) / l, (-pmax + loss0) / l, pmax, -pmin)
359    }
360}
361
362fn storage_obj(st: &Storage, idx: usize, p: f64) -> Value {
363    let mut m = Map::new();
364    m.insert("index".into(), Value::from(idx as u64));
365    m.insert("storage_bus".into(), Value::from(st.bus.0 as u64));
366    // ps/qs are the dispatch setpoint; PowerModels' make_per_unit! leaves them raw
367    // (it rescales the energy/ratings/limits below), so we do too.
368    m.insert("ps".into(), jnum(st.ps));
369    m.insert("qs".into(), jnum(st.qs));
370    m.insert("energy".into(), jnum(st.energy * p));
371    m.insert("energy_rating".into(), jnum(st.energy_rating * p));
372    m.insert("charge_rating".into(), jnum(st.charge_rating * p));
373    m.insert("discharge_rating".into(), jnum(st.discharge_rating * p));
374    m.insert("charge_efficiency".into(), jnum(st.charge_efficiency));
375    m.insert("discharge_efficiency".into(), jnum(st.discharge_efficiency));
376    m.insert("thermal_rating".into(), jnum(st.thermal_rating * p));
377    if let Some(current_rating) = st.current_rating {
378        m.insert("current_rating".into(), jnum(current_rating));
379    }
380    m.insert("qmin".into(), jnum(st.qmin * p));
381    m.insert("qmax".into(), jnum(st.qmax * p));
382    m.insert("r".into(), jnum(st.r));
383    m.insert("x".into(), jnum(st.x));
384    m.insert("p_loss".into(), jnum(st.p_loss * p));
385    m.insert("q_loss".into(), jnum(st.q_loss * p));
386    m.insert("status".into(), status_int(st.in_service));
387    m.insert("source_id".into(), source_id("storage", idx));
388    Value::Object(m)
389}
390
391fn switch_obj(sw: &Switch, idx: usize, p: f64) -> Value {
392    let mut m = Map::new();
393    m.insert("index".into(), Value::from(idx as u64));
394    m.insert("f_bus".into(), Value::from(sw.from.0 as u64));
395    m.insert("t_bus".into(), Value::from(sw.to.0 as u64));
396    m.insert("state".into(), status_int(sw.closed));
397    if let Some(rating) = sw.thermal_rating {
398        m.insert("thermal_rating".into(), jnum(rating * p));
399    }
400    if let Some(rating) = sw.current_rating {
401        m.insert("current_rating".into(), jnum(rating));
402    }
403    if let Some(pf) = sw.pf {
404        m.insert("pf".into(), jnum(pf * p));
405    }
406    if let Some(qf) = sw.qf {
407        m.insert("qf".into(), jnum(qf * p));
408    }
409    if let Some(pt) = sw.pt {
410        m.insert("pt".into(), jnum(pt * p));
411    }
412    if let Some(qt) = sw.qt {
413        m.insert("qt".into(), jnum(qt * p));
414    }
415    m.insert("source_id".into(), source_id("switch", idx));
416    Value::Object(m)
417}
418
419// ---- Reader: PowerModels JSON → Network -------------------------------------
420
421const FMT: &str = "PowerModels JSON";
422
423/// Parse PowerModels.jl network data JSON into a [`Network`]. Loads and shunts
424/// are read as first-class elements and the raw text is retained, so writing back
425/// to PowerModels JSON is a byte-exact echo. `per_unit = true` input (powerio's own
426/// output, and PowerModels' own export) is converted to the neutral MW/degree
427/// convention (powers ×baseMVA, angles to degrees, cost coefficients un-scaled),
428/// following PowerModels' own exceptions (storage `ps`/`qs` stay raw, dcline
429/// `pt`/`qf`/`qt` flip sign); `per_unit = false` is read as-is.
430pub fn parse_powermodels_json(content: &str) -> Result<Network> {
431    let mut warnings = Vec::new();
432    parse_powermodels_json_source(Arc::new(content.to_owned()), None, &mut warnings)
433}
434
435/// Owned-source entry used by the format hub: parse by borrowing `source`, then
436/// move the buffer into the retained source (no copy). `name_hint` (e.g. a file
437/// stem) names the network when the JSON carries no `name`.
438pub(crate) fn parse_powermodels_json_source(
439    source: Arc<String>,
440    name_hint: Option<&str>,
441    warnings: &mut Vec<String>,
442) -> Result<Network> {
443    let content: &str = &source;
444    let root: Value = serde_json::from_str(content).map_err(|e| Error::FormatRead {
445        format: FMT,
446        message: e.to_string(),
447    })?;
448    let root = root.as_object().ok_or_else(|| Error::FormatRead {
449        format: FMT,
450        message: "top level is not a JSON object".into(),
451    })?;
452
453    let base_mva =
454        root.get("baseMVA")
455            .and_then(Value::as_f64)
456            .ok_or_else(|| Error::FormatRead {
457                format: FMT,
458                message: "missing numeric `baseMVA`".into(),
459            })?;
460    let per_unit = root
461        .get("per_unit")
462        .and_then(Value::as_bool)
463        .unwrap_or(false);
464    if root
465        .get("multinetwork")
466        .and_then(Value::as_bool)
467        .unwrap_or(false)
468    {
469        warnings.push("multinetwork=true: only the top-level single snapshot was read".into());
470    }
471    let pscale = if per_unit { base_mva } else { 1.0 };
472    let ascale = if per_unit { normalize::RAD_TO_DEG } else { 1.0 };
473    let name = root
474        .get("name")
475        .and_then(Value::as_str)
476        .or(name_hint)
477        .unwrap_or("case")
478        .to_string();
479
480    let net = Network {
481        name,
482        base_mva,
483        base_frequency: crate::network::DEFAULT_BASE_FREQUENCY,
484        buses: sorted(root, "bus", "index")
485            .iter()
486            .map(|v| read_bus(v, ascale))
487            .collect::<Result<Vec<_>>>()?,
488        loads: sorted(root, "load", "index")
489            .iter()
490            .map(|v| read_load(v, pscale))
491            .collect(),
492        shunts: sorted(root, "shunt", "index")
493            .iter()
494            .map(|v| read_shunt(v, pscale))
495            .collect(),
496        branches: sorted(root, "branch", "index")
497            .iter()
498            .map(|v| read_branch(v, pscale, ascale))
499            .collect(),
500        switches: sorted(root, "switch", "index")
501            .iter()
502            .map(|v| read_switch(v, pscale))
503            .collect(),
504        generators: sorted(root, "gen", "index")
505            .iter()
506            .map(|v| read_gen(v, pscale, base_mva, per_unit))
507            .collect(),
508        storage: sorted(root, "storage", "index")
509            .iter()
510            .map(|v| read_storage(v, pscale))
511            .collect(),
512        hvdc: sorted(root, "dcline", "index")
513            .iter()
514            .map(|v| read_hvdc(v, pscale, base_mva, per_unit))
515            .collect(),
516        transformers_3w: Vec::new(),
517        areas: Vec::new(),
518        solver: None,
519        source_format: SourceFormat::PowerModelsJson,
520        source: Some(source),
521    };
522    net.check_references(FMT)?;
523    Ok(net)
524}
525
526/// Elements of a top-level section, ordered by their integer `idx_key` so a
527/// re-emitted file assigns the same running keys.
528fn sorted<'a>(root: &'a Map<String, Value>, section: &str, idx_key: &str) -> Vec<&'a Value> {
529    let Some(obj) = root.get(section).and_then(Value::as_object) else {
530        return Vec::new();
531    };
532    let mut items: Vec<&Value> = obj.values().collect();
533    items.sort_by_key(|v| v.get(idx_key).and_then(Value::as_i64).unwrap_or(0));
534    items
535}
536
537fn f(v: &Value, key: &str) -> f64 {
538    v.get(key).and_then(Value::as_f64).unwrap_or(0.0)
539}
540fn f_or(v: &Value, key: &str, default: f64) -> f64 {
541    v.get(key).and_then(Value::as_f64).unwrap_or(default)
542}
543fn uid(v: &Value, key: &str) -> usize {
544    v.get(key).and_then(Value::as_u64).unwrap_or(0) as usize
545}
546/// A 0/1 status field; absent ⇒ in service.
547fn flag(v: &Value, key: &str) -> bool {
548    v.get(key).and_then(Value::as_f64) != Some(0.0)
549}
550
551fn bustype(code: i64) -> BusType {
552    match code {
553        2 => BusType::Pv,
554        3 => BusType::Ref,
555        4 => BusType::Isolated,
556        _ => BusType::Pq,
557    }
558}
559
560/// Element keys the neutral model names directly are dropped here; whatever's left
561/// is preserved as extras for round-trip and cross-format passthrough.
562fn extras_excluding(v: &Value, known: &[&str]) -> crate::network::Extras {
563    v.as_object().map_or_else(Default::default, |obj| {
564        obj.iter()
565            .filter(|(k, _)| !known.contains(&k.as_str()))
566            .map(|(k, val)| (k.clone(), val.clone()))
567            .collect()
568    })
569}
570
571fn read_bus(v: &Value, ascale: f64) -> Result<Bus> {
572    let id = v
573        .get("bus_i")
574        .or_else(|| v.get("index"))
575        .and_then(Value::as_u64)
576        .ok_or_else(|| Error::FormatRead {
577            format: FMT,
578            message: "bus record missing integer `bus_i`".into(),
579        })? as usize;
580    Ok(Bus {
581        id: BusId(id),
582        kind: bustype(v.get("bus_type").and_then(Value::as_i64).unwrap_or(1)),
583        vm: f_or(v, "vm", 1.0),
584        va: f(v, "va") * ascale,
585        base_kv: f(v, "base_kv"),
586        vmax: f(v, "vmax"),
587        vmin: f(v, "vmin"),
588        evhi: None,
589        evlo: None,
590        area: uid(v, "area"),
591        zone: uid(v, "zone"),
592        name: v.get("name").and_then(Value::as_str).map(str::to_string),
593        uid: None,
594        extras: extras_excluding(
595            v,
596            &[
597                "bus_i",
598                "index",
599                "bus_type",
600                "vm",
601                "va",
602                "vmax",
603                "vmin",
604                "base_kv",
605                "area",
606                "zone",
607                "name",
608                "source_id",
609            ],
610        ),
611    })
612}
613
614fn read_load(v: &Value, pscale: f64) -> Load {
615    Load {
616        bus: BusId(uid(v, "load_bus")),
617        p: f(v, "pd") * pscale,
618        q: f(v, "qd") * pscale,
619        voltage_model: None,
620        in_service: flag(v, "status"),
621        uid: None,
622        extras: extras_excluding(v, &["load_bus", "pd", "qd", "status", "index", "source_id"]),
623    }
624}
625
626fn read_shunt(v: &Value, pscale: f64) -> Shunt {
627    Shunt {
628        bus: BusId(uid(v, "shunt_bus")),
629        g: f(v, "gs") * pscale,
630        b: f(v, "bs") * pscale,
631        in_service: flag(v, "status"),
632        control: None,
633        uid: None,
634        extras: extras_excluding(
635            v,
636            &["shunt_bus", "gs", "bs", "status", "index", "source_id"],
637        ),
638    }
639}
640
641fn read_branch(v: &Value, pscale: f64, ascale: f64) -> Branch {
642    // PowerModels stores the effective tap (1.0 for a line); the `transformer`
643    // flag disambiguates an explicit-tap transformer from a line, which is what
644    // the neutral raw-tap convention (0 = line) needs.
645    let transformer = v
646        .get("transformer")
647        .and_then(Value::as_bool)
648        .unwrap_or(false);
649    let tap = if transformer {
650        f_or(v, "tap", 1.0)
651    } else {
652        0.0
653    };
654    Branch {
655        from: BusId(uid(v, "f_bus")),
656        to: BusId(uid(v, "t_bus")),
657        r: f(v, "br_r"),
658        x: f(v, "br_x"),
659        b: f(v, "b_fr") + f(v, "b_to"),
660        charging: Some(BranchCharging {
661            g_fr: f(v, "g_fr"),
662            b_fr: f(v, "b_fr"),
663            g_to: f(v, "g_to"),
664            b_to: f(v, "b_to"),
665        }),
666        rate_a: f(v, "rate_a") * pscale,
667        rate_b: f(v, "rate_b") * pscale,
668        rate_c: f(v, "rate_c") * pscale,
669        rating_sets: Vec::new(),
670        current_ratings: has_any(v, &["c_rating_a", "c_rating_b", "c_rating_c"]).then_some(
671            BranchCurrentRatings {
672                c_rating_a: f(v, "c_rating_a"),
673                c_rating_b: f(v, "c_rating_b"),
674                c_rating_c: f(v, "c_rating_c"),
675            },
676        ),
677        tap,
678        shift: f(v, "shift") * ascale,
679        in_service: flag(v, "br_status"),
680        angmin: f(v, "angmin") * ascale,
681        angmax: f(v, "angmax") * ascale,
682        control: None,
683        solution: has_any(v, &["pf", "qf", "pt", "qt"]).then_some(BranchSolution {
684            pf: f(v, "pf") * pscale,
685            qf: f(v, "qf") * pscale,
686            pt: f(v, "pt") * pscale,
687            qt: f(v, "qt") * pscale,
688        }),
689        uid: None,
690        extras: extras_excluding(
691            v,
692            &[
693                "f_bus",
694                "t_bus",
695                "br_r",
696                "br_x",
697                "b_fr",
698                "b_to",
699                "g_fr",
700                "g_to",
701                "tap",
702                "shift",
703                "br_status",
704                "angmin",
705                "angmax",
706                "transformer",
707                "rate_a",
708                "rate_b",
709                "rate_c",
710                "c_rating_a",
711                "c_rating_b",
712                "c_rating_c",
713                "pf",
714                "qf",
715                "pt",
716                "qt",
717                "index",
718                "source_id",
719            ],
720        ),
721    }
722}
723
724fn has_any(v: &Value, keys: &[&str]) -> bool {
725    keys.iter().any(|key| v.get(*key).is_some())
726}
727
728fn read_switch(v: &Value, pscale: f64) -> Switch {
729    let closed = if v.get("state").is_some() {
730        flag(v, "state")
731    } else {
732        flag(v, "status")
733    };
734    Switch {
735        from: BusId(uid(v, "f_bus")),
736        to: BusId(uid(v, "t_bus")),
737        closed,
738        thermal_rating: v
739            .get("thermal_rating")
740            .and_then(Value::as_f64)
741            .map(|x| x * pscale),
742        current_rating: v.get("current_rating").and_then(Value::as_f64),
743        pf: v.get("pf").and_then(Value::as_f64).map(|x| x * pscale),
744        qf: v.get("qf").and_then(Value::as_f64).map(|x| x * pscale),
745        pt: v.get("pt").and_then(Value::as_f64).map(|x| x * pscale),
746        qt: v.get("qt").and_then(Value::as_f64).map(|x| x * pscale),
747        uid: None,
748        extras: extras_excluding(
749            v,
750            &[
751                "f_bus",
752                "t_bus",
753                "state",
754                "status",
755                "thermal_rating",
756                "current_rating",
757                "pf",
758                "qf",
759                "pt",
760                "qt",
761                "index",
762                "source_id",
763            ],
764        ),
765    }
766}
767
768fn read_gen(v: &Value, pscale: f64, base_mva: f64, per_unit: bool) -> Generator {
769    let mut caps: crate::network::GenCaps = [None; GEN_EXTRA_KEYS.len()];
770    for (i, key) in GEN_EXTRA_KEYS.iter().enumerate() {
771        if let Some(val) = v.get(*key).and_then(Value::as_f64) {
772            // Only the ramp rates are per-unit; the PQ curve points and apf are raw.
773            caps[i] = Some(if GEN_PU_KEYS.contains(key) {
774                val * pscale
775            } else {
776                val
777            });
778        }
779    }
780    let cost = v.get("model").map(|_| read_cost(v, base_mva, per_unit));
781    Generator {
782        bus: BusId(uid(v, "gen_bus")),
783        pg: f(v, "pg") * pscale,
784        qg: f(v, "qg") * pscale,
785        // The writer emits an unbounded limit (±Inf) as JSON null; read a missing
786        // limit back as unbounded, not as a binding 0.0. (±Inf · pscale stays ±Inf.)
787        pmax: f_or(v, "pmax", f64::INFINITY) * pscale,
788        pmin: f_or(v, "pmin", f64::NEG_INFINITY) * pscale,
789        qmax: f_or(v, "qmax", f64::INFINITY) * pscale,
790        qmin: f_or(v, "qmin", f64::NEG_INFINITY) * pscale,
791        vg: f_or(v, "vg", 1.0),
792        mbase: f_or(v, "mbase", base_mva),
793        in_service: flag(v, "gen_status"),
794        cost,
795        caps,
796        regulated_bus: None,
797        uid: None,
798    }
799}
800
801fn read_cost(v: &Value, base_mva: f64, per_unit: bool) -> GenCost {
802    // Keep non-numeric entries as NaN rather than dropping them: silently filtering
803    // would shift every later coefficient's polynomial degree.
804    let coeffs_raw: Vec<f64> = v
805        .get("cost")
806        .and_then(Value::as_array)
807        .map(|a| a.iter().map(|c| c.as_f64().unwrap_or(f64::NAN)).collect())
808        .unwrap_or_default();
809    let model = v.get("model").and_then(Value::as_u64).unwrap_or(2) as u8;
810    let k = coeffs_raw.len();
811    // Undo PowerModels' per-unit cost scaling for the neutral MW basis (the
812    // inverse of the writer's per-unit rescale); a non-per-unit source is read
813    // as-is.
814    let coeffs = if per_unit {
815        normalize::cost_from_pu(&coeffs_raw, model, base_mva)
816    } else {
817        coeffs_raw
818    };
819    // A polynomial's ncost is its coefficient count; a piecewise curve stores
820    // 2·ncost values ((mw, cost) pairs).
821    let default_ncost = if model == 1 { k / 2 } else { k };
822    GenCost {
823        model,
824        startup: f(v, "startup"),
825        shutdown: f(v, "shutdown"),
826        ncost: v
827            .get("ncost")
828            .and_then(Value::as_u64)
829            .map_or(default_ncost, |n| n as usize),
830        coeffs,
831    }
832}
833
834fn read_hvdc(v: &Value, pscale: f64, base_mva: f64, per_unit: bool) -> Hvdc {
835    // Aggregate bounds come from PowerModels' raw originals (mp_pmin/mp_pmax); fall
836    // back to the from-end per-unit bounds for input that lacks them.
837    let pmin = v
838        .get("mp_pmin")
839        .and_then(Value::as_f64)
840        .unwrap_or_else(|| f(v, "pminf") * pscale);
841    let pmax = v
842        .get("mp_pmax")
843        .and_then(Value::as_f64)
844        .unwrap_or_else(|| f(v, "pmaxf") * pscale);
845    Hvdc {
846        from: BusId(uid(v, "f_bus")),
847        to: BusId(uid(v, "t_bus")),
848        in_service: flag(v, "br_status"),
849        pf: f(v, "pf") * pscale,
850        // PowerModels flips Pt/Qf/Qt vs MATPOWER; undo it for the neutral model.
851        pt: -f(v, "pt") * pscale,
852        qf: -f(v, "qf") * pscale,
853        qt: -f(v, "qt") * pscale,
854        vf: f_or(v, "vf", 1.0),
855        vt: f_or(v, "vt", 1.0),
856        pmin,
857        pmax,
858        // Unbounded reactive limits (±Inf) write as null; read them back unbounded.
859        qminf: f_or(v, "qminf", f64::NEG_INFINITY) * pscale,
860        qmaxf: f_or(v, "qmaxf", f64::INFINITY) * pscale,
861        qmint: f_or(v, "qmint", f64::NEG_INFINITY) * pscale,
862        qmaxt: f_or(v, "qmaxt", f64::INFINITY) * pscale,
863        loss0: f(v, "loss0") * pscale,
864        loss1: f(v, "loss1"),
865        cost: v.get("model").map(|_| read_cost(v, base_mva, per_unit)),
866        uid: None,
867        extras: extras_excluding(
868            v,
869            &[
870                "f_bus",
871                "t_bus",
872                "br_status",
873                "pf",
874                "pt",
875                "qf",
876                "qt",
877                "vf",
878                "vt",
879                "pmin",
880                "pmax",
881                "mp_pmin",
882                "mp_pmax",
883                "pminf",
884                "pmaxf",
885                "pmint",
886                "pmaxt",
887                "qminf",
888                "qmaxf",
889                "qmint",
890                "qmaxt",
891                "loss0",
892                "loss1",
893                "model",
894                "ncost",
895                "startup",
896                "shutdown",
897                "cost",
898                "index",
899                "source_id",
900            ],
901        ),
902    }
903}
904
905fn read_storage(v: &Value, pscale: f64) -> Storage {
906    Storage {
907        bus: BusId(uid(v, "storage_bus")),
908        ps: f(v, "ps"),
909        qs: f(v, "qs"),
910        energy: f(v, "energy") * pscale,
911        energy_rating: f(v, "energy_rating") * pscale,
912        charge_rating: f(v, "charge_rating") * pscale,
913        discharge_rating: f(v, "discharge_rating") * pscale,
914        charge_efficiency: f_or(v, "charge_efficiency", 1.0),
915        discharge_efficiency: f_or(v, "discharge_efficiency", 1.0),
916        thermal_rating: f(v, "thermal_rating") * pscale,
917        current_rating: v.get("current_rating").and_then(Value::as_f64),
918        // Unbounded reactive limits (±Inf) write as null; read them back unbounded.
919        qmin: f_or(v, "qmin", f64::NEG_INFINITY) * pscale,
920        qmax: f_or(v, "qmax", f64::INFINITY) * pscale,
921        r: f(v, "r"),
922        x: f(v, "x"),
923        p_loss: f(v, "p_loss") * pscale,
924        q_loss: f(v, "q_loss") * pscale,
925        in_service: flag(v, "status"),
926        uid: None,
927        extras: extras_excluding(
928            v,
929            &[
930                "storage_bus",
931                "ps",
932                "qs",
933                "energy",
934                "energy_rating",
935                "charge_rating",
936                "discharge_rating",
937                "charge_efficiency",
938                "discharge_efficiency",
939                "thermal_rating",
940                "current_rating",
941                "qmin",
942                "qmax",
943                "r",
944                "x",
945                "p_loss",
946                "q_loss",
947                "status",
948                "index",
949                "source_id",
950            ],
951        ),
952    }
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958
959    fn approx(a: f64, b: f64) -> bool {
960        (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
961    }
962
963    #[test]
964    fn gen_pu_keys_subset_of_extra_keys() {
965        // The per-unitized columns must be a subset of the emitted capability
966        // columns; a key not in GEN_EXTRA_KEYS would never be written or scaled,
967        // and a typo here silently mis-scales a ramp rate.
968        for k in GEN_PU_KEYS {
969            assert!(
970                GEN_EXTRA_KEYS.contains(&k),
971                "{k} is not a GEN_EXTRA_KEYS column"
972            );
973        }
974    }
975
976    #[test]
977    fn dcline_p_bounds_four_quadrants() {
978        // loss0 = 1, loss1 = 0.1 ⇒ l = 0.9. Each sign quadrant of (pmin, pmax)
979        // hand-computed against PowerModels' _mp2pm_dcline!.
980        let q1 = dcline_p_bounds(2.0, 10.0, 1.0, 0.1);
981        assert!(
982            approx(q1.0, 2.0) && approx(q1.1, 10.0) && approx(q1.2, -8.0) && approx(q1.3, -0.8)
983        );
984
985        let q2 = dcline_p_bounds(2.0, -5.0, 1.0, 0.1);
986        assert!(
987            approx(q2.0, 2.0)
988                && approx(q2.1, 6.0 / 0.9)
989                && approx(q2.2, -5.0)
990                && approx(q2.3, -0.8)
991        );
992
993        let q3 = dcline_p_bounds(-3.0, 10.0, 1.0, 0.1);
994        assert!(
995            approx(q3.0, -2.0 / 0.9)
996                && approx(q3.1, 10.0)
997                && approx(q3.2, -8.0)
998                && approx(q3.3, 3.0)
999        );
1000
1001        let q4 = dcline_p_bounds(-3.0, -5.0, 1.0, 0.1);
1002        assert!(
1003            approx(q4.0, -2.0 / 0.9)
1004                && approx(q4.1, 6.0 / 0.9)
1005                && approx(q4.2, -5.0)
1006                && approx(q4.3, 3.0)
1007        );
1008    }
1009}