Skip to main content

powerio/format/
goc3.rs

1//! Read ARPA-E GO Challenge 3 JSON input data into the transmission `Network`.
2//!
3//! GO Challenge 3 is a unit commitment data model. `Network` is a static power
4//! flow model, so this reader maps the first time interval into static generator
5//! and load bounds, retains the original JSON source, and reports the scheduling
6//! data it leaves in the source document.
7
8use std::cmp::Ordering;
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11
12use serde_json::{Map, Value};
13
14use crate::network::{
15    Branch, BranchCharging, BranchRatingSet, Bus, BusId, BusType, Extras, GenCost, Generator, Hvdc,
16    Load, Network, Shunt, SourceFormat, TransformerControl, TransformerControlMode,
17};
18use crate::normalize;
19use crate::{Error, Result};
20
21const FMT: &str = "GO Challenge 3 JSON";
22
23#[derive(Debug)]
24struct Goc3BusMap {
25    by_uid: HashMap<String, BusId>,
26}
27
28impl Goc3BusMap {
29    fn get(&self, uid: &str) -> Result<BusId> {
30        self.by_uid
31            .get(uid)
32            .copied()
33            .ok_or_else(|| bad(format!("unknown bus uid `{uid}`")))
34    }
35}
36
37/// Parse a GO Challenge 3 JSON input file.
38pub fn parse_goc3_json(content: &str) -> Result<super::Parsed> {
39    let mut warnings = Vec::new();
40    let network = parse_goc3_source(Arc::new(content.to_owned()), None, &mut warnings)?;
41    Ok(super::Parsed { network, warnings })
42}
43
44#[allow(clippy::too_many_lines)]
45pub(crate) fn parse_goc3_source(
46    source: Arc<String>,
47    name_hint: Option<&str>,
48    warnings: &mut Vec<String>,
49) -> Result<Network> {
50    let root: Value = serde_json::from_str(&source).map_err(|e| bad(e.to_string()))?;
51    let root = root
52        .as_object()
53        .ok_or_else(|| bad("top level is not a JSON object"))?;
54    let network = root
55        .get("network")
56        .and_then(Value::as_object)
57        .ok_or_else(|| bad("missing object `network`"))?;
58
59    let base_mva = network
60        .get("general")
61        .and_then(Value::as_object)
62        .and_then(|general| number(general, "base_norm_mva"))
63        .unwrap_or_else(|| {
64            push_once(
65                warnings,
66                "missing `network.general.base_norm_mva`; using 100.0 MVA",
67            );
68            100.0
69        });
70    if !base_mva.is_finite() || base_mva <= 0.0 {
71        return Err(Error::InvalidBaseMva { base: base_mva });
72    }
73
74    let name = root
75        .get("uid")
76        .and_then(Value::as_str)
77        .or_else(|| {
78            network
79                .get("general")
80                .and_then(Value::as_object)
81                .and_then(|general| general.get("uid"))
82                .and_then(Value::as_str)
83        })
84        .or(name_hint)
85        .unwrap_or("goc3")
86        .to_owned();
87
88    warn_static_reduction(root, network, warnings);
89
90    let (mut buses, bus_map) = read_buses(network)?;
91    let bus_pos: HashMap<BusId, usize> = buses
92        .iter()
93        .enumerate()
94        .map(|(index, bus)| (bus.id, index))
95        .collect();
96    let time_series = root.get("time_series_input").and_then(Value::as_object);
97    let device_ts = device_time_series(time_series)?;
98
99    let mut branches = Vec::new();
100    branches.extend(read_branches(network, "ac_line", false, &bus_map)?);
101    branches.extend(read_branches(
102        network,
103        "two_winding_transformer",
104        true,
105        &bus_map,
106    )?);
107
108    let shunts = read_shunts(network, base_mva, &bus_map)?;
109    let mut loads = Vec::new();
110    let mut generators = Vec::new();
111    let mut generator_buses = HashSet::new();
112    let mut reference_candidate: Option<(BusId, f64)> = None;
113
114    for device in device_rows(network)? {
115        let obj = device.obj;
116        let bus = bus_ref(obj, "bus", &bus_map)?;
117        let ts = device
118            .uid
119            .as_deref()
120            .and_then(|key| device_ts.get(key).copied());
121
122        match device.table {
123            DeviceTable::Generators => {
124                let generator = read_producer(obj, ts, bus, base_mva, device.uid.clone());
125                generator_buses.insert(bus);
126                if reference_candidate
127                    .as_ref()
128                    .is_none_or(|(_, pmax)| generator.pmax > *pmax)
129                {
130                    reference_candidate = Some((bus, generator.pmax));
131                }
132                generators.push(generator);
133            }
134            DeviceTable::Loads => {
135                loads.push(read_consumer(obj, ts, bus, base_mva, device.uid.clone()));
136            }
137        }
138    }
139
140    assign_bus_types(
141        &mut buses,
142        &bus_pos,
143        &generator_buses,
144        reference_candidate,
145        warnings,
146    );
147
148    let hvdc = read_hvdc(network, base_mva, &bus_map)?;
149
150    let net = Network {
151        name,
152        base_mva,
153        base_frequency: crate::network::DEFAULT_BASE_FREQUENCY,
154        buses,
155        loads,
156        shunts,
157        branches,
158        switches: Vec::new(),
159        generators,
160        storage: Vec::new(),
161        hvdc,
162        transformers_3w: Vec::new(),
163        areas: Vec::new(),
164        solver: None,
165        source_format: SourceFormat::Goc3Json,
166        source: Some(source),
167    };
168    net.check_references(FMT)?;
169    Ok(net)
170}
171
172fn read_buses(network: &Map<String, Value>) -> Result<(Vec<Bus>, Goc3BusMap)> {
173    let items = section(network, "bus")?;
174    if items.is_empty() {
175        return Err(bad("missing non-empty `network.bus` section"));
176    }
177    let mut records = Vec::with_capacity(items.len());
178    let mut seen_uids = HashSet::new();
179    for item in items {
180        let obj = item_object(item, "bus")?;
181        let uid = item_uid(item, obj).ok_or_else(|| bad("bus record missing `uid`"))?;
182        if !seen_uids.insert(uid.clone()) {
183            return Err(bad(format!("duplicate bus uid `{uid}`")));
184        }
185        records.push((uid, obj));
186    }
187
188    let suffixes: Option<Vec<usize>> = records
189        .iter()
190        .map(|(uid, _)| official_bus_suffix(uid))
191        .collect();
192    let suffixes_unique = suffixes
193        .as_ref()
194        .is_some_and(|values| values.iter().copied().collect::<HashSet<_>>().len() == values.len());
195
196    let mut by_uid = HashMap::with_capacity(records.len());
197    let mut buses = Vec::with_capacity(records.len());
198    for (index, (uid, obj)) in records.into_iter().enumerate() {
199        let id = if suffixes_unique {
200            BusId(official_bus_suffix(&uid).expect("suffix checked above") + 1)
201        } else {
202            BusId(index + 1)
203        };
204        by_uid.insert(uid.clone(), id);
205        let initial = initial_status(obj);
206        buses.push(Bus {
207            id,
208            kind: BusType::Pq,
209            vm: initial.and_then(|s| number(s, "vm")).unwrap_or(1.0),
210            va: initial.and_then(|s| number(s, "va")).unwrap_or(0.0) * normalize::RAD_TO_DEG,
211            base_kv: number(obj, "base_nom_volt").unwrap_or(0.0),
212            vmax: number(obj, "vm_ub").unwrap_or(1.1),
213            vmin: number(obj, "vm_lb").unwrap_or(0.9),
214            evhi: None,
215            evlo: None,
216            area: 1,
217            zone: 1,
218            name: Some(uid.clone()),
219            uid: Some(uid),
220            extras: extras(
221                obj,
222                &["uid", "base_nom_volt", "vm_ub", "vm_lb", "initial_status"],
223            ),
224        });
225    }
226    Ok((buses, Goc3BusMap { by_uid }))
227}
228
229fn read_branches(
230    network: &Map<String, Value>,
231    section_name: &'static str,
232    transformer: bool,
233    buses: &Goc3BusMap,
234) -> Result<Vec<Branch>> {
235    section(network, section_name)?
236        .into_iter()
237        .map(|item| {
238            let obj = item_object(item, section_name)?;
239            let from = bus_ref(obj, "fr_bus", buses)?;
240            let to = bus_ref(obj, "to_bus", buses)?;
241            let initial = initial_status(obj);
242            let b = number(obj, "b").unwrap_or(0.0);
243            let rate_a = number(obj, "mva_ub_nom").unwrap_or(0.0);
244            let rate_b = number(obj, "mva_ub_em").unwrap_or(rate_a);
245            // GO Challenge 3 puts b/2 per terminal in addition to the extra
246            // g_fr/b_fr/g_to/b_to shunts (PNNL-35792 eq. 149/151).
247            let charging = if number(obj, "additional_shunt").unwrap_or(0.0) == 0.0 {
248                BranchCharging::from_total_b(b)
249            } else {
250                BranchCharging {
251                    g_fr: number(obj, "g_fr").unwrap_or(0.0),
252                    b_fr: b / 2.0 + number(obj, "b_fr").unwrap_or(0.0),
253                    g_to: number(obj, "g_to").unwrap_or(0.0),
254                    b_to: b / 2.0 + number(obj, "b_to").unwrap_or(0.0),
255                }
256            };
257            let tap = if transformer {
258                initial
259                    .and_then(|s| number(s, "tm"))
260                    .or_else(|| equal_bounds(obj, "tm_lb", "tm_ub"))
261                    .unwrap_or(1.0)
262            } else {
263                0.0
264            };
265            let shift = if transformer {
266                initial.and_then(|s| number(s, "ta")).unwrap_or(0.0) * normalize::RAD_TO_DEG
267            } else {
268                0.0
269            };
270            Ok(Branch {
271                from,
272                to,
273                r: number(obj, "r").unwrap_or(0.0),
274                x: number(obj, "x").unwrap_or(0.0),
275                b,
276                charging: Some(charging),
277                rate_a,
278                rate_b,
279                rate_c: rate_b,
280                rating_sets: (rate_b != 0.0 && (rate_b - rate_a).abs() > f64::EPSILON)
281                    .then(|| BranchRatingSet::new("mva_ub_em", rate_b))
282                    .into_iter()
283                    .collect(),
284                current_ratings: None,
285                tap,
286                shift,
287                in_service: initial_status_flag(obj, true),
288                angmin: -360.0,
289                angmax: 360.0,
290                control: shifter_control(obj, transformer),
291                solution: None,
292                uid: item_uid(item, obj),
293                extras: extras(
294                    obj,
295                    &[
296                        "uid",
297                        "fr_bus",
298                        "to_bus",
299                        "r",
300                        "x",
301                        "b",
302                        "mva_ub_nom",
303                        "mva_ub_em",
304                        "initial_status",
305                        "additional_shunt",
306                        "g_fr",
307                        "g_to",
308                        "b_fr",
309                        "b_to",
310                        "tm_lb",
311                        "tm_ub",
312                        "ta_lb",
313                        "ta_ub",
314                    ],
315                ),
316            })
317        })
318        .collect()
319}
320
321/// GOC3 `ta_lb`/`ta_ub` bound the phase shift decision variable: a device
322/// control range, not a bus angle difference limit, so they map to an
323/// `ActiveFlow` control block (whose tap limits carry the phase angle in
324/// degrees), never to `angmin`/`angmax`.
325fn shifter_control(obj: &Map<String, Value>, transformer: bool) -> Option<TransformerControl> {
326    if !transformer {
327        return None;
328    }
329    let lb = number(obj, "ta_lb");
330    let ub = number(obj, "ta_ub");
331    if lb.is_none() && ub.is_none() {
332        return None;
333    }
334    let mut control = TransformerControl::new(TransformerControlMode::ActiveFlow);
335    control.tap_min = lb.unwrap_or(-std::f64::consts::TAU) * normalize::RAD_TO_DEG;
336    control.tap_max = ub.unwrap_or(std::f64::consts::TAU) * normalize::RAD_TO_DEG;
337    Some(control)
338}
339
340fn read_shunts(
341    network: &Map<String, Value>,
342    base_mva: f64,
343    buses: &Goc3BusMap,
344) -> Result<Vec<Shunt>> {
345    section(network, "shunt")?
346        .into_iter()
347        .map(|item| {
348            let obj = item_object(item, "shunt")?;
349            let step = initial_status(obj)
350                .and_then(|s| number(s, "step"))
351                .unwrap_or(1.0);
352            Ok(Shunt {
353                bus: bus_ref(obj, "bus", buses)?,
354                g: number(obj, "gs").unwrap_or(0.0) * step * base_mva,
355                b: number(obj, "bs").unwrap_or(0.0) * step * base_mva,
356                in_service: step != 0.0,
357                control: None,
358                uid: item_uid(item, obj),
359                extras: extras(
360                    obj,
361                    &[
362                        "uid",
363                        "bus",
364                        "gs",
365                        "bs",
366                        "step_lb",
367                        "step_ub",
368                        "initial_status",
369                    ],
370                ),
371            })
372        })
373        .collect()
374}
375
376fn read_producer(
377    obj: &Map<String, Value>,
378    ts: Option<&Value>,
379    bus: BusId,
380    base_mva: f64,
381    uid: Option<String>,
382) -> Generator {
383    let initial = initial_status(obj);
384    Generator {
385        bus,
386        pg: initial.and_then(|s| number(s, "p")).unwrap_or(0.0) * base_mva,
387        qg: initial.and_then(|s| number(s, "q")).unwrap_or(0.0) * base_mva,
388        pmax: first_number(ts, "p_ub").unwrap_or(0.0) * base_mva,
389        pmin: first_number(ts, "p_lb").unwrap_or(0.0) * base_mva,
390        qmax: first_number(ts, "q_ub").unwrap_or(0.0) * base_mva,
391        qmin: first_number(ts, "q_lb").unwrap_or(0.0) * base_mva,
392        vg: 1.0,
393        mbase: base_mva,
394        in_service: initial_status_flag(obj, true),
395        cost: cost_at(obj, ts, 0, base_mva),
396        caps: [None; crate::network::GEN_EXTRA_KEYS.len()],
397        regulated_bus: None,
398        uid,
399    }
400}
401
402fn read_consumer(
403    obj: &Map<String, Value>,
404    ts: Option<&Value>,
405    bus: BusId,
406    base_mva: f64,
407    uid: Option<String>,
408) -> Load {
409    let initial = initial_status(obj);
410    let p = initial
411        .and_then(|s| number(s, "p"))
412        .or_else(|| first_number(ts, "p_ub"))
413        .unwrap_or(0.0)
414        .abs()
415        * base_mva;
416    let q = initial
417        .and_then(|s| number(s, "q"))
418        .or_else(|| first_number(ts, "q_ub"))
419        .unwrap_or(0.0)
420        .abs()
421        * base_mva;
422    Load {
423        bus,
424        p,
425        q,
426        voltage_model: None,
427        in_service: initial_status_flag(obj, true),
428        uid,
429        extras: extras(
430            obj,
431            &[
432                "uid",
433                "bus",
434                "device_type",
435                "initial_status",
436                "startup_cost",
437                "shutdown_cost",
438            ],
439        ),
440    }
441}
442
443fn read_hvdc(network: &Map<String, Value>, base_mva: f64, buses: &Goc3BusMap) -> Result<Vec<Hvdc>> {
444    section(network, "dc_line")?
445        .into_iter()
446        .map(|item| {
447            let obj = item_object(item, "dc_line")?;
448            let initial = initial_status(obj);
449            let pdc = initial.and_then(|s| number(s, "pdc_fr")).unwrap_or(0.0) * base_mva;
450            Ok(Hvdc {
451                from: bus_ref(obj, "fr_bus", buses)?,
452                to: bus_ref(obj, "to_bus", buses)?,
453                in_service: initial_status_flag(obj, true),
454                pf: pdc,
455                pt: -pdc,
456                qf: initial.and_then(|s| number(s, "qdc_fr")).unwrap_or(0.0) * base_mva,
457                qt: initial.and_then(|s| number(s, "qdc_to")).unwrap_or(0.0) * base_mva,
458                vf: 1.0,
459                vt: 1.0,
460                pmin: -number(obj, "pdc_ub").unwrap_or(0.0) * base_mva,
461                pmax: number(obj, "pdc_ub").unwrap_or(0.0) * base_mva,
462                qminf: number(obj, "qdc_fr_lb").unwrap_or(0.0) * base_mva,
463                qmaxf: number(obj, "qdc_fr_ub").unwrap_or(0.0) * base_mva,
464                qmint: number(obj, "qdc_to_lb").unwrap_or(0.0) * base_mva,
465                qmaxt: number(obj, "qdc_to_ub").unwrap_or(0.0) * base_mva,
466                loss0: 0.0,
467                loss1: 0.0,
468                cost: None,
469                uid: item_uid(item, obj),
470                extras: extras(
471                    obj,
472                    &[
473                        "uid",
474                        "fr_bus",
475                        "to_bus",
476                        "pdc_ub",
477                        "qdc_fr_lb",
478                        "qdc_fr_ub",
479                        "qdc_to_lb",
480                        "qdc_to_ub",
481                        "initial_status",
482                    ],
483                ),
484            })
485        })
486        .collect()
487}
488
489fn assign_bus_types(
490    buses: &mut [Bus],
491    bus_pos: &HashMap<BusId, usize>,
492    generator_buses: &HashSet<BusId>,
493    reference_candidate: Option<(BusId, f64)>,
494    warnings: &mut Vec<String>,
495) {
496    for bus in generator_buses {
497        super::set_bus_kind(buses, bus_pos, *bus, BusType::Pv);
498    }
499    if let Some((bus, _)) = reference_candidate
500        && bus_pos.contains_key(&bus)
501    {
502        super::set_bus_kind(buses, bus_pos, bus, BusType::Ref);
503        warnings.push(format!(
504            "GO Challenge 3 has no explicit reference bus; selected bus {} from the largest producer pmax",
505            bus.0
506        ));
507    }
508}
509
510/// Which payload table a simple dispatchable device row lands in.
511#[derive(Clone, Copy, Debug, PartialEq, Eq)]
512pub enum DeviceTable {
513    Generators,
514    Loads,
515}
516
517/// One simple dispatchable device with the payload row index the parser
518/// assigns it.
519pub struct DeviceRow<'a> {
520    pub table: DeviceTable,
521    pub row: usize,
522    pub uid: Option<String>,
523    pub obj: &'a Map<String, Value>,
524}
525
526/// Enumerate simple dispatchable devices with their generator/load row
527/// indices. Row assignment lives here and nowhere else: a consumer that
528/// addresses payload rows by index (the operating point extractor in
529/// `powerio-pkg`) must enumerate devices through this function so its indices
530/// match the parsed network, uid or no uid.
531pub fn device_rows(network: &Map<String, Value>) -> Result<Vec<DeviceRow<'_>>> {
532    let mut rows = Vec::new();
533    let mut generators = 0usize;
534    let mut loads = 0usize;
535    for item in section(network, "simple_dispatchable_device")? {
536        let obj = item_object(item, "simple_dispatchable_device")?;
537        let uid = item_uid(item, obj);
538        let (table, row) = match string(obj, "device_type").unwrap_or("producer") {
539            "producer" => {
540                generators += 1;
541                (DeviceTable::Generators, generators - 1)
542            }
543            "consumer" => {
544                loads += 1;
545                (DeviceTable::Loads, loads - 1)
546            }
547            other => {
548                return Err(bad(format!(
549                    "simple_dispatchable_device `{}` has unsupported `device_type` `{other}`",
550                    uid.unwrap_or_else(|| "?".into())
551                )));
552            }
553        };
554        rows.push(DeviceRow {
555            table,
556            row,
557            uid,
558            obj,
559        });
560    }
561    Ok(rows)
562}
563
564/// Piecewise marginal cost blocks for period `index`, integrated into a
565/// cumulative MATPOWER piecewise linear curve. Shared with the operating point
566/// extractor so a materialized period matches what this parser builds for the
567/// static payload.
568pub fn cost_at(
569    obj: &Map<String, Value>,
570    ts: Option<&Value>,
571    index: usize,
572    base_mva: f64,
573) -> Option<GenCost> {
574    let periods = ts?.get("cost")?.as_array()?;
575    let curve = periods.get(index)?.as_array()?;
576    let mut coeffs = vec![0.0, 0.0];
577    let mut p = 0.0;
578    let mut y = 0.0;
579    for segment in curve {
580        let values = segment.as_array()?;
581        let marginal = values.first()?.as_f64()?;
582        let width = values.get(1)?.as_f64()?;
583        if !marginal.is_finite() || !width.is_finite() || width <= 0.0 {
584            continue;
585        }
586        p += width * base_mva;
587        y += marginal * width;
588        coeffs.push(p);
589        coeffs.push(y);
590    }
591    (coeffs.len() >= 4).then_some(GenCost {
592        model: 1,
593        startup: number(obj, "startup_cost").unwrap_or(0.0),
594        shutdown: number(obj, "shutdown_cost").unwrap_or(0.0),
595        ncost: coeffs.len() / 2,
596        coeffs,
597    })
598}
599
600fn device_time_series(time_series: Option<&Map<String, Value>>) -> Result<HashMap<String, &Value>> {
601    let Some(time_series) = time_series else {
602        return Ok(HashMap::new());
603    };
604    let mut out = HashMap::new();
605    for item in section(time_series, "simple_dispatchable_device")? {
606        if let Some(key) = item.key {
607            out.insert(key.to_owned(), item.value);
608        }
609        if let Some(obj) = item.value.as_object() {
610            if let Some(uid) = string(obj, "uid") {
611                out.insert(uid.to_owned(), item.value);
612            }
613        }
614    }
615    Ok(out)
616}
617
618fn warn_static_reduction(
619    root: &Map<String, Value>,
620    network: &Map<String, Value>,
621    warnings: &mut Vec<String>,
622) {
623    if root.get("time_series_input").is_some() {
624        warnings.push(
625            "time_series_input reduced to the first interval for static Network dispatch and limits"
626                .into(),
627        );
628    }
629    if root.get("reliability").is_some() {
630        warnings.push("reliability contingencies retained in source only".into());
631    }
632    for section in [
633        "active_zonal_reserve",
634        "reactive_zonal_reserve",
635        "violation_cost",
636    ] {
637        if network.get(section).is_some() {
638            warnings.push(format!("network.{section} retained in source only"));
639        }
640    }
641    if !section(network, "simple_dispatchable_device")
642        .unwrap_or_default()
643        .is_empty()
644    {
645        warnings.push(
646            "simple dispatchable device commitment, ramp, reserve, and multi-interval cost data retained in source only"
647                .into(),
648        );
649    }
650}
651
652#[derive(Clone, Copy)]
653pub struct SectionItem<'a> {
654    pub key: Option<&'a str>,
655    pub value: &'a Value,
656}
657
658pub fn section<'a>(
659    parent: &'a Map<String, Value>,
660    name: &'static str,
661) -> Result<Vec<SectionItem<'a>>> {
662    let Some(value) = parent.get(name) else {
663        return Ok(Vec::new());
664    };
665    match value {
666        Value::Array(items) => Ok(items
667            .iter()
668            .map(|value| SectionItem { key: None, value })
669            .collect()),
670        Value::Object(map) => {
671            let mut items: Vec<_> = map
672                .iter()
673                .map(|(key, value)| SectionItem {
674                    key: Some(key.as_str()),
675                    value,
676                })
677                .collect();
678            items.sort_by(|a, b| compare_keys(a.key.unwrap_or(""), b.key.unwrap_or("")));
679            Ok(items)
680        }
681        other => Err(bad(format!(
682            "`network.{name}` is not an array or object, got {}",
683            kind(other)
684        ))),
685    }
686}
687
688fn item_object<'a>(
689    item: SectionItem<'a>,
690    section_name: &'static str,
691) -> Result<&'a Map<String, Value>> {
692    item.value.as_object().ok_or_else(|| {
693        bad(format!(
694            "`network.{section_name}` record is not an object, got {}",
695            kind(item.value)
696        ))
697    })
698}
699
700pub fn item_uid(item: SectionItem<'_>, obj: &Map<String, Value>) -> Option<String> {
701    string(obj, "uid")
702        .map(str::to_owned)
703        .or_else(|| item.key.map(str::to_owned))
704        .filter(|uid| !uid.is_empty())
705}
706
707// Numeric keys sort by value ahead of non-numeric keys, which sort
708// lexicographically. The tiers keep this a total order on mixed key sets
709// ("2" < "10" numerically while "10" < "1x" < "2" lexically is a cycle, and
710// sort_by panics on a comparator that is not a strict weak ordering).
711fn compare_keys(a: &str, b: &str) -> Ordering {
712    match (a.parse::<u64>(), b.parse::<u64>()) {
713        (Ok(a_num), Ok(b_num)) => a_num.cmp(&b_num).then_with(|| a.cmp(b)),
714        (Ok(_), Err(_)) => Ordering::Less,
715        (Err(_), Ok(_)) => Ordering::Greater,
716        (Err(_), Err(_)) => a.cmp(b),
717    }
718}
719
720fn bus_ref(obj: &Map<String, Value>, key: &'static str, buses: &Goc3BusMap) -> Result<BusId> {
721    let uid = string(obj, key).ok_or_else(|| bad(format!("missing string `{key}`")))?;
722    buses.get(uid)
723}
724
725fn official_bus_suffix(uid: &str) -> Option<usize> {
726    let rest = uid.strip_prefix("bus_")?;
727    (!rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()))
728        .then(|| rest.parse::<usize>().ok())
729        .flatten()
730}
731
732fn string<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
733    obj.get(key).and_then(Value::as_str)
734}
735
736pub fn number(obj: &Map<String, Value>, key: &str) -> Option<f64> {
737    obj.get(key).and_then(Value::as_f64)
738}
739
740fn first_number(value: Option<&Value>, key: &str) -> Option<f64> {
741    value?.get(key)?.as_array()?.first().and_then(Value::as_f64)
742}
743
744fn initial_status(obj: &Map<String, Value>) -> Option<&Map<String, Value>> {
745    obj.get("initial_status").and_then(Value::as_object)
746}
747
748fn initial_status_flag(obj: &Map<String, Value>, default: bool) -> bool {
749    initial_status(obj)
750        .and_then(|status| number(status, "on_status"))
751        .map_or(default, |v| v != 0.0)
752}
753
754fn equal_bounds(obj: &Map<String, Value>, low: &str, high: &str) -> Option<f64> {
755    let lo = number(obj, low)?;
756    let hi = number(obj, high)?;
757    ((lo - hi).abs() <= f64::EPSILON).then_some(lo)
758}
759
760fn extras(obj: &Map<String, Value>, known: &[&str]) -> Extras {
761    obj.iter()
762        .filter(|(key, _)| !known.contains(&key.as_str()))
763        .map(|(key, value)| (key.clone(), value.clone()))
764        .collect()
765}
766
767fn push_once(warnings: &mut Vec<String>, warning: &str) {
768    if !warnings.iter().any(|w| w == warning) {
769        warnings.push(warning.to_owned());
770    }
771}
772
773fn kind(value: &Value) -> &'static str {
774    match value {
775        Value::Null => "null",
776        Value::Bool(_) => "bool",
777        Value::Number(_) => "number",
778        Value::String(_) => "string",
779        Value::Array(_) => "array",
780        Value::Object(_) => "object",
781    }
782}
783
784fn bad(message: impl Into<String>) -> Error {
785    Error::FormatRead {
786        format: FMT,
787        message: message.into(),
788    }
789}
790
791/// Document-walking helpers shared with `powerio-pkg`'s operating point
792/// extractor, which must interpret a GOC3 document exactly as this parser
793/// does: same section ordering, same device row assignment, same cost
794/// mapping. Hidden: not part of the public format API.
795pub mod bridge {
796    pub use super::{
797        DeviceRow, DeviceTable, SectionItem, cost_at, device_rows, item_uid, number, section,
798    };
799}