Skip to main content

powerio/format/
egret.rs

1//! Read and write a [`BalancedNetwork`] as egret `ModelData` JSON.
2//!
3//! egret groups the network under `elements` (bus, load, branch, generator,
4//! shunt, dc_branch) with a small `system` block; values stay in MW/MVAr,
5//! degrees, with the base in `system.baseMVA`. Loads and shunts are first-class
6//! on the `BalancedNetwork`, generator cost becomes a polynomial/piecewise `cost_curve`,
7//! and a branch with a nonzero raw tap or a phase shift is typed `transformer`.
8//!
9//! The reader takes the power flow ModelData subset: numeric bus ids (as
10//! matpower- and pglib-derived files have), scalar element values. Unit
11//! commitment cases (`system.time_keys`, time-series values) are rejected. A
12//! same format writes return the retained source like every other format.
13
14use std::sync::Arc;
15
16use serde_json::{Map, Value};
17
18use super::{Conversion, finish, jnum, warn_extra_branch_rating_sets};
19use crate::network::{
20    BalancedNetwork, Branch, Bus, BusId, BusType, Extras, GenCost, Generator, Hvdc, Load,
21    LoadVoltageModel, Shunt, SourceFormat,
22};
23use crate::{Error, Result};
24
25const FMT: &str = "egret JSON";
26
27#[must_use]
28pub fn write_egret_json(net: &BalancedNetwork) -> Conversion {
29    let mut warnings = Vec::new();
30
31    let mut bus = Map::new();
32    for b in &net.buses {
33        bus.insert(b.id.to_string(), bus_obj(b));
34    }
35
36    // egret keys each load/shunt; use a global running suffix (load_1, load_2, …)
37    // so several loads on one bus stay distinct.
38    let mut load = Map::new();
39    for (i, l) in net.loads.iter().enumerate() {
40        load.insert(format!("load_{}", i + 1), load_obj(l));
41    }
42    let mut shunt = Map::new();
43    for (i, s) in net.shunts.iter().enumerate() {
44        shunt.insert(format!("shunt_{}", i + 1), shunt_obj(s));
45    }
46
47    let mut branch = Map::new();
48    for (i, br) in net.branches.iter().enumerate() {
49        branch.insert((i + 1).to_string(), branch_obj(br));
50    }
51
52    let mut generator = Map::new();
53    for (i, g) in net.generators.iter().enumerate() {
54        generator.insert((i + 1).to_string(), gen_obj(g, &mut warnings));
55    }
56
57    warn_egret_writer_losses(net, &mut warnings);
58
59    let mut elements = Map::new();
60    elements.insert("bus".into(), Value::Object(bus));
61    elements.insert("load".into(), Value::Object(load));
62    elements.insert("shunt".into(), Value::Object(shunt));
63    elements.insert("branch".into(), Value::Object(branch));
64    elements.insert("generator".into(), Value::Object(generator));
65
66    let mut system = Map::new();
67    system.insert("baseMVA".into(), jnum(net.base_mva));
68    match reference_bus(net) {
69        Some(r) => {
70            system.insert("reference_bus".into(), Value::String(r.id.to_string()));
71            system.insert("reference_bus_angle".into(), jnum(r.va));
72        }
73        None => warnings
74            .push("no single reference bus (BusType::Ref); system.reference_bus omitted".into()),
75    }
76
77    let mut root = Map::new();
78    root.insert("elements".into(), Value::Object(elements));
79    root.insert("system".into(), Value::Object(system));
80
81    finish(root, warnings)
82}
83
84fn warn_egret_writer_losses(net: &BalancedNetwork, warnings: &mut Vec<String>) {
85    if !net.hvdc.is_empty() {
86        warnings.push(format!(
87            "{} dcline(s) dropped: egret HVDC mapping not implemented",
88            net.hvdc.len()
89        ));
90    }
91    if !net.transformers_3w.is_empty() {
92        warnings.push(format!(
93            "{} 3-winding transformer(s) dropped: the egret writer emits no 3-winding record",
94            net.transformers_3w.len()
95        ));
96    }
97    if net
98        .buses
99        .iter()
100        .any(|b| b.evhi.is_some() || b.evlo.is_some())
101    {
102        warnings.push(
103            "emergency voltage band(s) (EVHI/EVLO) dropped: this writer carries one voltage band"
104                .into(),
105        );
106    }
107    if !net.storage.is_empty() {
108        warnings.push(format!(
109            "{} storage unit(s) dropped: egret storage mapping not implemented",
110            net.storage.len()
111        ));
112    }
113    let voltage_loads = net
114        .loads
115        .iter()
116        .filter(|l| {
117            l.voltage_model
118                .as_ref()
119                .is_some_and(LoadVoltageModel::has_non_matpower_fields)
120        })
121        .count();
122    if voltage_loads > 0 {
123        warnings.push(format!(
124            "{voltage_loads} voltage dependent load model(s) dropped: egret load records carry static p_load/q_load only"
125        ));
126    }
127    let terminal_charging = net
128        .branches
129        .iter()
130        .filter(|b| b.has_non_matpower_charging())
131        .count();
132    if terminal_charging > 0 {
133        warnings.push(format!(
134            "{terminal_charging} branch terminal admittance record(s) collapsed to total susceptance: egret branches cannot carry conductance or asymmetric terminal charging"
135        ));
136    }
137    let current_ratings = net
138        .branches
139        .iter()
140        .filter(|b| b.current_ratings.is_some())
141        .count();
142    if current_ratings > 0 {
143        warnings.push(format!(
144            "{current_ratings} branch current rating record(s) dropped: egret branch records carry MVA ratings only"
145        ));
146    }
147    warn_extra_branch_rating_sets("egret JSON", net, warnings);
148    let branch_solutions = net.branches.iter().filter(|b| b.solution.is_some()).count();
149    if branch_solutions > 0 {
150        warnings.push(format!(
151            "{branch_solutions} branch solution value set(s) dropped: egret branch result fields are not written"
152        ));
153    }
154}
155
156fn reference_bus(net: &BalancedNetwork) -> Option<&Bus> {
157    let mut refs = net.buses.iter().filter(|b| b.kind == BusType::Ref);
158    let first = refs.next()?;
159    if refs.next().is_some() {
160        None // not a single, unambiguous reference bus
161    } else {
162        Some(first)
163    }
164}
165
166fn bustype(kind: BusType) -> &'static str {
167    match kind {
168        BusType::Pq => "PQ",
169        BusType::Pv => "PV",
170        BusType::Ref => "ref",
171        BusType::Isolated => "isolated",
172    }
173}
174
175fn bus_obj(b: &Bus) -> Value {
176    let mut m = Map::new();
177    m.insert("base_kv".into(), jnum(b.base_kv));
178    m.insert(
179        "matpower_bustype".into(),
180        Value::String(bustype(b.kind).into()),
181    );
182    m.insert("vm".into(), jnum(b.vm));
183    m.insert("va".into(), jnum(b.va));
184    m.insert("v_min".into(), jnum(b.vmin));
185    m.insert("v_max".into(), jnum(b.vmax));
186    m.insert("area".into(), Value::String(b.area.to_string()));
187    m.insert("zone".into(), Value::String(b.zone.to_string()));
188    if let Some(name) = &b.name {
189        m.insert("name".into(), Value::String(name.clone()));
190    }
191    Value::Object(m)
192}
193
194fn load_obj(l: &Load) -> Value {
195    let mut m = Map::new();
196    m.insert("bus".into(), Value::String(l.bus.to_string()));
197    m.insert("p_load".into(), jnum(l.p));
198    m.insert("q_load".into(), jnum(l.q));
199    m.insert("in_service".into(), Value::Bool(l.in_service));
200    Value::Object(m)
201}
202
203fn shunt_obj(s: &Shunt) -> Value {
204    let mut m = Map::new();
205    m.insert("bus".into(), Value::String(s.bus.to_string()));
206    m.insert("shunt_type".into(), Value::String("fixed".into()));
207    m.insert("gs".into(), jnum(s.g));
208    m.insert("bs".into(), jnum(s.b));
209    Value::Object(m)
210}
211
212fn branch_obj(br: &Branch) -> Value {
213    let mut m = Map::new();
214    m.insert("from_bus".into(), Value::String(br.from.to_string()));
215    m.insert("to_bus".into(), Value::String(br.to.to_string()));
216    m.insert("resistance".into(), jnum(br.r));
217    m.insert("reactance".into(), jnum(br.x));
218    m.insert("charging_susceptance".into(), jnum(br.total_charging_b()));
219    m.insert("in_service".into(), Value::Bool(br.in_service));
220    m.insert("angle_diff_min".into(), jnum(br.angmin));
221    m.insert("angle_diff_max".into(), jnum(br.angmax));
222    if br.is_transformer() {
223        m.insert("branch_type".into(), Value::String("transformer".into()));
224        m.insert("transformer_tap_ratio".into(), jnum(br.effective_tap()));
225        m.insert("transformer_phase_shift".into(), jnum(br.shift));
226    } else {
227        m.insert("branch_type".into(), Value::String("line".into()));
228    }
229    // egret treats a zero rating as "unset"; emit only nonzero limits.
230    if br.rate_a != 0.0 {
231        m.insert("rating_long_term".into(), jnum(br.rate_a));
232    }
233    if br.rate_b != 0.0 {
234        m.insert("rating_short_term".into(), jnum(br.rate_b));
235    }
236    if br.rate_c != 0.0 {
237        m.insert("rating_emergency".into(), jnum(br.rate_c));
238    }
239    Value::Object(m)
240}
241
242fn gen_obj(g: &Generator, warnings: &mut Vec<String>) -> Value {
243    let mut m = Map::new();
244    m.insert("bus".into(), Value::String(g.bus.to_string()));
245    m.insert("generator_type".into(), Value::String("thermal".into()));
246    m.insert("in_service".into(), Value::Bool(g.in_service));
247    m.insert("pg".into(), jnum(g.pg));
248    m.insert("qg".into(), jnum(g.qg));
249    m.insert("vg".into(), jnum(g.vg));
250    m.insert("mbase".into(), jnum(g.mbase));
251    m.insert("p_min".into(), jnum(g.pmin));
252    m.insert("p_max".into(), jnum(g.pmax));
253    m.insert("q_min".into(), jnum(g.qmin));
254    m.insert("q_max".into(), jnum(g.qmax));
255    if let Some(cost) = &g.cost {
256        if let Some(curve) = cost_curve(cost) {
257            m.insert("p_cost".into(), curve);
258        } else {
259            warnings.push(format!(
260                "generator at bus {} has a cost model egret's writer can't express; cost dropped",
261                g.bus
262            ));
263        }
264    }
265    Value::Object(m)
266}
267
268/// egret `cost_curve`. MATPOWER model 2 (polynomial) maps to a degree→coefficient
269/// map; model 1 (piecewise linear) maps to `(mw, cost)` breakpoints.
270fn cost_curve(cost: &GenCost) -> Option<Value> {
271    let mut curve = Map::new();
272    curve.insert("data_type".into(), Value::String("cost_curve".into()));
273    match cost.model {
274        2 => {
275            // coeffs are highest-order first: coeffs[i] multiplies p^(k-1-i),
276            // where k = coeffs.len() (== ncost for a well-formed polynomial).
277            let mut values = Map::new();
278            let k = cost.coeffs.len();
279            for (i, &c) in cost.coeffs.iter().enumerate() {
280                values.insert((k - 1 - i).to_string(), jnum(c));
281            }
282            curve.insert("cost_curve_type".into(), Value::String("polynomial".into()));
283            curve.insert("values".into(), Value::Object(values));
284            Some(Value::Object(curve))
285        }
286        1 => {
287            let points: Vec<Value> = cost
288                .coeffs
289                .chunks_exact(2)
290                .map(|pt| Value::Array(vec![jnum(pt[0]), jnum(pt[1])]))
291                .collect();
292            curve.insert("cost_curve_type".into(), Value::String("piecewise".into()));
293            curve.insert("values".into(), Value::Array(points));
294            Some(Value::Object(curve))
295        }
296        _ => None,
297    }
298}
299
300/// Parse egret `ModelData` JSON into a [`BalancedNetwork`].
301///
302/// Inverts [`write_egret_json`]: the `elements` blocks map back to the typed
303/// model and `system.baseMVA`/`reference_bus` to the base and bus types. Takes
304/// the power flow subset (numeric bus ids, scalar values); a unit commitment
305/// case (`system.time_keys`) is rejected with a clear error.
306pub fn parse_egret_json(content: &str) -> Result<BalancedNetwork> {
307    parse_egret_source(Arc::new(content.to_owned()), None)
308}
309
310/// Owned-source entry used by the format hub: parse by borrowing `source`, then
311/// move the buffer into the retained source (no copy, byte-exact round-trip).
312/// `name_hint` (e.g. a file stem) names the network when the JSON has no
313/// `model_name`.
314pub(crate) fn parse_egret_source(
315    source: Arc<String>,
316    name_hint: Option<&str>,
317) -> Result<BalancedNetwork> {
318    let content: &str = &source;
319    let root: Value = serde_json::from_str(content).map_err(|e| bad(e.to_string()))?;
320    let root = root
321        .as_object()
322        .ok_or_else(|| bad("top level is not a JSON object"))?;
323
324    let system = obj(root, "system").ok_or_else(|| bad("missing `system` object"))?;
325    if system.contains_key("time_keys") {
326        return Err(bad(
327            "egret unit commitment cases (system.time_keys) are not supported; expected a power flow ModelData",
328        ));
329    }
330    let base_mva = system
331        .get("baseMVA")
332        .and_then(Value::as_f64)
333        .ok_or_else(|| bad("missing numeric system.baseMVA"))?;
334    let elements = obj(root, "elements").ok_or_else(|| bad("missing `elements` object"))?;
335    let name = root
336        .get("model_name")
337        .and_then(Value::as_str)
338        .or(name_hint)
339        .unwrap_or("case")
340        .to_string();
341
342    let mut buses = Vec::new();
343    if let Some(m) = obj(elements, "bus") {
344        for (k, v) in sorted_kv(m) {
345            buses.push(read_bus(k, v)?);
346        }
347    }
348    let mut loads = Vec::new();
349    if let Some(m) = obj(elements, "load") {
350        for v in sorted_vals(m) {
351            loads.push(read_load(v)?);
352        }
353    }
354    let mut shunts = Vec::new();
355    if let Some(m) = obj(elements, "shunt") {
356        for v in sorted_vals(m) {
357            shunts.push(read_shunt(v)?);
358        }
359    }
360    let mut branches = Vec::new();
361    if let Some(m) = obj(elements, "branch") {
362        for v in sorted_vals(m) {
363            branches.push(read_branch(v)?);
364        }
365    }
366    let mut generators = Vec::new();
367    if let Some(m) = obj(elements, "generator") {
368        for v in sorted_vals(m) {
369            generators.push(read_gen(v)?);
370        }
371    }
372    let mut hvdc = Vec::new();
373    if let Some(m) = obj(elements, "dc_branch") {
374        for v in sorted_vals(m) {
375            hvdc.push(read_dc_branch(v)?);
376        }
377    }
378
379    let net = BalancedNetwork {
380        name,
381        base_mva,
382        base_frequency: crate::network::DEFAULT_BASE_FREQUENCY,
383        geo: None,
384        buses,
385        loads,
386        shunts,
387        branches,
388        switches: Vec::new(),
389        generators,
390        storage: Vec::new(),
391        hvdc,
392        transformers_3w: Vec::new(),
393        areas: Vec::new(),
394        solver: None,
395        source_format: SourceFormat::EgretJson,
396        source: Some(source),
397    };
398    net.check_references(FMT)?;
399    Ok(net)
400}
401
402fn bad(message: impl Into<String>) -> Error {
403    Error::FormatRead {
404        format: FMT,
405        message: message.into(),
406    }
407}
408
409fn obj<'a>(v: &'a Map<String, Value>, key: &str) -> Option<&'a Map<String, Value>> {
410    v.get(key).and_then(Value::as_object)
411}
412
413/// Element entries sorted by the integer in the key: a bare id (`"1".."m"`, the
414/// bus/branch/generator keys) or the trailing index of a labeled key
415/// (`"load_10"` → 10). Keeps `load_2` before `load_10` so a re-emit reproduces
416/// the writer's element order (which keys by enumeration index).
417fn sorted_kv(map: &Map<String, Value>) -> Vec<(&String, &Value)> {
418    let mut items: Vec<(&String, &Value)> = map.iter().collect();
419    items.sort_by(|(a, _), (b, _)| num_key(a).cmp(&num_key(b)).then_with(|| a.cmp(b)));
420    items
421}
422
423fn sorted_vals(map: &Map<String, Value>) -> Vec<&Value> {
424    sorted_kv(map).into_iter().map(|(_, v)| v).collect()
425}
426
427/// The trailing run of digits as an integer (`"5"` → 5, `"load_10"` → 10); a key
428/// with no trailing digits sorts last. Scans bytes from the end, no allocation.
429fn num_key(k: &str) -> i64 {
430    let start = k.len() - k.bytes().rev().take_while(u8::is_ascii_digit).count();
431    k[start..].parse::<i64>().unwrap_or(i64::MAX)
432}
433
434/// A non-negative integer bus id from an f64 (egret writes some ids as numbers).
435/// Rejects negative, fractional, or out-of-range values rather than truncating or
436/// wrapping them onto the wrong bus.
437fn id_from_f64(x: f64) -> Option<usize> {
438    // Strict `<`: `usize::MAX as f64` rounds up to 2^64, so values in the gap just
439    // below it would pass `<=` and then saturate on the `as usize` cast.
440    (x >= 0.0 && x.fract() == 0.0 && x < usize::MAX as f64).then_some(x as usize)
441}
442
443/// A bus id from a JSON value: a numeric string (egret's convention) or a bare
444/// number. `None` for a non-integer, negative, or non-numeric value (named buses
445/// aren't representable in the integer `BusId` space).
446fn parse_id(v: &Value) -> Option<usize> {
447    match v {
448        Value::String(s) => {
449            let s = s.trim();
450            s.parse::<usize>()
451                .ok()
452                .or_else(|| s.parse::<f64>().ok().and_then(id_from_f64))
453        }
454        Value::Number(n) => n
455            .as_u64()
456            .map(|x| x as usize)
457            .or_else(|| n.as_f64().and_then(id_from_f64)),
458        _ => None,
459    }
460}
461
462fn id_field(v: &Value, key: &str) -> Result<BusId> {
463    let raw = v
464        .get(key)
465        .ok_or_else(|| bad(format!("element missing `{key}`")))?;
466    parse_id(raw)
467        .map(BusId)
468        .ok_or_else(|| bad(format!("`{key}` is not a numeric bus id: {raw}")))
469}
470
471/// Field `key` as f64, `0.0` when absent. A present-but-non-numeric value is a
472/// hard error, not a silent default. The PSS/E and PowerWorld
473/// readers also hold, so a garbled number can't quietly become a plausible `0.0`
474/// and corrupt the matrices downstream.
475fn f(v: &Value, key: &str) -> Result<f64> {
476    f_or(v, key, 0.0)
477}
478/// Field `key` as f64: absent or null ⇒ `default`, present but not a number ⇒ error.
479fn f_or(v: &Value, key: &str, default: f64) -> Result<f64> {
480    match v.get(key) {
481        None | Some(Value::Null) => Ok(default),
482        Some(x) => x
483            .as_f64()
484            .ok_or_else(|| bad(format!("`{key}` is not a number: {x}"))),
485    }
486}
487/// Field `key` as usize, accepting a number or a numeric string (egret writes
488/// `area`/`zone` as strings; its own parser writes them as numbers). Absent ⇒
489/// `default`; present but not a non-negative integer ⇒ error.
490fn usize_or(v: &Value, key: &str, default: usize) -> Result<usize> {
491    match v.get(key) {
492        None | Some(Value::Null) => Ok(default),
493        Some(x) => {
494            parse_id(x).ok_or_else(|| bad(format!("`{key}` is not a non-negative integer: {x}")))
495        }
496    }
497}
498/// Field `key` as bool: absent or null ⇒ `default`, present but not a bool ⇒ error.
499fn flag(v: &Value, key: &str, default: bool) -> Result<bool> {
500    match v.get(key) {
501        None | Some(Value::Null) => Ok(default),
502        Some(Value::Bool(b)) => Ok(*b),
503        Some(x) => Err(bad(format!("`{key}` is not a boolean: {x}"))),
504    }
505}
506
507fn bustype_from_str(s: &str) -> BusType {
508    match s {
509        "PV" => BusType::Pv,
510        "ref" => BusType::Ref,
511        "isolated" => BusType::Isolated,
512        _ => BusType::Pq,
513    }
514}
515
516/// Element keys the neutral model names directly are dropped here; whatever's
517/// left is preserved as extras for round trips and cross format conversion
518/// (the PowerModels reader's stance). `known` also carries the fixed stamps
519/// this module's writer emits with no model slot behind them (`shunt_type`),
520/// so a powerio-written file reads back extras-free.
521fn extras_excluding(v: &Value, known: &[&str]) -> Extras {
522    v.as_object().map_or_else(Default::default, |obj| {
523        obj.iter()
524            .filter(|(k, _)| !known.contains(&k.as_str()))
525            .map(|(k, val)| (k.clone(), val.clone()))
526            .collect()
527    })
528}
529
530fn read_bus(key: &str, v: &Value) -> Result<Bus> {
531    let id = key
532        .trim()
533        .parse::<usize>()
534        .map_err(|_| bad(format!("bus key is not a numeric id: {key:?}")))?;
535    Ok(Bus {
536        id: BusId(id),
537        kind: bustype_from_str(
538            v.get("matpower_bustype")
539                .and_then(Value::as_str)
540                .unwrap_or("PQ"),
541        ),
542        vm: f_or(v, "vm", 1.0)?,
543        va: f(v, "va")?,
544        base_kv: f(v, "base_kv")?,
545        vmax: f_or(v, "v_max", 1.1)?,
546        vmin: f_or(v, "v_min", 0.9)?,
547        evhi: None,
548        evlo: None,
549        area: usize_or(v, "area", 0)?,
550        zone: usize_or(v, "zone", 0)?,
551        name: v.get("name").and_then(Value::as_str).map(str::to_string),
552        uid: None,
553        location: None,
554        extras: extras_excluding(
555            v,
556            &[
557                "matpower_bustype",
558                "vm",
559                "va",
560                "base_kv",
561                "v_max",
562                "v_min",
563                "area",
564                "zone",
565                "name",
566            ],
567        ),
568    })
569}
570
571fn read_load(v: &Value) -> Result<Load> {
572    Ok(Load {
573        bus: id_field(v, "bus")?,
574        p: f(v, "p_load")?,
575        q: f(v, "q_load")?,
576        voltage_model: None,
577        in_service: flag(v, "in_service", true)?,
578        uid: None,
579        extras: extras_excluding(v, &["bus", "p_load", "q_load", "in_service"]),
580    })
581}
582
583fn read_shunt(v: &Value) -> Result<Shunt> {
584    Ok(Shunt {
585        bus: id_field(v, "bus")?,
586        g: f(v, "gs")?,
587        b: f(v, "bs")?,
588        in_service: flag(v, "in_service", true)?,
589        control: None,
590        uid: None,
591        extras: extras_excluding(v, &["bus", "gs", "bs", "in_service", "shunt_type"]),
592    })
593}
594
595fn read_branch(v: &Value) -> Result<Branch> {
596    let is_xf = v.get("branch_type").and_then(Value::as_str) == Some("transformer");
597    Ok(Branch {
598        from: id_field(v, "from_bus")?,
599        to: id_field(v, "to_bus")?,
600        r: f(v, "resistance")?,
601        x: f(v, "reactance")?,
602        b: f(v, "charging_susceptance")?,
603        charging: None,
604        rate_a: f(v, "rating_long_term")?,
605        rate_b: f(v, "rating_short_term")?,
606        rate_c: f(v, "rating_emergency")?,
607        rating_sets: Vec::new(),
608        current_ratings: None,
609        tap: if is_xf {
610            f_or(v, "transformer_tap_ratio", 1.0)?
611        } else {
612            0.0
613        },
614        shift: f(v, "transformer_phase_shift")?,
615        in_service: flag(v, "in_service", true)?,
616        angmin: f_or(v, "angle_diff_min", -360.0)?,
617        angmax: f_or(v, "angle_diff_max", 360.0)?,
618        control: None,
619        solution: None,
620        uid: None,
621        route: None,
622        extras: extras_excluding(
623            v,
624            &[
625                "from_bus",
626                "to_bus",
627                "resistance",
628                "reactance",
629                "charging_susceptance",
630                "rating_long_term",
631                "rating_short_term",
632                "rating_emergency",
633                "branch_type",
634                "transformer_tap_ratio",
635                "transformer_phase_shift",
636                "in_service",
637                "angle_diff_min",
638                "angle_diff_max",
639            ],
640        ),
641    })
642}
643
644fn read_gen(v: &Value) -> Result<Generator> {
645    let startup = f_or(v, "startup_cost", 0.0)?;
646    let shutdown = f_or(v, "shutdown_cost", 0.0)?;
647    // A present `p_cost` that doesn't parse is a hard error, not a silent drop:
648    // the same stance the scalar field helpers take, so a malformed cost curve
649    // can't quietly become a free generator.
650    let cost = match v.get("p_cost") {
651        None | Some(Value::Null) => None,
652        Some(pc) => Some(read_cost(pc, startup, shutdown).ok_or_else(|| {
653            bad("`p_cost` is present but has an unrecognized or malformed cost_curve")
654        })?),
655    };
656    Ok(Generator {
657        bus: id_field(v, "bus")?,
658        pg: f(v, "pg")?,
659        qg: f(v, "qg")?,
660        pmax: f(v, "p_max")?,
661        pmin: f(v, "p_min")?,
662        qmax: f(v, "q_max")?,
663        qmin: f(v, "q_min")?,
664        vg: f_or(v, "vg", 1.0)?,
665        mbase: f_or(v, "mbase", 100.0)?,
666        in_service: flag(v, "in_service", true)?,
667        cost,
668        caps: Default::default(),
669        regulated_bus: None,
670        uid: None,
671    })
672}
673
674fn read_dc_branch(v: &Value) -> Result<Hvdc> {
675    Ok(Hvdc {
676        from: id_field(v, "from_bus")?,
677        to: id_field(v, "to_bus")?,
678        in_service: flag(v, "in_service", true)?,
679        pf: f(v, "pf")?,
680        pt: f(v, "pt")?,
681        qf: f(v, "qf")?,
682        qt: f(v, "qt")?,
683        vf: f_or(v, "vf", 1.0)?,
684        vt: f_or(v, "vt", 1.0)?,
685        pmin: f(v, "pmin")?,
686        pmax: f(v, "pmax")?,
687        qminf: f(v, "qminf")?,
688        qmaxf: f(v, "qmaxf")?,
689        qmint: f(v, "qmint")?,
690        qmaxt: f(v, "qmaxt")?,
691        loss0: f(v, "loss0")?,
692        loss1: f_or(v, "loss_factor", 0.0)?,
693        cost: None,
694        uid: None,
695        extras: extras_excluding(
696            v,
697            &[
698                "from_bus",
699                "to_bus",
700                "in_service",
701                "pf",
702                "pt",
703                "qf",
704                "qt",
705                "vf",
706                "vt",
707                "pmin",
708                "pmax",
709                "qminf",
710                "qmaxf",
711                "qmint",
712                "qmaxt",
713                "loss0",
714                "loss_factor",
715            ],
716        ),
717    })
718}
719
720/// egret `p_cost` → [`GenCost`]. Polynomial `{exp: coeff}` becomes the
721/// highest-order-first coefficient vector (gaps filled with zeros); piecewise
722/// `[[p, c], ...]` becomes the flat `(mw, cost)` breakpoints.
723fn read_cost(p_cost: &Value, startup: f64, shutdown: f64) -> Option<GenCost> {
724    let m = p_cost.as_object()?;
725    match m.get("cost_curve_type").and_then(Value::as_str)? {
726        "polynomial" => {
727            // The exponent keys size the coefficient vector below; an
728            // unbounded key (a few bytes of JSON) would drive an arbitrarily
729            // large allocation. No physical cost curve goes past a handful of
730            // terms; keys beyond the cap are dropped like non-numeric ones.
731            const MAX_COST_EXPONENT: usize = 64;
732            let values = m.get("values")?.as_object()?;
733            let pairs: Vec<(usize, f64)> = values
734                .iter()
735                .filter_map(|(k, c)| Some((k.parse().ok()?, c.as_f64()?)))
736                .filter(|(e, _)| *e <= MAX_COST_EXPONENT)
737                .collect();
738            let max_exp = pairs.iter().map(|(e, _)| *e).max()?;
739            let mut coeffs = vec![0.0; max_exp + 1]; // index 0 = highest order
740            for (e, c) in pairs {
741                coeffs[max_exp - e] = c;
742            }
743            let ncost = coeffs.len();
744            Some(GenCost {
745                model: 2,
746                startup,
747                shutdown,
748                ncost,
749                coeffs,
750            })
751        }
752        "piecewise" => {
753            let values = m.get("values")?.as_array()?;
754            let mut coeffs = Vec::with_capacity(values.len() * 2);
755            for pt in values {
756                let pair = pt.as_array()?;
757                coeffs.push(pair.first()?.as_f64()?);
758                coeffs.push(pair.get(1)?.as_f64()?);
759            }
760            Some(GenCost {
761                model: 1,
762                startup,
763                shutdown,
764                ncost: values.len(),
765                coeffs,
766            })
767        }
768        _ => None,
769    }
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775    use crate::network::BusType;
776
777    #[test]
778    fn oversized_cost_exponent_is_dropped_not_allocated() {
779        // The exponent key sizes the coefficient vector; unbounded it would be
780        // an allocation of that many f64s from a few bytes of JSON, and a key
781        // at usize::MAX would wrap `max_exp + 1` to zero and index out of
782        // bounds.
783        let all_oversized: Value = serde_json::json!({
784            "cost_curve_type": "polynomial",
785            "values": {"100000000000": 5.0, "18446744073709551615": 1.0}
786        });
787        assert!(read_cost(&all_oversized, 0.0, 0.0).is_none());
788
789        let mixed: Value = serde_json::json!({
790            "cost_curve_type": "polynomial",
791            "values": {"2": 3.0, "100000000000": 1.0}
792        });
793        let cost = read_cost(&mixed, 0.0, 0.0).unwrap();
794        assert_eq!(cost.coeffs, vec![3.0, 0.0, 0.0]);
795    }
796
797    fn fixture(name: &str) -> String {
798        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
799            .join("../tests/data/egret")
800            .join(name);
801        std::fs::read_to_string(path).unwrap()
802    }
803
804    #[test]
805    fn reads_buses_loads_branches_and_reference() {
806        let net = parse_egret_json(&fixture("case30.json")).unwrap();
807        assert!((net.base_mva - 100.0).abs() < 1e-9);
808        assert_eq!(net.buses.len(), 30);
809        assert_eq!(net.loads.len(), 20);
810        assert_eq!(net.shunts.len(), 2);
811        assert_eq!(net.branches.len(), 41);
812        assert_eq!(net.generators.len(), 6);
813        // Exactly one reference bus, parsed from matpower_bustype.
814        let refs = net.buses.iter().filter(|b| b.kind == BusType::Ref).count();
815        assert_eq!(refs, 1);
816    }
817
818    #[test]
819    fn inverts_transformer_and_polynomial_cost() {
820        let net = parse_egret_json(&fixture("case14.json")).unwrap();
821        // case14 has tap-changing transformers (raw tap != 0 ⇒ is_transformer).
822        assert!(net.branches.iter().any(Branch::is_transformer));
823        // Generators carry a polynomial cost, highest order first.
824        let cost = net
825            .generators
826            .iter()
827            .find_map(|g| g.cost.as_ref())
828            .expect("a generator cost");
829        assert_eq!(cost.model, 2);
830        assert_eq!(cost.coeffs.len(), cost.ncost);
831    }
832
833    #[test]
834    fn maps_dc_branch_to_hvdc() {
835        let net = parse_egret_json(&fixture("dcline3.json")).unwrap();
836        assert_eq!(net.hvdc.len(), 1);
837        let dc = &net.hvdc[0];
838        assert_eq!((dc.from, dc.to), (BusId(1), BusId(3)));
839        assert!((dc.loss1 - 0.1).abs() < 1e-12); // loss_factor → loss1
840    }
841
842    #[test]
843    fn rejects_unit_commitment_time_series() {
844        let uc =
845            r#"{"elements":{"bus":{"1":{}}},"system":{"baseMVA":100.0,"time_keys":["1","2"]}}"#;
846        let err = parse_egret_json(uc).unwrap_err();
847        assert!(matches!(err, Error::FormatRead { .. }));
848    }
849
850    #[test]
851    fn rejects_present_but_malformed_numeric_field() {
852        // A present-but-non-numeric value must error, not silently default to 0.0
853        // (which for a reactance would drop the branch from every matrix). Absent
854        // fields still default, so the baseline parses.
855        let base = r#"{"elements":{"bus":{"1":{"matpower_bustype":"ref"},
856            "2":{"matpower_bustype":"PQ"}},"branch":{"1":{"from_bus":"1","to_bus":"2",
857            "reactance":REACT}}},"system":{"baseMVA":100.0,"reference_bus":"1"}}"#;
858        assert!(parse_egret_json(&base.replace("REACT", "0.1")).is_ok());
859        let err = parse_egret_json(&base.replace("REACT", "\"oops\"")).unwrap_err();
860        assert!(matches!(err, Error::FormatRead { .. }));
861    }
862
863    #[test]
864    fn piecewise_cost_round_trips() {
865        // The piecewise (model 1) path has its own (mw, cost) breakpoint layout,
866        // distinct from the polynomial path, and no vendored fixture exercises it.
867        // Round-trip it through cost_curve + read_cost so a transposed or dropped
868        // breakpoint can't slip by.
869        let cost = GenCost {
870            model: 1,
871            startup: 10.0,
872            shutdown: 5.0,
873            ncost: 3,
874            coeffs: vec![0.0, 0.0, 50.0, 1000.0, 100.0, 2500.0],
875        };
876        let curve = cost_curve(&cost).expect("model 1 maps to a piecewise curve");
877        let back = read_cost(&curve, 10.0, 5.0).expect("piecewise curve reads back");
878        assert_eq!(back.model, 1);
879        assert_eq!(back.ncost, 3);
880        assert_eq!(back.coeffs, cost.coeffs);
881        assert_eq!((back.startup, back.shutdown), (10.0, 5.0));
882    }
883
884    #[test]
885    fn dc_branch_reads_every_power_field() {
886        // dcline3.json leaves most dc_branch fields at their defaults, so pin the
887        // full field-name → Hvdc mapping here; a swapped key (pmax read into pmin)
888        // would otherwise ship silently.
889        let v = serde_json::json!({
890            "from_bus": "1", "to_bus": "2", "in_service": true,
891            "pf": 10.0, "pt": -9.5, "qf": 1.5, "qt": -1.0,
892            "vf": 1.02, "vt": 0.99, "pmin": -50.0, "pmax": 60.0,
893            "qminf": -5.0, "qmaxf": 5.0, "qmint": -4.0, "qmaxt": 4.5,
894            "loss0": 0.2, "loss_factor": 0.03
895        });
896        let h = read_dc_branch(&v).unwrap();
897        assert_eq!((h.from, h.to), (BusId(1), BusId(2)));
898        assert_eq!((h.pf, h.pt, h.qf, h.qt), (10.0, -9.5, 1.5, -1.0));
899        assert_eq!((h.vf, h.vt), (1.02, 0.99));
900        assert_eq!((h.pmin, h.pmax), (-50.0, 60.0));
901        assert_eq!((h.qminf, h.qmaxf, h.qmint, h.qmaxt), (-5.0, 5.0, -4.0, 4.5));
902        assert_eq!((h.loss0, h.loss1), (0.2, 0.03));
903    }
904
905    #[test]
906    fn unrecognized_element_fields_are_preserved_as_extras() {
907        // A field with no model slot must survive the read as extras.
908        // Consumed fields and the writer's own `shunt_type` stamp must
909        // stay out of extras.
910        let doc = r#"{"elements":{
911            "bus":{"1":{"matpower_bustype":"ref","vm":1.0,"vendor_ext":42},
912                   "2":{"matpower_bustype":"PQ"}},
913            "load":{"load_1":{"bus":"1","p_load":1.0,"q_load":0.5,"owner":"co-op"}},
914            "shunt":{"shunt_1":{"bus":"1","gs":0.0,"bs":5.0,"shunt_type":"fixed"}},
915            "branch":{"1":{"from_bus":"1","to_bus":"2","reactance":0.1,"pf":12.5}},
916            "dc_branch":{"1":{"from_bus":"1","to_bus":"2","rating_long_term":30.0}}},
917            "system":{"baseMVA":100.0,"reference_bus":"1"}}"#;
918        let net = parse_egret_json(doc).unwrap();
919        assert_eq!(
920            net.buses[0].extras.get("vendor_ext"),
921            Some(&Value::from(42))
922        );
923        assert!(!net.buses[0].extras.contains_key("vm"));
924        assert_eq!(
925            net.loads[0].extras.get("owner"),
926            Some(&Value::String("co-op".into()))
927        );
928        assert!(
929            net.shunts[0].extras.is_empty(),
930            "{:?}",
931            net.shunts[0].extras
932        );
933        assert_eq!(net.branches[0].extras.get("pf"), Some(&Value::from(12.5)));
934        assert_eq!(
935            net.hvdc[0].extras.get("rating_long_term"),
936            Some(&Value::from(30.0))
937        );
938    }
939
940    #[test]
941    fn rejects_present_but_malformed_cost() {
942        // A present `p_cost` the reader can't interpret is an error, not a silently
943        // free generator (cost dropped to None).
944        let v = serde_json::json!({
945            "bus": "1", "pg": 0.0, "qg": 0.0,
946            "p_max": 1.0, "p_min": 0.0, "q_max": 1.0, "q_min": -1.0,
947            "p_cost": {"data_type": "cost_curve", "cost_curve_type": "bogus", "values": {}}
948        });
949        assert!(matches!(read_gen(&v), Err(Error::FormatRead { .. })));
950    }
951}