Skip to main content

powerio/format/
goc3.rs

1//! Read ARPA-E GO Challenge 3 JSON input data into the transmission `BalancedNetwork`.
2//!
3//! GO Challenge 3 is a unit commitment data model. `BalancedNetwork` 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    BalancedNetwork, Branch, BranchCharging, BranchRatingSet, Bus, BusId, BusType, Extras, GenCost,
16    Generator, Hvdc, Load, Shunt, SourceFormat, TransformerControl, TransformerControlMode,
17};
18use crate::normalize;
19use crate::{Error, Result};
20
21const FMT: &str = "GO Challenge 3 JSON";
22
23/// GOC3 source document: the file parsed once and shared by the format's
24/// adapters (the balanced network reader here, the operating point extractor
25/// in `powerio-pkg`, and the SCOPF instance builder in `powerio-prob`), so
26/// section order, uid, bus ID, and device row rules have one owner.
27#[derive(Clone, Debug)]
28pub struct Goc3Document {
29    root: Map<String, Value>,
30}
31
32impl Goc3Document {
33    /// Parse one GOC3 JSON document.
34    pub fn parse(text: &str) -> Result<Self> {
35        let value: Value = serde_json::from_str(text).map_err(|error| bad(error.to_string()))?;
36        let Value::Object(root) = value else {
37            return Err(bad("top level is not a JSON object"));
38        };
39        Ok(Self { root })
40    }
41
42    #[must_use]
43    pub fn root(&self) -> &Map<String, Value> {
44        &self.root
45    }
46
47    pub fn network(&self) -> Result<&Map<String, Value>> {
48        self.root
49            .get("network")
50            .and_then(Value::as_object)
51            .ok_or_else(|| bad("missing object `network`"))
52    }
53
54    pub fn time_series_input(&self) -> Result<&Map<String, Value>> {
55        self.root
56            .get("time_series_input")
57            .and_then(Value::as_object)
58            .ok_or_else(|| bad("missing object `time_series_input`"))
59    }
60
61    #[must_use]
62    pub fn time_series_output(&self) -> Option<&Map<String, Value>> {
63        self.root
64            .get("time_series_output")
65            .and_then(Value::as_object)
66    }
67
68    #[must_use]
69    pub fn reliability(&self) -> Option<&Map<String, Value>> {
70        self.root.get("reliability").and_then(Value::as_object)
71    }
72
73    /// Read a network section in source document order.
74    pub fn network_records(&self, name: &'static str) -> Result<Vec<Goc3Record<'_>>> {
75        records(self.network()?, name)
76    }
77
78    /// Read a time series input section in source document order.
79    pub fn time_series_input_records(&self, name: &'static str) -> Result<Vec<Goc3Record<'_>>> {
80        records(self.time_series_input()?, name)
81    }
82
83    /// Read a time series output section in source document order.
84    pub fn time_series_output_records(&self, name: &'static str) -> Result<Vec<Goc3Record<'_>>> {
85        self.time_series_output()
86            .map_or_else(|| Ok(Vec::new()), |output| records(output, name))
87    }
88
89    /// Enumerate dispatchable devices with the balanced model row assignment.
90    pub fn dispatchable_devices(&self) -> Result<Vec<Goc3DeviceRecord<'_>>> {
91        device_rows(self.network()?)
92    }
93
94    /// Map bus UIDs to the external bus IDs assigned by the balanced reader.
95    ///
96    /// Uid uniqueness is validated by the parse that produced this document
97    /// (`read_buses` rejects a duplicate bus uid), so the map here cannot
98    /// silently collapse entries; a document constructed another way has no
99    /// such guarantee.
100    pub fn bus_ids(&self) -> Result<HashMap<String, BusId>> {
101        bus_id_by_uid(&section(self.network()?, "bus")?)
102    }
103
104    /// Build the period cost curve used by the balanced reader.
105    #[must_use]
106    pub fn dispatchable_device_cost_at(
107        &self,
108        device: &Map<String, Value>,
109        time_series: Option<&Value>,
110        period: usize,
111        base_mva: f64,
112    ) -> Option<GenCost> {
113        cost_at(device, time_series, period, base_mva)
114    }
115}
116
117/// One GOC3 section record in source document order.
118#[derive(Clone, Debug)]
119pub struct Goc3Record<'a> {
120    pub uid: Option<String>,
121    pub value: &'a Value,
122}
123
124#[derive(Debug)]
125struct Goc3BusMap {
126    by_uid: HashMap<String, BusId>,
127}
128
129impl Goc3BusMap {
130    fn get(&self, uid: &str) -> Result<BusId> {
131        self.by_uid
132            .get(uid)
133            .copied()
134            .ok_or_else(|| bad(format!("unknown bus uid `{uid}`")))
135    }
136}
137
138/// Parse a GO Challenge 3 JSON input file.
139pub fn parse_goc3_json(content: &str) -> Result<super::Parsed> {
140    let mut warnings = Vec::new();
141    let (network, document) = parse_goc3_source(Arc::new(content.to_owned()), None, &mut warnings)?;
142    Ok(super::Parsed {
143        network,
144        warnings,
145        document: Some(super::SourceDocument::Goc3(document)),
146    })
147}
148
149/// Parse GOC3 source text into the balanced network plus the document the
150/// parse went through, so the caller hands the document forward instead of
151/// letting downstream adapters reparse the retained text.
152#[allow(clippy::too_many_lines)]
153pub(crate) fn parse_goc3_source(
154    source: Arc<String>,
155    name_hint: Option<&str>,
156    warnings: &mut Vec<String>,
157) -> Result<(BalancedNetwork, Arc<Goc3Document>)> {
158    let document = Arc::new(Goc3Document::parse(&source)?);
159    let root = document.root();
160    let network = document.network()?;
161
162    let base_mva = network
163        .get("general")
164        .and_then(Value::as_object)
165        .and_then(|general| number(general, "base_norm_mva"))
166        .unwrap_or_else(|| {
167            push_once(
168                warnings,
169                "missing `network.general.base_norm_mva`; using 100.0 MVA",
170            );
171            100.0
172        });
173    if !base_mva.is_finite() || base_mva <= 0.0 {
174        return Err(Error::InvalidBaseMva { base: base_mva });
175    }
176
177    let name = root
178        .get("uid")
179        .and_then(Value::as_str)
180        .or_else(|| {
181            network
182                .get("general")
183                .and_then(Value::as_object)
184                .and_then(|general| general.get("uid"))
185                .and_then(Value::as_str)
186        })
187        .or(name_hint)
188        .unwrap_or("goc3")
189        .to_owned();
190
191    warn_static_reduction(root, network, warnings);
192
193    let (mut buses, bus_map) = read_buses(network)?;
194    let bus_pos: HashMap<BusId, usize> = buses
195        .iter()
196        .enumerate()
197        .map(|(index, bus)| (bus.id, index))
198        .collect();
199    let time_series = root.get("time_series_input").and_then(Value::as_object);
200    let device_ts = device_time_series(time_series)?;
201
202    let mut branches = Vec::new();
203    branches.extend(read_branches(network, "ac_line", false, &bus_map)?);
204    branches.extend(read_branches(
205        network,
206        "two_winding_transformer",
207        true,
208        &bus_map,
209    )?);
210
211    let shunts = read_shunts(network, base_mva, &bus_map)?;
212    let mut loads = Vec::new();
213    let mut generators = Vec::new();
214    let mut generator_buses = HashSet::new();
215    let mut reference_candidate: Option<(BusId, f64)> = None;
216
217    for device in device_rows(network)? {
218        let obj = device.obj;
219        let bus = bus_ref(obj, "bus", &bus_map)?;
220        let ts = device
221            .uid
222            .as_deref()
223            .and_then(|key| device_ts.get(key).copied());
224
225        match device.kind {
226            Goc3DeviceKind::Generators => {
227                let generator = read_producer(obj, ts, bus, base_mva, device.uid.clone());
228                generator_buses.insert(bus);
229                if reference_candidate
230                    .as_ref()
231                    .is_none_or(|(_, pmax)| generator.pmax > *pmax)
232                {
233                    reference_candidate = Some((bus, generator.pmax));
234                }
235                generators.push(generator);
236            }
237            Goc3DeviceKind::Loads => {
238                loads.push(read_consumer(obj, ts, bus, base_mva, device.uid.clone()));
239            }
240        }
241    }
242
243    assign_bus_types(
244        &mut buses,
245        &bus_pos,
246        &generator_buses,
247        reference_candidate,
248        warnings,
249    );
250
251    let hvdc = read_hvdc(network, base_mva, &bus_map)?;
252
253    let net = BalancedNetwork {
254        name,
255        base_mva,
256        base_frequency: crate::network::DEFAULT_BASE_FREQUENCY,
257        geo: None,
258        buses,
259        loads,
260        shunts,
261        branches,
262        switches: Vec::new(),
263        generators,
264        storage: Vec::new(),
265        hvdc,
266        transformers_3w: Vec::new(),
267        areas: Vec::new(),
268        solver: None,
269        source_format: SourceFormat::Goc3Json,
270        source: Some(source),
271    };
272    net.check_references(FMT)?;
273    Ok((net, document))
274}
275
276fn read_buses(network: &Map<String, Value>) -> Result<(Vec<Bus>, Goc3BusMap)> {
277    let items = section(network, "bus")?;
278    if items.is_empty() {
279        return Err(bad("missing non-empty `network.bus` section"));
280    }
281    let mut records = Vec::with_capacity(items.len());
282    let mut seen_uids = HashSet::new();
283    for item in &items {
284        let obj = item_object(*item, "bus")?;
285        let uid = item_uid(*item, obj).ok_or_else(|| bad("bus record missing `uid`"))?;
286        if !seen_uids.insert(uid.clone()) {
287            return Err(bad(format!("duplicate bus uid `{uid}`")));
288        }
289        records.push((uid, obj));
290    }
291
292    let ids = bus_id_by_uid(&items)?;
293
294    let mut by_uid = HashMap::with_capacity(records.len());
295    let mut buses = Vec::with_capacity(records.len());
296    for (uid, obj) in records {
297        let id = ids[&uid];
298        by_uid.insert(uid.clone(), id);
299        let initial = initial_status(obj);
300        buses.push(Bus {
301            id,
302            kind: BusType::Pq,
303            vm: initial.and_then(|s| number(s, "vm")).unwrap_or(1.0),
304            va: initial.and_then(|s| number(s, "va")).unwrap_or(0.0) * normalize::RAD_TO_DEG,
305            base_kv: number(obj, "base_nom_volt").unwrap_or(0.0),
306            vmax: number(obj, "vm_ub").unwrap_or(1.1),
307            vmin: number(obj, "vm_lb").unwrap_or(0.9),
308            evhi: None,
309            evlo: None,
310            area: 1,
311            zone: 1,
312            name: Some(uid.clone()),
313            uid: Some(uid),
314            location: None,
315            extras: extras(
316                obj,
317                &["uid", "base_nom_volt", "vm_ub", "vm_lb", "initial_status"],
318            ),
319        });
320    }
321    Ok((buses, Goc3BusMap { by_uid }))
322}
323
324fn read_branches(
325    network: &Map<String, Value>,
326    section_name: &'static str,
327    transformer: bool,
328    buses: &Goc3BusMap,
329) -> Result<Vec<Branch>> {
330    section(network, section_name)?
331        .into_iter()
332        .map(|item| {
333            let obj = item_object(item, section_name)?;
334            let from = bus_ref(obj, "fr_bus", buses)?;
335            let to = bus_ref(obj, "to_bus", buses)?;
336            let initial = initial_status(obj);
337            let b = number(obj, "b").unwrap_or(0.0);
338            let rate_a = number(obj, "mva_ub_nom").unwrap_or(0.0);
339            let rate_b = number(obj, "mva_ub_em").unwrap_or(rate_a);
340            // GO Challenge 3 puts b/2 per terminal in addition to the extra
341            // g_fr/b_fr/g_to/b_to shunts (PNNL-35792 eq. 149/151).
342            let charging = if number(obj, "additional_shunt").unwrap_or(0.0) == 0.0 {
343                BranchCharging::from_total_b(b)
344            } else {
345                BranchCharging {
346                    g_fr: number(obj, "g_fr").unwrap_or(0.0),
347                    b_fr: b / 2.0 + number(obj, "b_fr").unwrap_or(0.0),
348                    g_to: number(obj, "g_to").unwrap_or(0.0),
349                    b_to: b / 2.0 + number(obj, "b_to").unwrap_or(0.0),
350                }
351            };
352            let tap = if transformer {
353                initial
354                    .and_then(|s| number(s, "tm"))
355                    .or_else(|| equal_bounds(obj, "tm_lb", "tm_ub"))
356                    .unwrap_or(1.0)
357            } else {
358                0.0
359            };
360            let shift = if transformer {
361                initial.and_then(|s| number(s, "ta")).unwrap_or(0.0) * normalize::RAD_TO_DEG
362            } else {
363                0.0
364            };
365            Ok(Branch {
366                from,
367                to,
368                r: number(obj, "r").unwrap_or(0.0),
369                x: number(obj, "x").unwrap_or(0.0),
370                b,
371                charging: Some(charging),
372                rate_a,
373                rate_b,
374                rate_c: rate_b,
375                rating_sets: (rate_b != 0.0 && (rate_b - rate_a).abs() > f64::EPSILON)
376                    .then(|| BranchRatingSet::new("mva_ub_em", rate_b))
377                    .into_iter()
378                    .collect(),
379                current_ratings: None,
380                tap,
381                shift,
382                in_service: initial_status_flag(obj, true),
383                angmin: -360.0,
384                angmax: 360.0,
385                control: shifter_control(obj, transformer),
386                solution: None,
387                uid: item_uid(item, obj),
388                route: None,
389                extras: extras(
390                    obj,
391                    &[
392                        "uid",
393                        "fr_bus",
394                        "to_bus",
395                        "r",
396                        "x",
397                        "b",
398                        "mva_ub_nom",
399                        "mva_ub_em",
400                        "initial_status",
401                        "additional_shunt",
402                        "g_fr",
403                        "g_to",
404                        "b_fr",
405                        "b_to",
406                        "tm_lb",
407                        "tm_ub",
408                        "ta_lb",
409                        "ta_ub",
410                    ],
411                ),
412            })
413        })
414        .collect()
415}
416
417/// GOC3 `ta_lb`/`ta_ub` bound the phase shift decision variable: a device
418/// control range, not a bus angle difference limit, so they map to an
419/// `ActiveFlow` control block (whose tap limits carry the phase angle in
420/// degrees), never to `angmin`/`angmax`.
421fn shifter_control(obj: &Map<String, Value>, transformer: bool) -> Option<TransformerControl> {
422    if !transformer {
423        return None;
424    }
425    let lb = number(obj, "ta_lb");
426    let ub = number(obj, "ta_ub");
427    if lb.is_none() && ub.is_none() {
428        return None;
429    }
430    let mut control = TransformerControl::new(TransformerControlMode::ActiveFlow);
431    control.tap_min = lb.unwrap_or(-std::f64::consts::TAU) * normalize::RAD_TO_DEG;
432    control.tap_max = ub.unwrap_or(std::f64::consts::TAU) * normalize::RAD_TO_DEG;
433    Some(control)
434}
435
436fn read_shunts(
437    network: &Map<String, Value>,
438    base_mva: f64,
439    buses: &Goc3BusMap,
440) -> Result<Vec<Shunt>> {
441    section(network, "shunt")?
442        .into_iter()
443        .map(|item| {
444            let obj = item_object(item, "shunt")?;
445            let step = initial_status(obj)
446                .and_then(|s| number(s, "step"))
447                .unwrap_or(1.0);
448            Ok(Shunt {
449                bus: bus_ref(obj, "bus", buses)?,
450                g: number(obj, "gs").unwrap_or(0.0) * step * base_mva,
451                b: number(obj, "bs").unwrap_or(0.0) * step * base_mva,
452                in_service: step != 0.0,
453                control: None,
454                uid: item_uid(item, obj),
455                extras: extras(
456                    obj,
457                    &[
458                        "uid",
459                        "bus",
460                        "gs",
461                        "bs",
462                        "step_lb",
463                        "step_ub",
464                        "initial_status",
465                    ],
466                ),
467            })
468        })
469        .collect()
470}
471
472fn read_producer(
473    obj: &Map<String, Value>,
474    ts: Option<&Value>,
475    bus: BusId,
476    base_mva: f64,
477    uid: Option<String>,
478) -> Generator {
479    let initial = initial_status(obj);
480    Generator {
481        bus,
482        pg: initial.and_then(|s| number(s, "p")).unwrap_or(0.0) * base_mva,
483        qg: initial.and_then(|s| number(s, "q")).unwrap_or(0.0) * base_mva,
484        pmax: first_number(ts, "p_ub").unwrap_or(0.0) * base_mva,
485        pmin: first_number(ts, "p_lb").unwrap_or(0.0) * base_mva,
486        qmax: first_number(ts, "q_ub").unwrap_or(0.0) * base_mva,
487        qmin: first_number(ts, "q_lb").unwrap_or(0.0) * base_mva,
488        vg: 1.0,
489        mbase: base_mva,
490        in_service: initial_status_flag(obj, true),
491        cost: cost_at(obj, ts, 0, base_mva),
492        caps: [None; crate::network::GEN_EXTRA_KEYS.len()],
493        regulated_bus: None,
494        uid,
495    }
496}
497
498fn read_consumer(
499    obj: &Map<String, Value>,
500    ts: Option<&Value>,
501    bus: BusId,
502    base_mva: f64,
503    uid: Option<String>,
504) -> Load {
505    let initial = initial_status(obj);
506    let p = initial
507        .and_then(|s| number(s, "p"))
508        .or_else(|| first_number(ts, "p_ub"))
509        .unwrap_or(0.0)
510        .abs()
511        * base_mva;
512    let q = initial
513        .and_then(|s| number(s, "q"))
514        .or_else(|| first_number(ts, "q_ub"))
515        .unwrap_or(0.0)
516        .abs()
517        * base_mva;
518    Load {
519        bus,
520        p,
521        q,
522        voltage_model: None,
523        in_service: initial_status_flag(obj, true),
524        uid,
525        extras: extras(
526            obj,
527            &[
528                "uid",
529                "bus",
530                "device_type",
531                "initial_status",
532                "startup_cost",
533                "shutdown_cost",
534            ],
535        ),
536    }
537}
538
539fn read_hvdc(network: &Map<String, Value>, base_mva: f64, buses: &Goc3BusMap) -> Result<Vec<Hvdc>> {
540    section(network, "dc_line")?
541        .into_iter()
542        .map(|item| {
543            let obj = item_object(item, "dc_line")?;
544            let initial = initial_status(obj);
545            let pdc = initial.and_then(|s| number(s, "pdc_fr")).unwrap_or(0.0) * base_mva;
546            Ok(Hvdc {
547                from: bus_ref(obj, "fr_bus", buses)?,
548                to: bus_ref(obj, "to_bus", buses)?,
549                in_service: initial_status_flag(obj, true),
550                pf: pdc,
551                pt: -pdc,
552                qf: initial.and_then(|s| number(s, "qdc_fr")).unwrap_or(0.0) * base_mva,
553                qt: initial.and_then(|s| number(s, "qdc_to")).unwrap_or(0.0) * base_mva,
554                vf: 1.0,
555                vt: 1.0,
556                pmin: -number(obj, "pdc_ub").unwrap_or(0.0) * base_mva,
557                pmax: number(obj, "pdc_ub").unwrap_or(0.0) * base_mva,
558                qminf: number(obj, "qdc_fr_lb").unwrap_or(0.0) * base_mva,
559                qmaxf: number(obj, "qdc_fr_ub").unwrap_or(0.0) * base_mva,
560                qmint: number(obj, "qdc_to_lb").unwrap_or(0.0) * base_mva,
561                qmaxt: number(obj, "qdc_to_ub").unwrap_or(0.0) * base_mva,
562                loss0: 0.0,
563                loss1: 0.0,
564                cost: None,
565                uid: item_uid(item, obj),
566                extras: extras(
567                    obj,
568                    &[
569                        "uid",
570                        "fr_bus",
571                        "to_bus",
572                        "pdc_ub",
573                        "qdc_fr_lb",
574                        "qdc_fr_ub",
575                        "qdc_to_lb",
576                        "qdc_to_ub",
577                        "initial_status",
578                    ],
579                ),
580            })
581        })
582        .collect()
583}
584
585fn assign_bus_types(
586    buses: &mut [Bus],
587    bus_pos: &HashMap<BusId, usize>,
588    generator_buses: &HashSet<BusId>,
589    reference_candidate: Option<(BusId, f64)>,
590    warnings: &mut Vec<String>,
591) {
592    for bus in generator_buses {
593        super::set_bus_kind(buses, bus_pos, *bus, BusType::Pv);
594    }
595    if let Some((bus, _)) = reference_candidate
596        && bus_pos.contains_key(&bus)
597    {
598        super::set_bus_kind(buses, bus_pos, bus, BusType::Ref);
599        warnings.push(format!(
600            "GO Challenge 3 has no explicit reference bus; selected bus {} from the largest producer pmax",
601            bus.0
602        ));
603    }
604}
605
606/// Which payload table a simple dispatchable device row lands in.
607#[derive(Clone, Copy, Debug, PartialEq, Eq)]
608pub enum Goc3DeviceKind {
609    Generators,
610    Loads,
611}
612
613/// One simple dispatchable device with the payload row index the parser
614/// assigns it.
615pub struct Goc3DeviceRecord<'a> {
616    pub kind: Goc3DeviceKind,
617    pub row: usize,
618    pub uid: Option<String>,
619    pub obj: &'a Map<String, Value>,
620}
621
622/// Enumerate simple dispatchable devices with their generator/load row
623/// indices. Row assignment lives here and nowhere else: a consumer that
624/// addresses payload rows by index (the operating point extractor in
625/// `powerio-pkg`) must enumerate devices through this function so its indices
626/// match the parsed network, uid or no uid.
627fn device_rows(network: &Map<String, Value>) -> Result<Vec<Goc3DeviceRecord<'_>>> {
628    let mut rows = Vec::new();
629    let mut generators = 0usize;
630    let mut loads = 0usize;
631    // Time-series bounds and costs are joined back onto devices by uid
632    // (`device_time_series` is last-write-wins on its map), so a repeated uid
633    // would silently hand one device the other's series; reject it the way
634    // `read_buses` rejects a repeated bus uid.
635    let mut seen_uids = std::collections::HashSet::new();
636    for item in section(network, "simple_dispatchable_device")? {
637        let obj = item_object(item, "simple_dispatchable_device")?;
638        let uid = item_uid(item, obj);
639        if let Some(uid) = &uid {
640            if !seen_uids.insert(uid.clone()) {
641                return Err(bad(format!(
642                    "duplicate simple_dispatchable_device uid `{uid}`"
643                )));
644            }
645        }
646        let (table, row) = match string(obj, "device_type").unwrap_or("producer") {
647            "producer" => {
648                generators += 1;
649                (Goc3DeviceKind::Generators, generators - 1)
650            }
651            "consumer" => {
652                loads += 1;
653                (Goc3DeviceKind::Loads, loads - 1)
654            }
655            other => {
656                return Err(bad(format!(
657                    "simple_dispatchable_device `{}` has unsupported `device_type` `{other}`",
658                    uid.unwrap_or_else(|| "?".into())
659                )));
660            }
661        };
662        rows.push(Goc3DeviceRecord {
663            kind: table,
664            row,
665            uid,
666            obj,
667        });
668    }
669    Ok(rows)
670}
671
672/// Piecewise marginal cost blocks for period `index`, integrated into a
673/// cumulative MATPOWER piecewise linear curve. Shared with the operating point
674/// extractor so a materialized period matches what this parser builds for the
675/// static payload.
676fn cost_at(
677    obj: &Map<String, Value>,
678    ts: Option<&Value>,
679    index: usize,
680    base_mva: f64,
681) -> Option<GenCost> {
682    let periods = ts?.get("cost")?.as_array()?;
683    let curve = periods.get(index)?.as_array()?;
684    let mut coeffs = vec![0.0, 0.0];
685    let mut p = 0.0;
686    let mut y = 0.0;
687    for segment in curve {
688        let values = segment.as_array()?;
689        let marginal = values.first()?.as_f64()?;
690        let width = values.get(1)?.as_f64()?;
691        if !marginal.is_finite() || !width.is_finite() || width <= 0.0 {
692            continue;
693        }
694        p += width * base_mva;
695        y += marginal * width;
696        coeffs.push(p);
697        coeffs.push(y);
698    }
699    (coeffs.len() >= 4).then_some(GenCost {
700        model: 1,
701        startup: number(obj, "startup_cost").unwrap_or(0.0),
702        shutdown: number(obj, "shutdown_cost").unwrap_or(0.0),
703        ncost: coeffs.len() / 2,
704        coeffs,
705    })
706}
707
708fn device_time_series(time_series: Option<&Map<String, Value>>) -> Result<HashMap<String, &Value>> {
709    let Some(time_series) = time_series else {
710        return Ok(HashMap::new());
711    };
712    let mut out = HashMap::new();
713    for item in section(time_series, "simple_dispatchable_device")? {
714        if let Some(key) = item.key {
715            out.insert(key.to_owned(), item.value);
716        }
717        if let Some(obj) = item.value.as_object() {
718            if let Some(uid) = string(obj, "uid") {
719                out.insert(uid.to_owned(), item.value);
720            }
721        }
722    }
723    Ok(out)
724}
725
726fn warn_static_reduction(
727    root: &Map<String, Value>,
728    network: &Map<String, Value>,
729    warnings: &mut Vec<String>,
730) {
731    if root.get("time_series_input").is_some() {
732        warnings.push(
733            "time_series_input reduced to the first interval for static BalancedNetwork dispatch and limits"
734                .into(),
735        );
736    }
737    if root.get("reliability").is_some() {
738        warnings.push("reliability contingencies retained in source only".into());
739    }
740    for section in [
741        "active_zonal_reserve",
742        "reactive_zonal_reserve",
743        "violation_cost",
744    ] {
745        if network.get(section).is_some() {
746            warnings.push(format!("network.{section} retained in source only"));
747        }
748    }
749    if !section(network, "simple_dispatchable_device")
750        .unwrap_or_default()
751        .is_empty()
752    {
753        warnings.push(
754            "simple dispatchable device commitment, ramp, reserve, and multi-interval cost data retained in source only"
755                .into(),
756        );
757    }
758}
759
760#[derive(Clone, Copy)]
761struct SectionItem<'a> {
762    pub key: Option<&'a str>,
763    pub value: &'a Value,
764}
765
766fn section<'a>(parent: &'a Map<String, Value>, name: &'static str) -> Result<Vec<SectionItem<'a>>> {
767    let Some(value) = parent.get(name) else {
768        return Ok(Vec::new());
769    };
770    match value {
771        Value::Array(items) => Ok(items
772            .iter()
773            .map(|value| SectionItem { key: None, value })
774            .collect()),
775        Value::Object(map) => {
776            let mut items: Vec<_> = map
777                .iter()
778                .map(|(key, value)| SectionItem {
779                    key: Some(key.as_str()),
780                    value,
781                })
782                .collect();
783            items.sort_by(|a, b| compare_keys(a.key.unwrap_or(""), b.key.unwrap_or("")));
784            Ok(items)
785        }
786        other => Err(bad(format!(
787            "`network.{name}` is not an array or object, got {}",
788            kind(other)
789        ))),
790    }
791}
792
793fn item_object<'a>(
794    item: SectionItem<'a>,
795    section_name: &'static str,
796) -> Result<&'a Map<String, Value>> {
797    item.value.as_object().ok_or_else(|| {
798        bad(format!(
799            "`network.{section_name}` record is not an object, got {}",
800            kind(item.value)
801        ))
802    })
803}
804
805fn item_uid(item: SectionItem<'_>, obj: &Map<String, Value>) -> Option<String> {
806    string(obj, "uid")
807        .map(str::to_owned)
808        .or_else(|| item.key.map(str::to_owned))
809        .filter(|uid| !uid.is_empty())
810}
811
812// Numeric keys sort by value ahead of non-numeric keys, which sort
813// lexicographically. The tiers keep this a total order on mixed key sets
814// ("2" < "10" numerically while "10" < "1x" < "2" lexically is a cycle, and
815// sort_by panics on a comparator that is not a strict weak ordering).
816fn compare_keys(a: &str, b: &str) -> Ordering {
817    match (a.parse::<u64>(), b.parse::<u64>()) {
818        (Ok(a_num), Ok(b_num)) => a_num.cmp(&b_num).then_with(|| a.cmp(b)),
819        (Ok(_), Err(_)) => Ordering::Less,
820        (Err(_), Ok(_)) => Ordering::Greater,
821        (Err(_), Err(_)) => a.cmp(b),
822    }
823}
824
825fn bus_ref(obj: &Map<String, Value>, key: &'static str, buses: &Goc3BusMap) -> Result<BusId> {
826    let uid = string(obj, key).ok_or_else(|| bad(format!("missing string `{key}`")))?;
827    buses.get(uid)
828}
829
830fn official_bus_suffix(uid: &str) -> Option<usize> {
831    let rest = uid.strip_prefix("bus_")?;
832    (!rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()))
833        .then(|| rest.parse::<usize>().ok())
834        .flatten()
835}
836
837/// Map GOC3 bus uids to the same 1-based row positions `read_buses` assigns
838/// as `BusId`: the numeric `bus_<n>` suffix + 1 when every bus in `items` has
839/// one and they are unique, else the 1-based position in document order.
840/// Shared with `powerio-prob`'s GOC3 SCOPF adapter so its bus identities
841/// agree with `BalancedNetwork`'s `BusId` for the same document.
842fn bus_id_by_uid(items: &[SectionItem<'_>]) -> Result<HashMap<String, BusId>> {
843    let mut uids = Vec::with_capacity(items.len());
844    for item in items {
845        let obj = item
846            .value
847            .as_object()
848            .ok_or_else(|| bad("bus section item is not an object"))?;
849        let uid = item_uid(*item, obj).ok_or_else(|| bad("bus section item missing `uid`"))?;
850        uids.push(uid);
851    }
852    let suffixes: Vec<Option<usize>> = uids.iter().map(|uid| official_bus_suffix(uid)).collect();
853    let suffixes_unique = suffixes.iter().all(Option::is_some)
854        && suffixes
855            .iter()
856            .flatten()
857            .copied()
858            .collect::<HashSet<_>>()
859            .len()
860            == suffixes.len();
861    Ok(uids
862        .into_iter()
863        .zip(suffixes)
864        .enumerate()
865        .map(|(index, (uid, suffix))| {
866            // `suffix + 1` could overflow when a uid carries `usize::MAX` as
867            // its suffix (a single such bus is trivially "unique"); fall back
868            // to the 1-based position rather than panicking.
869            let id = match suffix.and_then(|s| s.checked_add(1)) {
870                Some(id) if suffixes_unique => id,
871                _ => index + 1,
872            };
873            (uid, BusId(id))
874        })
875        .collect())
876}
877
878fn string<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
879    obj.get(key).and_then(Value::as_str)
880}
881
882fn number(obj: &Map<String, Value>, key: &str) -> Option<f64> {
883    obj.get(key).and_then(Value::as_f64)
884}
885
886fn first_number(value: Option<&Value>, key: &str) -> Option<f64> {
887    value?.get(key)?.as_array()?.first().and_then(Value::as_f64)
888}
889
890fn initial_status(obj: &Map<String, Value>) -> Option<&Map<String, Value>> {
891    obj.get("initial_status").and_then(Value::as_object)
892}
893
894fn initial_status_flag(obj: &Map<String, Value>, default: bool) -> bool {
895    initial_status(obj)
896        .and_then(|status| number(status, "on_status"))
897        .map_or(default, |v| v != 0.0)
898}
899
900fn equal_bounds(obj: &Map<String, Value>, low: &str, high: &str) -> Option<f64> {
901    let lo = number(obj, low)?;
902    let hi = number(obj, high)?;
903    ((lo - hi).abs() <= f64::EPSILON).then_some(lo)
904}
905
906fn extras(obj: &Map<String, Value>, known: &[&str]) -> Extras {
907    obj.iter()
908        .filter(|(key, _)| !known.contains(&key.as_str()))
909        .map(|(key, value)| (key.clone(), value.clone()))
910        .collect()
911}
912
913fn push_once(warnings: &mut Vec<String>, warning: &str) {
914    if !warnings.iter().any(|w| w == warning) {
915        warnings.push(warning.to_owned());
916    }
917}
918
919fn kind(value: &Value) -> &'static str {
920    match value {
921        Value::Null => "null",
922        Value::Bool(_) => "bool",
923        Value::Number(_) => "number",
924        Value::String(_) => "string",
925        Value::Array(_) => "array",
926        Value::Object(_) => "object",
927    }
928}
929
930fn bad(message: impl Into<String>) -> Error {
931    Error::FormatRead {
932        format: FMT,
933        message: message.into(),
934    }
935}
936
937fn records<'a>(parent: &'a Map<String, Value>, name: &'static str) -> Result<Vec<Goc3Record<'a>>> {
938    section(parent, name).map(|items| {
939        items
940            .into_iter()
941            .map(|item| Goc3Record {
942                uid: item
943                    .value
944                    .as_object()
945                    .and_then(|object| item_uid(item, object))
946                    .or_else(|| item.key.map(str::to_owned)),
947                value: item.value,
948            })
949            .collect()
950    })
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956
957    #[test]
958    fn duplicate_device_uid_is_rejected() {
959        // Time-series bounds and costs join back onto devices by uid; a
960        // repeated uid would silently hand one device the other's series.
961        let network: Map<String, Value> = serde_json::from_str(
962            r#"{"simple_dispatchable_device":[
963                {"uid":"sd1","device_type":"producer"},
964                {"uid":"sd1","device_type":"consumer"}]}"#,
965        )
966        .unwrap();
967        let Err(err) = device_rows(&network) else {
968            panic!("duplicate uid must be rejected")
969        };
970        assert!(err.to_string().contains("duplicate"), "got: {err}");
971    }
972
973    #[test]
974    fn extreme_bus_suffix_does_not_overflow() {
975        // A `bus_<usize::MAX>` uid parses to usize::MAX and is trivially the
976        // unique suffix; `suffix + 1` would overflow (panic under overflow
977        // checks). The bus falls back to its 1-based position instead.
978        let content = format!(
979            r#"{{"network":{{"bus":[{{"uid":"bus_{}","base_nom_volt":100}}]}}}}"#,
980            usize::MAX
981        );
982        let parsed = parse_goc3_json(&content).unwrap();
983        assert_eq!(parsed.network.buses.len(), 1);
984        assert_eq!(parsed.network.buses[0].id, BusId(1));
985    }
986}