Skip to main content

powerio/format/
powermodels.rs

1//! Write a [`BalancedNetwork`] 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 `BalancedNetwork`; 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    BalancedNetwork, Branch, BranchCharging, BranchCurrentRatings, BranchSolution, Bus, BusId,
23    BusType, GEN_EXTRA_KEYS, GenCost, Generator, Hvdc, Load, LoadVoltageModel, 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: &BalancedNetwork) -> 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 → BalancedNetwork -------------------------------------
420
421const FMT: &str = "PowerModels JSON";
422
423/// Parse PowerModels.jl network data JSON into a [`BalancedNetwork`]. Loads and shunts
424/// are read as separate 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<BalancedNetwork> {
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<BalancedNetwork> {
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    // `baseMVA` is every per-unit divisor here; zero, negative, or non-finite
454    // would silently poison the scaled quantities with NaN/Inf or flipped
455    // signs, so reject it at the door.
456    let base_mva = root
457        .get("baseMVA")
458        .and_then(Value::as_f64)
459        .filter(|b| b.is_finite() && *b > 0.0)
460        .ok_or_else(|| Error::FormatRead {
461            format: FMT,
462            message: "missing, nonpositive, or non-finite numeric `baseMVA`".into(),
463        })?;
464    let per_unit = root
465        .get("per_unit")
466        .and_then(Value::as_bool)
467        .unwrap_or(false);
468    if root
469        .get("multinetwork")
470        .and_then(Value::as_bool)
471        .unwrap_or(false)
472    {
473        warnings.push("multinetwork=true: only the top-level single snapshot was read".into());
474    }
475    let pscale = if per_unit { base_mva } else { 1.0 };
476    let ascale = if per_unit { normalize::RAD_TO_DEG } else { 1.0 };
477    let name = root
478        .get("name")
479        .and_then(Value::as_str)
480        .or(name_hint)
481        .unwrap_or("case")
482        .to_string();
483
484    let net = BalancedNetwork {
485        name,
486        base_mva,
487        base_frequency: crate::network::DEFAULT_BASE_FREQUENCY,
488        geo: None,
489        buses: sorted(root, "bus", "index")
490            .iter()
491            .map(|v| read_bus(v, ascale))
492            .collect::<Result<Vec<_>>>()?,
493        loads: sorted(root, "load", "index")
494            .iter()
495            .map(|v| read_load(v, pscale))
496            .collect(),
497        shunts: sorted(root, "shunt", "index")
498            .iter()
499            .map(|v| read_shunt(v, pscale))
500            .collect(),
501        branches: read_branches(root, pscale, ascale, warnings),
502        switches: sorted(root, "switch", "index")
503            .iter()
504            .map(|v| read_switch(v, pscale))
505            .collect(),
506        generators: sorted(root, "gen", "index")
507            .iter()
508            .map(|v| read_gen(v, pscale, base_mva, per_unit))
509            .collect(),
510        storage: sorted(root, "storage", "index")
511            .iter()
512            .map(|v| read_storage(v, pscale))
513            .collect(),
514        hvdc: sorted(root, "dcline", "index")
515            .iter()
516            .map(|v| read_hvdc(v, pscale, base_mva, per_unit))
517            .collect(),
518        transformers_3w: Vec::new(),
519        areas: Vec::new(),
520        solver: None,
521        source_format: SourceFormat::PowerModelsJson,
522        source: Some(source),
523    };
524    net.check_references(FMT)?;
525    Ok(net)
526}
527
528/// Elements of a top-level section, ordered by their integer `idx_key` so a
529/// re-emitted file assigns the same running keys.
530fn sorted<'a>(root: &'a Map<String, Value>, section: &str, idx_key: &str) -> Vec<&'a Value> {
531    sorted_keyed(root, section, idx_key)
532        .into_iter()
533        .map(|(_, v)| v)
534        .collect()
535}
536
537/// [`sorted`] keeping each entry's map key. Only PowerModels.jl written files
538/// repeat the key in an inner `index`, so the key is the identity a
539/// diagnostic can always name.
540fn sorted_keyed<'a>(
541    root: &'a Map<String, Value>,
542    section: &str,
543    idx_key: &str,
544) -> Vec<(&'a str, &'a Value)> {
545    let Some(obj) = root.get(section).and_then(Value::as_object) else {
546        return Vec::new();
547    };
548    let mut items: Vec<(&str, &Value)> = obj.iter().map(|(k, v)| (k.as_str(), v)).collect();
549    items.sort_by_key(|(_, v)| v.get(idx_key).and_then(Value::as_i64).unwrap_or(0));
550    items
551}
552
553fn f(v: &Value, key: &str) -> f64 {
554    v.get(key).and_then(Value::as_f64).unwrap_or(0.0)
555}
556fn f_or(v: &Value, key: &str, default: f64) -> f64 {
557    v.get(key).and_then(Value::as_f64).unwrap_or(default)
558}
559fn uid(v: &Value, key: &str) -> usize {
560    v.get(key).and_then(Value::as_u64).unwrap_or(0) as usize
561}
562/// A 0/1 status field; absent ⇒ in service. Some producers write a JSON
563/// boolean instead of the MATPOWER 0/1 number; `as_f64` returns `None` for a
564/// bool, so without the explicit arm a `false` here would read back as in
565/// service.
566fn flag(v: &Value, key: &str) -> bool {
567    match v.get(key) {
568        Some(Value::Bool(b)) => *b,
569        Some(value) => value.as_f64() != Some(0.0),
570        None => true,
571    }
572}
573
574fn bustype(code: i64) -> BusType {
575    match code {
576        2 => BusType::Pv,
577        3 => BusType::Ref,
578        4 => BusType::Isolated,
579        _ => BusType::Pq,
580    }
581}
582
583/// Element keys the neutral model names directly are dropped here; whatever's left
584/// is preserved as extras for round trips and cross format conversion.
585fn extras_excluding(v: &Value, known: &[&str]) -> crate::network::Extras {
586    v.as_object().map_or_else(Default::default, |obj| {
587        obj.iter()
588            .filter(|(k, _)| !known.contains(&k.as_str()))
589            .map(|(k, val)| (k.clone(), val.clone()))
590            .collect()
591    })
592}
593
594fn read_bus(v: &Value, ascale: f64) -> Result<Bus> {
595    let id = v
596        .get("bus_i")
597        .or_else(|| v.get("index"))
598        .and_then(Value::as_u64)
599        .ok_or_else(|| Error::FormatRead {
600            format: FMT,
601            message: "bus record missing integer `bus_i`".into(),
602        })? as usize;
603    Ok(Bus {
604        id: BusId(id),
605        kind: bustype(v.get("bus_type").and_then(Value::as_i64).unwrap_or(1)),
606        vm: f_or(v, "vm", 1.0),
607        va: f(v, "va") * ascale,
608        base_kv: f(v, "base_kv"),
609        vmax: f(v, "vmax"),
610        vmin: f(v, "vmin"),
611        evhi: None,
612        evlo: None,
613        area: uid(v, "area"),
614        zone: uid(v, "zone"),
615        name: v.get("name").and_then(Value::as_str).map(str::to_string),
616        uid: None,
617        location: None,
618        extras: extras_excluding(
619            v,
620            &[
621                "bus_i",
622                "index",
623                "bus_type",
624                "vm",
625                "va",
626                "vmax",
627                "vmin",
628                "base_kv",
629                "area",
630                "zone",
631                "name",
632                "source_id",
633            ],
634        ),
635    })
636}
637
638fn read_load(v: &Value, pscale: f64) -> Load {
639    Load {
640        bus: BusId(uid(v, "load_bus")),
641        p: f(v, "pd") * pscale,
642        q: f(v, "qd") * pscale,
643        voltage_model: None,
644        in_service: flag(v, "status"),
645        uid: None,
646        extras: extras_excluding(v, &["load_bus", "pd", "qd", "status", "index", "source_id"]),
647    }
648}
649
650fn read_shunt(v: &Value, pscale: f64) -> Shunt {
651    Shunt {
652        bus: BusId(uid(v, "shunt_bus")),
653        g: f(v, "gs") * pscale,
654        b: f(v, "bs") * pscale,
655        in_service: flag(v, "status"),
656        control: None,
657        uid: None,
658        extras: extras_excluding(
659            v,
660            &["shunt_bus", "gs", "bs", "status", "index", "source_id"],
661        ),
662    }
663}
664
665/// Read the branch table, reporting the taps the `transformer` flag makes
666/// this reader discard. One aggregated warning names the first few branches
667/// and the total: a producer that never sets the flag would otherwise emit
668/// one line per transformer.
669fn read_branches(
670    root: &Map<String, Value>,
671    pscale: f64,
672    ascale: f64,
673    warnings: &mut Vec<String>,
674) -> Vec<Branch> {
675    const NAMED: usize = 3;
676    let mut discarded: Vec<String> = Vec::new();
677    let branches = sorted_keyed(root, "branch", "index")
678        .iter()
679        .map(|(key, v)| read_branch(v, pscale, ascale, key, &mut discarded))
680        .collect();
681    if !discarded.is_empty() {
682        let head = discarded
683            .iter()
684            .take(NAMED)
685            .cloned()
686            .collect::<Vec<_>>()
687            .join(", ");
688        let rest = discarded.len().saturating_sub(NAMED);
689        let tail = if rest > 0 {
690            format!(" and {rest} more")
691        } else {
692            String::new()
693        };
694        warnings.push(format!(
695            "{} branch(es) carry an off-nominal `tap` without `transformer: true`, \
696             so the tap is discarded and the branch reads as a line: {head}{tail}",
697            discarded.len()
698        ));
699    }
700    branches
701}
702
703// Exact compare on purpose: only the literal 1.0 carries no information.
704// An epsilon compare would silence the warning for real near-unit taps.
705#[allow(clippy::float_cmp)]
706fn read_branch(
707    v: &Value,
708    pscale: f64,
709    ascale: f64,
710    key: &str,
711    discarded: &mut Vec<String>,
712) -> Branch {
713    // PowerModels stores the effective tap (1.0 for a line); the `transformer`
714    // flag disambiguates an explicit-tap transformer from a line, which is what
715    // the neutral raw-tap convention (0 = line) needs.
716    let transformer = v
717        .get("transformer")
718        .and_then(Value::as_bool)
719        .unwrap_or(false);
720    let tap = if transformer {
721        f_or(v, "tap", 1.0)
722    } else {
723        // The `transformer` flag decides the type, so this rule drops a
724        // non-unit tap on an untagged branch. Warn about the drop. Taps of
725        // 1 and 0 both mean no off-nominal ratio and stay quiet.
726        if let Some(raw) = v.get("tap").and_then(Value::as_f64) {
727            if raw != 0.0 && raw != 1.0 {
728                discarded.push(format!(
729                    "`{key}` ({} -> {}) tap {raw}",
730                    uid(v, "f_bus"),
731                    uid(v, "t_bus"),
732                ));
733            }
734        }
735        0.0
736    };
737    Branch {
738        from: BusId(uid(v, "f_bus")),
739        to: BusId(uid(v, "t_bus")),
740        r: f(v, "br_r"),
741        x: f(v, "br_x"),
742        b: f(v, "b_fr") + f(v, "b_to"),
743        charging: Some(BranchCharging {
744            g_fr: f(v, "g_fr"),
745            b_fr: f(v, "b_fr"),
746            g_to: f(v, "g_to"),
747            b_to: f(v, "b_to"),
748        }),
749        rate_a: f(v, "rate_a") * pscale,
750        rate_b: f(v, "rate_b") * pscale,
751        rate_c: f(v, "rate_c") * pscale,
752        rating_sets: Vec::new(),
753        current_ratings: has_any(v, &["c_rating_a", "c_rating_b", "c_rating_c"]).then_some(
754            BranchCurrentRatings {
755                c_rating_a: f(v, "c_rating_a"),
756                c_rating_b: f(v, "c_rating_b"),
757                c_rating_c: f(v, "c_rating_c"),
758            },
759        ),
760        tap,
761        shift: f(v, "shift") * ascale,
762        in_service: flag(v, "br_status"),
763        angmin: f(v, "angmin") * ascale,
764        angmax: f(v, "angmax") * ascale,
765        control: None,
766        solution: has_any(v, &["pf", "qf", "pt", "qt"]).then_some(BranchSolution {
767            pf: f(v, "pf") * pscale,
768            qf: f(v, "qf") * pscale,
769            pt: f(v, "pt") * pscale,
770            qt: f(v, "qt") * pscale,
771        }),
772        uid: None,
773        route: None,
774        extras: extras_excluding(
775            v,
776            &[
777                "f_bus",
778                "t_bus",
779                "br_r",
780                "br_x",
781                "b_fr",
782                "b_to",
783                "g_fr",
784                "g_to",
785                "tap",
786                "shift",
787                "br_status",
788                "angmin",
789                "angmax",
790                "transformer",
791                "rate_a",
792                "rate_b",
793                "rate_c",
794                "c_rating_a",
795                "c_rating_b",
796                "c_rating_c",
797                "pf",
798                "qf",
799                "pt",
800                "qt",
801                "index",
802                "source_id",
803            ],
804        ),
805    }
806}
807
808fn has_any(v: &Value, keys: &[&str]) -> bool {
809    keys.iter().any(|key| v.get(*key).is_some())
810}
811
812fn read_switch(v: &Value, pscale: f64) -> Switch {
813    let closed = if v.get("state").is_some() {
814        flag(v, "state")
815    } else {
816        flag(v, "status")
817    };
818    Switch {
819        from: BusId(uid(v, "f_bus")),
820        to: BusId(uid(v, "t_bus")),
821        closed,
822        thermal_rating: v
823            .get("thermal_rating")
824            .and_then(Value::as_f64)
825            .map(|x| x * pscale),
826        current_rating: v.get("current_rating").and_then(Value::as_f64),
827        pf: v.get("pf").and_then(Value::as_f64).map(|x| x * pscale),
828        qf: v.get("qf").and_then(Value::as_f64).map(|x| x * pscale),
829        pt: v.get("pt").and_then(Value::as_f64).map(|x| x * pscale),
830        qt: v.get("qt").and_then(Value::as_f64).map(|x| x * pscale),
831        uid: None,
832        extras: extras_excluding(
833            v,
834            &[
835                "f_bus",
836                "t_bus",
837                "state",
838                "status",
839                "thermal_rating",
840                "current_rating",
841                "pf",
842                "qf",
843                "pt",
844                "qt",
845                "index",
846                "source_id",
847            ],
848        ),
849    }
850}
851
852fn read_gen(v: &Value, pscale: f64, base_mva: f64, per_unit: bool) -> Generator {
853    let mut caps: crate::network::GenCaps = [None; GEN_EXTRA_KEYS.len()];
854    for (i, key) in GEN_EXTRA_KEYS.iter().enumerate() {
855        if let Some(val) = v.get(*key).and_then(Value::as_f64) {
856            // Only the ramp rates are per-unit; the PQ curve points and apf are raw.
857            caps[i] = Some(if GEN_PU_KEYS.contains(key) {
858                val * pscale
859            } else {
860                val
861            });
862        }
863    }
864    let cost = v.get("model").map(|_| read_cost(v, base_mva, per_unit));
865    Generator {
866        bus: BusId(uid(v, "gen_bus")),
867        pg: f(v, "pg") * pscale,
868        qg: f(v, "qg") * pscale,
869        // The writer emits an unbounded limit (±Inf) as JSON null; read a missing
870        // limit back as unbounded, not as a binding 0.0. (±Inf · pscale stays ±Inf.)
871        pmax: f_or(v, "pmax", f64::INFINITY) * pscale,
872        pmin: f_or(v, "pmin", f64::NEG_INFINITY) * pscale,
873        qmax: f_or(v, "qmax", f64::INFINITY) * pscale,
874        qmin: f_or(v, "qmin", f64::NEG_INFINITY) * pscale,
875        vg: f_or(v, "vg", 1.0),
876        mbase: f_or(v, "mbase", base_mva),
877        in_service: flag(v, "gen_status"),
878        cost,
879        caps,
880        regulated_bus: None,
881        uid: None,
882    }
883}
884
885fn read_cost(v: &Value, base_mva: f64, per_unit: bool) -> GenCost {
886    // Keep non-numeric entries as NaN rather than dropping them: silently filtering
887    // would shift every later coefficient's polynomial degree.
888    let mut coeffs_raw: Vec<f64> = v
889        .get("cost")
890        .and_then(Value::as_array)
891        .map(|a| a.iter().map(|c| c.as_f64().unwrap_or(f64::NAN)).collect())
892        .unwrap_or_default();
893    // An out-of-range model number must not wrap into 1/2 (`as u8` turns 257
894    // into Piecewise and rescales coefficients that were never per-unit);
895    // saturate into the unknown-model passthrough instead.
896    let model = v
897        .get("model")
898        .and_then(Value::as_u64)
899        .map_or(2, |m| u8::try_from(m).unwrap_or(u8::MAX));
900    // MATPOWER pads gencost rows to the matrix width with trailing zeros, and
901    // third-party JSON can retain that padding. Trim to the declared ncost
902    // before the per-unit unscale, as `cost_to_pu` does on the way out, so
903    // padding can't read as a higher-degree polynomial and mis-scale every
904    // coefficient.
905    // Fallible conversion: an ncost beyond usize (32-bit targets) reads as
906    // undeclared instead of truncating into a small in-range value.
907    let declared_ncost = v
908        .get("ncost")
909        .and_then(Value::as_u64)
910        .and_then(|n| usize::try_from(n).ok());
911    if let Some(n) = declared_ncost {
912        let keep = if model == 1 { n.saturating_mul(2) } else { n };
913        if keep < coeffs_raw.len() {
914            coeffs_raw.truncate(keep);
915        }
916    }
917    let k = coeffs_raw.len();
918    // Undo PowerModels' per-unit cost scaling for the neutral MW basis (the
919    // inverse of the writer's per-unit rescale); a non-per-unit source is read
920    // as-is.
921    let coeffs = if per_unit {
922        normalize::cost_from_pu(&coeffs_raw, model, base_mva)
923    } else {
924        coeffs_raw
925    };
926    // A polynomial's ncost is its coefficient count; a piecewise curve stores
927    // 2·ncost values ((mw, cost) pairs).
928    let default_ncost = if model == 1 { k / 2 } else { k };
929    GenCost {
930        model,
931        startup: f(v, "startup"),
932        shutdown: f(v, "shutdown"),
933        // Clamp to what the coefficients can back: an ncost declared beyond
934        // the vector length would make the GenCost internally inconsistent.
935        ncost: declared_ncost.map_or(default_ncost, |n| n.min(default_ncost)),
936        coeffs,
937    }
938}
939
940fn read_hvdc(v: &Value, pscale: f64, base_mva: f64, per_unit: bool) -> Hvdc {
941    // Aggregate bounds come from PowerModels' raw originals (mp_pmin/mp_pmax); fall
942    // back to the from-end per-unit bounds for input that lacks them.
943    let pmin = v
944        .get("mp_pmin")
945        .and_then(Value::as_f64)
946        .unwrap_or_else(|| f(v, "pminf") * pscale);
947    let pmax = v
948        .get("mp_pmax")
949        .and_then(Value::as_f64)
950        .unwrap_or_else(|| f(v, "pmaxf") * pscale);
951    Hvdc {
952        from: BusId(uid(v, "f_bus")),
953        to: BusId(uid(v, "t_bus")),
954        in_service: flag(v, "br_status"),
955        pf: f(v, "pf") * pscale,
956        // PowerModels flips Pt/Qf/Qt vs MATPOWER; undo it for the neutral model.
957        pt: -f(v, "pt") * pscale,
958        qf: -f(v, "qf") * pscale,
959        qt: -f(v, "qt") * pscale,
960        vf: f_or(v, "vf", 1.0),
961        vt: f_or(v, "vt", 1.0),
962        pmin,
963        pmax,
964        // Unbounded reactive limits (±Inf) write as null; read them back unbounded.
965        qminf: f_or(v, "qminf", f64::NEG_INFINITY) * pscale,
966        qmaxf: f_or(v, "qmaxf", f64::INFINITY) * pscale,
967        qmint: f_or(v, "qmint", f64::NEG_INFINITY) * pscale,
968        qmaxt: f_or(v, "qmaxt", f64::INFINITY) * pscale,
969        loss0: f(v, "loss0") * pscale,
970        loss1: f(v, "loss1"),
971        cost: v.get("model").map(|_| read_cost(v, base_mva, per_unit)),
972        uid: None,
973        extras: extras_excluding(
974            v,
975            &[
976                "f_bus",
977                "t_bus",
978                "br_status",
979                "pf",
980                "pt",
981                "qf",
982                "qt",
983                "vf",
984                "vt",
985                "pmin",
986                "pmax",
987                "mp_pmin",
988                "mp_pmax",
989                "pminf",
990                "pmaxf",
991                "pmint",
992                "pmaxt",
993                "qminf",
994                "qmaxf",
995                "qmint",
996                "qmaxt",
997                "loss0",
998                "loss1",
999                "model",
1000                "ncost",
1001                "startup",
1002                "shutdown",
1003                "cost",
1004                "index",
1005                "source_id",
1006            ],
1007        ),
1008    }
1009}
1010
1011fn read_storage(v: &Value, pscale: f64) -> Storage {
1012    Storage {
1013        bus: BusId(uid(v, "storage_bus")),
1014        ps: f(v, "ps"),
1015        qs: f(v, "qs"),
1016        energy: f(v, "energy") * pscale,
1017        energy_rating: f(v, "energy_rating") * pscale,
1018        charge_rating: f(v, "charge_rating") * pscale,
1019        discharge_rating: f(v, "discharge_rating") * pscale,
1020        charge_efficiency: f_or(v, "charge_efficiency", 1.0),
1021        discharge_efficiency: f_or(v, "discharge_efficiency", 1.0),
1022        thermal_rating: f(v, "thermal_rating") * pscale,
1023        current_rating: v.get("current_rating").and_then(Value::as_f64),
1024        // Unbounded reactive limits (±Inf) write as null; read them back unbounded.
1025        qmin: f_or(v, "qmin", f64::NEG_INFINITY) * pscale,
1026        qmax: f_or(v, "qmax", f64::INFINITY) * pscale,
1027        r: f(v, "r"),
1028        x: f(v, "x"),
1029        p_loss: f(v, "p_loss") * pscale,
1030        q_loss: f(v, "q_loss") * pscale,
1031        in_service: flag(v, "status"),
1032        uid: None,
1033        extras: extras_excluding(
1034            v,
1035            &[
1036                "storage_bus",
1037                "ps",
1038                "qs",
1039                "energy",
1040                "energy_rating",
1041                "charge_rating",
1042                "discharge_rating",
1043                "charge_efficiency",
1044                "discharge_efficiency",
1045                "thermal_rating",
1046                "current_rating",
1047                "qmin",
1048                "qmax",
1049                "r",
1050                "x",
1051                "p_loss",
1052                "q_loss",
1053                "status",
1054                "index",
1055                "source_id",
1056            ],
1057        ),
1058    }
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063    use super::*;
1064
1065    fn approx(a: f64, b: f64) -> bool {
1066        (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
1067    }
1068
1069    #[test]
1070    fn boolean_status_fields_read_out_of_service() {
1071        let doc = r#"{"baseMVA":100.0,"per_unit":true,
1072            "bus":{"1":{"bus_i":1,"bus_type":3,"vm":1.0,"va":0.0,"base_kv":345.0},
1073                   "2":{"bus_i":2,"bus_type":1,"vm":1.0,"va":0.0,"base_kv":345.0}},
1074            "branch":{"1":{"f_bus":1,"t_bus":2,"br_r":0.01,"br_x":0.1,"br_status":false}},
1075            "gen":{"1":{"gen_bus":1,"pg":0.5,"gen_status":false}}}"#;
1076        let net = parse_powermodels_json(doc).unwrap();
1077        assert!(
1078            !net.branches[0].in_service,
1079            "br_status: false must read out of service"
1080        );
1081        assert!(
1082            !net.generators[0].in_service,
1083            "gen_status: false must read out of service"
1084        );
1085    }
1086
1087    #[test]
1088    #[allow(clippy::float_cmp)] // exact values pass straight through the reader
1089    fn nonunit_tap_without_transformer_flag_warns_and_stays_a_line() {
1090        let doc = r#"{"baseMVA":100.0,"per_unit":true,
1091            "bus":{"1":{"bus_i":1,"bus_type":3,"vm":1.0,"va":0.0,"base_kv":345.0},
1092                   "2":{"bus_i":2,"bus_type":1,"vm":1.0,"va":0.0,"base_kv":345.0}},
1093            "branch":{"1":{"index":1,"f_bus":1,"t_bus":2,"br_r":0.01,"br_x":0.1,"tap":1.05},
1094                      "2":{"index":2,"f_bus":1,"t_bus":2,"br_r":0.01,"br_x":0.1,"tap":1.0},
1095                      "3":{"index":3,"f_bus":1,"t_bus":2,"br_r":0.01,"br_x":0.1,
1096                           "tap":1.05,"transformer":true}}}"#;
1097        let mut warnings = Vec::new();
1098        let net =
1099            parse_powermodels_json_source(Arc::new(doc.to_owned()), None, &mut warnings).unwrap();
1100        // The inference rule is unchanged: without the flag the tap is dropped
1101        // (raw 0 = line); only the drop of a non-unit value is reported.
1102        assert_eq!(net.branches[0].tap, 0.0);
1103        assert_eq!(net.branches[1].tap, 0.0);
1104        assert_eq!(net.branches[2].tap, 1.05);
1105        // One aggregated warning naming the offending branch by its map key,
1106        // which is the identity a file without an inner `index` still has.
1107        assert_eq!(warnings.len(), 1, "{warnings:?}");
1108        assert!(
1109            warnings[0].contains("1 branch(es)") && warnings[0].contains("`1` (1 -> 2) tap 1.05"),
1110            "{warnings:?}"
1111        );
1112    }
1113
1114    #[test]
1115    fn nonpositive_base_mva_is_rejected() {
1116        for base in ["0.0", "-100.0", "1e999"] {
1117            let doc = format!(
1118                r#"{{"baseMVA":{base},"bus":{{"1":{{"bus_i":1,"bus_type":3,"vm":1.0,"va":0.0,"base_kv":345.0}}}}}}"#
1119            );
1120            assert!(
1121                parse_powermodels_json(&doc).is_err(),
1122                "baseMVA {base} must be rejected"
1123            );
1124        }
1125    }
1126
1127    #[test]
1128    fn padded_cost_rows_trim_to_ncost_before_unscaling() {
1129        // MATPOWER pads gencost rows to the matrix width; the trailing zero
1130        // here is padding, and ncost declares the real quadratic. Untrimmed,
1131        // the per-unit unscale would treat the row as cubic and divide every
1132        // coefficient by an extra factor of base.
1133        let v: Value = serde_json::json!({
1134            "gen_bus": 1, "model": 2, "ncost": 3,
1135            "cost": [1.0, 1.0, 1.0, 0.0]
1136        });
1137        let cost = read_cost(&v, 100.0, true);
1138        assert_eq!(cost.ncost, 3);
1139        assert_eq!(cost.coeffs.len(), 3);
1140        assert!(approx(cost.coeffs[0], 1e-4));
1141        assert!(approx(cost.coeffs[1], 1e-2));
1142        assert!(approx(cost.coeffs[2], 1.0));
1143    }
1144
1145    #[test]
1146    fn out_of_range_cost_model_does_not_wrap_into_rescaling() {
1147        // 257 as u8 would wrap to 1 (piecewise) and rescale coefficients that
1148        // were never per-unit; it must saturate into the unknown-model
1149        // passthrough instead.
1150        let v: Value = serde_json::json!({
1151            "gen_bus": 1, "model": 257,
1152            "cost": [10.0, 5.0]
1153        });
1154        let cost = read_cost(&v, 100.0, true);
1155        assert_eq!(cost.coeffs, vec![10.0, 5.0]);
1156    }
1157
1158    #[test]
1159    fn gen_pu_keys_subset_of_extra_keys() {
1160        // The per-unitized columns must be a subset of the emitted capability
1161        // columns; a key not in GEN_EXTRA_KEYS would never be written or scaled,
1162        // and a typo here silently mis-scales a ramp rate.
1163        for k in GEN_PU_KEYS {
1164            assert!(
1165                GEN_EXTRA_KEYS.contains(&k),
1166                "{k} is not a GEN_EXTRA_KEYS column"
1167            );
1168        }
1169    }
1170
1171    #[test]
1172    fn dcline_p_bounds_four_quadrants() {
1173        // loss0 = 1, loss1 = 0.1 ⇒ l = 0.9. Each sign quadrant of (pmin, pmax)
1174        // hand-computed against PowerModels' _mp2pm_dcline!.
1175        let q1 = dcline_p_bounds(2.0, 10.0, 1.0, 0.1);
1176        assert!(
1177            approx(q1.0, 2.0) && approx(q1.1, 10.0) && approx(q1.2, -8.0) && approx(q1.3, -0.8)
1178        );
1179
1180        let q2 = dcline_p_bounds(2.0, -5.0, 1.0, 0.1);
1181        assert!(
1182            approx(q2.0, 2.0)
1183                && approx(q2.1, 6.0 / 0.9)
1184                && approx(q2.2, -5.0)
1185                && approx(q2.3, -0.8)
1186        );
1187
1188        let q3 = dcline_p_bounds(-3.0, 10.0, 1.0, 0.1);
1189        assert!(
1190            approx(q3.0, -2.0 / 0.9)
1191                && approx(q3.1, 10.0)
1192                && approx(q3.2, -8.0)
1193                && approx(q3.3, 3.0)
1194        );
1195
1196        let q4 = dcline_p_bounds(-3.0, -5.0, 1.0, 0.1);
1197        assert!(
1198            approx(q4.0, -2.0 / 0.9)
1199                && approx(q4.1, 6.0 / 0.9)
1200                && approx(q4.2, -5.0)
1201                && approx(q4.3, 3.0)
1202        );
1203    }
1204}