Skip to main content

powerio/format/
egret.rs

1//! Read and write a [`Network`] 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 `Network`, 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    Branch, Bus, BusId, BusType, Extras, GenCost, Generator, Hvdc, Load, LoadVoltageModel, Network,
21    Shunt, SourceFormat,
22};
23use crate::{Error, Result};
24
25const FMT: &str = "egret JSON";
26
27#[must_use]
28pub fn write_egret_json(net: &Network) -> 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: &Network, 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: &Network) -> 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(
219        "charging_susceptance".into(),
220        jnum(br.legacy_total_charging_b()),
221    );
222    m.insert("in_service".into(), Value::Bool(br.in_service));
223    m.insert("angle_diff_min".into(), jnum(br.angmin));
224    m.insert("angle_diff_max".into(), jnum(br.angmax));
225    if br.is_transformer() {
226        m.insert("branch_type".into(), Value::String("transformer".into()));
227        m.insert("transformer_tap_ratio".into(), jnum(br.effective_tap()));
228        m.insert("transformer_phase_shift".into(), jnum(br.shift));
229    } else {
230        m.insert("branch_type".into(), Value::String("line".into()));
231    }
232    // egret treats a zero rating as "unset"; emit only nonzero limits.
233    if br.rate_a != 0.0 {
234        m.insert("rating_long_term".into(), jnum(br.rate_a));
235    }
236    if br.rate_b != 0.0 {
237        m.insert("rating_short_term".into(), jnum(br.rate_b));
238    }
239    if br.rate_c != 0.0 {
240        m.insert("rating_emergency".into(), jnum(br.rate_c));
241    }
242    Value::Object(m)
243}
244
245fn gen_obj(g: &Generator, warnings: &mut Vec<String>) -> Value {
246    let mut m = Map::new();
247    m.insert("bus".into(), Value::String(g.bus.to_string()));
248    m.insert("generator_type".into(), Value::String("thermal".into()));
249    m.insert("in_service".into(), Value::Bool(g.in_service));
250    m.insert("pg".into(), jnum(g.pg));
251    m.insert("qg".into(), jnum(g.qg));
252    m.insert("vg".into(), jnum(g.vg));
253    m.insert("mbase".into(), jnum(g.mbase));
254    m.insert("p_min".into(), jnum(g.pmin));
255    m.insert("p_max".into(), jnum(g.pmax));
256    m.insert("q_min".into(), jnum(g.qmin));
257    m.insert("q_max".into(), jnum(g.qmax));
258    if let Some(cost) = &g.cost {
259        if let Some(curve) = cost_curve(cost) {
260            m.insert("p_cost".into(), curve);
261        } else {
262            warnings.push(format!(
263                "generator at bus {} has a cost model egret's writer can't express; cost dropped",
264                g.bus
265            ));
266        }
267    }
268    Value::Object(m)
269}
270
271/// egret `cost_curve`. MATPOWER model 2 (polynomial) maps to a degree→coefficient
272/// map; model 1 (piecewise linear) maps to `(mw, cost)` breakpoints.
273fn cost_curve(cost: &GenCost) -> Option<Value> {
274    let mut curve = Map::new();
275    curve.insert("data_type".into(), Value::String("cost_curve".into()));
276    match cost.model {
277        2 => {
278            // coeffs are highest-order first: coeffs[i] multiplies p^(k-1-i),
279            // where k = coeffs.len() (== ncost for a well-formed polynomial).
280            let mut values = Map::new();
281            let k = cost.coeffs.len();
282            for (i, &c) in cost.coeffs.iter().enumerate() {
283                values.insert((k - 1 - i).to_string(), jnum(c));
284            }
285            curve.insert("cost_curve_type".into(), Value::String("polynomial".into()));
286            curve.insert("values".into(), Value::Object(values));
287            Some(Value::Object(curve))
288        }
289        1 => {
290            let points: Vec<Value> = cost
291                .coeffs
292                .chunks_exact(2)
293                .map(|pt| Value::Array(vec![jnum(pt[0]), jnum(pt[1])]))
294                .collect();
295            curve.insert("cost_curve_type".into(), Value::String("piecewise".into()));
296            curve.insert("values".into(), Value::Array(points));
297            Some(Value::Object(curve))
298        }
299        _ => None,
300    }
301}
302
303/// Parse egret `ModelData` JSON into a [`Network`].
304///
305/// Inverts [`write_egret_json`]: the `elements` blocks map back to the typed
306/// model and `system.baseMVA`/`reference_bus` to the base and bus types. Takes
307/// the power flow subset (numeric bus ids, scalar values); a unit commitment
308/// case (`system.time_keys`) is rejected with a clear error.
309pub fn parse_egret_json(content: &str) -> Result<Network> {
310    parse_egret_source(Arc::new(content.to_owned()), None)
311}
312
313/// Owned-source entry used by the format hub: parse by borrowing `source`, then
314/// move the buffer into the retained source (no copy, byte-exact round-trip).
315/// `name_hint` (e.g. a file stem) names the network when the JSON has no
316/// `model_name`.
317pub(crate) fn parse_egret_source(source: Arc<String>, name_hint: Option<&str>) -> Result<Network> {
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 = Network {
380        name,
381        base_mva,
382        base_frequency: crate::network::DEFAULT_BASE_FREQUENCY,
383        buses,
384        loads,
385        shunts,
386        branches,
387        switches: Vec::new(),
388        generators,
389        storage: Vec::new(),
390        hvdc,
391        transformers_3w: Vec::new(),
392        areas: Vec::new(),
393        solver: None,
394        source_format: SourceFormat::EgretJson,
395        source: Some(source),
396    };
397    net.check_references(FMT)?;
398    Ok(net)
399}
400
401fn bad(message: impl Into<String>) -> Error {
402    Error::FormatRead {
403        format: FMT,
404        message: message.into(),
405    }
406}
407
408fn obj<'a>(v: &'a Map<String, Value>, key: &str) -> Option<&'a Map<String, Value>> {
409    v.get(key).and_then(Value::as_object)
410}
411
412/// Element entries sorted by the integer in the key: a bare id (`"1".."m"`, the
413/// bus/branch/generator keys) or the trailing index of a labeled key
414/// (`"load_10"` → 10). Keeps `load_2` before `load_10` so a re-emit reproduces
415/// the writer's element order (which keys by enumeration index).
416fn sorted_kv(map: &Map<String, Value>) -> Vec<(&String, &Value)> {
417    let mut items: Vec<(&String, &Value)> = map.iter().collect();
418    items.sort_by(|(a, _), (b, _)| num_key(a).cmp(&num_key(b)).then_with(|| a.cmp(b)));
419    items
420}
421
422fn sorted_vals(map: &Map<String, Value>) -> Vec<&Value> {
423    sorted_kv(map).into_iter().map(|(_, v)| v).collect()
424}
425
426/// The trailing run of digits as an integer (`"5"` → 5, `"load_10"` → 10); a key
427/// with no trailing digits sorts last. Scans bytes from the end, no allocation.
428fn num_key(k: &str) -> i64 {
429    let start = k.len() - k.bytes().rev().take_while(u8::is_ascii_digit).count();
430    k[start..].parse::<i64>().unwrap_or(i64::MAX)
431}
432
433/// A non-negative integer bus id from an f64 (egret writes some ids as numbers).
434/// Rejects negative, fractional, or out-of-range values rather than truncating or
435/// wrapping them onto the wrong bus.
436fn id_from_f64(x: f64) -> Option<usize> {
437    // Strict `<`: `usize::MAX as f64` rounds up to 2^64, so values in the gap just
438    // below it would pass `<=` and then saturate on the `as usize` cast.
439    (x >= 0.0 && x.fract() == 0.0 && x < usize::MAX as f64).then_some(x as usize)
440}
441
442/// A bus id from a JSON value: a numeric string (egret's convention) or a bare
443/// number. `None` for a non-integer, negative, or non-numeric value (named buses
444/// aren't representable in the integer `BusId` space).
445fn parse_id(v: &Value) -> Option<usize> {
446    match v {
447        Value::String(s) => {
448            let s = s.trim();
449            s.parse::<usize>()
450                .ok()
451                .or_else(|| s.parse::<f64>().ok().and_then(id_from_f64))
452        }
453        Value::Number(n) => n
454            .as_u64()
455            .map(|x| x as usize)
456            .or_else(|| n.as_f64().and_then(id_from_f64)),
457        _ => None,
458    }
459}
460
461fn id_field(v: &Value, key: &str) -> Result<BusId> {
462    let raw = v
463        .get(key)
464        .ok_or_else(|| bad(format!("element missing `{key}`")))?;
465    parse_id(raw)
466        .map(BusId)
467        .ok_or_else(|| bad(format!("`{key}` is not a numeric bus id: {raw}")))
468}
469
470/// Field `key` as f64, `0.0` when absent. A present-but-non-numeric value is a
471/// hard error, not a silent default. The PSS/E and PowerWorld
472/// readers also hold, so a garbled number can't quietly become a plausible `0.0`
473/// and corrupt the matrices downstream.
474fn f(v: &Value, key: &str) -> Result<f64> {
475    f_or(v, key, 0.0)
476}
477/// Field `key` as f64: absent or null ⇒ `default`, present but not a number ⇒ error.
478fn f_or(v: &Value, key: &str, default: f64) -> Result<f64> {
479    match v.get(key) {
480        None | Some(Value::Null) => Ok(default),
481        Some(x) => x
482            .as_f64()
483            .ok_or_else(|| bad(format!("`{key}` is not a number: {x}"))),
484    }
485}
486/// Field `key` as usize, accepting a number or a numeric string (egret writes
487/// `area`/`zone` as strings; its own parser writes them as numbers). Absent ⇒
488/// `default`; present but not a non-negative integer ⇒ error.
489fn usize_or(v: &Value, key: &str, default: usize) -> Result<usize> {
490    match v.get(key) {
491        None | Some(Value::Null) => Ok(default),
492        Some(x) => {
493            parse_id(x).ok_or_else(|| bad(format!("`{key}` is not a non-negative integer: {x}")))
494        }
495    }
496}
497/// Field `key` as bool: absent or null ⇒ `default`, present but not a bool ⇒ error.
498fn flag(v: &Value, key: &str, default: bool) -> Result<bool> {
499    match v.get(key) {
500        None | Some(Value::Null) => Ok(default),
501        Some(Value::Bool(b)) => Ok(*b),
502        Some(x) => Err(bad(format!("`{key}` is not a boolean: {x}"))),
503    }
504}
505
506fn bustype_from_str(s: &str) -> BusType {
507    match s {
508        "PV" => BusType::Pv,
509        "ref" => BusType::Ref,
510        "isolated" => BusType::Isolated,
511        _ => BusType::Pq,
512    }
513}
514
515fn read_bus(key: &str, v: &Value) -> Result<Bus> {
516    let id = key
517        .trim()
518        .parse::<usize>()
519        .map_err(|_| bad(format!("bus key is not a numeric id: {key:?}")))?;
520    Ok(Bus {
521        id: BusId(id),
522        kind: bustype_from_str(
523            v.get("matpower_bustype")
524                .and_then(Value::as_str)
525                .unwrap_or("PQ"),
526        ),
527        vm: f_or(v, "vm", 1.0)?,
528        va: f(v, "va")?,
529        base_kv: f(v, "base_kv")?,
530        vmax: f_or(v, "v_max", 1.1)?,
531        vmin: f_or(v, "v_min", 0.9)?,
532        evhi: None,
533        evlo: None,
534        area: usize_or(v, "area", 0)?,
535        zone: usize_or(v, "zone", 0)?,
536        name: v.get("name").and_then(Value::as_str).map(str::to_string),
537        uid: None,
538        extras: Extras::new(),
539    })
540}
541
542fn read_load(v: &Value) -> Result<Load> {
543    Ok(Load {
544        bus: id_field(v, "bus")?,
545        p: f(v, "p_load")?,
546        q: f(v, "q_load")?,
547        voltage_model: None,
548        in_service: flag(v, "in_service", true)?,
549        uid: None,
550        extras: Extras::new(),
551    })
552}
553
554fn read_shunt(v: &Value) -> Result<Shunt> {
555    Ok(Shunt {
556        bus: id_field(v, "bus")?,
557        g: f(v, "gs")?,
558        b: f(v, "bs")?,
559        in_service: flag(v, "in_service", true)?,
560        control: None,
561        uid: None,
562        extras: Extras::new(),
563    })
564}
565
566fn read_branch(v: &Value) -> Result<Branch> {
567    let is_xf = v.get("branch_type").and_then(Value::as_str) == Some("transformer");
568    Ok(Branch {
569        from: id_field(v, "from_bus")?,
570        to: id_field(v, "to_bus")?,
571        r: f(v, "resistance")?,
572        x: f(v, "reactance")?,
573        b: f(v, "charging_susceptance")?,
574        charging: None,
575        rate_a: f(v, "rating_long_term")?,
576        rate_b: f(v, "rating_short_term")?,
577        rate_c: f(v, "rating_emergency")?,
578        rating_sets: Vec::new(),
579        current_ratings: None,
580        tap: if is_xf {
581            f_or(v, "transformer_tap_ratio", 1.0)?
582        } else {
583            0.0
584        },
585        shift: f(v, "transformer_phase_shift")?,
586        in_service: flag(v, "in_service", true)?,
587        angmin: f_or(v, "angle_diff_min", -360.0)?,
588        angmax: f_or(v, "angle_diff_max", 360.0)?,
589        control: None,
590        solution: None,
591        uid: None,
592        extras: Extras::new(),
593    })
594}
595
596fn read_gen(v: &Value) -> Result<Generator> {
597    let startup = f_or(v, "startup_cost", 0.0)?;
598    let shutdown = f_or(v, "shutdown_cost", 0.0)?;
599    // A present `p_cost` that doesn't parse is a hard error, not a silent drop:
600    // the same stance the scalar field helpers take, so a malformed cost curve
601    // can't quietly become a free generator.
602    let cost = match v.get("p_cost") {
603        None | Some(Value::Null) => None,
604        Some(pc) => Some(read_cost(pc, startup, shutdown).ok_or_else(|| {
605            bad("`p_cost` is present but has an unrecognized or malformed cost_curve")
606        })?),
607    };
608    Ok(Generator {
609        bus: id_field(v, "bus")?,
610        pg: f(v, "pg")?,
611        qg: f(v, "qg")?,
612        pmax: f(v, "p_max")?,
613        pmin: f(v, "p_min")?,
614        qmax: f(v, "q_max")?,
615        qmin: f(v, "q_min")?,
616        vg: f_or(v, "vg", 1.0)?,
617        mbase: f_or(v, "mbase", 100.0)?,
618        in_service: flag(v, "in_service", true)?,
619        cost,
620        caps: Default::default(),
621        regulated_bus: None,
622        uid: None,
623    })
624}
625
626fn read_dc_branch(v: &Value) -> Result<Hvdc> {
627    Ok(Hvdc {
628        from: id_field(v, "from_bus")?,
629        to: id_field(v, "to_bus")?,
630        in_service: flag(v, "in_service", true)?,
631        pf: f(v, "pf")?,
632        pt: f(v, "pt")?,
633        qf: f(v, "qf")?,
634        qt: f(v, "qt")?,
635        vf: f_or(v, "vf", 1.0)?,
636        vt: f_or(v, "vt", 1.0)?,
637        pmin: f(v, "pmin")?,
638        pmax: f(v, "pmax")?,
639        qminf: f(v, "qminf")?,
640        qmaxf: f(v, "qmaxf")?,
641        qmint: f(v, "qmint")?,
642        qmaxt: f(v, "qmaxt")?,
643        loss0: f(v, "loss0")?,
644        loss1: f_or(v, "loss_factor", 0.0)?,
645        cost: None,
646        uid: None,
647        extras: Extras::new(),
648    })
649}
650
651/// egret `p_cost` → [`GenCost`]. Polynomial `{exp: coeff}` becomes the
652/// highest-order-first coefficient vector (gaps filled with zeros); piecewise
653/// `[[p, c], ...]` becomes the flat `(mw, cost)` breakpoints.
654fn read_cost(p_cost: &Value, startup: f64, shutdown: f64) -> Option<GenCost> {
655    let m = p_cost.as_object()?;
656    match m.get("cost_curve_type").and_then(Value::as_str)? {
657        "polynomial" => {
658            let values = m.get("values")?.as_object()?;
659            let pairs: Vec<(usize, f64)> = values
660                .iter()
661                .filter_map(|(k, c)| Some((k.parse().ok()?, c.as_f64()?)))
662                .collect();
663            let max_exp = pairs.iter().map(|(e, _)| *e).max()?;
664            let mut coeffs = vec![0.0; max_exp + 1]; // index 0 = highest order
665            for (e, c) in pairs {
666                coeffs[max_exp - e] = c;
667            }
668            let ncost = coeffs.len();
669            Some(GenCost {
670                model: 2,
671                startup,
672                shutdown,
673                ncost,
674                coeffs,
675            })
676        }
677        "piecewise" => {
678            let values = m.get("values")?.as_array()?;
679            let mut coeffs = Vec::with_capacity(values.len() * 2);
680            for pt in values {
681                let pair = pt.as_array()?;
682                coeffs.push(pair.first()?.as_f64()?);
683                coeffs.push(pair.get(1)?.as_f64()?);
684            }
685            Some(GenCost {
686                model: 1,
687                startup,
688                shutdown,
689                ncost: values.len(),
690                coeffs,
691            })
692        }
693        _ => None,
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use crate::network::BusType;
701
702    fn fixture(name: &str) -> String {
703        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
704            .join("../tests/data/egret")
705            .join(name);
706        std::fs::read_to_string(path).unwrap()
707    }
708
709    #[test]
710    fn reads_buses_loads_branches_and_reference() {
711        let net = parse_egret_json(&fixture("case30.json")).unwrap();
712        assert!((net.base_mva - 100.0).abs() < 1e-9);
713        assert_eq!(net.buses.len(), 30);
714        assert_eq!(net.loads.len(), 20);
715        assert_eq!(net.shunts.len(), 2);
716        assert_eq!(net.branches.len(), 41);
717        assert_eq!(net.generators.len(), 6);
718        // Exactly one reference bus, parsed from matpower_bustype.
719        let refs = net.buses.iter().filter(|b| b.kind == BusType::Ref).count();
720        assert_eq!(refs, 1);
721    }
722
723    #[test]
724    fn inverts_transformer_and_polynomial_cost() {
725        let net = parse_egret_json(&fixture("case14.json")).unwrap();
726        // case14 has tap-changing transformers (raw tap != 0 ⇒ is_transformer).
727        assert!(net.branches.iter().any(Branch::is_transformer));
728        // Generators carry a polynomial cost, highest order first.
729        let cost = net
730            .generators
731            .iter()
732            .find_map(|g| g.cost.as_ref())
733            .expect("a generator cost");
734        assert_eq!(cost.model, 2);
735        assert_eq!(cost.coeffs.len(), cost.ncost);
736    }
737
738    #[test]
739    fn maps_dc_branch_to_hvdc() {
740        let net = parse_egret_json(&fixture("dcline3.json")).unwrap();
741        assert_eq!(net.hvdc.len(), 1);
742        let dc = &net.hvdc[0];
743        assert_eq!((dc.from, dc.to), (BusId(1), BusId(3)));
744        assert!((dc.loss1 - 0.1).abs() < 1e-12); // loss_factor → loss1
745    }
746
747    #[test]
748    fn rejects_unit_commitment_time_series() {
749        let uc =
750            r#"{"elements":{"bus":{"1":{}}},"system":{"baseMVA":100.0,"time_keys":["1","2"]}}"#;
751        let err = parse_egret_json(uc).unwrap_err();
752        assert!(matches!(err, Error::FormatRead { .. }));
753    }
754
755    #[test]
756    fn rejects_present_but_malformed_numeric_field() {
757        // A present-but-non-numeric value must error, not silently default to 0.0
758        // (which for a reactance would drop the branch from every matrix). Absent
759        // fields still default, so the baseline parses.
760        let base = r#"{"elements":{"bus":{"1":{"matpower_bustype":"ref"},
761            "2":{"matpower_bustype":"PQ"}},"branch":{"1":{"from_bus":"1","to_bus":"2",
762            "reactance":REACT}}},"system":{"baseMVA":100.0,"reference_bus":"1"}}"#;
763        assert!(parse_egret_json(&base.replace("REACT", "0.1")).is_ok());
764        let err = parse_egret_json(&base.replace("REACT", "\"oops\"")).unwrap_err();
765        assert!(matches!(err, Error::FormatRead { .. }));
766    }
767
768    #[test]
769    fn piecewise_cost_round_trips() {
770        // The piecewise (model 1) path has its own (mw, cost) breakpoint layout,
771        // distinct from the polynomial path, and no vendored fixture exercises it.
772        // Round-trip it through cost_curve + read_cost so a transposed or dropped
773        // breakpoint can't slip by.
774        let cost = GenCost {
775            model: 1,
776            startup: 10.0,
777            shutdown: 5.0,
778            ncost: 3,
779            coeffs: vec![0.0, 0.0, 50.0, 1000.0, 100.0, 2500.0],
780        };
781        let curve = cost_curve(&cost).expect("model 1 maps to a piecewise curve");
782        let back = read_cost(&curve, 10.0, 5.0).expect("piecewise curve reads back");
783        assert_eq!(back.model, 1);
784        assert_eq!(back.ncost, 3);
785        assert_eq!(back.coeffs, cost.coeffs);
786        assert_eq!((back.startup, back.shutdown), (10.0, 5.0));
787    }
788
789    #[test]
790    fn dc_branch_reads_every_power_field() {
791        // dcline3.json leaves most dc_branch fields at their defaults, so pin the
792        // full field-name → Hvdc mapping here; a swapped key (pmax read into pmin)
793        // would otherwise ship silently.
794        let v = serde_json::json!({
795            "from_bus": "1", "to_bus": "2", "in_service": true,
796            "pf": 10.0, "pt": -9.5, "qf": 1.5, "qt": -1.0,
797            "vf": 1.02, "vt": 0.99, "pmin": -50.0, "pmax": 60.0,
798            "qminf": -5.0, "qmaxf": 5.0, "qmint": -4.0, "qmaxt": 4.5,
799            "loss0": 0.2, "loss_factor": 0.03
800        });
801        let h = read_dc_branch(&v).unwrap();
802        assert_eq!((h.from, h.to), (BusId(1), BusId(2)));
803        assert_eq!((h.pf, h.pt, h.qf, h.qt), (10.0, -9.5, 1.5, -1.0));
804        assert_eq!((h.vf, h.vt), (1.02, 0.99));
805        assert_eq!((h.pmin, h.pmax), (-50.0, 60.0));
806        assert_eq!((h.qminf, h.qmaxf, h.qmint, h.qmaxt), (-5.0, 5.0, -4.0, 4.5));
807        assert_eq!((h.loss0, h.loss1), (0.2, 0.03));
808    }
809
810    #[test]
811    fn rejects_present_but_malformed_cost() {
812        // A present `p_cost` the reader can't interpret is an error, not a silently
813        // free generator (cost dropped to None).
814        let v = serde_json::json!({
815            "bus": "1", "pg": 0.0, "qg": 0.0,
816            "p_max": 1.0, "p_min": 0.0, "q_max": 1.0, "q_min": -1.0,
817            "p_cost": {"data_type": "cost_curve", "cost_curve_type": "bogus", "values": {}}
818        });
819        assert!(matches!(read_gen(&v), Err(Error::FormatRead { .. })));
820    }
821}