Skip to main content

powerio_prob/scopf/
projection.rs

1use std::collections::{BTreeMap, HashSet};
2
3use powerio::BusId;
4use powerio::format::goc3::Goc3Document;
5use serde_json::{Map, Value};
6
7use super::error::{ScopfError, ScopfResult};
8use super::goc3::{
9    Goc3Adapter, cost_cube, float_matrix, float_vec, initial_status, json_error, require_field,
10    require_num, require_str,
11};
12use super::types::{
13    ScopfAcContingencySurvivors, ScopfAcLineRow, ScopfAcLineSurvivorRow, ScopfActiveReserveRow,
14    ScopfActiveReserveSetRow, ScopfBusRow, ScopfCostRow, ScopfDcContingencyFlowRow, ScopfDcLineRow,
15    ScopfDeviceClassLayout, ScopfDeviceRow, ScopfEnergyWindowMaxCsRow, ScopfEnergyWindowMaxPrRow,
16    ScopfEnergyWindowMinCsRow, ScopfEnergyWindowMinPrRow, ScopfEnergyWindowPeriodMaxCsRow,
17    ScopfEnergyWindowPeriodMaxPrRow, ScopfEnergyWindowPeriodMinCsRow,
18    ScopfEnergyWindowPeriodMinPrRow, ScopfEnergyWindows, ScopfFixedPhaseRow, ScopfFixedRatioRow,
19    ScopfInstance, ScopfLengths, ScopfPriceBlockRow, ScopfPriceBlocks, ScopfReactiveReserveRow,
20    ScopfReactiveReserveSetRow, ScopfShuntRow, ScopfStaticData, ScopfStaticDataProjection,
21    ScopfTransformerRow, ScopfTransformerSurvivorRow, ScopfVariablePhaseRow, ScopfVariableRatioRow,
22    ScopfViolationCost,
23};
24
25type Result<T> = ScopfResult<T>;
26
27/// The device class of a `simple_dispatchable_device` row. An absent
28/// `device_type` defaults to `producer`, the same rule the balanced GOC3
29/// reader applies in `device_rows`.
30fn sdd_device_type(obj: &Map<String, Value>) -> &str {
31    obj.get("device_type")
32        .and_then(Value::as_str)
33        .unwrap_or("producer")
34}
35
36/// One required 0/1 mode flag on a `simple_dispatchable_device` row. GOC3
37/// writes these as JSON numbers, so a boolean is rejected by name. A writer
38/// that emits every number as a float states the same flag as `0.0`/`1.0`, so
39/// the value is read as a number and then required to be one of the two.
40fn require_flag(obj: &Map<String, Value>, uid: &str, key: &str) -> Result<i64> {
41    let raw = obj.get(key).ok_or_else(|| {
42        json_error(format!(
43            "simple_dispatchable_device `{uid}` missing `{key}`"
44        ))
45    })?;
46    // 0 and 1 are exact in binary floating point, so equality is the right
47    // test here: it takes the two flags and nothing near them.
48    #[allow(clippy::float_cmp)]
49    let flag = raw.as_f64().and_then(|value| {
50        if value == 0.0 {
51            Some(0)
52        } else if value == 1.0 {
53            Some(1)
54        } else {
55            None
56        }
57    });
58    flag.ok_or_else(|| {
59        json_error(format!(
60            "simple_dispatchable_device `{uid}` `{key}` is not 0 or 1"
61        ))
62    })
63}
64
65fn validate_period_len(
66    kind: &str,
67    uid: &str,
68    field: &str,
69    actual: usize,
70    expected: usize,
71) -> Result<()> {
72    if actual == expected {
73        return Ok(());
74    }
75    Err(json_error(format!(
76        "{kind} `{uid}` `{field}` has {actual} periods; expected {expected}"
77    )))
78}
79
80impl Goc3Adapter {
81    fn cost_vector(&self, device_type: &str) -> Result<Vec<ScopfCostRow>> {
82        let mut rows = Vec::new();
83        for uid in self.sdd_order() {
84            let val = self.sdd.get(&uid)?;
85            if sdd_device_type(val) != device_type {
86                continue;
87            }
88            let ts_val = self.sdd_ts.get(&uid)?;
89            let bus = self.goc3_bus_id(require_str(val, "bus")?)?;
90            let cost = ts_val.get("cost").ok_or_else(|| {
91                json_error(format!(
92                    "simple_dispatchable_device time series `{uid}` missing `cost`"
93                ))
94            })?;
95            let cost = cost_cube(cost)?;
96            validate_period_len(
97                "simple_dispatchable_device time series",
98                &uid,
99                "cost",
100                cost.len(),
101                self.dt.len(),
102            )?;
103            rows.push(ScopfCostRow { bus, uid, cost });
104        }
105        Ok(rows)
106    }
107
108    fn twt_variable_phase(&self) -> Result<Vec<ScopfVariablePhaseRow>> {
109        let mut rows = Vec::new();
110        for (j_xf, uid) in self.twt.uids().iter().enumerate() {
111            let val = self.twt.get(uid)?;
112            let (lb, ub) = (require_num(val, "ta_lb")?, require_num(val, "ta_ub")?);
113            if lb < ub {
114                rows.push(ScopfVariablePhaseRow {
115                    j_xf,
116                    phi_min: lb,
117                    phi_max: ub,
118                });
119            }
120        }
121        rows.sort_by_key(|r| r.j_xf);
122        Ok(rows)
123    }
124
125    fn twt_fixed_phase(&self) -> Result<Vec<ScopfFixedPhaseRow>> {
126        let mut rows = Vec::new();
127        for (j_xf, uid) in self.twt.uids().iter().enumerate() {
128            let val = self.twt.get(uid)?;
129            let (lb, ub) = (require_num(val, "ta_lb")?, require_num(val, "ta_ub")?);
130            if lb >= ub {
131                let phi_o = require_num(initial_status(val)?, "ta")?;
132                rows.push(ScopfFixedPhaseRow { j_xf, phi_o });
133            }
134        }
135        rows.sort_by_key(|r| r.j_xf);
136        Ok(rows)
137    }
138
139    fn twt_variable_ratio(&self) -> Result<Vec<ScopfVariableRatioRow>> {
140        let mut rows = Vec::new();
141        for (j_xf, uid) in self.twt.uids().iter().enumerate() {
142            let val = self.twt.get(uid)?;
143            let (lb, ub) = (require_num(val, "tm_lb")?, require_num(val, "tm_ub")?);
144            if lb < ub {
145                rows.push(ScopfVariableRatioRow {
146                    j_xf,
147                    tau_min: lb,
148                    tau_max: ub,
149                });
150            }
151        }
152        rows.sort_by_key(|r| r.j_xf);
153        Ok(rows)
154    }
155
156    fn twt_fixed_ratio(&self) -> Result<Vec<ScopfFixedRatioRow>> {
157        let mut rows = Vec::new();
158        for (j_xf, uid) in self.twt.uids().iter().enumerate() {
159            let val = self.twt.get(uid)?;
160            let (lb, ub) = (require_num(val, "tm_lb")?, require_num(val, "tm_ub")?);
161            if lb >= ub {
162                let tau_o = require_num(initial_status(val)?, "tm")?;
163                rows.push(ScopfFixedRatioRow { j_xf, tau_o });
164            }
165        }
166        rows.sort_by_key(|r| r.j_xf);
167        Ok(rows)
168    }
169
170    fn sdd_row(&self, uid: &str) -> Result<ScopfDeviceRow> {
171        const SDD: &str = "simple_dispatchable_device";
172        const SDD_TS: &str = "simple_dispatchable_device time series";
173        let val = self.sdd.get(uid)?;
174        let ts_val = self.sdd_ts.get(uid)?;
175        let initial = initial_status(val)?;
176        let ts = |key| require_field(ts_val, SDD_TS, uid, key);
177        // Both flags are required, so a document that omits one fails here
178        // instead of reading as "no capability". A parameter is read only when
179        // its own mode is selected.
180        let q_bound_cap = require_flag(val, uid, "q_bound_cap")?;
181        let q_linear_cap = require_flag(val, uid, "q_linear_cap")?;
182        if q_bound_cap == 1 && q_linear_cap == 1 {
183            return Err(json_error(format!(
184                "{SDD} `{uid}` sets both `q_bound_cap` and `q_linear_cap`; the two modes are mutually exclusive"
185            )));
186        }
187        let cap = |flag: i64, key: &str| -> Result<Option<f64>> {
188            if flag == 1 {
189                Ok(Some(require_num(val, key)?))
190            } else {
191                Ok(None)
192            }
193        };
194        let row = ScopfDeviceRow {
195            bus: self.goc3_bus_id(require_str(val, "bus")?)?,
196            uid: uid.to_owned(),
197            c_on: require_num(val, "on_cost")?,
198            c_su: require_num(val, "startup_cost")?,
199            c_sd: require_num(val, "shutdown_cost")?,
200            p_ru: require_num(val, "p_ramp_up_ub")?,
201            p_rd: require_num(val, "p_ramp_down_ub")?,
202            p_ru_su: require_num(val, "p_startup_ramp_ub")?,
203            p_rd_sd: require_num(val, "p_shutdown_ramp_ub")?,
204            c_rgu: float_vec(ts("p_reg_res_up_cost")?)?,
205            c_rgd: float_vec(ts("p_reg_res_down_cost")?)?,
206            c_scr: float_vec(ts("p_syn_res_cost")?)?,
207            c_nsc: float_vec(ts("p_nsyn_res_cost")?)?,
208            c_rru_on: float_vec(ts("p_ramp_res_up_online_cost")?)?,
209            c_rru_off: float_vec(ts("p_ramp_res_up_offline_cost")?)?,
210            c_rrd_on: float_vec(ts("p_ramp_res_down_online_cost")?)?,
211            c_rrd_off: float_vec(ts("p_ramp_res_down_offline_cost")?)?,
212            c_qru: float_vec(ts("q_res_up_cost")?)?,
213            c_qrd: float_vec(ts("q_res_down_cost")?)?,
214            p_rgu_max: require_num(val, "p_reg_res_up_ub")?,
215            p_rgd_max: require_num(val, "p_reg_res_down_ub")?,
216            p_scr_max: require_num(val, "p_syn_res_ub")?,
217            p_nsc_max: require_num(val, "p_nsyn_res_ub")?,
218            p_rru_on_max: require_num(val, "p_ramp_res_up_online_ub")?,
219            p_rru_off_max: require_num(val, "p_ramp_res_up_offline_ub")?,
220            p_rrd_on_max: require_num(val, "p_ramp_res_down_online_ub")?,
221            p_rrd_off_max: require_num(val, "p_ramp_res_down_offline_ub")?,
222            p_0: require_num(initial, "p")?,
223            q_0: require_num(initial, "q")?,
224            p_max: float_vec(ts("p_ub")?)?,
225            p_min: float_vec(ts("p_lb")?)?,
226            q_max: float_vec(ts("q_ub")?)?,
227            q_min: float_vec(ts("q_lb")?)?,
228            sus: float_matrix(require_field(val, SDD, uid, "startup_states")?)?,
229            q_bound_cap,
230            q_linear_cap,
231            beta_ub: cap(q_bound_cap, "beta_ub")?,
232            beta_lb: cap(q_bound_cap, "beta_lb")?,
233            q_0_ub: cap(q_bound_cap, "q_0_ub")?,
234            q_0_lb: cap(q_bound_cap, "q_0_lb")?,
235            beta: cap(q_linear_cap, "beta")?,
236            q_p0: cap(q_linear_cap, "q_0")?,
237        };
238        for (field, actual) in [
239            ("p_reg_res_up_cost", row.c_rgu.len()),
240            ("p_reg_res_down_cost", row.c_rgd.len()),
241            ("p_syn_res_cost", row.c_scr.len()),
242            ("p_nsyn_res_cost", row.c_nsc.len()),
243            ("p_ramp_res_up_online_cost", row.c_rru_on.len()),
244            ("p_ramp_res_up_offline_cost", row.c_rru_off.len()),
245            ("p_ramp_res_down_online_cost", row.c_rrd_on.len()),
246            ("p_ramp_res_down_offline_cost", row.c_rrd_off.len()),
247            ("q_res_up_cost", row.c_qru.len()),
248            ("q_res_down_cost", row.c_qrd.len()),
249            ("p_ub", row.p_max.len()),
250            ("p_lb", row.p_min.len()),
251            ("q_ub", row.q_max.len()),
252            ("q_lb", row.q_min.len()),
253        ] {
254            validate_period_len(SDD_TS, uid, field, actual, self.dt.len())?;
255        }
256        Ok(row)
257    }
258
259    fn sdd_rows(&self, device_type: &str) -> Result<Vec<ScopfDeviceRow>> {
260        let mut rows = Vec::new();
261        for uid in self.sdd_order() {
262            if sdd_device_type(self.sdd.get(&uid)?) == device_type {
263                rows.push(self.sdd_row(&uid)?);
264            }
265        }
266        Ok(rows)
267    }
268
269    /// One (bus, zone, device) membership set: `ids` is the reserve zone
270    /// section's uid list in document order, so the `zone_index` passed to
271    /// `mkrow` matches the `n_p`/`n_q` the reserve rows assign from the same
272    /// order. `uids_key` names the bus field listing its zone uids and
273    /// `device_type` filters the zone's devices. The Rust equivalent of
274    /// `reserve_set` in `src/goc3.jl`, iterating buses in `bus_order` and
275    /// devices in `devices_by_bus` order (`src/goc3.jl` iterates both as
276    /// `Dict`s here). `devices_by_bus`/`bus_order` are precomputed once by
277    /// the caller; four membership sets share them.
278    fn reserve_set<R>(
279        &self,
280        ids: &[String],
281        uids_key: &str,
282        device_type: &str,
283        devices_by_bus: &BTreeMap<String, Vec<String>>,
284        bus_order: &[String],
285        mkrow: impl Fn(BusId, usize, String) -> R,
286    ) -> Result<Vec<R>> {
287        let mut rows = Vec::new();
288        for (zone_index, id) in ids.iter().enumerate() {
289            for bus_uid in bus_order {
290                let bus_obj = self.bus.get(bus_uid)?;
291                let member = bus_obj
292                    .get(uids_key)
293                    .and_then(Value::as_array)
294                    .is_some_and(|zones| zones.iter().any(|z| z.as_str() == Some(id.as_str())));
295                if !member {
296                    continue;
297                }
298                let Some(devices) = devices_by_bus.get(bus_uid) else {
299                    continue;
300                };
301                for dev_uid in devices {
302                    let device = self.sdd.get(dev_uid)?;
303                    if sdd_device_type(device) == device_type {
304                        rows.push(mkrow(
305                            self.goc3_bus_id(bus_uid)?,
306                            zone_index,
307                            dev_uid.clone(),
308                        ));
309                    }
310                }
311            }
312        }
313        Ok(rows)
314    }
315}
316
317/// Build the static SCOPF index sets from parsed GOC3 tables
318/// (`_build_static_projection` in `src/goc3.jl`). Pure function of `tables`; no unit
319/// commitment solution is used.
320// One flat builder mirroring `_build_static_projection`'s single `sc_data` literal
321// in `src/goc3.jl`; splitting it into a builder per row family would scatter
322// the one-to-one correspondence with the Julia source this port is checked
323// against. `additional_shunt` is a discrete 0/1 flag read straight from
324// JSON, not an accumulated float, so the exact comparison is intentional.
325#[allow(clippy::too_many_lines, clippy::float_cmp)]
326fn build_static_projection(tables: &Goc3Adapter) -> Result<ScopfStaticDataProjection> {
327    let l_j_xf = tables.twt.uids().len();
328    let l_j_ln = tables.ac_line.uids().len();
329    let l_j_ac = l_j_ln + l_j_xf;
330    let l_j_dc = tables.dc_line.uids().len();
331    let l_j_br = l_j_ac + l_j_dc;
332    let l_j_cs = tables.sdd_ids_consumer.len();
333    let l_j_pr = tables.sdd_ids_producer.len();
334    let l_j_cspr = l_j_cs + l_j_pr;
335    let l_j_sh = tables.shunt.uids().len();
336    let i = tables.bus.uids().len();
337    let l_t = tables.dt.len();
338    let l_n_p = tables.azr.uids().len();
339    let l_n_q = tables.rzr.uids().len();
340    // The survivor builders read the same section. A client that sizes a per
341    // contingency array must not have to reach back into the source document
342    // for the one number that fixes its extent.
343    let k = tables.contingencies()?.len();
344
345    let lengths = ScopfLengths {
346        l_j_xf,
347        l_j_ln,
348        l_j_ac,
349        l_j_dc,
350        l_j_br,
351        l_j_cs,
352        l_j_pr,
353        l_j_cspr,
354        l_j_sh,
355        i,
356        l_t,
357        l_n_p,
358        l_n_q,
359        k,
360    };
361
362    let mut bus: Vec<ScopfBusRow> = tables
363        .bus
364        .uids()
365        .iter()
366        .map(|uid| {
367            let val = tables.bus.get(uid)?;
368            Ok(ScopfBusRow {
369                i: tables.goc3_bus_id(uid)?,
370                uid: uid.clone(),
371                v_min: require_num(val, "vm_lb")?,
372                v_max: require_num(val, "vm_ub")?,
373            })
374        })
375        .collect::<Result<_>>()?;
376    bus.sort_by_key(|r| r.i);
377
378    let shunt: Vec<ScopfShuntRow> = tables
379        .shunt
380        .uids()
381        .iter()
382        .enumerate()
383        .map(|(j_sh, uid)| {
384            let val = tables.shunt.get(uid)?;
385            Ok(ScopfShuntRow {
386                j_sh,
387                uid: uid.clone(),
388                bus: tables.goc3_bus_id(require_str(val, "bus")?)?,
389                g_sh: require_num(val, "gs")?,
390                b_sh: require_num(val, "bs")?,
391            })
392        })
393        .collect::<Result<_>>()?;
394    let mut acl_branch: Vec<ScopfAcLineRow> = tables
395        .ac_line
396        .uids()
397        .iter()
398        .enumerate()
399        .map(|(j_ln, uid)| {
400            let val = tables.ac_line.get(uid)?;
401            let (g_sr, b_sr, b_ch, g_fr, g_to, b_fr, b_to) = branch_admittance(uid, val)?;
402            Ok(ScopfAcLineRow {
403                j_ln,
404                uid: uid.clone(),
405                to_bus: tables.goc3_bus_id(require_str(val, "to_bus")?)?,
406                fr_bus: tables.goc3_bus_id(require_str(val, "fr_bus")?)?,
407                c_su: require_num(val, "connection_cost")?,
408                c_sd: require_num(val, "disconnection_cost")?,
409                s_max: require_num(val, "mva_ub_nom")?,
410                g_sr,
411                b_sr,
412                b_ch,
413                g_fr,
414                g_to,
415                b_fr,
416                b_to,
417            })
418        })
419        .collect::<Result<_>>()?;
420    acl_branch.sort_by_key(|r| r.j_ln);
421
422    let mut acx_branch: Vec<ScopfTransformerRow> = tables
423        .twt
424        .uids()
425        .iter()
426        .enumerate()
427        .map(|(j_xf, uid)| {
428            let val = tables.twt.get(uid)?;
429            let (g_sr, b_sr, b_ch, g_fr, g_to, b_fr, b_to) = branch_admittance(uid, val)?;
430            Ok(ScopfTransformerRow {
431                j_xf,
432                uid: uid.clone(),
433                to_bus: tables.goc3_bus_id(require_str(val, "to_bus")?)?,
434                fr_bus: tables.goc3_bus_id(require_str(val, "fr_bus")?)?,
435                c_su: require_num(val, "connection_cost")?,
436                c_sd: require_num(val, "disconnection_cost")?,
437                s_max: require_num(val, "mva_ub_nom")?,
438                g_sr,
439                b_sr,
440                b_ch,
441                g_fr,
442                g_to,
443                b_fr,
444                b_to,
445            })
446        })
447        .collect::<Result<_>>()?;
448    acx_branch.sort_by_key(|r| r.j_xf);
449
450    let mut dc_branch: Vec<ScopfDcLineRow> = tables
451        .dc_line
452        .uids()
453        .iter()
454        .enumerate()
455        .map(|(j_dc, uid)| {
456            let val = tables.dc_line.get(uid)?;
457            Ok(ScopfDcLineRow {
458                j_dc,
459                uid: uid.clone(),
460                pdc_max: require_num(val, "pdc_ub")?,
461                qdc_fr_min: require_num(val, "qdc_fr_lb")?,
462                qdc_to_min: require_num(val, "qdc_to_lb")?,
463                qdc_fr_max: require_num(val, "qdc_fr_ub")?,
464                qdc_to_max: require_num(val, "qdc_to_ub")?,
465                to_bus: tables.goc3_bus_id(require_str(val, "to_bus")?)?,
466                fr_bus: tables.goc3_bus_id(require_str(val, "fr_bus")?)?,
467            })
468        })
469        .collect::<Result<_>>()?;
470    dc_branch.sort_by_key(|r| r.j_dc);
471
472    let cost_vector_pr = tables.cost_vector("producer")?;
473    let cost_vector_cs = tables.cost_vector("consumer")?;
474    let prod = tables.sdd_rows("producer")?;
475    let cons = tables.sdd_rows("consumer")?;
476
477    let mut active_reserve: Vec<ScopfActiveReserveRow> = tables
478        .azr
479        .uids()
480        .iter()
481        .enumerate()
482        .map(|(n_p, uid)| {
483            let val = tables.azr.get(uid)?;
484            let ts_val = tables.azr_ts.get(uid)?;
485            let row = ScopfActiveReserveRow {
486                n_p,
487                uid: uid.clone(),
488                c_rgu: require_num(val, "REG_UP_vio_cost")?,
489                c_rgd: require_num(val, "REG_DOWN_vio_cost")?,
490                c_scr: require_num(val, "SYN_vio_cost")?,
491                c_nsc: require_num(val, "NSYN_vio_cost")?,
492                c_rru: require_num(val, "RAMPING_RESERVE_UP_vio_cost")?,
493                c_rrd: require_num(val, "RAMPING_RESERVE_DOWN_vio_cost")?,
494                sigma_rgu: require_num(val, "REG_UP")?,
495                sigma_rgd: require_num(val, "REG_DOWN")?,
496                sigma_scr: require_num(val, "SYN")?,
497                sigma_nsc: require_num(val, "NSYN")?,
498                p_rru_min: float_vec(ts_val.get("RAMPING_RESERVE_UP").ok_or_else(|| {
499                    json_error(format!(
500                        "active_zonal_reserve time series `{uid}` missing `RAMPING_RESERVE_UP`"
501                    ))
502                })?)?,
503                p_rrd_min: float_vec(ts_val.get("RAMPING_RESERVE_DOWN").ok_or_else(|| {
504                    json_error(format!(
505                        "active_zonal_reserve time series `{uid}` missing `RAMPING_RESERVE_DOWN`"
506                    ))
507                })?)?,
508            };
509            validate_period_len(
510                "active_zonal_reserve time series",
511                uid,
512                "RAMPING_RESERVE_UP",
513                row.p_rru_min.len(),
514                tables.dt.len(),
515            )?;
516            validate_period_len(
517                "active_zonal_reserve time series",
518                uid,
519                "RAMPING_RESERVE_DOWN",
520                row.p_rrd_min.len(),
521                tables.dt.len(),
522            )?;
523            Ok(row)
524        })
525        .collect::<Result<_>>()?;
526    active_reserve.sort_by_key(|r| r.n_p);
527
528    let mut reactive_reserve: Vec<ScopfReactiveReserveRow> = tables
529        .rzr
530        .uids()
531        .iter()
532        .enumerate()
533        .map(|(n_q, uid)| {
534            let val = tables.rzr.get(uid)?;
535            let ts_val = tables.rzr_ts.get(uid)?;
536            let row = ScopfReactiveReserveRow {
537                n_q,
538                uid: uid.clone(),
539                c_qru: require_num(val, "REACT_UP_vio_cost")?,
540                c_qrd: require_num(val, "REACT_DOWN_vio_cost")?,
541                q_qru_min: float_vec(ts_val.get("REACT_UP").ok_or_else(|| {
542                    json_error(format!(
543                        "reactive_zonal_reserve time series `{uid}` missing `REACT_UP`"
544                    ))
545                })?)?,
546                q_qrd_min: float_vec(ts_val.get("REACT_DOWN").ok_or_else(|| {
547                    json_error(format!(
548                        "reactive_zonal_reserve time series `{uid}` missing `REACT_DOWN`"
549                    ))
550                })?)?,
551            };
552            validate_period_len(
553                "reactive_zonal_reserve time series",
554                uid,
555                "REACT_UP",
556                row.q_qru_min.len(),
557                tables.dt.len(),
558            )?;
559            validate_period_len(
560                "reactive_zonal_reserve time series",
561                uid,
562                "REACT_DOWN",
563                row.q_qrd_min.len(),
564                tables.dt.len(),
565            )?;
566            Ok(row)
567        })
568        .collect::<Result<_>>()?;
569    reactive_reserve.sort_by_key(|r| r.n_q);
570
571    let devices_by_bus = tables.devices_by_bus()?;
572    let bus_order = tables.bus_order();
573    let active_reserve_set_pr = tables.reserve_set(
574        tables.azr.uids(),
575        "active_reserve_uids",
576        "producer",
577        &devices_by_bus,
578        &bus_order,
579        |i, n_p, uid| ScopfActiveReserveSetRow { i, n_p, uid },
580    )?;
581    let active_reserve_set_cs = tables.reserve_set(
582        tables.azr.uids(),
583        "active_reserve_uids",
584        "consumer",
585        &devices_by_bus,
586        &bus_order,
587        |i, n_p, uid| ScopfActiveReserveSetRow { i, n_p, uid },
588    )?;
589    let reactive_reserve_set_pr = tables.reserve_set(
590        tables.rzr.uids(),
591        "reactive_reserve_uids",
592        "producer",
593        &devices_by_bus,
594        &bus_order,
595        |i, n_q, uid| ScopfReactiveReserveSetRow { i, n_q, uid },
596    )?;
597    let reactive_reserve_set_cs = tables.reserve_set(
598        tables.rzr.uids(),
599        "reactive_reserve_uids",
600        "consumer",
601        &devices_by_bus,
602        &bus_order,
603        |i, n_q, uid| ScopfReactiveReserveSetRow { i, n_q, uid },
604    )?;
605
606    let static_data = ScopfStaticData {
607        bus,
608        shunt,
609        acl_branch,
610        acx_branch,
611        vpd: tables.twt_variable_phase()?,
612        fpd: tables.twt_fixed_phase()?,
613        vwr: tables.twt_variable_ratio()?,
614        fwr: tables.twt_fixed_ratio()?,
615        dc_branch,
616        prod,
617        cons,
618        active_reserve,
619        reactive_reserve,
620        active_reserve_set_pr,
621        active_reserve_set_cs,
622        reactive_reserve_set_pr,
623        reactive_reserve_set_cs,
624    };
625
626    Ok(ScopfStaticDataProjection {
627        static_data,
628        lengths,
629        cost_vector_pr,
630        cost_vector_cs,
631    })
632}
633
634fn interval_midpoints(dt: &[f64]) -> Vec<f64> {
635    let mut a_end = 0.0;
636    dt.iter()
637        .map(|d| {
638            let start = a_end;
639            a_end += d;
640            f64::midpoint(start, a_end)
641        })
642        .collect()
643}
644
645/// One `(window_index, uid, start, end, bound)` row, before it is packed
646/// into a [`ScopfEnergyWindowMaxPrRow`]-family struct.
647type EnergyWindowTuple = (usize, String, f64, f64, f64);
648/// One `(window_index, uid, period, duration)` row, before it is packed into
649/// a [`ScopfEnergyWindowPeriodMaxPrRow`]-family struct.
650type EnergyWindowPeriodTuple = (usize, String, usize, f64);
651
652/// One energy-requirement window set and its per-period membership rows in
653/// one pass: `device_type`/`req_key` select the producer/consumer max/min
654/// window list. The Rust equivalent of `windows` and `window_periods`
655/// together in `src/goc3.jl`'s `_build_energy_windows` (there, two separate
656/// passes over the same device/window set; fused here since a window row and
657/// its period memberships come from the same parsed `(start, end, bound)`).
658/// Device iteration uses [`Goc3Adapter::sdd_order`] (see the module-level
659/// order note; `src/goc3.jl` iterates `keys(data.sdd_lookup)`, a `Dict`,
660/// here).
661fn sdd_windows(
662    tables: &Goc3Adapter,
663    a_mid: &[f64],
664    device_type: &str,
665    req_key: &str,
666    eps: f64,
667) -> Result<(Vec<EnergyWindowTuple>, Vec<EnergyWindowPeriodTuple>)> {
668    let mut windows = Vec::new();
669    let mut window_periods = Vec::new();
670    let mut ind = 0usize;
671    for uid in tables.sdd_order() {
672        let val = tables.sdd.get(&uid)?;
673        if sdd_device_type(val) != device_type {
674            continue;
675        }
676        let req = require_field(val, "simple_dispatchable_device", &uid, req_key)?
677            .as_array()
678            .ok_or_else(|| {
679                json_error(format!(
680                    "simple_dispatchable_device `{uid}` `{req_key}` is not an array"
681                ))
682            })?;
683        for w in req {
684            let w = float_vec(w)?;
685            let [start, end, bound] = w[..] else {
686                return Err(json_error(format!(
687                    "simple_dispatchable_device `{uid}` `{req_key}` window is not a 3-element array"
688                )));
689            };
690            windows.push((ind, uid.clone(), start, end, bound));
691            for (t0, &m) in a_mid.iter().enumerate() {
692                if start + eps < m && m <= end + eps {
693                    window_periods.push((ind, uid.clone(), t0, tables.dt[t0]));
694                }
695            }
696            ind += 1;
697        }
698    }
699    Ok((windows, window_periods))
700}
701
702/// Build the multi-interval energy requirement window sets and their
703/// per-period membership sets (`_build_energy_windows` in `src/goc3.jl`).
704/// Pure function of `tables`.
705// Four max/min x producer/consumer variants, each packed into its own
706// distinctly-named row struct to keep Julia's exact field spelling on the
707// document (see the module doc comment); the packing is what pushes this over
708// the line budget.
709#[allow(clippy::too_many_lines)]
710fn build_energy_windows(tables: &Goc3Adapter) -> Result<ScopfEnergyWindows> {
711    const EPS_TIME: f64 = 1e-6;
712    let a_mid = interval_midpoints(&tables.dt);
713
714    let (max_pr, t_max_pr) = sdd_windows(tables, &a_mid, "producer", "energy_req_ub", EPS_TIME)?;
715    let (max_cs, t_max_cs) = sdd_windows(tables, &a_mid, "consumer", "energy_req_ub", EPS_TIME)?;
716    let (min_pr, t_min_pr) = sdd_windows(tables, &a_mid, "producer", "energy_req_lb", EPS_TIME)?;
717    let (min_cs, t_min_cs) = sdd_windows(tables, &a_mid, "consumer", "energy_req_lb", EPS_TIME)?;
718
719    let w_en_max_pr = max_pr
720        .into_iter()
721        .map(
722            |(w_en_max_pr_ind, uid, a_en_max_start, a_en_max_end, e_max)| {
723                ScopfEnergyWindowMaxPrRow {
724                    w_en_max_pr_ind,
725                    uid,
726                    a_en_max_start,
727                    a_en_max_end,
728                    e_max,
729                }
730            },
731        )
732        .collect();
733    let w_en_max_cs = max_cs
734        .into_iter()
735        .map(
736            |(w_en_max_cs_ind, uid, a_en_max_start, a_en_max_end, e_max)| {
737                ScopfEnergyWindowMaxCsRow {
738                    w_en_max_cs_ind,
739                    uid,
740                    a_en_max_start,
741                    a_en_max_end,
742                    e_max,
743                }
744            },
745        )
746        .collect();
747    let w_en_min_pr = min_pr
748        .into_iter()
749        .map(
750            |(w_en_min_pr_ind, uid, a_en_min_start, a_en_min_end, e_min)| {
751                ScopfEnergyWindowMinPrRow {
752                    w_en_min_pr_ind,
753                    uid,
754                    a_en_min_start,
755                    a_en_min_end,
756                    e_min,
757                }
758            },
759        )
760        .collect();
761    let w_en_min_cs = min_cs
762        .into_iter()
763        .map(
764            |(w_en_min_cs_ind, uid, a_en_min_start, a_en_min_end, e_min)| {
765                ScopfEnergyWindowMinCsRow {
766                    w_en_min_cs_ind,
767                    uid,
768                    a_en_min_start,
769                    a_en_min_end,
770                    e_min,
771                }
772            },
773        )
774        .collect();
775
776    let t_w_en_max_pr = t_max_pr
777        .into_iter()
778        .map(
779            |(w_en_max_pr_ind, uid, t, dt)| ScopfEnergyWindowPeriodMaxPrRow {
780                w_en_max_pr_ind,
781                uid,
782                t,
783                dt,
784            },
785        )
786        .collect();
787    let t_w_en_max_cs = t_max_cs
788        .into_iter()
789        .map(
790            |(w_en_max_cs_ind, uid, t, dt)| ScopfEnergyWindowPeriodMaxCsRow {
791                w_en_max_cs_ind,
792                uid,
793                t,
794                dt,
795            },
796        )
797        .collect();
798    let t_w_en_min_pr = t_min_pr
799        .into_iter()
800        .map(
801            |(w_en_min_pr_ind, uid, t, dt)| ScopfEnergyWindowPeriodMinPrRow {
802                w_en_min_pr_ind,
803                uid,
804                t,
805                dt,
806            },
807        )
808        .collect();
809    let t_w_en_min_cs = t_min_cs
810        .into_iter()
811        .map(
812            |(w_en_min_cs_ind, uid, t, dt)| ScopfEnergyWindowPeriodMinCsRow {
813                w_en_min_cs_ind,
814                uid,
815                t,
816                dt,
817            },
818        )
819        .collect();
820
821    Ok(ScopfEnergyWindows {
822        w_en_max_pr,
823        w_en_max_cs,
824        w_en_min_pr,
825        w_en_min_cs,
826        t_w_en_max_pr,
827        t_w_en_max_cs,
828        t_w_en_min_pr,
829        t_w_en_min_cs,
830    })
831}
832
833fn flatten_price_blocks(cost_vector: &[ScopfCostRow]) -> Vec<ScopfPriceBlockRow> {
834    let mut rows = Vec::new();
835    let mut flat_k = 0usize;
836    for pc in cost_vector {
837        for (t0, cost_t) in pc.cost.iter().enumerate() {
838            for (m0, cost_tm) in cost_t.iter().enumerate() {
839                let (c_en, p_max) = (cost_tm[0], cost_tm[1]);
840                rows.push(ScopfPriceBlockRow {
841                    flat_k,
842                    uid: pc.uid.clone(),
843                    t: t0,
844                    m: m0,
845                    c_en,
846                    p_max,
847                });
848                flat_k += 1;
849            }
850        }
851    }
852    rows
853}
854
855/// Flatten the per-device energy cost curves into one row per (device,
856/// period, cost block), unscaled in the GOC3 document's own per-unit
857/// convention (`_build_price_blocks` in `src/goc3.jl`). Pure function of the
858/// cost vectors [`build_static_projection`] returns; infallible, since
859/// [`ScopfCostRow::cost`](ScopfCostRow) is already validated numeric data.
860fn build_price_blocks(
861    cost_vector_pr: &[ScopfCostRow],
862    cost_vector_cs: &[ScopfCostRow],
863) -> ScopfPriceBlocks {
864    ScopfPriceBlocks {
865        producer: flatten_price_blocks(cost_vector_pr),
866        consumer: flatten_price_blocks(cost_vector_cs),
867    }
868}
869
870/// Series admittance `(g_sr, b_sr) = (r, -x) / (r² + x²)` for one GOC3
871/// branch record. A zero or non-finite `r² + x²` is rejected by name instead
872/// of writing NaN into the instance; `src/goc3.jl` computes the same formula
873/// unguarded, so this is deliberately stricter than the Julia parser.
874fn series_terms(r: f64, x: f64, uid: &str) -> Result<(f64, f64)> {
875    let denom = x * x + r * r;
876    if denom == 0.0 {
877        return Err(json_error(format!(
878            "branch `{uid}` has zero series impedance (r = x = 0)"
879        )));
880    }
881    if !denom.is_finite() {
882        return Err(json_error(format!(
883            "branch `{uid}` has non-finite series impedance"
884        )));
885    }
886    Ok((r / denom, -x / denom))
887}
888
889/// Series admittance and terminal shunt parameters shared by AC lines and
890/// transformers: `(g_sr, b_sr, b_ch, g_fr, g_to, b_fr, b_to)`, from
891/// `r`/`x`/`b` and, when `additional_shunt` is set, `g_fr`/`g_to`/`b_fr`/
892/// `b_to`. The common body of `acl_branch`/`acx_branch` in
893/// `_build_static_projection` (`src/goc3.jl`). `additional_shunt` is a discrete 0/1
894/// flag read straight from JSON, not an accumulated float, so the exact
895/// comparison is intentional.
896#[allow(clippy::type_complexity, clippy::float_cmp)]
897fn branch_admittance(
898    uid: &str,
899    val: &Map<String, Value>,
900) -> Result<(f64, f64, f64, f64, f64, f64, f64)> {
901    let (r, x) = (require_num(val, "r")?, require_num(val, "x")?);
902    let (g_sr, b_sr) = series_terms(r, x, uid)?;
903    let additional_shunt = require_num(val, "additional_shunt")? == 1.0;
904    let (g_fr, g_to, b_fr, b_to) = if additional_shunt {
905        (
906            require_num(val, "g_fr")?,
907            require_num(val, "g_to")?,
908            require_num(val, "b_fr")?,
909            require_num(val, "b_to")?,
910        )
911    } else {
912        (0.0, 0.0, 0.0, 0.0)
913    };
914    Ok((g_sr, b_sr, require_num(val, "b")?, g_fr, g_to, b_fr, b_to))
915}
916
917/// One contingency index and its outaged component UIDs.
918fn contingency_outages(ctg_idx: usize, ctg: &Value) -> Result<(usize, HashSet<&str>)> {
919    let ctg_obj = ctg
920        .as_object()
921        .ok_or_else(|| json_error("reliability.contingency item is not an object"))?;
922    let ctg_uid = require_str(ctg_obj, "uid")?;
923    let outaged = require_field(ctg_obj, "contingency", ctg_uid, "components")?
924        .as_array()
925        .ok_or_else(|| {
926            json_error(format!(
927                "contingency `{ctg_uid}` `components` is not an array"
928            ))
929        })?
930        .iter()
931        .map(|v| {
932            v.as_str()
933                .ok_or_else(|| json_error("component uid is not a string"))
934        })
935        .collect::<Result<_>>()?;
936    Ok((ctg_idx, outaged))
937}
938
939/// Enumerate, for each contingency, the AC lines and transformers that
940/// remain in service: the branch is not among the contingency's outaged
941/// components (`_build_ac_contingency_survivors` in `src/goc3.jl`). The outer
942/// vector follows `reliability.contingency`'s document order (which need not
943/// match ascending `ctg`); rows within one contingency follow the section's
944/// document order (see the module-level order note; `src/goc3.jl` iterates
945/// `values(lookup)`, a `Dict`, here).
946fn build_ac_contingency_survivors(tables: &Goc3Adapter) -> Result<ScopfAcContingencySurvivors> {
947    let contingencies = tables.contingencies()?;
948
949    let mut ln = Vec::with_capacity(contingencies.len());
950    let mut xf = Vec::with_capacity(contingencies.len());
951    for (ctg_idx, ctg) in contingencies.iter().enumerate() {
952        let (ctg_idx, outaged) = contingency_outages(ctg_idx, ctg)?;
953
954        let mut ln_rows = Vec::new();
955        for (j_ln, uid) in tables.ac_line.uids().iter().enumerate() {
956            if outaged.contains(uid.as_str()) {
957                continue;
958            }
959            let val = tables.ac_line.get(uid)?;
960            let (r, x) = (require_num(val, "r")?, require_num(val, "x")?);
961            let (_, b_sr) = series_terms(r, x, uid)?;
962            ln_rows.push(ScopfAcLineSurvivorRow {
963                ctg: ctg_idx,
964                j_ln,
965                uid: uid.clone(),
966                to_bus: tables.goc3_bus_id(require_str(val, "to_bus")?)?,
967                fr_bus: tables.goc3_bus_id(require_str(val, "fr_bus")?)?,
968                b_sr,
969                s_max_ctg: require_num(val, "mva_ub_em")?,
970            });
971        }
972        ln.push(ln_rows);
973
974        let mut xf_rows = Vec::new();
975        for (j_xf, uid) in tables.twt.uids().iter().enumerate() {
976            if outaged.contains(uid.as_str()) {
977                continue;
978            }
979            let val = tables.twt.get(uid)?;
980            let (r, x) = (require_num(val, "r")?, require_num(val, "x")?);
981            let (_, b_sr) = series_terms(r, x, uid)?;
982            xf_rows.push(ScopfTransformerSurvivorRow {
983                ctg: ctg_idx,
984                j_xf,
985                uid: uid.clone(),
986                to_bus: tables.goc3_bus_id(require_str(val, "to_bus")?)?,
987                fr_bus: tables.goc3_bus_id(require_str(val, "fr_bus")?)?,
988                b_sr,
989                s_max_ctg: require_num(val, "mva_ub_em")?,
990            });
991        }
992        xf.push(xf_rows);
993    }
994
995    Ok(ScopfAcContingencySurvivors { ln, xf })
996}
997
998fn build_dc_contingency_flows(tables: &Goc3Adapter) -> Result<Vec<ScopfDcContingencyFlowRow>> {
999    let contingencies = tables.contingencies()?;
1000    let mut rows = Vec::new();
1001    let mut flat_jtk_dc = 0usize;
1002    for (ctg_idx, ctg) in contingencies.iter().enumerate() {
1003        let (ctg_idx, outaged) = contingency_outages(ctg_idx, ctg)?;
1004
1005        for (t0, &dt) in tables.dt.iter().enumerate() {
1006            for (j_dc, uid) in tables.dc_line.uids().iter().enumerate() {
1007                if outaged.contains(uid.as_str()) {
1008                    continue;
1009                }
1010                let val = tables.dc_line.get(uid)?;
1011                rows.push(ScopfDcContingencyFlowRow {
1012                    flat_jtk_dc,
1013                    ctg: ctg_idx,
1014                    j_dc,
1015                    to_bus: tables.goc3_bus_id(require_str(val, "to_bus")?)?,
1016                    fr_bus: tables.goc3_bus_id(require_str(val, "fr_bus")?)?,
1017                    t: t0,
1018                    dt,
1019                });
1020                flat_jtk_dc += 1;
1021            }
1022        }
1023    }
1024    Ok(rows)
1025}
1026
1027/// The case's four violation prices. Each one is separately optional: the 14
1028/// bus validation case has no `e_vio_cost`, so a required field would refuse a
1029/// document GOCompetition ships.
1030fn build_violation_cost(tables: &Goc3Adapter) -> ScopfViolationCost {
1031    let price = |key: &str| tables.violation_cost.get(key).and_then(Value::as_f64);
1032    ScopfViolationCost {
1033        p_bus: price("p_bus_vio_cost"),
1034        q_bus: price("q_bus_vio_cost"),
1035        s: price("s_vio_cost"),
1036        e: price("e_vio_cost"),
1037    }
1038}
1039
1040/// How the two device classes sit in the `simple_dispatchable_device`
1041/// section, read in document order. Document order is the index rule for
1042/// every per-class index here, so this needs no UID shape.
1043fn device_class_blocks(tables: &Goc3Adapter) -> Result<ScopfDeviceClassLayout> {
1044    let mut runs: Vec<&str> = Vec::new();
1045    for uid in tables.sdd.uids() {
1046        let kind = sdd_device_type(tables.sdd.get(uid)?);
1047        let kind = if kind == "consumer" {
1048            "consumer"
1049        } else {
1050            "producer"
1051        };
1052        if runs.last() != Some(&kind) {
1053            runs.push(kind);
1054        }
1055    }
1056    if runs.len() > 2 {
1057        return Ok(ScopfDeviceClassLayout::Interleaved);
1058    }
1059    Ok(ScopfDeviceClassLayout::Contiguous {
1060        producers_first: runs.first() != Some(&"consumer"),
1061    })
1062}
1063
1064fn project_scopf_instance(tables: &Goc3Adapter) -> Result<ScopfInstance> {
1065    let ScopfStaticDataProjection {
1066        static_data,
1067        lengths,
1068        cost_vector_pr,
1069        cost_vector_cs,
1070    } = build_static_projection(tables)?;
1071    let device_class_layout = device_class_blocks(tables)?;
1072    Ok(ScopfInstance {
1073        static_data,
1074        lengths,
1075        energy_windows: build_energy_windows(tables)?,
1076        price_blocks: build_price_blocks(&cost_vector_pr, &cost_vector_cs),
1077        ac_contingency_survivors: build_ac_contingency_survivors(tables)?,
1078        dc_contingency_flows: build_dc_contingency_flows(tables)?,
1079        violation_cost: build_violation_cost(tables),
1080        device_class_layout,
1081    })
1082}
1083
1084fn build_scopf_instance(document: &Goc3Document) -> Result<ScopfInstance> {
1085    let tables = Goc3Adapter::from_document(document)?;
1086    project_scopf_instance(&tables)
1087}
1088
1089/// Parse source text and build its SCOPF instance.
1090pub fn parse_scopf_str(text: &str, from: &str) -> Result<ScopfInstance> {
1091    if from != "goc3-json" {
1092        return Err(ScopfError::UnsupportedFormat(from.to_owned()));
1093    }
1094    let document = Goc3Document::parse(text)?;
1095    build_scopf_instance(&document)
1096}