Skip to main content

powerio/
gridfm.rs

1//! The gridfm-datakit Parquet dataset reader: rebuild [`BalancedNetwork`]
2//! values, and every scenario as one shared identity
3//! [`ScenarioSet`](powerio_core::ScenarioSet), from the four table schema
4//! `gridfm-datakit` writes (`bus_data`, `gen_data`, `branch_data`,
5//! `y_bus_data`). `y_bus_data` is ignored on read; branches carry raw
6//! `r/x/b`. The write side, which derives `y_bus_data` and the branch flows,
7//! lives in `powerio-matrix` behind its `gridfm` feature.
8//!
9//! The balanced view follows GridFM's own representation: `bus`, `from_bus`,
10//! and `to_bus` are dense `[0, n)` indices; loads and shunts are nodal totals;
11//! and every branch with unit tap and zero phase shift is a line. These are
12//! source facts rather than losses, so the reader reports diagnostics only
13//! when it must replace malformed or missing input. Units follow datakit:
14//! `Pd, Qd, Pg, Qg` MW/MVAr, `Vm` per unit, `Va` degrees, `r, x, b` per unit,
15//! and `GS, BS` divided by `base_mva`.
16
17use std::path::Path;
18
19use arrow::array::{Array, ArrayRef, Float64Array, Int64Array};
20use arrow::record_batch::RecordBatch;
21use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
22
23use powerio_tx::network::{
24    Branch, BranchSolution, Bus, BusId, BusType, Generator, Load, Shunt, SourceFormat,
25};
26use powerio_tx::{BalancedNetwork, GenCost};
27
28use crate::collect::Diagnostics;
29
30type Error = powerio_tx::Error;
31type Result<T> = std::result::Result<T, Error>;
32
33/// GridFM reader diagnostics.
34pub mod codes {
35    powerio_core::diagnostic_codes! {
36        READ_GRIDFM_FIELD_DROPPED = "READ.GRIDFM.FIELD_DROPPED", Warning,
37            "a field the gridfm schema does not carry is absent from the network";
38        READ_GRIDFM_VALUE_DEFAULTED = "READ.GRIDFM.VALUE_DEFAULTED", Warning,
39            "a manifest value the reader needs was absent and was defaulted";
40        READ_GRIDFM_VALUE_INFERRED = "READ.GRIDFM.VALUE_INFERRED", Warning,
41            "an identity the gridfm schema does not store was synthesized";
42        READ_GRIDFM_VALUE_COLLAPSED = "READ.GRIDFM.VALUE_COLLAPSED", Warning,
43            "nodal totals were folded into synthetic per bus elements";
44        READ_GRIDFM_ELEMENT_RELABELED = "READ.GRIDFM.ELEMENT_RELABELED", Warning,
45            "a unity ratio transformer is indistinguishable from a line and reads as one";
46    }
47}
48/// One rebuilt scenario of a gridfm dataset.
49#[derive(Debug, Clone)]
50pub struct GridfmRead {
51    /// The reconstructed network (`source_format = SourceFormat::Gridfm`).
52    pub network: BalancedNetwork,
53    /// The scenario id these rows came from.
54    pub scenario: i64,
55    /// Values replaced because required source metadata was missing or invalid.
56    pub diagnostics: Vec<powerio_core::Diagnostic>,
57}
58
59/// Build one [`BalancedNetwork`] from in-memory gridfm tables, selecting
60/// `scenario`'s rows. The pure inverse of the writer's single snapshot
61/// batches: `base_mva` and `name` come from the caller (the disk path reads
62/// them from `gridfm_meta.json`).
63///
64/// # Errors
65/// [`powerio_tx::Error::FormatRead`] if a required column is missing or mistyped, a column
66/// carries nulls, a dense index is negative, or `scenario` isn't present; plus
67/// whatever [`BalancedNetwork::validate`] rejects (duplicate / dangling bus ids).
68pub fn read_gridfm_network(
69    bus_table: &RecordBatch,
70    generator_table: &RecordBatch,
71    branch_table: &RecordBatch,
72    scenario: i64,
73    base_mva: f64,
74    name: &str,
75) -> Result<GridfmRead> {
76    let bus = bus_columns(std::slice::from_ref(bus_table))?;
77    let gens = gen_columns(std::slice::from_ref(generator_table))?;
78    let branch = branch_columns(std::slice::from_ref(branch_table))?;
79    build_network_from_columns(
80        &bus,
81        &gens,
82        &branch,
83        scenario,
84        base_mva,
85        name,
86        Diagnostics::new(),
87    )
88}
89
90/// Open one dataset directory through the pinned acquisition and extract
91/// every table the reader uses: the same entry walk and buffer reads
92/// [`parse_gridfm_source`] performs, so the path entry points share its
93/// symbolic link and escape refusals.
94struct DatasetTables {
95    base_mva: f64,
96    name: String,
97    meta_warnings: Diagnostics,
98    bus: BusColumns,
99    gens: GenColumns,
100    branch: BranchColumns,
101}
102
103fn open_dataset_tables(dir: &Path) -> Result<DatasetTables> {
104    let source =
105        powerio_core::Source::open(dir).map_err(|error| powerio_tx::Error::FormatRead {
106            format: "gridfm",
107            message: format!("opening {}: {error}", dir.display()),
108        })?;
109    let entries = source
110        .entry_names()
111        .map_err(|error| powerio_tx::Error::FormatRead {
112            format: "gridfm",
113            message: format!("listing {}: {error}", dir.display()),
114        })?;
115    let prefix = resolve_raw_prefix(&entries)?;
116    let (base_mva, name, meta_warnings) = read_meta_source(&source, &prefix);
117    let bus = bus_columns(&read_parquet_buffer(&source, &prefix, "bus_data.parquet")?)?;
118    let gens = gen_columns(&read_parquet_buffer(&source, &prefix, "gen_data.parquet")?)?;
119    let branch = branch_columns(&read_parquet_buffer(
120        &source,
121        &prefix,
122        "branch_data.parquet",
123    )?)?;
124    Ok(DatasetTables {
125        base_mva,
126        name,
127        meta_warnings,
128        bus,
129        gens,
130        branch,
131    })
132}
133
134/// Read one `scenario` from a gridfm dataset on disk and rebuild a [`BalancedNetwork`].
135/// The inverse of `powerio_matrix::emit_gridfm_dataset`.
136///
137/// `dir` is resolved leniently: the leaf `raw/` directory holding the parquet
138/// files, a `<case>/` directory with a `raw/` child, or a parent directory with
139/// exactly one `*/raw/` child all work. `base_mva` and the case name come from
140/// `gridfm_meta.json` (a missing manifest defaults `base_mva` to 100 and warns).
141///
142/// # Errors
143/// Propagates [`read_gridfm_network`] plus any filesystem / Parquet read error.
144pub fn read_gridfm_dataset(dir: impl AsRef<Path>, scenario: i64) -> Result<GridfmRead> {
145    let tables = open_dataset_tables(dir.as_ref())?;
146    build_network_from_columns(
147        &tables.bus,
148        &tables.gens,
149        &tables.branch,
150        scenario,
151        tables.base_mva,
152        &tables.name,
153        tables.meta_warnings,
154    )
155}
156
157/// Read every scenario from a gridfm dataset, one [`BalancedNetwork`] per `scenario` id
158/// (sorted ascending) over the shared topology — the read side of the scenario
159/// batch (#57). Each scenario is rebuilt independently, so two scenarios may
160/// differ in branch status, bus types, and reference bus.
161///
162/// # Errors
163/// Propagates [`read_gridfm_dataset`]'s filesystem / Parquet / build errors.
164pub fn read_gridfm_scenarios(dir: impl AsRef<Path>) -> Result<Vec<GridfmRead>> {
165    // Extract every column once and reuse across scenarios; rebuilding each
166    // scenario from the raw batches would re-concatenate each table n_scenarios
167    // times (O(n_scenarios × table_size)).
168    let tables = open_dataset_tables(dir.as_ref())?;
169    distinct_sorted(&tables.bus.scenario)
170        .into_iter()
171        .map(|s| {
172            build_network_from_columns(
173                &tables.bus,
174                &tables.gens,
175                &tables.branch,
176                s,
177                tables.base_mva,
178                &tables.name,
179                tables.meta_warnings.clone(),
180            )
181        })
182        .collect()
183}
184
185/// The distinct scenario ids in a gridfm dataset, ascending — the keys
186/// [`read_gridfm_scenarios`] rebuilds a [`BalancedNetwork`] for. Reads only `bus_data`'s
187/// scenario column, so it enumerates a dataset's scenarios without rebuilding
188/// every network.
189///
190/// # Errors
191/// Propagates the directory resolution and `bus_data.parquet` read errors.
192pub fn list_gridfm_scenario_ids(dir: impl AsRef<Path>) -> Result<Vec<i64>> {
193    let source = powerio_core::Source::open(dir.as_ref()).map_err(|error| {
194        powerio_tx::Error::FormatRead {
195            format: "gridfm",
196            message: format!("opening {}: {error}", dir.as_ref().display()),
197        }
198    })?;
199    let entries = source
200        .entry_names()
201        .map_err(|error| powerio_tx::Error::FormatRead {
202            format: "gridfm",
203            message: format!("listing {}: {error}", dir.as_ref().display()),
204        })?;
205    let prefix = resolve_raw_prefix(&entries)?;
206    let bus = bus_columns(&read_parquet_buffer(&source, &prefix, "bus_data.parquet")?)?;
207    Ok(distinct_sorted(&bus.scenario))
208}
209
210/// The distinct values of `scenario`, ascending.
211fn distinct_sorted(scenario: &[i64]) -> Vec<i64> {
212    let mut ids = scenario.to_vec();
213    ids.sort_unstable();
214    ids.dedup();
215    ids
216}
217
218/// Every scenario of a gridfm dataset as one [`ScenarioSet`] over shared
219/// element identities: each scenario's network reuses the first scenario's
220/// table allocation wherever the rebuilt table is equal, so unchanged
221/// topology and parameters are stored once and only the tables a scenario
222/// actually changes are held per scenario. Scenario ids are the dataset's
223/// `scenario` values, ascending; the diagnostics are every scenario's read
224/// findings in that order.
225///
226/// The current gridfm profile is raw snapshot data that names no solved
227/// calculation, so the set is network data — never a solution set.
228///
229/// [`ScenarioSet`]: powerio_core::ScenarioSet
230///
231/// # Errors
232/// Propagates [`read_gridfm_scenarios`], plus a scenario identity the set
233/// rejects.
234pub fn read_gridfm_scenario_set(
235    dir: impl AsRef<Path>,
236) -> std::result::Result<
237    (
238        powerio_core::ScenarioSet<BalancedNetwork>,
239        Vec<powerio_core::Diagnostic>,
240    ),
241    powerio_core::Error,
242> {
243    let reads = read_gridfm_scenarios(dir)
244        .map_err(|error| powerio_core::Error::new(error.code(), error.to_string()))?;
245    let mut diagnostics = Vec::new();
246    let mut scenarios = Vec::with_capacity(reads.len());
247    let mut donor: Option<BalancedNetwork> = None;
248    for read in reads {
249        let mut network = read.network;
250        match &donor {
251            Some(base) => network.share_equal_tables(base),
252            None => donor = Some(network.clone()),
253        }
254        diagnostics.extend(read.diagnostics);
255        let id = powerio_core::ScenarioId::new(read.scenario.to_string())?;
256        scenarios.push(powerio_core::Scenario::new(id, None, network));
257    }
258    let set = powerio_core::ScenarioSet::new(scenarios)?;
259    Ok((set, diagnostics))
260}
261
262/// The unperturbed base case: [`read_gridfm_dataset`] at `scenario = 0` (datakit's
263/// convention). There is no single "shared base" beyond a chosen scenario — bus
264/// types, branch status, and reference bus all vary per scenario — so the base
265/// case is just scenario 0.
266///
267/// # Errors
268/// Propagates [`read_gridfm_dataset`].
269pub fn read_gridfm_base_case(dir: impl AsRef<Path>) -> Result<GridfmRead> {
270    read_gridfm_dataset(dir, 0)
271}
272
273/// Every `bus_data` column the reader uses, concatenated across all batches once.
274/// Extracting columns up front lets a multi-scenario read reuse them rather than
275/// re-concatenating the whole table for each scenario.
276struct BusColumns {
277    scenario: Vec<i64>,
278    bus: Vec<i64>,
279    pv: Vec<i64>,
280    refc: Vec<i64>,
281    vm: Vec<f64>,
282    va: Vec<f64>,
283    vn_kv: Vec<f64>,
284    min_vm: Vec<f64>,
285    max_vm: Vec<f64>,
286    pd: Vec<f64>,
287    qd: Vec<f64>,
288    gs: Vec<f64>,
289    bs: Vec<f64>,
290}
291
292fn bus_columns(batches: &[RecordBatch]) -> Result<BusColumns> {
293    Ok(BusColumns {
294        scenario: i64_col(batches, "scenario")?,
295        bus: i64_col(batches, "bus")?,
296        pv: i64_col(batches, "PV")?,
297        refc: i64_col(batches, "REF")?,
298        vm: f64_col(batches, "Vm")?,
299        va: f64_col(batches, "Va")?,
300        vn_kv: f64_col(batches, "vn_kv")?,
301        min_vm: f64_col(batches, "min_vm_pu")?,
302        max_vm: f64_col(batches, "max_vm_pu")?,
303        pd: f64_col(batches, "Pd")?,
304        qd: f64_col(batches, "Qd")?,
305        gs: f64_col(batches, "GS")?,
306        bs: f64_col(batches, "BS")?,
307    })
308}
309
310/// Every `gen_data` column the reader uses (cost is `cp0`/`cp1`/`cp2`).
311struct GenColumns {
312    scenario: Vec<i64>,
313    bus: Vec<i64>,
314    p_mw: Vec<f64>,
315    q_mvar: Vec<f64>,
316    min_p: Vec<f64>,
317    max_p: Vec<f64>,
318    min_q: Vec<f64>,
319    max_q: Vec<f64>,
320    cp0: Vec<f64>,
321    cp1: Vec<f64>,
322    cp2: Vec<f64>,
323    in_service: Vec<i64>,
324}
325
326fn gen_columns(batches: &[RecordBatch]) -> Result<GenColumns> {
327    Ok(GenColumns {
328        scenario: i64_col(batches, "scenario")?,
329        bus: i64_col(batches, "bus")?,
330        p_mw: f64_col(batches, "p_mw")?,
331        q_mvar: f64_col(batches, "q_mvar")?,
332        min_p: f64_col(batches, "min_p_mw")?,
333        max_p: f64_col(batches, "max_p_mw")?,
334        min_q: f64_col(batches, "min_q_mvar")?,
335        max_q: f64_col(batches, "max_q_mvar")?,
336        cp0: f64_col(batches, "cp0_eur")?,
337        cp1: f64_col(batches, "cp1_eur_per_mw")?,
338        cp2: f64_col(batches, "cp2_eur_per_mw2")?,
339        in_service: i64_col(batches, "in_service")?,
340    })
341}
342
343/// Every `branch_data` column the reader uses. The `Y**` columns are derived
344/// from the raw branch fields and validated independently by GridFM tooling.
345struct BranchColumns {
346    scenario: Vec<i64>,
347    from_bus: Vec<i64>,
348    to_bus: Vec<i64>,
349    r: Vec<f64>,
350    x: Vec<f64>,
351    b: Vec<f64>,
352    tap: Vec<f64>,
353    shift: Vec<f64>,
354    ang_min: Vec<f64>,
355    ang_max: Vec<f64>,
356    rate_a: Vec<f64>,
357    status: Vec<i64>,
358    pf: Vec<f64>,
359    qf: Vec<f64>,
360    pt: Vec<f64>,
361    qt: Vec<f64>,
362}
363
364fn branch_columns(batches: &[RecordBatch]) -> Result<BranchColumns> {
365    Ok(BranchColumns {
366        scenario: i64_col(batches, "scenario")?,
367        from_bus: i64_col(batches, "from_bus")?,
368        to_bus: i64_col(batches, "to_bus")?,
369        r: f64_col(batches, "r")?,
370        x: f64_col(batches, "x")?,
371        b: f64_col(batches, "b")?,
372        tap: f64_col(batches, "tap")?,
373        shift: f64_col(batches, "shift")?,
374        ang_min: f64_col(batches, "ang_min")?,
375        ang_max: f64_col(batches, "ang_max")?,
376        rate_a: f64_col(batches, "rate_a")?,
377        status: i64_col(batches, "br_status")?,
378        pf: f64_col(batches, "pf")?,
379        qf: f64_col(batches, "qf")?,
380        pt: f64_col(batches, "pt")?,
381        qt: f64_col(batches, "qt")?,
382    })
383}
384
385/// The shared core: rebuild one scenario's [`BalancedNetwork`] from already-extracted
386/// columns. The columns are concatenated once by the caller and reused across
387/// scenarios, so a multi-scenario read doesn't re-copy each table per scenario.
388/// `warnings` is seeded with any manifest-level notes (e.g. a defaulted
389/// `base_mva`) and extended with the per-read fidelity notes.
390// tap == 1.0 / != 0.0 reads are exact, not approximate; the builder is one long
391// linear pass over the three tables, so the length is inherent.
392#[allow(clippy::float_cmp, clippy::too_many_lines)]
393fn build_network_from_columns(
394    bus: &BusColumns,
395    gens: &GenColumns,
396    branch: &BranchColumns,
397    scenario: i64,
398    base_mva: f64,
399    name: &str,
400    warnings: Diagnostics,
401) -> Result<GridfmRead> {
402    // --- buses, loads, shunts (bus_data) ---
403    let bus_rows = scenario_rows(&bus.scenario, scenario);
404    if bus_rows.is_empty() {
405        let mut avail = bus.scenario.clone();
406        avail.sort_unstable();
407        avail.dedup();
408        return Err(powerio_tx::Error::FormatRead {
409            format: "gridfm",
410            message: format!("scenario {scenario} not present; available: {avail:?}"),
411        });
412    }
413
414    let bus_id = &bus.bus;
415    let pv = &bus.pv;
416    let refc = &bus.refc;
417    let vm = &bus.vm;
418    let va = &bus.va;
419    let vn_kv = &bus.vn_kv;
420    let min_vm = &bus.min_vm;
421    let max_vm = &bus.max_vm;
422    let pd = &bus.pd;
423    let qd = &bus.qd;
424    let gs = &bus.gs;
425    let bs = &bus.bs;
426
427    let mut buses = Vec::with_capacity(bus_rows.len());
428    let mut loads = Vec::new();
429    let mut shunts = Vec::new();
430    // Dense bus index -> voltage magnitude, so a generator recovers its `vg`
431    // setpoint from its bus (gridfm has no separate gen voltage column, but a
432    // generator's setpoint is its bus's regulated `Vm`).
433    let mut bus_vm: std::collections::HashMap<i64, f64> =
434        std::collections::HashMap::with_capacity(bus_rows.len());
435    for &r in &bus_rows {
436        let id = dense_bus_id(bus_id[r])?;
437        bus_vm.insert(bus_id[r], vm[r]);
438        // REF / PV / PQ one-hot; the writer guarantees exactly one set, but read
439        // defensively (REF wins, then PV, else PQ).
440        let kind = if refc[r] != 0 {
441            BusType::Ref
442        } else if pv[r] != 0 {
443            BusType::Pv
444        } else {
445            BusType::Pq
446        };
447        let mut bus = Bus::new(id, kind, vn_kv[r]);
448        bus.vm = vm[r];
449        bus.va = va[r];
450        bus.vmax = max_vm[r];
451        bus.vmin = min_vm[r];
452        bus.area = 0;
453        bus.zone = 0;
454        buses.push(bus);
455        if pd[r] != 0.0 || qd[r] != 0.0 {
456            loads.push(Load::new(id, pd[r], qd[r]));
457        }
458        // Undo the writer's `/ base_mva` (powerio-matrix/src/io/gridfm.rs) to recover MW/MVAr at V=1.
459        if gs[r] != 0.0 || bs[r] != 0.0 {
460            shunts.push(Shunt::new(id, gs[r] * base_mva, bs[r] * base_mva));
461        }
462    }
463
464    // --- generators (gen_data) ---
465    let gen_rows = scenario_rows(&gens.scenario, scenario);
466    require_scenario_block(&gens.scenario, scenario, &gen_rows, "gen_data")?;
467    let g_bus = &gens.bus;
468    let p_mw = &gens.p_mw;
469    let q_mvar = &gens.q_mvar;
470    let min_p = &gens.min_p;
471    let max_p = &gens.max_p;
472    let min_q = &gens.min_q;
473    let max_q = &gens.max_q;
474    let cp0 = &gens.cp0;
475    let cp1 = &gens.cp1;
476    let cp2 = &gens.cp2;
477    let g_in = &gens.in_service;
478
479    let mut generators = Vec::with_capacity(gen_rows.len());
480    for &r in &gen_rows {
481        // GridFM states one quadratic polynomial per generator. A zero triple
482        // is the zero polynomial; any ambiguity introduced by a producer that
483        // used zero as a missing-value sentinel belongs to that producer.
484        let cost = Some(GenCost::new(2, 0.0, 0.0, vec![cp2[r], cp1[r], cp0[r]]));
485        let mut generator = Generator::new(dense_bus_id(g_bus[r])?);
486        generator.pg = p_mw[r];
487        generator.qg = q_mvar[r];
488        generator.pmax = max_p[r];
489        generator.pmin = min_p[r];
490        generator.qmax = max_q[r];
491        generator.qmin = min_q[r];
492        // The schema has no gen vg; recover the setpoint from the bus's Vm
493        // (falls back to 1.0 only if the gen references an absent bus, which
494        // `validate()` below then rejects).
495        generator.vg = bus_vm.get(&g_bus[r]).copied().unwrap_or(1.0);
496        generator.mbase = base_mva;
497        generator.in_service = g_in[r] != 0;
498        generator.cost = cost;
499        generators.push(generator);
500    }
501
502    // --- branches (branch_data); Y** is derived from the raw fields ---
503    let br_rows = scenario_rows(&branch.scenario, scenario);
504    require_scenario_block(&branch.scenario, scenario, &br_rows, "branch_data")?;
505    let from_bus = &branch.from_bus;
506    let to_bus = &branch.to_bus;
507    let r_col = &branch.r;
508    let x_col = &branch.x;
509    let b_col = &branch.b;
510    let tap = &branch.tap;
511    let shift = &branch.shift;
512    let ang_min = &branch.ang_min;
513    let ang_max = &branch.ang_max;
514    let rate_a = &branch.rate_a;
515    let br_status = &branch.status;
516    let pf = &branch.pf;
517    let qf = &branch.qf;
518    let pt = &branch.pt;
519    let qt = &branch.qt;
520
521    let mut branches = Vec::with_capacity(br_rows.len());
522    // The writer stores the *effective* tap (`Branch::calc_effective_tap`), so a line
523    // (raw tap 0) lands as 1.0. Map unit tap + no shift back to the raw `tap == 0`
524    // line convention, otherwise every line reads as a transformer
525    // (`is_transformer` keys off `tap != 0`) and a read→write to a format that
526    // splits lines from transformers (PSS/E, PowerWorld) misclassifies them. A
527    // genuine unity-ratio, zero-shift transformer is not distinguished by the
528    // GridFM row and therefore is a line in the balanced view.
529    for &row in &br_rows {
530        let shift_v = shift[row];
531        let tap_out = if tap[row] == 1.0 && shift_v == 0.0 {
532            0.0
533        } else {
534            tap[row]
535        };
536        let mut branch = Branch::new(
537            dense_bus_id(from_bus[row])?,
538            dense_bus_id(to_bus[row])?,
539            r_col[row],
540            x_col[row],
541        );
542        branch.b = b_col[row];
543        branch.rate_a = rate_a[row];
544        branch.tap = tap_out;
545        branch.shift = shift_v;
546        branch.in_service = br_status[row] != 0;
547        branch.angmin = ang_min[row];
548        branch.angmax = ang_max[row];
549        branch.solution = Some(BranchSolution::new(pf[row], qf[row], pt[row], qt[row]));
550        branches.push(branch);
551    }
552
553    let mut net = BalancedNetwork::new(name, base_mva);
554    *net.buses_mut() = buses;
555    *net.loads_mut() = loads;
556    *net.shunts_mut() = shunts;
557    *net.branches_mut() = branches;
558    *net.generators_mut() = generators;
559    *net.source_format_mut() = SourceFormat::Gridfm;
560    net.validate()?;
561
562    Ok(GridfmRead {
563        network: net,
564        scenario,
565        diagnostics: warnings.into_records(),
566    })
567}
568
569// --- reader helpers --------------------------------------------------------
570
571/// One `READ.GRIDFM.VALUE_DEFAULTED` note, for a manifest the reader could not
572/// use.
573fn defaulted_meta(message: impl Into<String>) -> Diagnostics {
574    let mut diagnostics = Diagnostics::new();
575    diagnostics.push(&codes::READ_GRIDFM_VALUE_DEFAULTED, message);
576    diagnostics
577}
578
579/// Row indices whose `scenario` column equals `scenario`, in table order.
580fn scenario_rows(scen: &[i64], scenario: i64) -> Vec<usize> {
581    scen.iter()
582        .enumerate()
583        .filter_map(|(i, &s)| (s == scenario).then_some(i))
584        .collect()
585}
586
587/// Check a gen/branch table: empty `rows` is fine when the whole table is empty
588/// (a case legitimately has no generators, or no branches), but if the table
589/// holds rows for *other* scenarios yet none for this one, the dataset is partial
590/// or corrupt and would silently yield a wrong-but-valid network — error instead.
591fn require_scenario_block(
592    scen_col: &[i64],
593    scenario: i64,
594    rows: &[usize],
595    table: &str,
596) -> Result<()> {
597    if rows.is_empty() && !scen_col.is_empty() {
598        return Err(powerio_tx::Error::FormatRead {
599            format: "gridfm",
600            message: format!(
601                "scenario {scenario} has no {table} rows, but the table holds {} row(s) for other \
602                 scenarios — a partial or corrupt dataset",
603                scen_col.len()
604            ),
605        });
606    }
607    Ok(())
608}
609
610/// A dense `[0, n)` parquet index → 1-based [`BusId`]. Errors on a negative index.
611fn dense_bus_id(v: i64) -> Result<BusId> {
612    let idx = usize::try_from(v).map_err(|_| powerio_tx::Error::FormatRead {
613        format: "gridfm",
614        message: format!("negative dense bus index {v}"),
615    })?;
616    Ok(BusId(idx + 1))
617}
618
619/// Look up a named column, erroring if absent.
620fn column<'a>(b: &'a RecordBatch, name: &str) -> Result<&'a ArrayRef> {
621    b.column_by_name(name)
622        .ok_or_else(|| powerio_tx::Error::FormatRead {
623            format: "gridfm",
624            message: format!("missing column `{name}`"),
625        })
626}
627
628/// Concatenate a named non-null `Int64` column across all batches.
629fn i64_col(batches: &[RecordBatch], name: &str) -> Result<Vec<i64>> {
630    let mut out = Vec::with_capacity(batches.iter().map(RecordBatch::num_rows).sum());
631    for b in batches {
632        let arr = column(b, name)?;
633        let col = arr.as_any().downcast_ref::<Int64Array>().ok_or_else(|| {
634            powerio_tx::Error::FormatRead {
635                format: "gridfm",
636                message: format!("column `{name}` is not Int64"),
637            }
638        })?;
639        if col.null_count() > 0 {
640            return Err(powerio_tx::Error::FormatRead {
641                format: "gridfm",
642                message: format!("column `{name}` has nulls"),
643            });
644        }
645        out.extend_from_slice(col.values());
646    }
647    Ok(out)
648}
649
650/// Concatenate a named non-null `Float64` column across all batches.
651fn f64_col(batches: &[RecordBatch], name: &str) -> Result<Vec<f64>> {
652    let mut out = Vec::with_capacity(batches.iter().map(RecordBatch::num_rows).sum());
653    for b in batches {
654        let arr = column(b, name)?;
655        let col = arr.as_any().downcast_ref::<Float64Array>().ok_or_else(|| {
656            powerio_tx::Error::FormatRead {
657                format: "gridfm",
658                message: format!("column `{name}` is not Float64"),
659            }
660        })?;
661        if col.null_count() > 0 {
662            return Err(powerio_tx::Error::FormatRead {
663                format: "gridfm",
664                message: format!("column `{name}` has nulls"),
665            });
666        }
667        out.extend_from_slice(col.values());
668    }
669    Ok(out)
670}
671
672/// Read one scenario from a dataset directory in the named `from` format.
673/// This function dispatches dataset format names. `gridfm` is the currently
674/// supported dataset format; `scenario`
675/// selects within it. PyPSA CSV directories are case inputs rather than
676/// datasets and parse through the ordinary parse.
677///
678/// # Errors
679/// [`powerio_tx::Error::UnknownFormat`] for a non-dataset format name;
680/// otherwise as [`read_gridfm_dataset`].
681pub fn read_dataset_dir(
682    dir: impl AsRef<std::path::Path>,
683    from: &str,
684    scenario: i64,
685) -> Result<GridfmRead> {
686    require_dataset_format(from)?;
687    read_gridfm_dataset(dir, scenario)
688}
689
690/// Return the distinct scenario IDs in ascending order for dataset directory
691/// `dir` in the named `from` format.
692///
693/// # Errors
694/// As [`read_dataset_dir`].
695pub fn list_dataset_scenario_ids(dir: impl AsRef<std::path::Path>, from: &str) -> Result<Vec<i64>> {
696    require_dataset_format(from)?;
697    list_gridfm_scenario_ids(dir)
698}
699
700fn require_dataset_format(from: &str) -> Result<()> {
701    if from.eq_ignore_ascii_case("gridfm") {
702        return Ok(());
703    }
704    Err(powerio_tx::Error::UnknownFormat(format!(
705        "{from} is not a dataset directory format (dataset formats: gridfm); \
706         PyPSA CSV directories parse through the ordinary parse"
707    )))
708}
709
710/// Zero copy `bytes::Bytes` over an acquired source buffer, for the parquet
711/// reader.
712struct BufferBytes(powerio_core::SourceBuffer);
713
714impl AsRef<[u8]> for BufferBytes {
715    fn as_ref(&self) -> &[u8] {
716        self.0.bytes()
717    }
718}
719
720fn core_error(error: &powerio_tx::Error) -> powerio_core::Error {
721    powerio_core::Error::new(error.code(), error.to_string())
722}
723
724/// The raw table prefix within the entry listing, resolved as leniently as
725/// the path reader resolves directories: the tables at the root, under
726/// `raw/`, or under exactly one `<case>/raw/`.
727fn resolve_raw_prefix(entries: &[powerio_core::ArtifactPath]) -> Result<String> {
728    let holds = |prefix: &str| {
729        entries
730            .iter()
731            .any(|entry| entry.as_str() == format!("{prefix}bus_data.parquet"))
732    };
733    if holds("") {
734        return Ok(String::new());
735    }
736    if holds("raw/") {
737        return Ok("raw/".to_owned());
738    }
739    let mut nested: Vec<String> = entries
740        .iter()
741        .filter_map(|entry| {
742            entry
743                .as_str()
744                .strip_suffix("/raw/bus_data.parquet")
745                .filter(|case| !case.contains('/'))
746                .map(|case| format!("{case}/raw/"))
747        })
748        .collect();
749    nested.sort();
750    nested.dedup();
751    match nested.len() {
752        0 => Err(powerio_tx::Error::FormatRead {
753            format: "gridfm",
754            message: "no gridfm dataset found: no bus_data.parquet at the root, under raw/, \
755                      or under a single <case>/raw/"
756                .to_owned(),
757        }),
758        1 => Ok(nested.remove(0)),
759        n => Err(powerio_tx::Error::FormatRead {
760            format: "gridfm",
761            message: format!(
762                "{n} <case>/raw/ dataset directories found; open one of them directly"
763            ),
764        }),
765    }
766}
767
768fn read_parquet_buffer(
769    source: &powerio_core::Source,
770    prefix: &str,
771    file: &str,
772) -> Result<Vec<RecordBatch>> {
773    let name = format!("{prefix}{file}");
774    let path = powerio_core::ArtifactPath::new(name.clone()).map_err(|error| {
775        powerio_tx::Error::FormatRead {
776            format: "gridfm",
777            message: error.to_string(),
778        }
779    })?;
780    let buffer = source
781        .buffer(&path)
782        .map_err(|error| powerio_tx::Error::FormatRead {
783            format: "gridfm",
784            message: format!("acquiring {name}: {error}"),
785        })?;
786    let bytes = bytes::Bytes::from_owner(BufferBytes(buffer));
787    let reader = ParquetRecordBatchReaderBuilder::try_new(bytes)
788        .and_then(ParquetRecordBatchReaderBuilder::build)
789        .map_err(|e| powerio_tx::Error::FormatRead {
790            format: "gridfm",
791            message: format!("reading {name}: {e}"),
792        })?;
793    reader
794        .collect::<std::result::Result<Vec<_>, _>>()
795        .map_err(|e| powerio_tx::Error::FormatRead {
796            format: "gridfm",
797            message: format!("decoding {name}: {e}"),
798        })
799}
800
801fn read_meta_source(source: &powerio_core::Source, prefix: &str) -> (f64, String, Diagnostics) {
802    let fallback_name = || {
803        prefix.strip_suffix("/raw/").map_or_else(
804            || {
805                std::path::Path::new(source.name())
806                    .file_name()
807                    .and_then(|s| s.to_str())
808                    .map_or_else(|| "gridfm".to_owned(), str::to_owned)
809            },
810            str::to_owned,
811        )
812    };
813    let Ok(meta_path) = powerio_core::ArtifactPath::new(format!("{prefix}gridfm_meta.json")) else {
814        return (
815            100.0,
816            fallback_name(),
817            defaulted_meta("gridfm_meta.json name did not validate; base_mva defaulted to 100"),
818        );
819    };
820    let Ok(buffer) = source.buffer(&meta_path) else {
821        return (
822            100.0,
823            fallback_name(),
824            defaulted_meta("gridfm_meta.json could not be acquired; base_mva defaulted to 100"),
825        );
826    };
827    let Ok(text) = std::str::from_utf8(buffer.content_bytes()) else {
828        return (
829            100.0,
830            fallback_name(),
831            defaulted_meta("gridfm_meta.json is not UTF-8; base_mva defaulted to 100"),
832        );
833    };
834    let Ok(meta) = serde_json::from_str::<serde_json::Value>(text) else {
835        return (
836            100.0,
837            fallback_name(),
838            defaulted_meta("gridfm_meta.json is not valid JSON; base_mva defaulted to 100"),
839        );
840    };
841    let name = meta
842        .get("case_name")
843        .and_then(serde_json::Value::as_str)
844        .map_or_else(fallback_name, str::to_string);
845    let mut warnings = Diagnostics::new();
846    let base = match meta.get("base_mva").and_then(serde_json::Value::as_f64) {
847        Some(b) if b.is_finite() && b > 0.0 => b,
848        _ => {
849            warnings.push(
850                &codes::READ_GRIDFM_VALUE_DEFAULTED,
851                "gridfm_meta.json has no usable base_mva (absent or not a positive number); \
852                 defaulted to 100",
853            );
854            100.0
855        }
856    };
857    (base, name, warnings)
858}
859
860/// Parse a gridfm dataset from the retained directory source: every scenario
861/// as one scenario set over shared element identities, with each scenario's
862/// read findings in ascending scenario order.
863pub(crate) fn parse_gridfm_source(
864    source: &powerio_core::Source,
865) -> std::result::Result<
866    (
867        powerio_core::ScenarioSet<BalancedNetwork>,
868        Vec<powerio_core::Diagnostic>,
869    ),
870    powerio_core::Error,
871> {
872    let entries = source.entry_names()?;
873    let prefix = resolve_raw_prefix(&entries).map_err(|error| core_error(&error))?;
874    let (base_mva, name, meta_warnings) = read_meta_source(source, &prefix);
875    let bus = bus_columns(
876        &read_parquet_buffer(source, &prefix, "bus_data.parquet")
877            .map_err(|error| core_error(&error))?,
878    )
879    .map_err(|error| core_error(&error))?;
880    let gens = gen_columns(
881        &read_parquet_buffer(source, &prefix, "gen_data.parquet")
882            .map_err(|error| core_error(&error))?,
883    )
884    .map_err(|error| core_error(&error))?;
885    let branch = branch_columns(
886        &read_parquet_buffer(source, &prefix, "branch_data.parquet")
887            .map_err(|error| core_error(&error))?,
888    )
889    .map_err(|error| core_error(&error))?;
890
891    let mut diagnostics = Vec::new();
892    let mut scenarios = Vec::new();
893    let mut donor: Option<BalancedNetwork> = None;
894    for scenario in distinct_sorted(&bus.scenario) {
895        let read = build_network_from_columns(
896            &bus,
897            &gens,
898            &branch,
899            scenario,
900            base_mva,
901            &name,
902            meta_warnings.clone(),
903        )
904        .map_err(|error| core_error(&error))?;
905        let mut network = read.network;
906        match &donor {
907            Some(base) => network.share_equal_tables(base),
908            None => donor = Some(network.clone()),
909        }
910        diagnostics.extend(read.diagnostics);
911        let id = powerio_core::ScenarioId::new(read.scenario.to_string())?;
912        scenarios.push(powerio_core::Scenario::new(id, None, network));
913    }
914    let set = powerio_core::ScenarioSet::new(scenarios)?;
915    Ok((set, diagnostics))
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921
922    #[test]
923    fn require_scenario_block_flags_partial_tables() {
924        // Empty table → ok (a legitimately element-less case). Present → ok.
925        // Absent from a non-empty table → error (a partial or corrupt
926        // dataset would otherwise silently yield a wrong but valid network).
927        assert!(require_scenario_block(&[], 0, &[], "gen_data").is_ok());
928        assert!(require_scenario_block(&[0, 0, 1], 0, &[0, 1], "gen_data").is_ok());
929        let err = require_scenario_block(&[0, 0], 1, &[], "branch_data").unwrap_err();
930        assert!(
931            matches!(
932                err,
933                powerio_tx::Error::FormatRead {
934                    format: "gridfm",
935                    ..
936                }
937            ),
938            "got {err:?}"
939        );
940    }
941}