Skip to main content

powerio_matrix/io/
gridfm.rs

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