Skip to main content

powerio_matrix/io/
gridfm.rs

1//! GridFM interchange: a parsed case as the gridfm-datakit Parquet schema.
2//!
3//! [`gridfm-datakit`](https://github.com/gridfm) writes per-scenario Parquet
4//! tables that [`gridfm-graphkit`](https://github.com/gridfm)'s
5//! `HeteroGridDatasetDisk` trains a GNN on. This module emits the same four
6//! tables — `bus_data`, `gen_data`, `branch_data`, `y_bus_data` — from one
7//! parsed [`BalancedNetwork`], so graphkit can train on powerio output directly and the
8//! scenario-batch path (issue #14) has its on-disk format.
9//!
10//! The reverse — [`read_gridfm_dataset`] / [`read_gridfm_scenarios`] /
11//! [`gridfm_base_case`], with the pure [`read_gridfm_network`] over in-memory
12//! batches — rebuilds a [`BalancedNetwork`] from such a dataset (lossy but
13//! power flow complete; see [`GridfmRead`]), the ML→classical return leg
14//! (issue #60). One reader plus the existing writers means gridfm → any classical
15//! format. `y_bus_data` is ignored on read; branches carry raw `r/x/b`.
16//!
17//! # Snapshots and scenarios
18//!
19//! powerio has no power flow solver. One parsed case is one snapshot
20//! (`scenario = 0`): voltages and generator dispatch are the case's stored
21//! values, and branch flows `pf/qf/pt/qt` are computed from those voltages and
22//! the branch admittances (`branch_flows`). For a solved MATPOWER case the
23//! stored voltages are the converged operating point, so the flows match what a
24//! solver would report to float tolerance; for an unsolved/flat start case they
25//! are the flows at the stored voltages, not a re-solved dispatch.
26//!
27//! A scenario batch ([`write_gridfm_batch`] / [`gridfm_record_batches_batch`])
28//! row-stacks many snapshots into the four tables, keyed by the `scenario`
29//! column. The snapshots share a base element set — the same bus/branch/gen
30//! counts and bus-id ordering, so the dense bus index means the same bus across
31//! scenarios — enforced by the shape check ([`Error::ScenarioShapeMismatch`]).
32//! Within that, load, dispatch, voltages, branch status, bus type, and costs may
33//! all differ per snapshot. This matches datakit, whose topology variants (N-K,
34//! random component drop) toggle `BR_STATUS`/`GEN_STATUS` on a fixed element set,
35//! and graphkit's `HeteroGridDatasetDisk`, which groups by `scenario` and
36//! rebuilds the graph independently for each one. powerio doesn't generate the
37//! perturbations; a caller (e.g. a scenario generator) supplies the snapshots.
38//!
39//! # Units
40//!
41//! - `Pd, Qd, Pg, Qg, p_mw, q_mvar` are MW/MVAr, passed through from the case
42//!   (loads and generator setpoints are already MW/MVAr). The branch flows
43//!   `pf, qf, pt, qt` are MW/MVAr too, computed in per-unit and scaled by
44//!   `base_mva`.
45//! - `Vm` per-unit, `Va` degrees; `r, x, b` and the `Y**` admittances per-unit.
46//! - `GS, BS` are the MATPOWER shunt values (MW/MVAr at V = 1) divided by
47//!   `base_mva`, matching datakit's normalization.
48//! - Costs are the raw MATPOWER coefficients: `cp2 = c2`, `cp1 = c1`,
49//!   `cp0 = c0`. A cost row gridfm can't represent (piecewise, missing,
50//!   malformed, or cubic and higher) emits zeros — graphkit ignores the cost
51//!   columns — and is counted in the manifest. The `_eur` suffixes are
52//!   datakit's column names, not a unit powerio converts to.
53//! - `bus`, `from_bus`, `to_bus` are dense `[0, n)` indices; `idx` is the
54//!   0-based generator/branch row. An out of service branch keeps its physical
55//!   `Y**` admittances but carries zero flows (its `br_status` is 0).
56
57// Bus/branch indices and Y_bus nnz counts cast to `i64` for the Arrow columns;
58// they are bounded far below `i64::MAX`, so the wrap clippy warns about can't
59// happen.
60#![allow(clippy::cast_possible_wrap)]
61
62use std::path::{Path, PathBuf};
63use std::sync::Arc;
64
65use arrow::array::{Array, ArrayRef, Float64Array, Int64Array};
66use arrow::datatypes::{Field, Schema};
67use arrow::record_batch::RecordBatch;
68use num_complex::Complex64;
69use parquet::arrow::ArrowWriter;
70use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
71use parquet::basic::Compression;
72use parquet::file::properties::WriterProperties;
73use serde::Serialize;
74
75use crate::indexed::IndexedNetwork;
76use crate::matrix::{BuildOptions, YbusFlags, branch_admittance, branch_flows, build_ybus};
77use crate::network::{Branch, Bus, BusId, BusType, Generator, Load, Shunt, SourceFormat};
78use crate::{
79    BalancedNetwork, ElementCounts, Error, GenCost, GenCostPatch, MissingGenCostPolicy, Result,
80    ScenarioMismatch,
81};
82
83/// Options for the gridfm export — the batch-wide knobs. The scenario id is a
84/// per-snapshot property (set via [`GridfmSnapshot::new`] / [`numbered_snapshots`],
85/// or the explicit argument to the single-case [`write_gridfm_dataset`] /
86/// [`gridfm_record_batches`]), not an option here.
87#[derive(Debug, Clone)]
88pub struct GridfmOptions {
89    /// Also write `y_bus_data.parquet`. graphkit reconstructs admittances from
90    /// the branch table and ignores it, but datakit emits it, so the default is
91    /// `true` for parity.
92    pub include_y_bus: bool,
93    /// Apply transformer tap ratios to the admittances. Default `true` (the
94    /// physical admittances datakit stores).
95    pub include_taps: bool,
96    /// Apply phase shifts to the admittances. Default `true`.
97    pub include_shifts: bool,
98    /// How missing generator cost rows are handled before export.
99    pub missing_gen_cost: MissingGenCostPolicy,
100    /// Explicit generator cost patches applied to each snapshot before the missing
101    /// cost policy.
102    pub gen_cost_patches: Vec<GenCostPatch>,
103}
104
105impl Default for GridfmOptions {
106    fn default() -> Self {
107        Self {
108            include_y_bus: true,
109            include_taps: true,
110            include_shifts: true,
111            missing_gen_cost: MissingGenCostPolicy::Preserve,
112            gen_cost_patches: Vec::new(),
113        }
114    }
115}
116
117impl GridfmOptions {
118    /// The Y_bus build flags these options select (only taps and shifts matter
119    /// for the gridfm admittances; the Bp/Bpp scheme and zero-impedance policy
120    /// don't apply).
121    fn build_options(&self) -> BuildOptions {
122        BuildOptions {
123            include_taps: self.include_taps,
124            include_shifts: self.include_shifts,
125            ..Default::default()
126        }
127    }
128
129    fn cost_options_default(&self) -> bool {
130        self.missing_gen_cost.is_preserve() && self.gen_cost_patches.is_empty()
131    }
132}
133
134/// One snapshot in a gridfm scenario batch: a parsed [`BalancedNetwork`] and the scenario
135/// id stamped into its rows.
136///
137/// powerio has no solver, so each snapshot is an operating point a caller (e.g. a
138/// scenario generator) has already produced. Snapshots in one batch share a base
139/// element set — the same bus/branch/gen counts and bus-id ordering — so the
140/// dense bus index means the same bus across snapshots and the tables stay
141/// schema-consistent. The builders enforce that and otherwise return
142/// [`Error::ScenarioShapeMismatch`]. Within that, load, dispatch, voltages,
143/// branch status, bus type, and costs may all vary per snapshot — this mirrors
144/// gridfm-datakit, whose topology variants (N-K, random component drop) toggle
145/// `BR_STATUS`/`GEN_STATUS` on a fixed element set, and gridfm-graphkit, which
146/// rebuilds the graph independently for every scenario.
147#[derive(Debug, Clone, Copy)]
148pub struct GridfmSnapshot<'a> {
149    /// The parsed case for this scenario.
150    net: &'a BalancedNetwork,
151    /// The scenario id stamped into the `scenario`/`load_scenario_idx` columns.
152    scenario: i64,
153}
154
155impl<'a> GridfmSnapshot<'a> {
156    /// A snapshot pairing a network with the scenario id stamped into its rows.
157    /// For the common "k-th input is `base + k`" numbering, prefer
158    /// [`numbered_snapshots`], which assigns ids with checked arithmetic.
159    #[must_use]
160    pub fn new(net: &'a BalancedNetwork, scenario: i64) -> Self {
161        Self { net, scenario }
162    }
163}
164
165/// The gridfm-datakit tables as Arrow record batches. The Parquet writer builds
166/// from these; a deferred gridfm-schema Arrow C Data Interface export (issue #38)
167/// would reuse them. (The raw network Arrow export that ships in powerio-capi is
168/// a different, lighter schema.)
169///
170/// For a scenario batch the tables are row stacked: each table holds the rows of
171/// every snapshot back-to-back, keyed by the `scenario` column (0-based dense bus
172/// indices and generator/branch `idx` reset per scenario).
173#[derive(Debug, Clone)]
174#[non_exhaustive]
175pub struct GridfmTables {
176    pub bus: RecordBatch,
177    pub generator: RecordBatch,
178    pub branch: RecordBatch,
179    /// `None` when [`GridfmOptions::include_y_bus`] is off — the table isn't
180    /// built (graphkit reconstructs admittances from the branch table anyway).
181    pub y_bus: Option<RecordBatch>,
182}
183
184/// What [`write_gridfm_dataset`] wrote, plus the counts of columns it had to zero
185/// (see the manifest) so a caller can surface them.
186#[derive(Debug, Clone)]
187pub struct GridfmOutputs {
188    pub dir: PathBuf,
189    pub files: Vec<PathBuf>,
190    /// Branches with `r² + x² = 0`, whose admittance/flow columns were zeroed.
191    pub dropped_zero_impedance: usize,
192    /// Generators whose cost row gridfm couldn't represent, whose `cp*` columns
193    /// were zeroed.
194    pub degenerate_cost_gens: usize,
195    /// Generators with no cost after applying the export cost policy.
196    pub missing_cost_gens: usize,
197    /// Generators with a present cost row gridfm cannot represent.
198    pub unsupported_cost_gens: usize,
199    /// Generators whose missing cost was filled by the export policy.
200    pub synthesized_gen_costs: usize,
201    /// Generator costs replaced by explicit user patches.
202    pub patched_gen_costs: usize,
203}
204
205#[derive(Serialize)]
206struct GridfmMeta {
207    case_name: String,
208    base_mva: f64,
209    /// The first snapshot's scenario id (the base for a batch).
210    scenario: i64,
211    /// Number of stacked scenarios (1 for a single case).
212    n_scenarios: usize,
213    schema: &'static str,
214    /// Shared base element set (equal across all snapshots by the shape check).
215    n_buses: usize,
216    n_branches: usize,
217    /// In-service branch count of the **first** snapshot; branch status may
218    /// differ per scenario, so this describes scenario 0, not the whole batch.
219    n_branches_in_service: usize,
220    n_gens: usize,
221    /// Reference (slack) bus of the **first** snapshot. Each snapshot resolves
222    /// its own reference and carries it in the bus `REF` / gen `is_slack_gen`
223    /// columns; this records scenario 0's.
224    reference_bus: usize,
225    /// Branches with `r² + x² = 0` (admittance/flow columns zeroed), summed over
226    /// every snapshot in the batch.
227    dropped_zero_impedance: usize,
228    /// Generators whose cost row gridfm can't represent (piecewise, missing,
229    /// malformed, or cubic and higher; `cp*` columns zeroed), summed over every
230    /// snapshot in the batch.
231    degenerate_cost_gens: usize,
232    /// Missing cost rows after applying the export cost policy.
233    missing_cost_gens: usize,
234    /// Present cost rows that gridfm cannot represent.
235    unsupported_cost_gens: usize,
236    /// Same as `degenerate_cost_gens`; named for the physical `cp*` columns.
237    zeroed_cost_gens: usize,
238    cost_policy: MissingGenCostPolicy,
239    synthesized_gen_costs: usize,
240    patched_gen_costs: usize,
241    files: Vec<String>,
242    powerio_version: String,
243}
244
245/// Build the four gridfm tables for one network, stamping `scenario` into the id
246/// columns. Pure (no I/O). A thin wrapper over [`gridfm_record_batches_batch`]
247/// for one snapshot.
248///
249/// # Errors
250/// [`powerio::Error::ReferenceBusCount`] unless the case has exactly one reference bus
251/// (graphkit needs a slack), [`Error::NormalizedGridfmSnapshot`] for a normalized
252/// input, [`Error::NonFiniteGridfmValue`] for a NaN/Inf physical quantity (or a
253/// NaN limit — `±Inf` limits are the valid unbounded sentinel) that would reach
254/// Parquet, [`powerio::Error::NonFiniteSusceptance`] if a finite branch impedance still
255/// yields a non-finite admittance, and [`powerio::Error::UnknownBus`] if a generator or
256/// branch references a bus the network doesn't define.
257pub fn gridfm_record_batches(
258    net: &BalancedNetwork,
259    scenario: i64,
260    opts: &GridfmOptions,
261) -> Result<GridfmTables> {
262    let snap = GridfmSnapshot::new(net, scenario);
263    gridfm_record_batches_batch(std::slice::from_ref(&snap), opts)
264}
265
266/// Build the four gridfm tables for a batch of scenarios, row stacked and keyed
267/// by the `scenario` column. Pure (no I/O). Each snapshot carries its own
268/// scenario id; the `include_y_bus`/taps/shifts flags apply to every snapshot.
269///
270/// # Errors
271/// [`Error::EmptyScenarioBatch`] for an empty batch,
272/// [`Error::ScenarioShapeMismatch`] if the snapshots don't share one base element
273/// set (counts + bus-id order), plus everything [`gridfm_record_batches`] can
274/// return.
275pub fn gridfm_record_batches_batch(
276    snapshots: &[GridfmSnapshot],
277    opts: &GridfmOptions,
278) -> Result<GridfmTables> {
279    if opts.cost_options_default() {
280        let views = snapshot_views(snapshots)?;
281        return tables_from_views(&views, opts);
282    }
283
284    let (nets, _) = policy_adjusted_snapshots(snapshots, opts)?;
285    let adjusted: Vec<_> = nets
286        .iter()
287        .zip(snapshots)
288        .map(|(net, snap)| GridfmSnapshot::new(net, snap.scenario))
289        .collect();
290    let views = snapshot_views(&adjusted)?;
291    tables_from_views(&views, opts)
292}
293
294/// The four tables from already-built, shape-checked snapshot views. The Y_bus
295/// table is built only when [`GridfmOptions::include_y_bus`] is set — otherwise
296/// it's `None` and the per-snapshot `build_ybus` is skipped entirely.
297fn tables_from_views(views: &[SnapshotView], opts: &GridfmOptions) -> Result<GridfmTables> {
298    Ok(GridfmTables {
299        bus: bus_batch(views)?,
300        generator: gen_batch(views)?,
301        branch: branch_batch(views, opts)?,
302        y_bus: if opts.include_y_bus {
303            Some(y_bus_batch(views, opts)?)
304        } else {
305            None
306        },
307    })
308}
309
310/// A resolved snapshot: its indexed view, scenario id, and reference bus.
311struct SnapshotView<'a> {
312    view: IndexedNetwork<'a>,
313    scenario: i64,
314    ref_bus: usize,
315}
316
317/// Build and shape-check the views for a scenario batch. Every snapshot must
318/// resolve to exactly one reference bus and share the first snapshot's base
319/// element set (bus / branch / generator counts and bus-id ordering), so the
320/// row-stacked tables stay schema-consistent.
321fn snapshot_views<'a>(snapshots: &'a [GridfmSnapshot<'a>]) -> Result<Vec<SnapshotView<'a>>> {
322    let first = snapshots.first().ok_or(Error::EmptyScenarioBatch)?;
323    let expected = shape_of(first.net);
324    let expected_ids: Vec<BusId> = first.net.buses.iter().map(|b| b.id).collect();
325
326    let mut views = Vec::with_capacity(snapshots.len());
327    for (k, snap) in snapshots.iter().enumerate() {
328        let got = shape_of(snap.net);
329        if got != expected {
330            return Err(Error::ScenarioShapeMismatch {
331                index: k,
332                reason: ScenarioMismatch::Counts { expected, got },
333            });
334        }
335        let ids_match = snap
336            .net
337            .buses
338            .iter()
339            .map(|b| b.id)
340            .eq(expected_ids.iter().copied());
341        if !ids_match {
342            return Err(Error::ScenarioShapeMismatch {
343                index: k,
344                reason: ScenarioMismatch::BusOrder,
345            });
346        }
347        validate_snapshot_inputs(snap.net, snap.scenario)?;
348        let view = IndexedNetwork::new(snap.net);
349        let ref_bus = view.reference_bus_index()?;
350        views.push(SnapshotView {
351            view,
352            scenario: snap.scenario,
353            ref_bus,
354        });
355    }
356    Ok(views)
357}
358
359fn validate_snapshot_inputs(net: &BalancedNetwork, scenario: i64) -> Result<()> {
360    if net.is_normalized() {
361        return Err(Error::NormalizedGridfmSnapshot { scenario });
362    }
363    net.check_base_mva()?;
364
365    // Two tiers: physical quantities must be finite, while limit fields admit
366    // `±Inf` as the unbounded sentinel (the PowerModels reader defaults absent
367    // gen limits to `±Inf`, MATPOWER files carry literal `Inf` tokens, and
368    // Parquet stores `±Inf` natively) — only `NaN` is corrupt there.
369    for (row, b) in net.buses.iter().enumerate() {
370        finite(scenario, "bus", row, "vm", b.vm)?;
371        finite(scenario, "bus", row, "va", b.va)?;
372        finite(scenario, "bus", row, "base_kv", b.base_kv)?;
373        not_nan(scenario, "bus", row, "vmax", b.vmax)?;
374        not_nan(scenario, "bus", row, "vmin", b.vmin)?;
375    }
376    for (row, l) in net.loads.iter().enumerate() {
377        finite(scenario, "load", row, "p", l.p)?;
378        finite(scenario, "load", row, "q", l.q)?;
379    }
380    for (row, s) in net.shunts.iter().enumerate() {
381        finite(scenario, "shunt", row, "g", s.g)?;
382        finite(scenario, "shunt", row, "b", s.b)?;
383    }
384    for (row, br) in net.branches.iter().enumerate() {
385        finite(scenario, "branch", row, "r", br.r)?;
386        finite(scenario, "branch", row, "x", br.x)?;
387        finite(scenario, "branch", row, "b", br.total_charging_b())?;
388        finite(scenario, "branch", row, "tap", br.tap)?;
389        finite(scenario, "branch", row, "shift", br.shift)?;
390        not_nan(scenario, "branch", row, "angmin", br.angmin)?;
391        not_nan(scenario, "branch", row, "angmax", br.angmax)?;
392        not_nan(scenario, "branch", row, "rate_a", br.rate_a)?;
393    }
394    for (row, g) in net.generators.iter().enumerate() {
395        finite(scenario, "generator", row, "pg", g.pg)?;
396        finite(scenario, "generator", row, "qg", g.qg)?;
397        not_nan(scenario, "generator", row, "pmax", g.pmax)?;
398        not_nan(scenario, "generator", row, "pmin", g.pmin)?;
399        not_nan(scenario, "generator", row, "qmax", g.qmax)?;
400        not_nan(scenario, "generator", row, "qmin", g.qmin)?;
401        // Validate exactly what lands in the `cp0/cp1/cp2` columns, not the
402        // raw coefficient list (non-representable rows export as zeros).
403        let (cp0, cp1, cp2) = gridfm_cost(g.cost.as_ref());
404        finite(scenario, "gencost", row, "cp0", cp0)?;
405        finite(scenario, "gencost", row, "cp1", cp1)?;
406        finite(scenario, "gencost", row, "cp2", cp2)?;
407    }
408    Ok(())
409}
410
411fn finite(
412    scenario: i64,
413    element: &'static str,
414    row: usize,
415    field: &'static str,
416    value: f64,
417) -> Result<()> {
418    if value.is_finite() {
419        Ok(())
420    } else {
421        Err(Error::NonFiniteGridfmValue {
422            scenario,
423            element,
424            row,
425            field,
426            value,
427        })
428    }
429}
430
431/// Limit fields: `±Inf` is the valid unbounded sentinel, only `NaN` is corrupt.
432fn not_nan(
433    scenario: i64,
434    element: &'static str,
435    row: usize,
436    field: &'static str,
437    value: f64,
438) -> Result<()> {
439    if value.is_nan() {
440        Err(Error::NonFiniteGridfmValue {
441            scenario,
442            element,
443            row,
444            field,
445            value,
446        })
447    } else {
448        Ok(())
449    }
450}
451
452/// The base element shape a scenario batch shares.
453fn shape_of(net: &BalancedNetwork) -> ElementCounts {
454    ElementCounts {
455        buses: net.buses.len(),
456        branches: net.branches.len(),
457        gens: net.generators.len(),
458    }
459}
460
461#[derive(Debug, Clone, Copy, Default)]
462struct CostPolicyBatchReport {
463    synthesized: usize,
464    patched: usize,
465}
466
467fn policy_adjusted_snapshots(
468    snapshots: &[GridfmSnapshot],
469    opts: &GridfmOptions,
470) -> Result<(Vec<BalancedNetwork>, CostPolicyBatchReport)> {
471    let mut report = CostPolicyBatchReport::default();
472    let mut nets = Vec::with_capacity(snapshots.len());
473    for snap in snapshots {
474        let mut net = snap.net.clone();
475        let r = net.apply_gen_cost_policy(&opts.gen_cost_patches, opts.missing_gen_cost)?;
476        report.synthesized += r.synthesized;
477        report.patched += r.patched;
478        nets.push(net);
479    }
480    Ok((nets, report))
481}
482
483/// Number a list of networks into snapshots, stamping the k-th `base + k` — the
484/// one place the "k-th input is scenario `base + k`" rule lives, so the CLI and
485/// the Python binding can't drift. Checked: returns [`Error::ScenarioIdOverflow`]
486/// rather than wrapping or panicking if a scenario id exceeds `i64`.
487pub fn numbered_snapshots<'a>(
488    nets: &[&'a BalancedNetwork],
489    base: i64,
490) -> Result<Vec<GridfmSnapshot<'a>>> {
491    nets.iter()
492        .enumerate()
493        .map(|(k, &net)| {
494            let scenario = i64::try_from(k)
495                .ok()
496                .and_then(|offset| base.checked_add(offset))
497                .ok_or(Error::ScenarioIdOverflow { base, index: k })?;
498            Ok(GridfmSnapshot::new(net, scenario))
499        })
500        .collect()
501}
502
503/// Write the gridfm-datakit Parquet dataset for one case under
504/// `out_dir/<sanitized network name>/raw/` (see
505/// [`sanitize_stem`](crate::sanitize_stem)), matching datakit's directory
506/// layout. Stamps
507/// `scenario` into the id columns. Writes `bus_data.parquet`, `gen_data.parquet`,
508/// `branch_data.parquet`, optionally `y_bus_data.parquet`, and a
509/// `gridfm_meta.json` manifest.
510///
511/// Expects a raw snapshot (powers in MW, angles in degrees); pass the parsed
512/// `BalancedNetwork`, not a [`to_normalized`](powerio::BalancedNetwork::to_normalized) per-unit
513/// product, whose fields would be mislabeled.
514///
515/// # Errors
516/// Propagates [`gridfm_record_batches`] and any filesystem/Parquet error.
517pub fn write_gridfm_dataset(
518    net: &BalancedNetwork,
519    scenario: i64,
520    out_dir: impl AsRef<Path>,
521    opts: &GridfmOptions,
522) -> Result<GridfmOutputs> {
523    let snap = GridfmSnapshot::new(net, scenario);
524    write_gridfm_batch(std::slice::from_ref(&snap), out_dir, opts)
525}
526
527/// Write a batch of scenarios as one gridfm-datakit dataset under
528/// `out_dir/<sanitized network name>/raw/` (see
529/// [`sanitize_stem`](crate::sanitize_stem)), row-stacking every snapshot's
530/// tables and keying
531/// them by the `scenario` column. The dataset name and the base element counts
532/// come from the first snapshot (shared across the batch by the shape check); the
533/// dropped/degenerate counts are summed over every snapshot, while `reference_bus`
534/// / `n_branches_in_service` record the first snapshot only (they can differ per
535/// scenario, so the manifest documents them as scenario 0's).
536///
537/// # Errors
538/// Propagates [`gridfm_record_batches_batch`] and any filesystem/Parquet error.
539pub fn write_gridfm_batch(
540    snapshots: &[GridfmSnapshot],
541    out_dir: impl AsRef<Path>,
542    opts: &GridfmOptions,
543) -> Result<GridfmOutputs> {
544    if opts.cost_options_default() {
545        return write_gridfm_batch_inner(
546            snapshots,
547            out_dir.as_ref(),
548            opts,
549            CostPolicyBatchReport::default(),
550        );
551    }
552
553    let (nets, cost_report) = policy_adjusted_snapshots(snapshots, opts)?;
554    let adjusted: Vec<_> = nets
555        .iter()
556        .zip(snapshots)
557        .map(|(net, snap)| GridfmSnapshot::new(net, snap.scenario))
558        .collect();
559    write_gridfm_batch_inner(&adjusted, out_dir.as_ref(), opts, cost_report)
560}
561
562fn write_gridfm_batch_inner(
563    snapshots: &[GridfmSnapshot],
564    out_dir: &Path,
565    opts: &GridfmOptions,
566    cost_report: CostPolicyBatchReport,
567) -> Result<GridfmOutputs> {
568    let views = snapshot_views(snapshots)?;
569    let tables = tables_from_views(&views, opts)?;
570
571    // The shape check guarantees every snapshot shares the base element set, so
572    // the name and structural counts come from the first.
573    let net = views[0].view.network();
574    // The network name comes from input content, so it must not steer the
575    // output path: unsanitized, a name like `../../x` would write outside
576    // `out_dir`. The manifest keeps the original name.
577    let dir = out_dir.join(crate::sanitize_stem(&net.name)).join("raw");
578    std::fs::create_dir_all(&dir)?;
579
580    let mut files = Vec::new();
581    put_parquet(&dir, "bus_data.parquet", &tables.bus, &mut files)?;
582    put_parquet(&dir, "gen_data.parquet", &tables.generator, &mut files)?;
583    put_parquet(&dir, "branch_data.parquet", &tables.branch, &mut files)?;
584    if let Some(y_bus) = &tables.y_bus {
585        put_parquet(&dir, "y_bus_data.parquet", y_bus, &mut files)?;
586    }
587
588    // Branch status and costs may differ per scenario, so count the zeroed rows
589    // across every snapshot — the totals describe the whole stacked dataset.
590    let dropped_zero_impedance: usize = views
591        .iter()
592        .flat_map(|v| v.view.network().branches.iter())
593        .filter(|br| br.r * br.r + br.x * br.x == 0.0)
594        .count();
595    let missing_cost_gens: usize = views
596        .iter()
597        .flat_map(|v| v.view.network().generators.iter())
598        .filter(|g| g.cost.is_none())
599        .count();
600    let unsupported_cost_gens: usize = views
601        .iter()
602        .flat_map(|v| v.view.network().generators.iter())
603        .filter(|g| g.cost.is_some() && !cost_representable(g.cost.as_ref()))
604        .count();
605    let degenerate_cost_gens = missing_cost_gens + unsupported_cost_gens;
606
607    let meta = GridfmMeta {
608        case_name: net.name.clone(),
609        base_mva: net.base_mva,
610        scenario: views[0].scenario,
611        n_scenarios: views.len(),
612        schema: "gridfm-datakit",
613        n_buses: net.buses.len(),
614        n_branches: net.branches.len(),
615        n_branches_in_service: net.branches.iter().filter(|b| b.in_service).count(),
616        n_gens: net.generators.len(),
617        reference_bus: views[0].ref_bus,
618        dropped_zero_impedance,
619        degenerate_cost_gens,
620        missing_cost_gens,
621        unsupported_cost_gens,
622        zeroed_cost_gens: degenerate_cost_gens,
623        cost_policy: opts.missing_gen_cost,
624        synthesized_gen_costs: cost_report.synthesized,
625        patched_gen_costs: cost_report.patched,
626        files: files
627            .iter()
628            .filter_map(|p| p.file_name().and_then(|s| s.to_str()).map(str::to_string))
629            .collect(),
630        powerio_version: env!("CARGO_PKG_VERSION").to_string(),
631    };
632    let meta_path = dir.join("gridfm_meta.json");
633    let json = serde_json::to_string_pretty(&meta).map_err(|e| Error::Parquet(e.to_string()))?;
634    std::fs::write(&meta_path, json)?;
635    files.push(meta_path);
636
637    Ok(GridfmOutputs {
638        dir,
639        files,
640        dropped_zero_impedance,
641        degenerate_cost_gens,
642        missing_cost_gens,
643        unsupported_cost_gens,
644        synthesized_gen_costs: cost_report.synthesized,
645        patched_gen_costs: cost_report.patched,
646    })
647}
648
649// --- table builders --------------------------------------------------------
650
651fn bus_batch(snaps: &[SnapshotView]) -> Result<RecordBatch> {
652    let total: usize = snaps.iter().map(|s| s.view.n()).sum();
653    let mut scenario = Vec::with_capacity(total);
654    let mut bus_idx = Vec::with_capacity(total);
655    let (mut pd, mut qd) = (Vec::with_capacity(total), Vec::with_capacity(total));
656    let (mut pg_col, mut qg_col) = (Vec::with_capacity(total), Vec::with_capacity(total));
657    let (mut vm, mut va) = (Vec::with_capacity(total), Vec::with_capacity(total));
658    let (mut pq, mut pv, mut refc) = (
659        Vec::with_capacity(total),
660        Vec::with_capacity(total),
661        Vec::with_capacity(total),
662    );
663    let mut vn_kv = Vec::with_capacity(total);
664    let (mut min_vm, mut max_vm) = (Vec::with_capacity(total), Vec::with_capacity(total));
665    let (mut gs, mut bs) = (Vec::with_capacity(total), Vec::with_capacity(total));
666
667    for s in snaps {
668        let view = &s.view;
669        let n = view.n();
670        let base = view.base_mva();
671        let buses = &view.network().buses;
672
673        // Per-bus generation, summed over in-service generators (dense order).
674        let mut pg = vec![0.0; n];
675        let mut qg = vec![0.0; n];
676        for (_, g) in view.in_service_gens() {
677            if let Some(i) = view.bus_index(g.bus) {
678                pg[i] += g.pg;
679                qg[i] += g.qg;
680            }
681        }
682
683        scenario.resize(scenario.len() + n, s.scenario);
684        bus_idx.extend(0..n as i64);
685        pd.extend_from_slice(view.pd());
686        qd.extend_from_slice(view.qd());
687        pg_col.extend(pg);
688        qg_col.extend(qg);
689        vm.extend(buses.iter().map(|b| b.vm));
690        va.extend(buses.iter().map(|b| b.va));
691        pq.extend(buses.iter().map(|b| i64::from(b.kind == BusType::Pq)));
692        pv.extend(buses.iter().map(|b| i64::from(b.kind == BusType::Pv)));
693        refc.extend(buses.iter().map(|b| i64::from(b.kind == BusType::Ref)));
694        vn_kv.extend(buses.iter().map(|b| b.base_kv));
695        min_vm.extend(buses.iter().map(|b| b.vmin));
696        max_vm.extend(buses.iter().map(|b| b.vmax));
697        gs.extend(view.gs().iter().map(|g| g / base));
698        bs.extend(view.bs().iter().map(|b| b / base));
699    }
700
701    batch(with_scenario_pair(
702        scenario,
703        vec![
704            ("bus", i64s(bus_idx)),
705            ("Pd", f64s(pd)),
706            ("Qd", f64s(qd)),
707            ("Pg", f64s(pg_col)),
708            ("Qg", f64s(qg_col)),
709            ("Vm", f64s(vm)),
710            ("Va", f64s(va)),
711            ("PQ", i64s(pq)),
712            ("PV", i64s(pv)),
713            ("REF", i64s(refc)),
714            ("vn_kv", f64s(vn_kv)),
715            ("min_vm_pu", f64s(min_vm)),
716            ("max_vm_pu", f64s(max_vm)),
717            ("GS", f64s(gs)),
718            ("BS", f64s(bs)),
719        ],
720    ))
721}
722
723fn gen_batch(snaps: &[SnapshotView]) -> Result<RecordBatch> {
724    let total: usize = snaps.iter().map(|s| s.view.generators().len()).sum();
725    let mut scenario = Vec::with_capacity(total);
726    let mut idx = Vec::with_capacity(total);
727    let mut bus = Vec::with_capacity(total);
728    let (mut p_mw, mut q_mvar) = (Vec::with_capacity(total), Vec::with_capacity(total));
729    let (mut min_p, mut max_p) = (Vec::with_capacity(total), Vec::with_capacity(total));
730    let (mut min_q, mut max_q) = (Vec::with_capacity(total), Vec::with_capacity(total));
731    let (mut cp0, mut cp1, mut cp2) = (
732        Vec::with_capacity(total),
733        Vec::with_capacity(total),
734        Vec::with_capacity(total),
735    );
736    let mut in_service = Vec::with_capacity(total);
737    let mut is_slack = Vec::with_capacity(total);
738
739    for s in snaps {
740        let view = &s.view;
741        // One pass over the snapshot's generators: every column gets one push per
742        // generator, in dense source order.
743        for (row, g) in view.generators().iter().enumerate() {
744            let i = view.bus_index(g.bus).ok_or(powerio::Error::UnknownBus {
745                bus_id: g.bus,
746                element_index: row,
747            })?;
748            scenario.push(s.scenario);
749            idx.push(row as i64);
750            bus.push(i as i64);
751            is_slack.push(i64::from(i == s.ref_bus));
752            let (c0, c1, c2) = gridfm_cost(g.cost.as_ref());
753            cp0.push(c0);
754            cp1.push(c1);
755            cp2.push(c2);
756            p_mw.push(g.pg);
757            q_mvar.push(g.qg);
758            min_p.push(g.pmin);
759            max_p.push(g.pmax);
760            min_q.push(g.qmin);
761            max_q.push(g.qmax);
762            in_service.push(i64::from(g.in_service));
763        }
764    }
765
766    batch(with_scenario_pair(
767        scenario,
768        vec![
769            ("idx", i64s(idx)),
770            ("bus", i64s(bus)),
771            ("p_mw", f64s(p_mw)),
772            ("q_mvar", f64s(q_mvar)),
773            ("min_p_mw", f64s(min_p)),
774            ("max_p_mw", f64s(max_p)),
775            ("min_q_mvar", f64s(min_q)),
776            ("max_q_mvar", f64s(max_q)),
777            ("cp0_eur", f64s(cp0)),
778            ("cp1_eur_per_mw", f64s(cp1)),
779            ("cp2_eur_per_mw2", f64s(cp2)),
780            ("in_service", i64s(in_service)),
781            ("is_slack_gen", i64s(is_slack)),
782        ],
783    ))
784}
785
786#[allow(clippy::too_many_lines, clippy::many_single_char_names)]
787fn branch_batch(snaps: &[SnapshotView], opts: &GridfmOptions) -> Result<RecordBatch> {
788    let total: usize = snaps.iter().map(|s| s.view.branches().len()).sum();
789
790    // Same flags the Y_bus builder derives, so the branch admittance columns and
791    // y_bus_data come from one kernel. The taps/shifts flags are batch-wide (from
792    // `opts`); the admittances themselves are recomputed per snapshot.
793    let flags = YbusFlags {
794        unity_taps: !opts.include_taps,
795        zero_shifts: !opts.include_shifts,
796        ..Default::default()
797    };
798
799    let mut scenario = Vec::with_capacity(total);
800    let mut idx = Vec::with_capacity(total);
801    let (mut from_bus, mut to_bus) = (Vec::with_capacity(total), Vec::with_capacity(total));
802    let (mut pf, mut qf, mut pt, mut qt) = (
803        Vec::with_capacity(total),
804        Vec::with_capacity(total),
805        Vec::with_capacity(total),
806        Vec::with_capacity(total),
807    );
808    let (mut yff_r, mut yff_i) = (Vec::with_capacity(total), Vec::with_capacity(total));
809    let (mut yft_r, mut yft_i) = (Vec::with_capacity(total), Vec::with_capacity(total));
810    let (mut ytf_r, mut ytf_i) = (Vec::with_capacity(total), Vec::with_capacity(total));
811    let (mut ytt_r, mut ytt_i) = (Vec::with_capacity(total), Vec::with_capacity(total));
812    let (mut r_col, mut x_col, mut b_col) = (
813        Vec::with_capacity(total),
814        Vec::with_capacity(total),
815        Vec::with_capacity(total),
816    );
817    let (mut tap, mut shift) = (Vec::with_capacity(total), Vec::with_capacity(total));
818    let (mut ang_min, mut ang_max) = (Vec::with_capacity(total), Vec::with_capacity(total));
819    let mut rate_a = Vec::with_capacity(total);
820    let mut br_status = Vec::with_capacity(total);
821
822    for s in snaps {
823        let view = &s.view;
824        let base = view.base_mva();
825        let branches = view.branches();
826        let buses = &view.network().buses;
827        // Complex bus voltages `vm·e^{jθ}`, dense order, for the flow evaluation.
828        let v: Vec<Complex64> = buses
829            .iter()
830            .map(|b| Complex64::from_polar(b.vm, b.va.to_radians()))
831            .collect();
832
833        scenario.resize(scenario.len() + branches.len(), s.scenario);
834        idx.extend(0..branches.len() as i64);
835
836        for (row, br) in branches.iter().enumerate() {
837            let i = view.bus_index(br.from).ok_or(powerio::Error::UnknownBus {
838                bus_id: br.from,
839                element_index: row,
840            })?;
841            let j = view.bus_index(br.to).ok_or(powerio::Error::UnknownBus {
842                bus_id: br.to,
843                element_index: row,
844            })?;
845            from_bus.push(i as i64);
846            to_bus.push(j as i64);
847
848            // Zero-impedance branch → `None` → zeroed admittance/flow columns (never NaN).
849            let shift_rad = if flags.zero_shifts {
850                0.0
851            } else {
852                view.angle_radians(br.shift)
853            };
854            let block = branch_admittance(br, flags, shift_rad, row)?;
855            let [y_ff, y_ft, y_tf, y_tt] = block.unwrap_or([Complex64::new(0.0, 0.0); 4]);
856            yff_r.push(y_ff.re);
857            yff_i.push(y_ff.im);
858            yft_r.push(y_ft.re);
859            yft_i.push(y_ft.im);
860            ytf_r.push(y_tf.re);
861            ytf_i.push(y_tf.im);
862            ytt_r.push(y_tt.re);
863            ytt_i.push(y_tt.im);
864
865            let (sf, st) = if br.in_service && block.is_some() {
866                branch_flows(&[y_ff, y_ft, y_tf, y_tt], v[i], v[j])
867            } else {
868                (Complex64::new(0.0, 0.0), Complex64::new(0.0, 0.0))
869            };
870            pf.push(sf.re * base);
871            qf.push(sf.im * base);
872            pt.push(st.re * base);
873            qt.push(st.im * base);
874
875            r_col.push(br.r);
876            x_col.push(br.x);
877            b_col.push(br.total_charging_b());
878            tap.push(br.effective_tap());
879            shift.push(br.shift);
880            ang_min.push(br.angmin);
881            ang_max.push(br.angmax);
882            rate_a.push(br.rate_a);
883            br_status.push(i64::from(br.in_service));
884        }
885    }
886
887    batch(with_scenario_pair(
888        scenario,
889        vec![
890            ("idx", i64s(idx)),
891            ("from_bus", i64s(from_bus)),
892            ("to_bus", i64s(to_bus)),
893            ("pf", f64s(pf)),
894            ("qf", f64s(qf)),
895            ("pt", f64s(pt)),
896            ("qt", f64s(qt)),
897            ("r", f64s(r_col)),
898            ("x", f64s(x_col)),
899            ("b", f64s(b_col)),
900            ("Yff_r", f64s(yff_r)),
901            ("Yff_i", f64s(yff_i)),
902            ("Yft_r", f64s(yft_r)),
903            ("Yft_i", f64s(yft_i)),
904            ("Ytf_r", f64s(ytf_r)),
905            ("Ytf_i", f64s(ytf_i)),
906            ("Ytt_r", f64s(ytt_r)),
907            ("Ytt_i", f64s(ytt_i)),
908            ("tap", f64s(tap)),
909            ("shift", f64s(shift)),
910            ("ang_min", f64s(ang_min)),
911            ("ang_max", f64s(ang_max)),
912            ("rate_a", f64s(rate_a)),
913            ("br_status", i64s(br_status)),
914        ],
915    ))
916}
917
918fn y_bus_batch(snaps: &[SnapshotView], opts: &GridfmOptions) -> Result<RecordBatch> {
919    // Upper bound on stacked nnz: each snapshot's Y_bus has at most 4 entries per
920    // branch plus a diagonal. The exact count varies (lossless branches, dropped
921    // zeros, per-scenario branch status), so this only sizes the allocation.
922    let est: usize = snaps
923        .iter()
924        .map(|s| 4 * s.view.branches().len() + s.view.n())
925        .sum();
926    let mut scenario = Vec::with_capacity(est);
927    let mut index1 = Vec::with_capacity(est);
928    let mut index2 = Vec::with_capacity(est);
929    let mut g_vals = Vec::with_capacity(est);
930    let mut b_vals = Vec::with_capacity(est);
931
932    for s in snaps {
933        let parts = build_ybus(&s.view, &opts.build_options())?;
934        // G and B don't share a sparsity pattern: a lossless branch (r = 0) is a
935        // pure reactance, so its G entries are structurally zero where B's aren't.
936        // datakit keys y_bus rows on the complex value being nonzero, i.e. the
937        // union of the G and B positions. Merge into a sorted (row, col) map so the
938        // output is row-major like `np.nonzero`, then drop any all-zero position.
939        let mut entries: std::collections::BTreeMap<(usize, usize), (f64, f64)> =
940            std::collections::BTreeMap::new();
941        for (row, g_row) in parts.g.outer_iterator().enumerate() {
942            for (col, &gv) in g_row.iter() {
943                entries.entry((row, col)).or_default().0 = gv;
944            }
945        }
946        for (row, b_row) in parts.b.outer_iterator().enumerate() {
947            for (col, &bv) in b_row.iter() {
948                entries.entry((row, col)).or_default().1 = bv;
949            }
950        }
951
952        for ((row, col), (gv, bv)) in entries {
953            if gv == 0.0 && bv == 0.0 {
954                continue;
955            }
956            scenario.push(s.scenario);
957            index1.push(row as i64);
958            index2.push(col as i64);
959            g_vals.push(gv);
960            b_vals.push(bv);
961        }
962    }
963
964    batch(with_scenario_pair(
965        scenario,
966        vec![
967            ("index1", i64s(index1)),
968            ("index2", i64s(index2)),
969            ("G", f64s(g_vals)),
970            ("B", f64s(b_vals)),
971        ],
972    ))
973}
974
975// --- small helpers ---------------------------------------------------------
976
977/// `(cp0, cp1, cp2)` = raw MATPOWER `(c0, c1, c2)` from a polynomial cost row.
978/// Coefficients are highest-order first, so `ncost == 3` is `[c2, c1, c0]`.
979/// Piecewise, missing, or malformed rows give zeros.
980fn gridfm_cost(cost: Option<&GenCost>) -> (f64, f64, f64) {
981    match cost {
982        Some(c) if c.model == 2 && c.coeffs.len() >= c.ncost => match c.ncost {
983            3 => (c.coeffs[2], c.coeffs[1], c.coeffs[0]),
984            2 => (c.coeffs[1], c.coeffs[0], 0.0),
985            1 => (c.coeffs[0], 0.0, 0.0),
986            _ => (0.0, 0.0, 0.0),
987        },
988        _ => (0.0, 0.0, 0.0),
989    }
990}
991
992/// Whether [`gridfm_cost`] can represent this cost row (used for the manifest's
993/// degenerate-cost count).
994fn cost_representable(cost: Option<&GenCost>) -> bool {
995    matches!(cost, Some(c) if c.model == 2 && c.coeffs.len() >= c.ncost && (1..=3).contains(&c.ncost))
996}
997
998fn put_parquet(
999    dir: &Path,
1000    name: &str,
1001    batch: &RecordBatch,
1002    files: &mut Vec<PathBuf>,
1003) -> Result<()> {
1004    let path = dir.join(name);
1005    let file = std::fs::File::create(&path)?;
1006    let props = WriterProperties::builder()
1007        .set_compression(Compression::SNAPPY)
1008        .build();
1009    let mut writer = ArrowWriter::try_new(file, batch.schema(), Some(props))
1010        .map_err(|e| Error::Parquet(e.to_string()))?;
1011    writer
1012        .write(batch)
1013        .map_err(|e| Error::Parquet(e.to_string()))?;
1014    writer.close().map_err(|e| Error::Parquet(e.to_string()))?;
1015    files.push(path);
1016    Ok(())
1017}
1018
1019/// Assemble a [`RecordBatch`] from named columns, in order; field types are read
1020/// off the arrays and all columns are non-null.
1021fn batch(columns: Vec<(&str, ArrayRef)>) -> Result<RecordBatch> {
1022    let fields: Vec<Field> = columns
1023        .iter()
1024        .map(|(name, arr)| Field::new(*name, arr.data_type().clone(), false))
1025        .collect();
1026    let arrays: Vec<ArrayRef> = columns.into_iter().map(|(_, arr)| arr).collect();
1027    RecordBatch::try_new(Arc::new(Schema::new(fields)), arrays)
1028        .map_err(|e| Error::Parquet(e.to_string()))
1029}
1030
1031fn i64s(v: Vec<i64>) -> ArrayRef {
1032    Arc::new(Int64Array::from(v))
1033}
1034
1035fn f64s(v: Vec<f64>) -> ArrayRef {
1036    Arc::new(Float64Array::from(v))
1037}
1038
1039/// Prepend the `scenario` / `load_scenario_idx` id columns (which hold identical
1040/// values) to a table's other columns. The Int64 array is built once and the Arc
1041/// shared, so the duplicate column costs a pointer, not a second `Vec`.
1042fn with_scenario_pair(
1043    scenario: Vec<i64>,
1044    rest: Vec<(&'static str, ArrayRef)>,
1045) -> Vec<(&'static str, ArrayRef)> {
1046    let scenario = i64s(scenario);
1047    let mut cols = Vec::with_capacity(rest.len() + 2);
1048    cols.push(("scenario", scenario.clone()));
1049    cols.push(("load_scenario_idx", scenario));
1050    cols.extend(rest);
1051    cols
1052}
1053
1054// === Reader: gridfm dataset → BalancedNetwork (issue #60) ==========================
1055//
1056// The inverse of the writer above: select one `scenario` out of the gridfm tables
1057// and rebuild a `BalancedNetwork`. Lossy but power flow complete — original bus ids are
1058// synthesized `1..n`, nodal load/shunt is folded to one synthetic element per
1059// bus, and HVDC/storage/piecewise costs are gone (see [`GridfmRead::warnings`]).
1060// `y_bus_data` is never read; branches carry raw `r/x/b`.
1061
1062/// One scenario read out of a gridfm dataset: the reconstructed [`BalancedNetwork`] plus
1063/// the fidelity warnings the lossy read couldn't avoid (mirroring
1064/// [`Conversion::warnings`](powerio::Conversion)).
1065#[derive(Debug, Clone)]
1066#[non_exhaustive]
1067pub struct GridfmRead {
1068    /// The reconstructed network (`source_format = SourceFormat::Gridfm`).
1069    pub network: BalancedNetwork,
1070    /// The scenario id these rows came from.
1071    pub scenario: i64,
1072    /// What the gridfm schema couldn't round-trip — synthesized bus ids, folded
1073    /// per-bus load/shunt, dropped HVDC/storage, etc.
1074    pub warnings: Vec<String>,
1075}
1076
1077/// Build one [`BalancedNetwork`] from in-memory gridfm tables, selecting `scenario`'s
1078/// rows. The pure inverse of [`gridfm_record_batches`]: `base_mva` and `name`
1079/// come from the caller (the disk path reads them from `gridfm_meta.json`).
1080///
1081/// # Errors
1082/// [`powerio::Error::FormatRead`] if a required column is missing or mistyped, a column
1083/// carries nulls, a dense index is negative, or `scenario` isn't present; plus
1084/// whatever [`BalancedNetwork::validate`] rejects (duplicate / dangling bus ids).
1085pub fn read_gridfm_network(
1086    tables: &GridfmTables,
1087    scenario: i64,
1088    base_mva: f64,
1089    name: &str,
1090) -> Result<GridfmRead> {
1091    let bus = bus_columns(std::slice::from_ref(&tables.bus))?;
1092    let gens = gen_columns(std::slice::from_ref(&tables.generator))?;
1093    let branch = branch_columns(std::slice::from_ref(&tables.branch))?;
1094    build_network_from_columns(&bus, &gens, &branch, scenario, base_mva, name, Vec::new())
1095}
1096
1097/// Read one `scenario` from a gridfm dataset on disk and rebuild a [`BalancedNetwork`].
1098/// The inverse of [`write_gridfm_dataset`].
1099///
1100/// `dir` is resolved leniently: the leaf `raw/` directory holding the parquet
1101/// files, a `<case>/` directory with a `raw/` child, or a parent directory with
1102/// exactly one `*/raw/` child all work. `base_mva` and the case name come from
1103/// `gridfm_meta.json` (a missing manifest defaults `base_mva` to 100 and warns).
1104///
1105/// # Errors
1106/// Propagates [`read_gridfm_network`] plus any filesystem / Parquet read error.
1107pub fn read_gridfm_dataset(dir: impl AsRef<Path>, scenario: i64) -> Result<GridfmRead> {
1108    let raw = resolve_raw_dir(dir.as_ref())?;
1109    let (base_mva, name, warnings) = read_meta(&raw);
1110    let bus = bus_columns(&read_parquet(&raw.join("bus_data.parquet"))?)?;
1111    let gens = gen_columns(&read_parquet(&raw.join("gen_data.parquet"))?)?;
1112    let branch = branch_columns(&read_parquet(&raw.join("branch_data.parquet"))?)?;
1113    build_network_from_columns(&bus, &gens, &branch, scenario, base_mva, &name, warnings)
1114}
1115
1116/// Read every scenario from a gridfm dataset, one [`BalancedNetwork`] per `scenario` id
1117/// (sorted ascending) over the shared topology — the read side of the scenario
1118/// batch (#57). Each scenario is rebuilt independently, so two scenarios may
1119/// differ in branch status, bus types, and reference bus.
1120///
1121/// # Errors
1122/// Propagates [`read_gridfm_dataset`]'s filesystem / Parquet / build errors.
1123pub fn read_gridfm_scenarios(dir: impl AsRef<Path>) -> Result<Vec<GridfmRead>> {
1124    let raw = resolve_raw_dir(dir.as_ref())?;
1125    let (base_mva, name, warnings) = read_meta(&raw);
1126    // Extract every column once and reuse across scenarios; rebuilding each
1127    // scenario from the raw batches would re-concatenate each table n_scenarios
1128    // times (O(n_scenarios × table_size)).
1129    let bus = bus_columns(&read_parquet(&raw.join("bus_data.parquet"))?)?;
1130    let gens = gen_columns(&read_parquet(&raw.join("gen_data.parquet"))?)?;
1131    let branch = branch_columns(&read_parquet(&raw.join("branch_data.parquet"))?)?;
1132
1133    distinct_sorted(&bus.scenario)
1134        .into_iter()
1135        .map(|s| {
1136            build_network_from_columns(&bus, &gens, &branch, s, base_mva, &name, warnings.clone())
1137        })
1138        .collect()
1139}
1140
1141/// The distinct scenario ids in a gridfm dataset, ascending — the keys
1142/// [`read_gridfm_scenarios`] rebuilds a [`BalancedNetwork`] for. Reads only `bus_data`'s
1143/// scenario column, so it enumerates a dataset's scenarios without rebuilding
1144/// every network; the C ABI's `pio_scenario_ids` is a thin wrapper over it.
1145///
1146/// # Errors
1147/// Propagates the directory resolution and `bus_data.parquet` read errors.
1148pub fn gridfm_scenario_ids(dir: impl AsRef<Path>) -> Result<Vec<i64>> {
1149    let raw = resolve_raw_dir(dir.as_ref())?;
1150    let bus = bus_columns(&read_parquet(&raw.join("bus_data.parquet"))?)?;
1151    Ok(distinct_sorted(&bus.scenario))
1152}
1153
1154/// The distinct values of `scenario`, ascending.
1155fn distinct_sorted(scenario: &[i64]) -> Vec<i64> {
1156    let mut ids = scenario.to_vec();
1157    ids.sort_unstable();
1158    ids.dedup();
1159    ids
1160}
1161
1162/// The unperturbed base case: [`read_gridfm_dataset`] at `scenario = 0` (datakit's
1163/// convention). There is no single "shared base" beyond a chosen scenario — bus
1164/// types, branch status, and reference bus all vary per scenario — so the base
1165/// case is just scenario 0.
1166///
1167/// # Errors
1168/// Propagates [`read_gridfm_dataset`].
1169pub fn gridfm_base_case(dir: impl AsRef<Path>) -> Result<GridfmRead> {
1170    read_gridfm_dataset(dir, 0)
1171}
1172
1173/// Every `bus_data` column the reader uses, concatenated across all batches once.
1174/// Extracting columns up front lets a multi-scenario read reuse them rather than
1175/// re-concatenating the whole table for each scenario.
1176struct BusColumns {
1177    scenario: Vec<i64>,
1178    bus: Vec<i64>,
1179    pv: Vec<i64>,
1180    refc: Vec<i64>,
1181    vm: Vec<f64>,
1182    va: Vec<f64>,
1183    vn_kv: Vec<f64>,
1184    min_vm: Vec<f64>,
1185    max_vm: Vec<f64>,
1186    pd: Vec<f64>,
1187    qd: Vec<f64>,
1188    gs: Vec<f64>,
1189    bs: Vec<f64>,
1190}
1191
1192fn bus_columns(batches: &[RecordBatch]) -> Result<BusColumns> {
1193    Ok(BusColumns {
1194        scenario: i64_col(batches, "scenario")?,
1195        bus: i64_col(batches, "bus")?,
1196        pv: i64_col(batches, "PV")?,
1197        refc: i64_col(batches, "REF")?,
1198        vm: f64_col(batches, "Vm")?,
1199        va: f64_col(batches, "Va")?,
1200        vn_kv: f64_col(batches, "vn_kv")?,
1201        min_vm: f64_col(batches, "min_vm_pu")?,
1202        max_vm: f64_col(batches, "max_vm_pu")?,
1203        pd: f64_col(batches, "Pd")?,
1204        qd: f64_col(batches, "Qd")?,
1205        gs: f64_col(batches, "GS")?,
1206        bs: f64_col(batches, "BS")?,
1207    })
1208}
1209
1210/// Every `gen_data` column the reader uses (cost is `cp0`/`cp1`/`cp2`).
1211struct GenColumns {
1212    scenario: Vec<i64>,
1213    bus: Vec<i64>,
1214    p_mw: Vec<f64>,
1215    q_mvar: Vec<f64>,
1216    min_p: Vec<f64>,
1217    max_p: Vec<f64>,
1218    min_q: Vec<f64>,
1219    max_q: Vec<f64>,
1220    cp0: Vec<f64>,
1221    cp1: Vec<f64>,
1222    cp2: Vec<f64>,
1223    in_service: Vec<i64>,
1224}
1225
1226fn gen_columns(batches: &[RecordBatch]) -> Result<GenColumns> {
1227    Ok(GenColumns {
1228        scenario: i64_col(batches, "scenario")?,
1229        bus: i64_col(batches, "bus")?,
1230        p_mw: f64_col(batches, "p_mw")?,
1231        q_mvar: f64_col(batches, "q_mvar")?,
1232        min_p: f64_col(batches, "min_p_mw")?,
1233        max_p: f64_col(batches, "max_p_mw")?,
1234        min_q: f64_col(batches, "min_q_mvar")?,
1235        max_q: f64_col(batches, "max_q_mvar")?,
1236        cp0: f64_col(batches, "cp0_eur")?,
1237        cp1: f64_col(batches, "cp1_eur_per_mw")?,
1238        cp2: f64_col(batches, "cp2_eur_per_mw2")?,
1239        in_service: i64_col(batches, "in_service")?,
1240    })
1241}
1242
1243/// Every `branch_data` column the reader uses (`Y**` / flow columns are ignored).
1244struct BranchColumns {
1245    scenario: Vec<i64>,
1246    from_bus: Vec<i64>,
1247    to_bus: Vec<i64>,
1248    r: Vec<f64>,
1249    x: Vec<f64>,
1250    b: Vec<f64>,
1251    tap: Vec<f64>,
1252    shift: Vec<f64>,
1253    ang_min: Vec<f64>,
1254    ang_max: Vec<f64>,
1255    rate_a: Vec<f64>,
1256    status: Vec<i64>,
1257}
1258
1259fn branch_columns(batches: &[RecordBatch]) -> Result<BranchColumns> {
1260    Ok(BranchColumns {
1261        scenario: i64_col(batches, "scenario")?,
1262        from_bus: i64_col(batches, "from_bus")?,
1263        to_bus: i64_col(batches, "to_bus")?,
1264        r: f64_col(batches, "r")?,
1265        x: f64_col(batches, "x")?,
1266        b: f64_col(batches, "b")?,
1267        tap: f64_col(batches, "tap")?,
1268        shift: f64_col(batches, "shift")?,
1269        ang_min: f64_col(batches, "ang_min")?,
1270        ang_max: f64_col(batches, "ang_max")?,
1271        rate_a: f64_col(batches, "rate_a")?,
1272        status: i64_col(batches, "br_status")?,
1273    })
1274}
1275
1276/// The shared core: rebuild one scenario's [`BalancedNetwork`] from already-extracted
1277/// columns. The columns are concatenated once by the caller and reused across
1278/// scenarios, so a multi-scenario read doesn't re-copy each table per scenario.
1279/// `warnings` is seeded with any manifest-level notes (e.g. a defaulted
1280/// `base_mva`) and extended with the per-read fidelity notes.
1281// tap == 1.0 / != 0.0 reads are exact, not approximate; the builder is one long
1282// linear pass over the three tables, so the length is inherent.
1283#[allow(clippy::float_cmp, clippy::too_many_lines)]
1284fn build_network_from_columns(
1285    bus: &BusColumns,
1286    gens: &GenColumns,
1287    branch: &BranchColumns,
1288    scenario: i64,
1289    base_mva: f64,
1290    name: &str,
1291    mut warnings: Vec<String>,
1292) -> Result<GridfmRead> {
1293    // --- buses, loads, shunts (bus_data) ---
1294    let bus_rows = scenario_rows(&bus.scenario, scenario);
1295    if bus_rows.is_empty() {
1296        let mut avail = bus.scenario.clone();
1297        avail.sort_unstable();
1298        avail.dedup();
1299        return Err(powerio::Error::FormatRead {
1300            format: "gridfm",
1301            message: format!("scenario {scenario} not present; available: {avail:?}"),
1302        }
1303        .into());
1304    }
1305
1306    let bus_id = &bus.bus;
1307    let pv = &bus.pv;
1308    let refc = &bus.refc;
1309    let vm = &bus.vm;
1310    let va = &bus.va;
1311    let vn_kv = &bus.vn_kv;
1312    let min_vm = &bus.min_vm;
1313    let max_vm = &bus.max_vm;
1314    let pd = &bus.pd;
1315    let qd = &bus.qd;
1316    let gs = &bus.gs;
1317    let bs = &bus.bs;
1318
1319    let mut buses = Vec::with_capacity(bus_rows.len());
1320    let mut loads = Vec::new();
1321    let mut shunts = Vec::new();
1322    // Dense bus index -> voltage magnitude, so a generator recovers its `vg`
1323    // setpoint from its bus (gridfm has no separate gen voltage column, but a
1324    // generator's setpoint is its bus's regulated `Vm`).
1325    let mut bus_vm: std::collections::HashMap<i64, f64> =
1326        std::collections::HashMap::with_capacity(bus_rows.len());
1327    for &r in &bus_rows {
1328        let id = dense_bus_id(bus_id[r])?;
1329        bus_vm.insert(bus_id[r], vm[r]);
1330        // REF / PV / PQ one-hot; the writer guarantees exactly one set, but read
1331        // defensively (REF wins, then PV, else PQ).
1332        let kind = if refc[r] != 0 {
1333            BusType::Ref
1334        } else if pv[r] != 0 {
1335            BusType::Pv
1336        } else {
1337            BusType::Pq
1338        };
1339        let mut bus = Bus::new(id, kind, vn_kv[r]);
1340        bus.vm = vm[r];
1341        bus.va = va[r];
1342        bus.vmax = max_vm[r];
1343        bus.vmin = min_vm[r];
1344        bus.area = 0;
1345        bus.zone = 0;
1346        buses.push(bus);
1347        if pd[r] != 0.0 || qd[r] != 0.0 {
1348            loads.push(Load::new(id, pd[r], qd[r]));
1349        }
1350        // Undo the writer's `/ base_mva` (gridfm.rs:487) to recover MW/MVAr at V=1.
1351        if gs[r] != 0.0 || bs[r] != 0.0 {
1352            shunts.push(Shunt::new(id, gs[r] * base_mva, bs[r] * base_mva));
1353        }
1354    }
1355
1356    // --- generators (gen_data) ---
1357    let gen_rows = scenario_rows(&gens.scenario, scenario);
1358    require_scenario_block(&gens.scenario, scenario, &gen_rows, "gen_data")?;
1359    let g_bus = &gens.bus;
1360    let p_mw = &gens.p_mw;
1361    let q_mvar = &gens.q_mvar;
1362    let min_p = &gens.min_p;
1363    let max_p = &gens.max_p;
1364    let min_q = &gens.min_q;
1365    let max_q = &gens.max_q;
1366    let cp0 = &gens.cp0;
1367    let cp1 = &gens.cp1;
1368    let cp2 = &gens.cp2;
1369    let g_in = &gens.in_service;
1370
1371    let mut generators = Vec::with_capacity(gen_rows.len());
1372    for &r in &gen_rows {
1373        // Any nonzero coefficient is a real polynomial cost. An all-zero triple is
1374        // ambiguous in the schema — a generator with no cost, a genuine zero
1375        // polynomial cost, or a piecewise/cubic+ cost the writer couldn't represent
1376        // (all written as `(0, 0, 0)`) — so it reads back as `None` (see warnings).
1377        let cost = if cp0[r] != 0.0 || cp1[r] != 0.0 || cp2[r] != 0.0 {
1378            Some(GenCost::new(2, 0.0, 0.0, vec![cp2[r], cp1[r], cp0[r]]))
1379        } else {
1380            None
1381        };
1382        let mut generator = Generator::new(dense_bus_id(g_bus[r])?);
1383        generator.pg = p_mw[r];
1384        generator.qg = q_mvar[r];
1385        generator.pmax = max_p[r];
1386        generator.pmin = min_p[r];
1387        generator.qmax = max_q[r];
1388        generator.qmin = min_q[r];
1389        // The schema has no gen vg; recover the setpoint from the bus's Vm
1390        // (falls back to 1.0 only if the gen references an absent bus, which
1391        // `validate()` below then rejects).
1392        generator.vg = bus_vm.get(&g_bus[r]).copied().unwrap_or(1.0);
1393        generator.mbase = base_mva;
1394        generator.in_service = g_in[r] != 0;
1395        generator.cost = cost;
1396        generators.push(generator);
1397    }
1398
1399    // --- branches (branch_data); Y** and pf/qf/pt/qt are ignored ---
1400    let br_rows = scenario_rows(&branch.scenario, scenario);
1401    require_scenario_block(&branch.scenario, scenario, &br_rows, "branch_data")?;
1402    let from_bus = &branch.from_bus;
1403    let to_bus = &branch.to_bus;
1404    let r_col = &branch.r;
1405    let x_col = &branch.x;
1406    let b_col = &branch.b;
1407    let tap = &branch.tap;
1408    let shift = &branch.shift;
1409    let ang_min = &branch.ang_min;
1410    let ang_max = &branch.ang_max;
1411    let rate_a = &branch.rate_a;
1412    let br_status = &branch.status;
1413
1414    let mut branches = Vec::with_capacity(br_rows.len());
1415    // The writer stores the *effective* tap (`Branch::effective_tap`), so a line
1416    // (raw tap 0) lands as 1.0. Map unit tap + no shift back to the raw `tap == 0`
1417    // line convention, otherwise every line reads as a transformer
1418    // (`is_transformer` keys off `tap != 0`) and a read→write to a format that
1419    // splits lines from transformers (PSS/E, PowerWorld) misclassifies them. A
1420    // genuine unity-ratio, zero-shift transformer is electrically identical to a
1421    // line and is (unavoidably) read as one.
1422    let mut unit_tap_lines = 0usize;
1423    for &row in &br_rows {
1424        let shift_v = shift[row];
1425        let tap_out = if tap[row] == 1.0 && shift_v == 0.0 {
1426            unit_tap_lines += 1;
1427            0.0
1428        } else {
1429            tap[row]
1430        };
1431        let mut branch = Branch::new(
1432            dense_bus_id(from_bus[row])?,
1433            dense_bus_id(to_bus[row])?,
1434            r_col[row],
1435            x_col[row],
1436        );
1437        branch.b = b_col[row];
1438        branch.rate_a = rate_a[row];
1439        branch.tap = tap_out;
1440        branch.shift = shift_v;
1441        branch.in_service = br_status[row] != 0;
1442        branch.angmin = ang_min[row];
1443        branch.angmax = ang_max[row];
1444        branches.push(branch);
1445    }
1446
1447    let mut net = BalancedNetwork::new(name, base_mva);
1448    net.buses = buses;
1449    net.loads = loads;
1450    net.shunts = shunts;
1451    net.branches = branches;
1452    net.generators = generators;
1453    net.source_format = SourceFormat::Gridfm;
1454    net.validate()?;
1455
1456    // --- fidelity warnings: what the gridfm schema couldn't carry back ---
1457    warnings.push(format!(
1458        "synthesized bus ids 1..={}; original bus ids are not stored in a gridfm dataset, \
1459         so a written case is renumbered",
1460        net.buses.len()
1461    ));
1462    if !net.loads.is_empty() {
1463        warnings.push(format!(
1464            "folded nodal load into {} synthetic per-bus Load(s); per-load granularity is \
1465             not recoverable",
1466            net.loads.len()
1467        ));
1468    }
1469    if !net.shunts.is_empty() {
1470        warnings.push(format!(
1471            "folded nodal shunts into {} synthetic per-bus Shunt(s); per-shunt granularity \
1472             is not recoverable",
1473            net.shunts.len()
1474        ));
1475    }
1476    if unit_tap_lines > 0 {
1477        warnings.push(format!(
1478            "{unit_tap_lines} branch(es) had unit effective tap and no phase shift and were read \
1479             as lines (raw tap 0); a unity-ratio, zero-shift transformer in the source is \
1480             indistinguishable from a line and is read as one (the power flow is identical)"
1481        ));
1482    }
1483    let no_cost_gens = net.generators.iter().filter(|g| g.cost.is_none()).count();
1484    if no_cost_gens > 0 {
1485        warnings.push(format!(
1486            "{no_cost_gens} generator(s) read with no cost: an all-zero cost triple in the dataset \
1487             is the writer's encoding for a generator with no cost, a genuine zero polynomial \
1488             cost, or a piecewise/cubic+ cost it couldn't represent — indistinguishable on read"
1489        ));
1490    }
1491    warnings.push(
1492        "HVDC, storage, areas/zones, bus names, rate_b/rate_c, generator mbase/ramp limits, \
1493         and startup/shutdown costs are absent from the gridfm schema"
1494            .to_string(),
1495    );
1496
1497    Ok(GridfmRead {
1498        network: net,
1499        scenario,
1500        warnings,
1501    })
1502}
1503
1504// --- reader helpers --------------------------------------------------------
1505
1506/// Resolve the directory holding the gridfm parquet files, accepting (in order)
1507/// the leaf `raw/` dir itself, a `<case>/` dir with a `raw/` child, or a parent
1508/// dir with exactly one `*/raw/` child.
1509fn resolve_raw_dir(dir: &Path) -> Result<PathBuf> {
1510    let has_bus = |d: &Path| d.join("bus_data.parquet").is_file();
1511    if has_bus(dir) {
1512        return Ok(dir.to_path_buf());
1513    }
1514    let nested = dir.join("raw");
1515    if has_bus(&nested) {
1516        return Ok(nested);
1517    }
1518    // Parent-of-<case> fallback: scan for exactly one `*/raw/bus_data.parquet`.
1519    // Surface read_dir / entry IO errors (missing dir, permissions) rather than
1520    // masking them as "no dataset found".
1521    let mut matches: Vec<PathBuf> = Vec::new();
1522    let entries = std::fs::read_dir(dir).map_err(|e| powerio::Error::FormatRead {
1523        format: "gridfm",
1524        message: format!("reading directory {}: {e}", dir.display()),
1525    })?;
1526    for entry in entries {
1527        let entry = entry.map_err(|e| powerio::Error::FormatRead {
1528            format: "gridfm",
1529            message: format!("reading an entry of {}: {e}", dir.display()),
1530        })?;
1531        let raw = entry.path().join("raw");
1532        if has_bus(&raw) {
1533            matches.push(raw);
1534        }
1535    }
1536    match matches.len() {
1537        1 => Ok(matches.pop().expect("len checked")),
1538        0 => Err(powerio::Error::FormatRead {
1539            format: "gridfm",
1540            message: format!(
1541                "no gridfm dataset under {}; expected bus_data.parquet in the directory, a \
1542                 raw/ child, or a single <case>/raw/ child",
1543                dir.display()
1544            ),
1545        }
1546        .into()),
1547        n => Err(powerio::Error::FormatRead {
1548            format: "gridfm",
1549            message: format!(
1550                "{n} gridfm datasets under {}; point at the specific <case>/raw directory",
1551                dir.display()
1552            ),
1553        }
1554        .into()),
1555    }
1556}
1557
1558/// Read `base_mva` and the case name from `raw/gridfm_meta.json`. A missing or
1559/// unreadable manifest is not fatal — a bare prediction may lack one — so default
1560/// `base_mva` to 100, derive the name from the `<case>/raw` parent, and warn.
1561fn read_meta(raw: &Path) -> (f64, String, Vec<String>) {
1562    let case_from_path = || {
1563        raw.parent()
1564            .and_then(Path::file_name)
1565            .and_then(|s| s.to_str())
1566            .map_or_else(|| "gridfm".to_string(), str::to_string)
1567    };
1568    let text = match std::fs::read_to_string(raw.join("gridfm_meta.json")) {
1569        Ok(text) => text,
1570        // Not just "not found": surface the underlying error (permissions, non-UTF-8,
1571        // etc.) so a dataset problem is diagnosable, while still defaulting base_mva.
1572        Err(e) => {
1573            return (
1574                100.0,
1575                case_from_path(),
1576                vec![format!(
1577                    "gridfm_meta.json could not be read ({e}); base_mva defaulted to 100"
1578                )],
1579            );
1580        }
1581    };
1582    let Ok(meta) = serde_json::from_str::<serde_json::Value>(&text) else {
1583        return (
1584            100.0,
1585            case_from_path(),
1586            vec!["gridfm_meta.json is not valid JSON; base_mva defaulted to 100".to_string()],
1587        );
1588    };
1589    let name = meta
1590        .get("case_name")
1591        .and_then(serde_json::Value::as_str)
1592        .map_or_else(case_from_path, str::to_string);
1593    let mut warnings = Vec::new();
1594    // base_mva must be a positive, finite number; anything else (absent, zero,
1595    // negative, NaN) defaults to 100 with a note — shunt recovery scales by it.
1596    let base = match meta.get("base_mva").and_then(serde_json::Value::as_f64) {
1597        Some(b) if b.is_finite() && b > 0.0 => b,
1598        _ => {
1599            warnings.push(
1600                "gridfm_meta.json has no usable base_mva (absent or not a positive number); \
1601                 defaulted to 100"
1602                    .to_string(),
1603            );
1604            100.0
1605        }
1606    };
1607    (base, name, warnings)
1608}
1609
1610/// Open a parquet file and collect every record batch (one for powerio-written
1611/// data, possibly several for an externally-written prediction).
1612fn read_parquet(path: &Path) -> Result<Vec<RecordBatch>> {
1613    let file = std::fs::File::open(path).map_err(|e| powerio::Error::FormatRead {
1614        format: "gridfm",
1615        message: format!("opening {}: {e}", path.display()),
1616    })?;
1617    let reader = ParquetRecordBatchReaderBuilder::try_new(file)
1618        .and_then(ParquetRecordBatchReaderBuilder::build)
1619        .map_err(|e| powerio::Error::FormatRead {
1620            format: "gridfm",
1621            message: format!("reading {}: {e}", path.display()),
1622        })?;
1623    reader
1624        .collect::<std::result::Result<Vec<_>, _>>()
1625        .map_err(|e| {
1626            Error::Core(powerio::Error::FormatRead {
1627                format: "gridfm",
1628                message: format!("decoding {}: {e}", path.display()),
1629            })
1630        })
1631}
1632
1633/// Row indices whose `scenario` column equals `scenario`, in table order.
1634fn scenario_rows(scen: &[i64], scenario: i64) -> Vec<usize> {
1635    scen.iter()
1636        .enumerate()
1637        .filter_map(|(i, &s)| (s == scenario).then_some(i))
1638        .collect()
1639}
1640
1641/// Guard a gen/branch table: empty `rows` is fine when the whole table is empty
1642/// (a case legitimately has no generators, or no branches), but if the table
1643/// holds rows for *other* scenarios yet none for this one, the dataset is partial
1644/// or corrupt and would silently yield a wrong-but-valid network — error instead.
1645fn require_scenario_block(
1646    scen_col: &[i64],
1647    scenario: i64,
1648    rows: &[usize],
1649    table: &str,
1650) -> Result<()> {
1651    if rows.is_empty() && !scen_col.is_empty() {
1652        return Err(powerio::Error::FormatRead {
1653            format: "gridfm",
1654            message: format!(
1655                "scenario {scenario} has no {table} rows, but the table holds {} row(s) for other \
1656                 scenarios — a partial or corrupt dataset",
1657                scen_col.len()
1658            ),
1659        }
1660        .into());
1661    }
1662    Ok(())
1663}
1664
1665/// A dense `[0, n)` parquet index → 1-based [`BusId`]. Errors on a negative index.
1666fn dense_bus_id(v: i64) -> Result<BusId> {
1667    let idx = usize::try_from(v).map_err(|_| powerio::Error::FormatRead {
1668        format: "gridfm",
1669        message: format!("negative dense bus index {v}"),
1670    })?;
1671    Ok(BusId(idx + 1))
1672}
1673
1674/// Look up a named column, erroring if absent.
1675fn column<'a>(b: &'a RecordBatch, name: &str) -> Result<&'a ArrayRef> {
1676    b.column_by_name(name).ok_or_else(|| {
1677        Error::Core(powerio::Error::FormatRead {
1678            format: "gridfm",
1679            message: format!("missing column `{name}`"),
1680        })
1681    })
1682}
1683
1684/// Concatenate a named non-null `Int64` column across all batches.
1685fn i64_col(batches: &[RecordBatch], name: &str) -> Result<Vec<i64>> {
1686    let mut out = Vec::with_capacity(batches.iter().map(RecordBatch::num_rows).sum());
1687    for b in batches {
1688        let arr = column(b, name)?;
1689        let col = arr.as_any().downcast_ref::<Int64Array>().ok_or_else(|| {
1690            powerio::Error::FormatRead {
1691                format: "gridfm",
1692                message: format!("column `{name}` is not Int64"),
1693            }
1694        })?;
1695        if col.null_count() > 0 {
1696            return Err(powerio::Error::FormatRead {
1697                format: "gridfm",
1698                message: format!("column `{name}` has nulls"),
1699            }
1700            .into());
1701        }
1702        out.extend_from_slice(col.values());
1703    }
1704    Ok(out)
1705}
1706
1707/// Concatenate a named non-null `Float64` column across all batches.
1708fn f64_col(batches: &[RecordBatch], name: &str) -> Result<Vec<f64>> {
1709    let mut out = Vec::with_capacity(batches.iter().map(RecordBatch::num_rows).sum());
1710    for b in batches {
1711        let arr = column(b, name)?;
1712        let col = arr.as_any().downcast_ref::<Float64Array>().ok_or_else(|| {
1713            powerio::Error::FormatRead {
1714                format: "gridfm",
1715                message: format!("column `{name}` is not Float64"),
1716            }
1717        })?;
1718        if col.null_count() > 0 {
1719            return Err(powerio::Error::FormatRead {
1720                format: "gridfm",
1721                message: format!("column `{name}` has nulls"),
1722            }
1723            .into());
1724        }
1725        out.extend_from_slice(col.values());
1726    }
1727    Ok(out)
1728}
1729
1730#[cfg(test)]
1731mod tests {
1732    use super::*;
1733    use crate::network::{Branch, BranchCharging, Bus, BusId, BusType, Generator};
1734    use arrow::array::{Float64Array, Int64Array};
1735    use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
1736
1737    const BUS_COLS: &[&str] = &[
1738        "scenario",
1739        "load_scenario_idx",
1740        "bus",
1741        "Pd",
1742        "Qd",
1743        "Pg",
1744        "Qg",
1745        "Vm",
1746        "Va",
1747        "PQ",
1748        "PV",
1749        "REF",
1750        "vn_kv",
1751        "min_vm_pu",
1752        "max_vm_pu",
1753        "GS",
1754        "BS",
1755    ];
1756    const GEN_COLS: &[&str] = &[
1757        "scenario",
1758        "load_scenario_idx",
1759        "idx",
1760        "bus",
1761        "p_mw",
1762        "q_mvar",
1763        "min_p_mw",
1764        "max_p_mw",
1765        "min_q_mvar",
1766        "max_q_mvar",
1767        "cp0_eur",
1768        "cp1_eur_per_mw",
1769        "cp2_eur_per_mw2",
1770        "in_service",
1771        "is_slack_gen",
1772    ];
1773    const BRANCH_COLS: &[&str] = &[
1774        "scenario",
1775        "load_scenario_idx",
1776        "idx",
1777        "from_bus",
1778        "to_bus",
1779        "pf",
1780        "qf",
1781        "pt",
1782        "qt",
1783        "r",
1784        "x",
1785        "b",
1786        "Yff_r",
1787        "Yff_i",
1788        "Yft_r",
1789        "Yft_i",
1790        "Ytf_r",
1791        "Ytf_i",
1792        "Ytt_r",
1793        "Ytt_i",
1794        "tap",
1795        "shift",
1796        "ang_min",
1797        "ang_max",
1798        "rate_a",
1799        "br_status",
1800    ];
1801    const YBUS_COLS: &[&str] = &[
1802        "scenario",
1803        "load_scenario_idx",
1804        "index1",
1805        "index2",
1806        "G",
1807        "B",
1808    ];
1809
1810    fn case14() -> BalancedNetwork {
1811        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case14.m");
1812        crate::parse_matpower_file(path).unwrap()
1813    }
1814
1815    fn names(b: &RecordBatch) -> Vec<String> {
1816        b.schema()
1817            .fields()
1818            .iter()
1819            .map(|f| f.name().clone())
1820            .collect()
1821    }
1822
1823    fn col_i64<'a>(b: &'a RecordBatch, name: &str) -> &'a Int64Array {
1824        b.column_by_name(name)
1825            .unwrap()
1826            .as_any()
1827            .downcast_ref()
1828            .unwrap()
1829    }
1830
1831    fn col_f64<'a>(b: &'a RecordBatch, name: &str) -> &'a Float64Array {
1832        b.column_by_name(name)
1833            .unwrap()
1834            .as_any()
1835            .downcast_ref()
1836            .unwrap()
1837    }
1838
1839    fn read(path: &Path) -> RecordBatch {
1840        let file = std::fs::File::open(path).unwrap();
1841        let mut reader = ParquetRecordBatchReaderBuilder::try_new(file)
1842            .unwrap()
1843            .build()
1844            .unwrap();
1845        // case14 fits in one row group / batch.
1846        reader.next().unwrap().unwrap()
1847    }
1848
1849    #[test]
1850    fn schema_and_row_counts_match_case14() {
1851        let net = case14();
1852        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
1853
1854        assert_eq!(names(&tables.bus), BUS_COLS);
1855        assert_eq!(names(&tables.generator), GEN_COLS);
1856        assert_eq!(names(&tables.branch), BRANCH_COLS);
1857        assert_eq!(names(tables.y_bus.as_ref().unwrap()), YBUS_COLS);
1858
1859        assert_eq!(tables.bus.num_rows(), net.buses.len()); // 14
1860        assert_eq!(tables.generator.num_rows(), net.generators.len()); // 5
1861        assert_eq!(tables.branch.num_rows(), net.branches.len()); // 20
1862    }
1863
1864    #[test]
1865    fn branch_b_uses_terminal_charging_projection() {
1866        let mut net = BalancedNetwork::in_memory(
1867            "terminal-projection",
1868            100.0,
1869            vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
1870            Vec::new(),
1871        );
1872        let mut br = branch(1, 2, 0.01, 0.1);
1873        br.charging = Some(BranchCharging::new(0.01, 0.02, 0.03, 0.05));
1874        net.branches.push(br);
1875
1876        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
1877        let b = col_f64(&tables.branch, "b").value(0);
1878        assert!((b - 0.07).abs() < 1e-12);
1879
1880        let yff_i = col_f64(&tables.branch, "Yff_i").value(0);
1881        let ytt_i = col_f64(&tables.branch, "Ytt_i").value(0);
1882        let y_series_i = -0.1 / (0.01 * 0.01 + 0.1 * 0.1);
1883        let recovered_b = yff_i + ytt_i - 2.0 * y_series_i;
1884        assert!((recovered_b - b).abs() < 1e-12);
1885    }
1886
1887    #[test]
1888    fn parquet_round_trips_through_reader() {
1889        let net = case14();
1890        let dir = tempfile::tempdir().unwrap();
1891        let out = write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
1892
1893        let raw = dir.path().join("case14").join("raw");
1894        assert_eq!(out.dir, raw);
1895        for f in ["bus_data", "gen_data", "branch_data", "y_bus_data"] {
1896            assert!(raw.join(format!("{f}.parquet")).is_file(), "missing {f}");
1897        }
1898        assert!(raw.join("gridfm_meta.json").is_file());
1899
1900        let bus = read(&raw.join("bus_data.parquet"));
1901        assert_eq!(names(&bus), BUS_COLS);
1902        assert_eq!(bus.num_rows(), net.buses.len());
1903        assert_eq!(names(&read(&raw.join("gen_data.parquet"))), GEN_COLS);
1904        assert_eq!(names(&read(&raw.join("branch_data.parquet"))), BRANCH_COLS);
1905        assert_eq!(names(&read(&raw.join("y_bus_data.parquet"))), YBUS_COLS);
1906    }
1907
1908    #[test]
1909    fn bus_table_values_are_consistent() {
1910        let net = case14();
1911        let view = IndexedNetwork::new(&net);
1912        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
1913        let bus = &tables.bus;
1914
1915        // Exactly one reference bus; PQ/PV/REF partition every bus.
1916        let (pq, pv, r) = (col_i64(bus, "PQ"), col_i64(bus, "PV"), col_i64(bus, "REF"));
1917        assert_eq!(r.values().iter().sum::<i64>(), 1);
1918        for i in 0..bus.num_rows() {
1919            assert_eq!(pq.value(i) + pv.value(i) + r.value(i), 1);
1920        }
1921
1922        // GS/BS are the per-bus shunt aggregate divided by base_mva.
1923        let base = net.base_mva;
1924        let gs = col_f64(bus, "GS");
1925        for i in 0..bus.num_rows() {
1926            assert!((gs.value(i) - view.gs()[i] / base).abs() < 1e-12);
1927        }
1928
1929        // `bus` column is the dense 0..n range.
1930        let bus_idx = col_i64(bus, "bus");
1931        for i in 0..bus.num_rows() {
1932            assert_eq!(bus_idx.value(i), i as i64);
1933        }
1934    }
1935
1936    #[test]
1937    fn branch_admittance_columns_match_build_ybus() {
1938        // The branch table's Y** columns are the same kernel build_ybus scatters,
1939        // so a known in-service branch's block must equal branch_admittance.
1940        let net = case14();
1941        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
1942        let br = &tables.branch;
1943
1944        let yff_r = col_f64(br, "Yff_r");
1945        let yff_i = col_f64(br, "Yff_i");
1946        for (row, branch) in net.branches.iter().enumerate() {
1947            // Raw fixture, so the shift is in degrees — convert as build_ybus does.
1948            let shift_rad = branch.shift.to_radians();
1949            if let Some(block) =
1950                branch_admittance(branch, YbusFlags::default(), shift_rad, row).unwrap()
1951            {
1952                assert!((yff_r.value(row) - block[0].re).abs() < 1e-12);
1953                assert!((yff_i.value(row) - block[0].im).abs() < 1e-12);
1954            }
1955        }
1956    }
1957
1958    #[test]
1959    fn is_slack_gen_marks_the_reference_bus() {
1960        let net = case14();
1961        let view = IndexedNetwork::new(&net);
1962        let ref_bus = view.reference_bus_index().unwrap();
1963        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
1964        let g = &tables.generator;
1965
1966        let bus = col_i64(g, "bus");
1967        let slack = col_i64(g, "is_slack_gen");
1968        for i in 0..g.num_rows() {
1969            assert_eq!(slack.value(i) == 1, bus.value(i) as usize == ref_bus);
1970        }
1971        assert!(slack.values().contains(&1), "no slack generator");
1972    }
1973
1974    #[test]
1975    fn branch_flows_close_the_power_balance_on_a_solved_case() {
1976        // case14 ships a converged operating point, so the active flows must obey
1977        // KCL: total branch loss = generation - demand - shunt draw. This is the
1978        // value-level guard on branch_flows (a missing ×base, a sign flip, or a
1979        // wrong conj would break it). Every branch's real loss is also ≥ 0.
1980        let net = case14();
1981        let view = IndexedNetwork::new(&net);
1982        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
1983        let br = &tables.branch;
1984        let (pf, pt, status) = (
1985            col_f64(br, "pf"),
1986            col_f64(br, "pt"),
1987            col_i64(br, "br_status"),
1988        );
1989
1990        let mut loss = 0.0;
1991        for i in 0..br.num_rows() {
1992            if status.value(i) == 1 {
1993                let l = pf.value(i) + pt.value(i);
1994                assert!(l >= -1e-6, "branch {i} has negative real loss {l}");
1995                loss += l;
1996            }
1997        }
1998        assert!(loss > 1.0, "case14 has ~13 MW of real loss, got {loss}");
1999
2000        let gen_p: f64 = net
2001            .generators
2002            .iter()
2003            .filter(|g| g.in_service)
2004            .map(|g| g.pg)
2005            .sum();
2006        let load_p: f64 = net.loads.iter().map(|l| l.p).sum();
2007        // Real shunt draw at the stored voltages: gs() is MW at V = 1.
2008        let shunt_p: f64 = (0..view.n())
2009            .map(|i| view.gs()[i] * net.buses[i].vm.powi(2))
2010            .sum();
2011        assert!(
2012            (loss - (gen_p - load_p - shunt_p)).abs() < 0.5,
2013            "power balance off: loss {loss} vs gen-load-shunt {}",
2014            gen_p - load_p - shunt_p
2015        );
2016    }
2017
2018    #[test]
2019    fn zero_impedance_branch_zeros_columns_and_is_counted() {
2020        // No vendored fixture has r = x = 0, so build one: branch 0 is a zero-
2021        // impedance tie, branch 1 is normal. The tie's admittance and flow columns
2022        // must be zero (never NaN), and the manifest must count it.
2023        let net = BalancedNetwork::in_memory(
2024            "zeroimp",
2025            100.0,
2026            vec![
2027                bus(1, BusType::Ref),
2028                bus(2, BusType::Pq),
2029                bus(3, BusType::Pq),
2030            ],
2031            vec![branch(1, 2, 0.0, 0.0), branch(2, 3, 0.01, 0.1)],
2032        );
2033        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2034        let br = &tables.branch;
2035        for col in [
2036            "Yff_r", "Yff_i", "Yft_r", "Yft_i", "Ytf_r", "Ytf_i", "Ytt_r", "Ytt_i", "pf", "qf",
2037            "pt", "qt",
2038        ] {
2039            let v = col_f64(br, col).value(0);
2040            assert!(
2041                v == 0.0,
2042                "{col} should be 0 for the zero-impedance branch, got {v}"
2043            );
2044        }
2045
2046        let dir = tempfile::tempdir().unwrap();
2047        let out = write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2048        assert_eq!(out.dropped_zero_impedance, 1);
2049        let meta: serde_json::Value = serde_json::from_str(
2050            &std::fs::read_to_string(out.dir.join("gridfm_meta.json")).unwrap(),
2051        )
2052        .unwrap();
2053        assert_eq!(meta["dropped_zero_impedance"], 1);
2054    }
2055
2056    #[test]
2057    fn gridfm_cost_maps_every_arm_to_raw_coefficients() {
2058        // Polynomial coeffs are highest-order first: [c2, c1, c0] -> (cp0, cp1, cp2).
2059        assert_eq!(
2060            gridfm_cost(Some(&gencost(2, 3, vec![2.0, 3.0, 4.0]))),
2061            (4.0, 3.0, 2.0)
2062        );
2063        assert_eq!(
2064            gridfm_cost(Some(&gencost(2, 2, vec![3.0, 4.0]))),
2065            (4.0, 3.0, 0.0)
2066        );
2067        assert_eq!(
2068            gridfm_cost(Some(&gencost(2, 1, vec![4.0]))),
2069            (4.0, 0.0, 0.0)
2070        );
2071        // Unrepresentable rows collapse to zeros and report as not representable.
2072        let piecewise = gencost(1, 2, vec![0.0, 0.0, 1.0, 1.0]);
2073        let malformed = gencost(2, 3, vec![1.0]); // fewer coeffs than ncost claims
2074        assert_eq!(gridfm_cost(Some(&piecewise)), (0.0, 0.0, 0.0));
2075        assert_eq!(gridfm_cost(Some(&malformed)), (0.0, 0.0, 0.0));
2076        assert_eq!(gridfm_cost(None), (0.0, 0.0, 0.0));
2077        assert!(!cost_representable(Some(&piecewise)));
2078        assert!(!cost_representable(Some(&malformed)));
2079        assert!(!cost_representable(None));
2080        assert!(cost_representable(Some(&gencost(
2081            2,
2082            3,
2083            vec![1.0, 2.0, 3.0]
2084        ))));
2085    }
2086
2087    #[test]
2088    fn missing_reference_bus_errors() {
2089        // gridfm_record_batches' documented precondition: exactly one ref bus.
2090        let net = BalancedNetwork::in_memory(
2091            "noref",
2092            100.0,
2093            vec![bus(1, BusType::Pq), bus(2, BusType::Pq)],
2094            vec![branch(1, 2, 0.01, 0.1)],
2095        );
2096        let err = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap_err();
2097        assert!(
2098            matches!(err, Error::Core(powerio::Error::ReferenceBusCount { .. })),
2099            "got {err:?}"
2100        );
2101    }
2102
2103    #[test]
2104    fn non_finite_bus_voltage_errors_before_parquet() {
2105        let mut net = case14();
2106        net.buses[0].vm = f64::NAN;
2107        let err = gridfm_record_batches(&net, 7, &GridfmOptions::default()).unwrap_err();
2108        match err {
2109            Error::NonFiniteGridfmValue {
2110                scenario,
2111                element,
2112                row,
2113                field,
2114                value,
2115            } => {
2116                assert_eq!(scenario, 7);
2117                assert_eq!(element, "bus");
2118                assert_eq!(row, 0);
2119                assert_eq!(field, "vm");
2120                assert!(value.is_nan());
2121            }
2122            other => panic!("expected NonFiniteGridfmValue, got {other:?}"),
2123        }
2124    }
2125
2126    #[test]
2127    fn non_finite_tap_errors_even_without_y_bus_table() {
2128        let mut net = case14();
2129        net.branches[0].tap = f64::NAN;
2130        let opts = GridfmOptions {
2131            include_y_bus: false,
2132            ..Default::default()
2133        };
2134        let err = gridfm_record_batches(&net, 0, &opts).unwrap_err();
2135        assert!(
2136            matches!(
2137                err,
2138                Error::NonFiniteGridfmValue {
2139                    element: "branch",
2140                    row: 0,
2141                    field: "tap",
2142                    ..
2143                }
2144            ),
2145            "got {err:?}"
2146        );
2147    }
2148
2149    #[test]
2150    fn normalized_snapshot_is_rejected_in_release_builds() {
2151        let net = case14().to_normalized().unwrap();
2152        let err = gridfm_record_batches(&net, 3, &GridfmOptions::default()).unwrap_err();
2153        assert!(
2154            matches!(err, Error::NormalizedGridfmSnapshot { scenario: 3 }),
2155            "got {err:?}"
2156        );
2157    }
2158
2159    #[test]
2160    fn non_finite_representable_cost_errors() {
2161        let mut net = BalancedNetwork::in_memory(
2162            "badcost",
2163            100.0,
2164            vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
2165            vec![branch(1, 2, 0.01, 0.1)],
2166        );
2167        net.generators
2168            .push(gen_at(1, gencost(2, 3, vec![f64::NAN, 1.0, 0.0])));
2169
2170        // coeffs are highest-order first, so the NaN lands in the cp2 column.
2171        let err = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap_err();
2172        assert!(
2173            matches!(
2174                err,
2175                Error::NonFiniteGridfmValue {
2176                    element: "gencost",
2177                    row: 0,
2178                    field: "cp2",
2179                    ..
2180                }
2181            ),
2182            "got {err:?}"
2183        );
2184    }
2185
2186    #[test]
2187    fn unbounded_limits_export_as_infinity() {
2188        // PowerModels defaults absent gen limits to ±Inf and MATPOWER carries
2189        // literal Inf tokens; the unbounded sentinel must reach the Parquet
2190        // columns, not abort the export (only NaN is corrupt in a limit).
2191        let mut net = case14();
2192        net.generators[0].qmax = f64::INFINITY;
2193        net.generators[0].qmin = f64::NEG_INFINITY;
2194        net.generators[1].pmax = f64::INFINITY;
2195        net.branches[0].angmin = f64::NEG_INFINITY;
2196        net.branches[0].angmax = f64::INFINITY;
2197        net.branches[1].rate_a = f64::INFINITY;
2198        net.buses[0].vmax = f64::INFINITY;
2199        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2200        let qmax = col_f64(&tables.generator, "max_q_mvar");
2201        assert!(qmax.value(0).is_infinite() && qmax.value(0) > 0.0);
2202    }
2203
2204    #[test]
2205    fn nan_limit_still_errors() {
2206        let mut net = case14();
2207        net.generators[0].qmax = f64::NAN;
2208        let err = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap_err();
2209        assert!(
2210            matches!(
2211                err,
2212                Error::NonFiniteGridfmValue {
2213                    element: "generator",
2214                    field: "qmax",
2215                    ..
2216                }
2217            ),
2218            "got {err:?}"
2219        );
2220    }
2221
2222    /// case14 with every load and generator setpoint scaled — a perturbed
2223    /// operating point on the same topology, the scenario-batch invariant.
2224    fn scaled(net: &BalancedNetwork, factor: f64) -> BalancedNetwork {
2225        let mut s = net.clone();
2226        for l in &mut s.loads {
2227            l.p *= factor;
2228            l.q *= factor;
2229        }
2230        for g in &mut s.generators {
2231            g.pg *= factor;
2232            g.qg *= factor;
2233        }
2234        s
2235    }
2236
2237    #[test]
2238    fn batch_stacks_scenarios_keyed_by_scenario_column() {
2239        let base = case14();
2240        let up = scaled(&base, 1.1);
2241        let down = scaled(&base, 0.9);
2242        let snaps = [
2243            GridfmSnapshot {
2244                net: &base,
2245                scenario: 0,
2246            },
2247            GridfmSnapshot {
2248                net: &up,
2249                scenario: 1,
2250            },
2251            GridfmSnapshot {
2252                net: &down,
2253                scenario: 2,
2254            },
2255        ];
2256        let tables = gridfm_record_batches_batch(&snaps, &GridfmOptions::default()).unwrap();
2257
2258        // Schema is unchanged; rows are 3× the single-snapshot counts.
2259        assert_eq!(names(&tables.bus), BUS_COLS);
2260        assert_eq!(names(&tables.branch), BRANCH_COLS);
2261        assert_eq!(tables.bus.num_rows(), 3 * base.buses.len());
2262        assert_eq!(tables.generator.num_rows(), 3 * base.generators.len());
2263        assert_eq!(tables.branch.num_rows(), 3 * base.branches.len());
2264
2265        // The scenario column is blocked 0.., and the dense bus index resets to
2266        // 0..n within each scenario.
2267        let n = base.buses.len();
2268        let scen = col_i64(&tables.bus, "scenario");
2269        let lsi = col_i64(&tables.bus, "load_scenario_idx");
2270        let bus_idx = col_i64(&tables.bus, "bus");
2271        for k in 0..3 {
2272            for i in 0..n {
2273                let row = k * n + i;
2274                assert_eq!(scen.value(row), k as i64);
2275                assert_eq!(lsi.value(row), k as i64);
2276                assert_eq!(bus_idx.value(row), i as i64);
2277            }
2278        }
2279
2280        // The first scenario's rows match the standalone single-case tables, so
2281        // batching is a pure row-stack over the established single-snapshot path.
2282        // Compare every column bit-exactly (not just one), so a per-column offset
2283        // or ordering regression in the row-stack can't slip through.
2284        let single = gridfm_record_batches(&base, 0, &GridfmOptions::default()).unwrap();
2285        let bit_exact = |b: &RecordBatch, s: &RecordBatch, col: &str, rows: usize| {
2286            let (bb, ss) = (col_f64(b, col), col_f64(s, col));
2287            for i in 0..rows {
2288                assert_eq!(
2289                    bb.value(i).to_bits(),
2290                    ss.value(i).to_bits(),
2291                    "scenario-0 {col}[{i}] differs from the single-case path"
2292                );
2293            }
2294        };
2295        for col in ["Pd", "Qd", "Pg", "Qg", "Vm", "Va", "GS", "BS"] {
2296            bit_exact(&tables.bus, &single.bus, col, n);
2297        }
2298        bit_exact(
2299            &tables.generator,
2300            &single.generator,
2301            "p_mw",
2302            base.generators.len(),
2303        );
2304        bit_exact(&tables.branch, &single.branch, "pf", base.branches.len());
2305
2306        // The perturbed scenario's load really differs (guards against stamping
2307        // the same network three times).
2308        let pd_batch = col_f64(&tables.bus, "Pd");
2309        let pd_single = col_f64(&single.bus, "Pd");
2310        assert!((pd_batch.value(n) - 1.1 * pd_single.value(0)).abs() < 1e-9);
2311    }
2312
2313    #[test]
2314    fn hostile_network_name_stays_inside_out_dir() {
2315        // The dataset directory comes from the network name, which is input
2316        // content; a separator-bearing name must not write outside out_dir.
2317        let mut net = case14();
2318        net.name = "../escape".to_string();
2319        let dir = tempfile::tempdir().unwrap();
2320        let out = write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2321        assert!(
2322            out.dir.starts_with(dir.path()),
2323            "dataset dir {:?} escaped {:?}",
2324            out.dir,
2325            dir.path()
2326        );
2327        assert!(out.dir.exists());
2328    }
2329
2330    #[test]
2331    fn batch_dataset_writes_stacked_parquet_with_scenario_count() {
2332        let base = case14();
2333        let up = scaled(&base, 1.25);
2334        let snaps = [
2335            GridfmSnapshot {
2336                net: &base,
2337                scenario: 0,
2338            },
2339            GridfmSnapshot {
2340                net: &up,
2341                scenario: 1,
2342            },
2343        ];
2344        let dir = tempfile::tempdir().unwrap();
2345        let out = write_gridfm_batch(&snaps, dir.path(), &GridfmOptions::default()).unwrap();
2346
2347        let bus = read(&out.dir.join("bus_data.parquet"));
2348        assert_eq!(bus.num_rows(), 2 * base.buses.len());
2349        let scen = col_i64(&bus, "scenario");
2350        assert_eq!(scen.value(0), 0);
2351        assert_eq!(scen.value(base.buses.len()), 1);
2352
2353        let meta: serde_json::Value = serde_json::from_str(
2354            &std::fs::read_to_string(out.dir.join("gridfm_meta.json")).unwrap(),
2355        )
2356        .unwrap();
2357        assert_eq!(meta["n_scenarios"], 2);
2358        assert_eq!(meta["scenario"], 0);
2359    }
2360
2361    #[test]
2362    fn empty_batch_errors() {
2363        let err = gridfm_record_batches_batch(&[], &GridfmOptions::default()).unwrap_err();
2364        assert!(matches!(err, Error::EmptyScenarioBatch), "got {err:?}");
2365    }
2366
2367    #[test]
2368    fn shape_mismatch_across_snapshots_errors() {
2369        let big = case14();
2370        let small = BalancedNetwork::in_memory(
2371            "small",
2372            100.0,
2373            vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
2374            vec![branch(1, 2, 0.01, 0.1)],
2375        );
2376        let snaps = [
2377            GridfmSnapshot {
2378                net: &big,
2379                scenario: 0,
2380            },
2381            GridfmSnapshot {
2382                net: &small,
2383                scenario: 1,
2384            },
2385        ];
2386        let err = gridfm_record_batches_batch(&snaps, &GridfmOptions::default()).unwrap_err();
2387        assert!(
2388            matches!(
2389                err,
2390                Error::ScenarioShapeMismatch {
2391                    index: 1,
2392                    reason: ScenarioMismatch::Counts { .. }
2393                }
2394            ),
2395            "got {err:?}"
2396        );
2397    }
2398
2399    #[test]
2400    fn bus_order_mismatch_is_reported_distinctly() {
2401        // Same counts and the same bus-id set, but a different ordering: the dense
2402        // bus index would mean different buses across snapshots, so the batch is
2403        // rejected with the BusOrder reason (not the same-tuple Counts message).
2404        let base = case14();
2405        let mut reordered = base.clone();
2406        reordered.buses.swap(0, 1);
2407        let snaps = [
2408            GridfmSnapshot {
2409                net: &base,
2410                scenario: 0,
2411            },
2412            GridfmSnapshot {
2413                net: &reordered,
2414                scenario: 1,
2415            },
2416        ];
2417        let err = gridfm_record_batches_batch(&snaps, &GridfmOptions::default()).unwrap_err();
2418        assert!(
2419            matches!(
2420                err,
2421                Error::ScenarioShapeMismatch {
2422                    index: 1,
2423                    reason: ScenarioMismatch::BusOrder
2424                }
2425            ),
2426            "got {err:?}"
2427        );
2428    }
2429
2430    #[test]
2431    fn manifest_counts_sum_over_the_batch() {
2432        // Two snapshots on the same element set, but only the second has a zeroed
2433        // branch impedance. The manifest's dropped count describes the whole
2434        // dataset (a total of 1), not just the first snapshot — branch status and
2435        // impedance may legitimately differ per scenario.
2436        let base = case14();
2437        let mut perturbed = base.clone();
2438        perturbed.branches[0].r = 0.0;
2439        perturbed.branches[0].x = 0.0;
2440        let snaps = [
2441            GridfmSnapshot {
2442                net: &base,
2443                scenario: 0,
2444            },
2445            GridfmSnapshot {
2446                net: &perturbed,
2447                scenario: 1,
2448            },
2449        ];
2450        let dir = tempfile::tempdir().unwrap();
2451        let out = write_gridfm_batch(&snaps, dir.path(), &GridfmOptions::default()).unwrap();
2452        assert_eq!(out.dropped_zero_impedance, 1);
2453        let meta: serde_json::Value = serde_json::from_str(
2454            &std::fs::read_to_string(out.dir.join("gridfm_meta.json")).unwrap(),
2455        )
2456        .unwrap();
2457        assert_eq!(meta["dropped_zero_impedance"], 1);
2458    }
2459
2460    #[test]
2461    fn y_bus_table_is_absent_when_disabled() {
2462        let net = case14();
2463        let opts = GridfmOptions {
2464            include_y_bus: false,
2465            ..Default::default()
2466        };
2467        let tables = gridfm_record_batches(&net, 0, &opts).unwrap();
2468        assert!(tables.y_bus.is_none(), "y_bus should not be built");
2469
2470        let dir = tempfile::tempdir().unwrap();
2471        let out = write_gridfm_dataset(&net, 0, dir.path(), &opts).unwrap();
2472        assert!(
2473            !out.dir.join("y_bus_data.parquet").exists(),
2474            "y_bus_data.parquet should not be written"
2475        );
2476    }
2477
2478    #[test]
2479    fn numbered_snapshots_stamps_base_plus_k_and_checks_overflow() {
2480        // The shared builder both bindings use: the k-th network is scenario
2481        // `base + k`, in order.
2482        let net = case14();
2483        let snaps = numbered_snapshots(&[&net, &net, &net], 5).unwrap();
2484        assert_eq!(snaps.len(), 3);
2485        assert_eq!(snaps[0].scenario, 5);
2486        assert_eq!(snaps[1].scenario, 6);
2487        assert_eq!(snaps[2].scenario, 7);
2488
2489        // Overflow is checked (not wrapped to a negative id, not a panic) and names
2490        // the offending index.
2491        let err = numbered_snapshots(&[&net, &net], i64::MAX).unwrap_err();
2492        assert!(
2493            matches!(err, Error::ScenarioIdOverflow { index: 1, .. }),
2494            "got {err:?}"
2495        );
2496    }
2497
2498    #[test]
2499    fn out_of_service_generator_is_listed_but_excluded_from_bus_aggregate() {
2500        // Two paths react to `g.in_service`: gen_data emits an `in_service` column
2501        // for every generator (keeping its setpoint), while bus `Pg`/`Qg` aggregate
2502        // only in-service generation (`view.in_service_gens()`). Exercise the
2503        // `false` case on both.
2504        let mut net = BalancedNetwork::in_memory(
2505            "genoutage",
2506            100.0,
2507            vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
2508            vec![branch(1, 2, 0.01, 0.1)],
2509        );
2510        let mut g_on = gen_at(1, gencost(2, 3, vec![0.0, 1.0, 0.0]));
2511        g_on.pg = 50.0;
2512        let mut g_off = gen_at(2, gencost(2, 3, vec![0.0, 1.0, 0.0]));
2513        g_off.pg = 30.0;
2514        g_off.in_service = false;
2515        net.generators.push(g_on);
2516        net.generators.push(g_off);
2517
2518        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2519
2520        // gen_data lists both gens in source order, flags the out-of-service one,
2521        // and keeps its setpoint.
2522        let g = &tables.generator;
2523        assert_eq!(g.num_rows(), 2);
2524        let in_service = col_i64(g, "in_service");
2525        assert_eq!(in_service.value(0), 1, "in-service gen flagged 1");
2526        assert_eq!(in_service.value(1), 0, "out-of-service gen flagged 0");
2527        assert!(
2528            (col_f64(g, "p_mw").value(1) - 30.0).abs() < 1e-12,
2529            "gen_data keeps the out-of-service setpoint"
2530        );
2531
2532        // bus Pg aggregates only in-service generation: bus 1 (dense 0) gets 50,
2533        // bus 2 (dense 1) excludes the out-of-service gen's 30.
2534        let pg = col_f64(&tables.bus, "Pg");
2535        assert!(
2536            (pg.value(0) - 50.0).abs() < 1e-12,
2537            "in-service gen folded into bus Pg"
2538        );
2539        assert!(
2540            pg.value(1) == 0.0,
2541            "out-of-service gen excluded from bus Pg, got {}",
2542            pg.value(1)
2543        );
2544    }
2545
2546    #[test]
2547    fn out_of_service_branch_zeros_flows_but_keeps_admittance() {
2548        // An out-of-service branch keeps its physical Y** admittances but carries
2549        // zero flows and `br_status = 0` — the path datakit's topology variants
2550        // exercise. Use non-flat voltages so an *in-service* branch carries real
2551        // flow, which makes the zero on the tripped branch meaningful (not just an
2552        // artifact of a flat start).
2553        let mut net = BalancedNetwork::in_memory(
2554            "outage",
2555            100.0,
2556            vec![
2557                bus(1, BusType::Ref),
2558                bus(2, BusType::Pq),
2559                bus(3, BusType::Pq),
2560            ],
2561            vec![branch(1, 2, 0.01, 0.1), branch(2, 3, 0.02, 0.2)],
2562        );
2563        net.buses[1].va = -3.0;
2564        net.buses[2].va = -6.0;
2565        net.branches[0].in_service = false; // trip branch 0
2566
2567        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2568        let br = &tables.branch;
2569        let status = col_i64(br, "br_status");
2570        assert_eq!(status.value(0), 0, "tripped branch reports br_status 0");
2571        assert_eq!(status.value(1), 1, "in-service branch reports br_status 1");
2572
2573        for col in ["pf", "qf", "pt", "qt"] {
2574            let v = col_f64(br, col).value(0);
2575            assert!(
2576                v == 0.0,
2577                "{col} must be zero on the out-of-service branch, got {v}"
2578            );
2579        }
2580        // The in-service branch really carries flow at these voltages — guards
2581        // against a flat start false pass that would zero every branch anyway.
2582        assert!(
2583            col_f64(br, "pf").value(1).abs() > 1e-6,
2584            "in-service branch should carry nonzero flow"
2585        );
2586        // Admittances are retained for the tripped branch (unlike a zero-impedance
2587        // branch, which zeroes them).
2588        assert!(
2589            col_f64(br, "Yff_i").value(0).abs() > 0.0,
2590            "out-of-service branch keeps its physical Y** admittances"
2591        );
2592    }
2593
2594    fn bus(id: usize, kind: BusType) -> Bus {
2595        Bus::new(BusId(id), kind, 1.0)
2596    }
2597
2598    fn branch(from: usize, to: usize, r: f64, x: f64) -> Branch {
2599        Branch::new(BusId(from), BusId(to), r, x)
2600    }
2601
2602    fn gencost(model: u8, ncost: usize, coeffs: Vec<f64>) -> GenCost {
2603        GenCost::with_ncost(model, 0.0, 0.0, ncost, coeffs)
2604    }
2605
2606    fn gen_at(bus: usize, cost: GenCost) -> Generator {
2607        let mut generator = Generator::new(BusId(bus));
2608        generator.pmax = 100.0;
2609        generator.qmax = 50.0;
2610        generator.qmin = -50.0;
2611        generator.mbase = 100.0;
2612        generator.cost = Some(cost);
2613        generator
2614    }
2615
2616    #[test]
2617    fn degenerate_cost_gen_zeros_columns_and_is_counted() {
2618        // Counterpart to the zero-impedance test: a piecewise (model 1) cost row
2619        // gets zeroed cp* columns and is counted; a polynomial row is kept.
2620        let mut net = BalancedNetwork::in_memory(
2621            "degen",
2622            100.0,
2623            vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
2624            vec![branch(1, 2, 0.01, 0.1)],
2625        );
2626        net.generators
2627            .push(gen_at(1, gencost(1, 2, vec![0.0, 0.0, 1.0, 1.0]))); // piecewise
2628        net.generators
2629            .push(gen_at(2, gencost(2, 3, vec![0.01, 5.0, 0.0]))); // polynomial
2630
2631        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2632        let g = &tables.generator;
2633        let (cp0, cp1, cp2) = (
2634            col_f64(g, "cp0_eur"),
2635            col_f64(g, "cp1_eur_per_mw"),
2636            col_f64(g, "cp2_eur_per_mw2"),
2637        );
2638        assert_eq!((cp0.value(0), cp1.value(0), cp2.value(0)), (0.0, 0.0, 0.0));
2639        assert_eq!((cp0.value(1), cp1.value(1), cp2.value(1)), (0.0, 5.0, 0.01));
2640
2641        let dir = tempfile::tempdir().unwrap();
2642        let out = write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2643        assert_eq!(out.degenerate_cost_gens, 1);
2644        assert_eq!(out.missing_cost_gens, 0);
2645        assert_eq!(out.unsupported_cost_gens, 1);
2646        let meta: serde_json::Value = serde_json::from_str(
2647            &std::fs::read_to_string(out.dir.join("gridfm_meta.json")).unwrap(),
2648        )
2649        .unwrap();
2650        assert_eq!(meta["degenerate_cost_gens"], 1);
2651        assert_eq!(meta["missing_cost_gens"], 0);
2652        assert_eq!(meta["unsupported_cost_gens"], 1);
2653        assert_eq!(meta["zeroed_cost_gens"], 1);
2654    }
2655
2656    #[test]
2657    fn missing_gridfm_costs_are_split_and_fill_policy_reduces_missing_count() {
2658        let mut net = BalancedNetwork::in_memory(
2659            "missing-cost",
2660            100.0,
2661            vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
2662            vec![branch(1, 2, 0.01, 0.1)],
2663        );
2664        let mut missing = gen_at(1, gencost(2, 3, vec![1.0, 2.0, 3.0]));
2665        missing.cost = None;
2666        net.generators.push(missing);
2667        net.generators
2668            .push(gen_at(2, gencost(1, 2, vec![0.0, 0.0, 1.0, 1.0])));
2669
2670        let dir = tempfile::tempdir().unwrap();
2671        let out = write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2672        assert_eq!(out.degenerate_cost_gens, 2);
2673        assert_eq!(out.missing_cost_gens, 1);
2674        assert_eq!(out.unsupported_cost_gens, 1);
2675
2676        let opts = GridfmOptions {
2677            missing_gen_cost: MissingGenCostPolicy::zero(),
2678            ..Default::default()
2679        };
2680        let dir = tempfile::tempdir().unwrap();
2681        let out = write_gridfm_dataset(&net, 0, dir.path(), &opts).unwrap();
2682        assert_eq!(out.synthesized_gen_costs, 1);
2683        assert_eq!(out.missing_cost_gens, 0);
2684        assert_eq!(out.unsupported_cost_gens, 1);
2685        assert_eq!(out.degenerate_cost_gens, 1);
2686        let meta: serde_json::Value = serde_json::from_str(
2687            &std::fs::read_to_string(out.dir.join("gridfm_meta.json")).unwrap(),
2688        )
2689        .unwrap();
2690        assert_eq!(meta["cost_policy"]["mode"], "fill");
2691        assert_eq!(meta["synthesized_gen_costs"], 1);
2692    }
2693
2694    #[test]
2695    fn scenario_id_and_tap_toggle_take_effect() {
2696        let net = case14();
2697
2698        // The scenario id (an explicit argument now) reaches both id columns.
2699        let bus = gridfm_record_batches(&net, 7, &GridfmOptions::default())
2700            .unwrap()
2701            .bus;
2702        assert_eq!(col_i64(&bus, "scenario").value(0), 7);
2703        assert_eq!(col_i64(&bus, "load_scenario_idx").value(0), 7);
2704
2705        // Turning taps off changes a transformer's admittance columns.
2706        let on = gridfm_record_batches(&net, 0, &GridfmOptions::default())
2707            .unwrap()
2708            .branch;
2709        let off = gridfm_record_batches(
2710            &net,
2711            0,
2712            &GridfmOptions {
2713                include_taps: false,
2714                ..Default::default()
2715            },
2716        )
2717        .unwrap()
2718        .branch;
2719        let tap = col_f64(&on, "tap");
2720        let xfmr = (0..on.num_rows())
2721            .find(|&i| (tap.value(i) - 1.0).abs() > 1e-9)
2722            .expect("case14 has off-nominal transformers");
2723        // case14 transformers are lossless (r = 0), so compare the susceptance
2724        // (imaginary) part, which scales with 1/tap².
2725        assert!(
2726            (col_f64(&on, "Yff_i").value(xfmr) - col_f64(&off, "Yff_i").value(xfmr)).abs() > 1e-9,
2727            "taps off should change the transformer's Yff"
2728        );
2729    }
2730
2731    // --- reader (issue #60) ------------------------------------------------
2732
2733    /// The power flow complete fingerprint the read path preserves: element
2734    /// counts, nodal load / generation totals, branch impedance sums, base_mva,
2735    /// and the reference-bus count. Bus ids, per-element granularity, costs, and
2736    /// HVDC/storage are excluded from that guarantee.
2737    #[allow(clippy::type_complexity)]
2738    fn fingerprint(
2739        net: &BalancedNetwork,
2740    ) -> (
2741        usize,
2742        usize,
2743        usize,
2744        usize,
2745        f64,
2746        f64,
2747        f64,
2748        f64,
2749        f64,
2750        f64,
2751        f64,
2752    ) {
2753        (
2754            net.buses.len(),
2755            net.branches.len(),
2756            net.generators.len(),
2757            net.buses.iter().filter(|b| b.kind == BusType::Ref).count(),
2758            net.loads.iter().map(|l| l.p).sum(),
2759            net.loads.iter().map(|l| l.q).sum(),
2760            net.generators.iter().map(|g| g.pg).sum(),
2761            net.branches.iter().map(|b| b.r).sum(),
2762            net.branches.iter().map(|b| b.x).sum(),
2763            net.branches.iter().map(|b| b.b).sum(),
2764            net.base_mva,
2765        )
2766    }
2767
2768    fn assert_fingerprint_close(got: &BalancedNetwork, want: &BalancedNetwork) {
2769        let (g, w) = (fingerprint(got), fingerprint(want));
2770        assert_eq!(
2771            (g.0, g.1, g.2, g.3),
2772            (w.0, w.1, w.2, w.3),
2773            "bus/branch/gen/ref counts differ"
2774        );
2775        for (a, b, label) in [
2776            (g.4, w.4, "load P"),
2777            (g.5, w.5, "load Q"),
2778            (g.6, w.6, "gen P"),
2779            (g.7, w.7, "sum r"),
2780            (g.8, w.8, "sum x"),
2781            (g.9, w.9, "sum b"),
2782            (g.10, w.10, "base_mva"),
2783        ] {
2784            assert!((a - b).abs() < 1e-9, "{label} differs: {a} vs {b}");
2785        }
2786    }
2787
2788    #[test]
2789    fn read_round_trips_power_flow_fingerprint() {
2790        let net = case14();
2791        let dir = tempfile::tempdir().unwrap();
2792        write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2793
2794        let read = read_gridfm_dataset(dir.path().join("case14").join("raw"), 0).unwrap();
2795        assert_eq!(read.scenario, 0);
2796        assert_eq!(read.network.source_format, SourceFormat::Gridfm);
2797        assert_eq!(read.network.name, "case14");
2798        assert!(read.network.source.is_none());
2799        assert_fingerprint_close(&read.network, &net);
2800        // The reconstruction is structurally valid (validate() already ran inside).
2801        read.network.validate().unwrap();
2802    }
2803
2804    #[test]
2805    fn read_gridfm_network_pure_path_matches_disk() {
2806        // The in-memory inverse of gridfm_record_batches reproduces the same
2807        // fingerprint with no disk I/O.
2808        let net = case14();
2809        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2810        let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
2811        assert_fingerprint_close(&read.network, &net);
2812    }
2813
2814    #[test]
2815    fn read_recovers_shunt_at_base_mva() {
2816        // case14 has a single bus shunt (Bs = 19 at bus 9). The writer divides by
2817        // base_mva; the reader must multiply it back.
2818        let net = case14();
2819        let want_b: f64 = net.shunts.iter().map(|s| s.b).sum();
2820        assert!(want_b.abs() > 1.0, "fixture should have a real shunt");
2821
2822        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2823        let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
2824        let got_b: f64 = read.network.shunts.iter().map(|s| s.b).sum();
2825        assert!(
2826            (got_b - want_b).abs() < 1e-9,
2827            "shunt b not recovered at base_mva: {got_b} vs {want_b}"
2828        );
2829    }
2830
2831    #[test]
2832    fn read_scenarios_yields_distinct_networks() {
2833        // A 2-scenario batch (base + load×1.1): each scenario reads back to its own
2834        // BalancedNetwork, and gridfm_base_case picks scenario 0.
2835        let base = case14();
2836        let up = scaled(&base, 1.1);
2837        let snaps = [
2838            GridfmSnapshot {
2839                net: &base,
2840                scenario: 0,
2841            },
2842            GridfmSnapshot {
2843                net: &up,
2844                scenario: 1,
2845            },
2846        ];
2847        let dir = tempfile::tempdir().unwrap();
2848        let out = write_gridfm_batch(&snaps, dir.path(), &GridfmOptions::default()).unwrap();
2849
2850        let reads = read_gridfm_scenarios(&out.dir).unwrap();
2851        assert_eq!(reads.len(), 2);
2852        assert_eq!((reads[0].scenario, reads[1].scenario), (0, 1));
2853
2854        let load0: f64 = reads[0].network.loads.iter().map(|l| l.p).sum();
2855        let load1: f64 = reads[1].network.loads.iter().map(|l| l.p).sum();
2856        assert!(load0 > 0.0);
2857        assert!(
2858            (load1 - 1.1 * load0).abs() < 1e-6,
2859            "scenario 1 load should be 1.1× scenario 0: {load1} vs {load0}"
2860        );
2861
2862        let base_case = gridfm_base_case(&out.dir).unwrap();
2863        assert_fingerprint_close(&base_case.network, &reads[0].network);
2864    }
2865
2866    #[test]
2867    fn read_resolves_lenient_directory_layouts() {
2868        // resolve_raw_dir accepts the leaf raw/ dir, the <case>/ dir, and the
2869        // parent out/ dir.
2870        let net = case14();
2871        let dir = tempfile::tempdir().unwrap();
2872        write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2873        let out = dir.path(); // parent
2874        let case_dir = out.join("case14");
2875        let raw_dir = case_dir.join("raw");
2876        for d in [raw_dir.clone(), case_dir, out.to_path_buf()] {
2877            let read = read_gridfm_dataset(&d, 0)
2878                .unwrap_or_else(|e| panic!("failed to resolve {}: {e}", d.display()));
2879            assert_eq!(read.network.buses.len(), net.buses.len());
2880        }
2881    }
2882
2883    #[test]
2884    fn read_missing_scenario_errors() {
2885        let net = case14();
2886        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2887        let err = read_gridfm_network(&tables, 99, net.base_mva, &net.name).unwrap_err();
2888        assert!(
2889            matches!(
2890                err,
2891                Error::Core(powerio::Error::FormatRead {
2892                    format: "gridfm",
2893                    ..
2894                })
2895            ),
2896            "got {err:?}"
2897        );
2898    }
2899
2900    #[test]
2901    fn read_no_dataset_errors() {
2902        // An empty (but existing) directory: read_dir succeeds, finds nothing.
2903        let dir = tempfile::tempdir().unwrap();
2904        let err = read_gridfm_dataset(dir.path(), 0).unwrap_err();
2905        assert!(
2906            matches!(
2907                err,
2908                Error::Core(powerio::Error::FormatRead {
2909                    format: "gridfm",
2910                    ..
2911                })
2912            ),
2913            "got {err:?}"
2914        );
2915        // A non-existent directory: read_dir's IO error is surfaced, not masked.
2916        let missing = dir.path().join("does-not-exist");
2917        let err = read_gridfm_dataset(&missing, 0).unwrap_err();
2918        assert!(
2919            matches!(
2920                err,
2921                Error::Core(powerio::Error::FormatRead {
2922                    format: "gridfm",
2923                    ..
2924                })
2925            ),
2926            "got {err:?}"
2927        );
2928    }
2929
2930    #[test]
2931    fn read_defaults_unusable_base_mva_to_100() {
2932        // A manifest base_mva of 0 (or negative/NaN) is unusable — shunt recovery
2933        // scales by it — so the reader defaults to 100 and warns instead of
2934        // silently producing a network with zeroed shunts.
2935        let net = case14();
2936        let dir = tempfile::tempdir().unwrap();
2937        let out = write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2938        let meta_path = out.dir.join("gridfm_meta.json");
2939        let mut meta: serde_json::Value =
2940            serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
2941        meta["base_mva"] = serde_json::json!(0.0);
2942        std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
2943
2944        let read = read_gridfm_dataset(&out.dir, 0).unwrap();
2945        assert!(
2946            (read.network.base_mva - 100.0).abs() < 1e-9,
2947            "base_mva should default to 100, got {}",
2948            read.network.base_mva
2949        );
2950        assert!(
2951            read.warnings.iter().any(|w| w.contains("base_mva")),
2952            "expected a base_mva warning, got {:?}",
2953            read.warnings
2954        );
2955    }
2956
2957    #[test]
2958    fn read_surfaces_fidelity_warnings() {
2959        let net = case14();
2960        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2961        let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
2962        assert!(!read.warnings.is_empty());
2963        assert!(
2964            read.warnings
2965                .iter()
2966                .any(|w| w.contains("synthesized bus ids")),
2967            "expected the bus-id synthesis warning, got {:?}",
2968            read.warnings
2969        );
2970        // case14 has loads and a shunt, so those folding warnings appear too.
2971        assert!(read.warnings.iter().any(|w| w.contains("nodal load")));
2972        assert!(read.warnings.iter().any(|w| w.contains("nodal shunts")));
2973    }
2974
2975    #[test]
2976    fn read_recovers_gen_vg_from_bus_vm() {
2977        // gridfm has no gen vg column; vg is recovered from the gen's bus Vm.
2978        // case14's slack bus 1 sits at Vm = 1.06, so its generator reads vg ≈ 1.06
2979        // (not the old hard-coded 1.0).
2980        let net = case14();
2981        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2982        let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
2983        for g in &read.network.generators {
2984            let bus = read
2985                .network
2986                .buses
2987                .iter()
2988                .find(|b| b.id == g.bus)
2989                .expect("gen references a known bus");
2990            assert!(
2991                (g.vg - bus.vm).abs() < 1e-12,
2992                "vg should equal the bus Vm: {} vs {}",
2993                g.vg,
2994                bus.vm
2995            );
2996        }
2997        assert!(
2998            read.network
2999                .generators
3000                .iter()
3001                .any(|g| (g.vg - 1.0).abs() > 1e-3),
3002            "expected a generator with vg != 1.0 (case14's slack is at 1.06)"
3003        );
3004    }
3005
3006    #[test]
3007    fn read_maps_unit_tap_lines_back_to_zero() {
3008        // The writer stores effective tap (a line's raw 0 becomes 1.0). The reader
3009        // must map unit tap + no shift back to raw tap 0 so lines stay lines;
3010        // otherwise every line reads as a transformer and a read→write to PSS/E /
3011        // PowerWorld misclassifies them. case14 has both lines and off-nominal
3012        // transformers, so the line/transformer split must survive the round trip.
3013        let net = case14();
3014        let n_lines = net.branches.iter().filter(|b| !b.is_transformer()).count();
3015        let n_xfmr = net.branches.iter().filter(|b| b.is_transformer()).count();
3016        assert!(
3017            n_lines > 0 && n_xfmr > 0,
3018            "fixture needs both lines and transformers"
3019        );
3020
3021        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
3022        let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
3023        let read_lines = read
3024            .network
3025            .branches
3026            .iter()
3027            .filter(|b| !b.is_transformer())
3028            .count();
3029        let read_xfmr = read
3030            .network
3031            .branches
3032            .iter()
3033            .filter(|b| b.is_transformer())
3034            .count();
3035        assert_eq!(
3036            read_lines, n_lines,
3037            "lines must read back as lines (raw tap 0)"
3038        );
3039        assert_eq!(
3040            read_xfmr, n_xfmr,
3041            "transformers must keep their off-nominal ratio"
3042        );
3043        assert!(
3044            read.warnings.iter().any(|w| w.contains("read as lines")),
3045            "expected the unit-tap warning, got {:?}",
3046            read.warnings
3047        );
3048    }
3049
3050    #[test]
3051    fn read_allows_a_case_with_no_generators() {
3052        // gen_data may be legitimately empty (a power flow case with no mpc.gen);
3053        // the scenario guard must not reject it — only a *partial* table (rows for
3054        // other scenarios but not this one) is an error.
3055        let net = BalancedNetwork::in_memory(
3056            "nogen",
3057            100.0,
3058            vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
3059            vec![branch(1, 2, 0.01, 0.1)],
3060        );
3061        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
3062        let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
3063        assert!(read.network.generators.is_empty());
3064        assert_eq!(read.network.branches.len(), 1);
3065    }
3066
3067    #[test]
3068    fn read_all_zero_cost_reads_as_none_with_ambiguity_warning() {
3069        // A genuine zero polynomial cost writes (0,0,0), indistinguishable from a
3070        // no-cost generator or a zeroed unrepresentable cost; the reader reads None
3071        // and the warning describes the ambiguity (not a false "piecewise/cubic").
3072        let mut net = BalancedNetwork::in_memory(
3073            "zerocost",
3074            100.0,
3075            vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
3076            vec![branch(1, 2, 0.01, 0.1)],
3077        );
3078        net.generators
3079            .push(gen_at(1, gencost(2, 3, vec![0.0, 0.0, 0.0])));
3080        let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
3081        let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
3082        assert!(
3083            read.network.generators[0].cost.is_none(),
3084            "all-zero cost should read back as None"
3085        );
3086        assert!(
3087            read.warnings
3088                .iter()
3089                .any(|w| w.contains("read with no cost")),
3090            "expected the no-cost ambiguity warning, got {:?}",
3091            read.warnings
3092        );
3093    }
3094
3095    #[test]
3096    fn require_scenario_block_flags_partial_tables() {
3097        // Empty table → ok (a legitimately element-less case). Present → ok. Absent
3098        // from a non-empty table → error (the partial/corrupt dataset Copilot flagged).
3099        assert!(require_scenario_block(&[], 0, &[], "gen_data").is_ok());
3100        assert!(require_scenario_block(&[0, 0, 1], 0, &[0, 1], "gen_data").is_ok());
3101        let err = require_scenario_block(&[0, 0], 1, &[], "branch_data").unwrap_err();
3102        assert!(
3103            matches!(
3104                err,
3105                Error::Core(powerio::Error::FormatRead {
3106                    format: "gridfm",
3107                    ..
3108                })
3109            ),
3110            "got {err:?}"
3111        );
3112    }
3113}