1#![allow(clippy::cast_possible_wrap)]
61
62use std::path::{Path, PathBuf};
63use std::sync::Arc;
64
65use arrow::array::{Array, ArrayRef, Float64Array, Int64Array};
66use arrow::datatypes::{Field, Schema};
67use arrow::record_batch::RecordBatch;
68use num_complex::Complex64;
69use parquet::arrow::ArrowWriter;
70use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
71use parquet::basic::Compression;
72use parquet::file::properties::WriterProperties;
73use serde::Serialize;
74
75use crate::indexed::IndexedNetwork;
76use crate::matrix::{BuildOptions, YbusFlags, branch_admittance, branch_flows, build_ybus};
77use crate::network::{Branch, Bus, BusId, BusType, Generator, Load, Shunt, SourceFormat};
78use crate::{
79 BalancedNetwork, ElementCounts, Error, GenCost, GenCostPatch, MissingGenCostPolicy, Result,
80 ScenarioMismatch,
81};
82
83#[derive(Debug, Clone)]
88pub struct GridfmOptions {
89 pub include_y_bus: bool,
93 pub include_taps: bool,
96 pub include_shifts: bool,
98 pub missing_gen_cost: MissingGenCostPolicy,
100 pub gen_cost_patches: Vec<GenCostPatch>,
103}
104
105impl Default for GridfmOptions {
106 fn default() -> Self {
107 Self {
108 include_y_bus: true,
109 include_taps: true,
110 include_shifts: true,
111 missing_gen_cost: MissingGenCostPolicy::Preserve,
112 gen_cost_patches: Vec::new(),
113 }
114 }
115}
116
117impl GridfmOptions {
118 fn build_options(&self) -> BuildOptions {
122 BuildOptions {
123 include_taps: self.include_taps,
124 include_shifts: self.include_shifts,
125 ..Default::default()
126 }
127 }
128
129 fn cost_options_default(&self) -> bool {
130 self.missing_gen_cost.is_preserve() && self.gen_cost_patches.is_empty()
131 }
132}
133
134#[derive(Debug, Clone, Copy)]
148pub struct GridfmSnapshot<'a> {
149 net: &'a BalancedNetwork,
151 scenario: i64,
153}
154
155impl<'a> GridfmSnapshot<'a> {
156 #[must_use]
160 pub fn new(net: &'a BalancedNetwork, scenario: i64) -> Self {
161 Self { net, scenario }
162 }
163}
164
165#[derive(Debug, Clone)]
174#[non_exhaustive]
175pub struct GridfmTables {
176 pub bus: RecordBatch,
177 pub generator: RecordBatch,
178 pub branch: RecordBatch,
179 pub y_bus: Option<RecordBatch>,
182}
183
184#[derive(Debug, Clone)]
187pub struct GridfmOutputs {
188 pub dir: PathBuf,
189 pub files: Vec<PathBuf>,
190 pub dropped_zero_impedance: usize,
192 pub degenerate_cost_gens: usize,
195 pub missing_cost_gens: usize,
197 pub unsupported_cost_gens: usize,
199 pub synthesized_gen_costs: usize,
201 pub patched_gen_costs: usize,
203}
204
205#[derive(Serialize)]
206struct GridfmMeta {
207 case_name: String,
208 base_mva: f64,
209 scenario: i64,
211 n_scenarios: usize,
213 schema: &'static str,
214 n_buses: usize,
216 n_branches: usize,
217 n_branches_in_service: usize,
220 n_gens: usize,
221 reference_bus: usize,
225 dropped_zero_impedance: usize,
228 degenerate_cost_gens: usize,
232 missing_cost_gens: usize,
234 unsupported_cost_gens: usize,
236 zeroed_cost_gens: usize,
238 cost_policy: MissingGenCostPolicy,
239 synthesized_gen_costs: usize,
240 patched_gen_costs: usize,
241 files: Vec<String>,
242 powerio_version: String,
243}
244
245pub fn gridfm_record_batches(
258 net: &BalancedNetwork,
259 scenario: i64,
260 opts: &GridfmOptions,
261) -> Result<GridfmTables> {
262 let snap = GridfmSnapshot::new(net, scenario);
263 gridfm_record_batches_batch(std::slice::from_ref(&snap), opts)
264}
265
266pub fn gridfm_record_batches_batch(
276 snapshots: &[GridfmSnapshot],
277 opts: &GridfmOptions,
278) -> Result<GridfmTables> {
279 if opts.cost_options_default() {
280 let views = snapshot_views(snapshots)?;
281 return tables_from_views(&views, opts);
282 }
283
284 let (nets, _) = policy_adjusted_snapshots(snapshots, opts)?;
285 let adjusted: Vec<_> = nets
286 .iter()
287 .zip(snapshots)
288 .map(|(net, snap)| GridfmSnapshot::new(net, snap.scenario))
289 .collect();
290 let views = snapshot_views(&adjusted)?;
291 tables_from_views(&views, opts)
292}
293
294fn tables_from_views(views: &[SnapshotView], opts: &GridfmOptions) -> Result<GridfmTables> {
298 Ok(GridfmTables {
299 bus: bus_batch(views)?,
300 generator: gen_batch(views)?,
301 branch: branch_batch(views, opts)?,
302 y_bus: if opts.include_y_bus {
303 Some(y_bus_batch(views, opts)?)
304 } else {
305 None
306 },
307 })
308}
309
310struct SnapshotView<'a> {
312 view: IndexedNetwork<'a>,
313 scenario: i64,
314 ref_bus: usize,
315}
316
317fn snapshot_views<'a>(snapshots: &'a [GridfmSnapshot<'a>]) -> Result<Vec<SnapshotView<'a>>> {
322 let first = snapshots.first().ok_or(Error::EmptyScenarioBatch)?;
323 let expected = shape_of(first.net);
324 let expected_ids: Vec<BusId> = first.net.buses.iter().map(|b| b.id).collect();
325
326 let mut views = Vec::with_capacity(snapshots.len());
327 for (k, snap) in snapshots.iter().enumerate() {
328 let got = shape_of(snap.net);
329 if got != expected {
330 return Err(Error::ScenarioShapeMismatch {
331 index: k,
332 reason: ScenarioMismatch::Counts { expected, got },
333 });
334 }
335 let ids_match = snap
336 .net
337 .buses
338 .iter()
339 .map(|b| b.id)
340 .eq(expected_ids.iter().copied());
341 if !ids_match {
342 return Err(Error::ScenarioShapeMismatch {
343 index: k,
344 reason: ScenarioMismatch::BusOrder,
345 });
346 }
347 validate_snapshot_inputs(snap.net, snap.scenario)?;
348 let view = IndexedNetwork::new(snap.net);
349 let ref_bus = view.reference_bus_index()?;
350 views.push(SnapshotView {
351 view,
352 scenario: snap.scenario,
353 ref_bus,
354 });
355 }
356 Ok(views)
357}
358
359fn validate_snapshot_inputs(net: &BalancedNetwork, scenario: i64) -> Result<()> {
360 if net.is_normalized() {
361 return Err(Error::NormalizedGridfmSnapshot { scenario });
362 }
363 net.check_base_mva()?;
364
365 for (row, b) in net.buses.iter().enumerate() {
370 finite(scenario, "bus", row, "vm", b.vm)?;
371 finite(scenario, "bus", row, "va", b.va)?;
372 finite(scenario, "bus", row, "base_kv", b.base_kv)?;
373 not_nan(scenario, "bus", row, "vmax", b.vmax)?;
374 not_nan(scenario, "bus", row, "vmin", b.vmin)?;
375 }
376 for (row, l) in net.loads.iter().enumerate() {
377 finite(scenario, "load", row, "p", l.p)?;
378 finite(scenario, "load", row, "q", l.q)?;
379 }
380 for (row, s) in net.shunts.iter().enumerate() {
381 finite(scenario, "shunt", row, "g", s.g)?;
382 finite(scenario, "shunt", row, "b", s.b)?;
383 }
384 for (row, br) in net.branches.iter().enumerate() {
385 finite(scenario, "branch", row, "r", br.r)?;
386 finite(scenario, "branch", row, "x", br.x)?;
387 finite(scenario, "branch", row, "b", br.total_charging_b())?;
388 finite(scenario, "branch", row, "tap", br.tap)?;
389 finite(scenario, "branch", row, "shift", br.shift)?;
390 not_nan(scenario, "branch", row, "angmin", br.angmin)?;
391 not_nan(scenario, "branch", row, "angmax", br.angmax)?;
392 not_nan(scenario, "branch", row, "rate_a", br.rate_a)?;
393 }
394 for (row, g) in net.generators.iter().enumerate() {
395 finite(scenario, "generator", row, "pg", g.pg)?;
396 finite(scenario, "generator", row, "qg", g.qg)?;
397 not_nan(scenario, "generator", row, "pmax", g.pmax)?;
398 not_nan(scenario, "generator", row, "pmin", g.pmin)?;
399 not_nan(scenario, "generator", row, "qmax", g.qmax)?;
400 not_nan(scenario, "generator", row, "qmin", g.qmin)?;
401 let (cp0, cp1, cp2) = gridfm_cost(g.cost.as_ref());
404 finite(scenario, "gencost", row, "cp0", cp0)?;
405 finite(scenario, "gencost", row, "cp1", cp1)?;
406 finite(scenario, "gencost", row, "cp2", cp2)?;
407 }
408 Ok(())
409}
410
411fn finite(
412 scenario: i64,
413 element: &'static str,
414 row: usize,
415 field: &'static str,
416 value: f64,
417) -> Result<()> {
418 if value.is_finite() {
419 Ok(())
420 } else {
421 Err(Error::NonFiniteGridfmValue {
422 scenario,
423 element,
424 row,
425 field,
426 value,
427 })
428 }
429}
430
431fn not_nan(
433 scenario: i64,
434 element: &'static str,
435 row: usize,
436 field: &'static str,
437 value: f64,
438) -> Result<()> {
439 if value.is_nan() {
440 Err(Error::NonFiniteGridfmValue {
441 scenario,
442 element,
443 row,
444 field,
445 value,
446 })
447 } else {
448 Ok(())
449 }
450}
451
452fn shape_of(net: &BalancedNetwork) -> ElementCounts {
454 ElementCounts {
455 buses: net.buses.len(),
456 branches: net.branches.len(),
457 gens: net.generators.len(),
458 }
459}
460
461#[derive(Debug, Clone, Copy, Default)]
462struct CostPolicyBatchReport {
463 synthesized: usize,
464 patched: usize,
465}
466
467fn policy_adjusted_snapshots(
468 snapshots: &[GridfmSnapshot],
469 opts: &GridfmOptions,
470) -> Result<(Vec<BalancedNetwork>, CostPolicyBatchReport)> {
471 let mut report = CostPolicyBatchReport::default();
472 let mut nets = Vec::with_capacity(snapshots.len());
473 for snap in snapshots {
474 let mut net = snap.net.clone();
475 let r = net.apply_gen_cost_policy(&opts.gen_cost_patches, opts.missing_gen_cost)?;
476 report.synthesized += r.synthesized;
477 report.patched += r.patched;
478 nets.push(net);
479 }
480 Ok((nets, report))
481}
482
483pub fn numbered_snapshots<'a>(
488 nets: &[&'a BalancedNetwork],
489 base: i64,
490) -> Result<Vec<GridfmSnapshot<'a>>> {
491 nets.iter()
492 .enumerate()
493 .map(|(k, &net)| {
494 let scenario = i64::try_from(k)
495 .ok()
496 .and_then(|offset| base.checked_add(offset))
497 .ok_or(Error::ScenarioIdOverflow { base, index: k })?;
498 Ok(GridfmSnapshot::new(net, scenario))
499 })
500 .collect()
501}
502
503pub fn write_gridfm_dataset(
518 net: &BalancedNetwork,
519 scenario: i64,
520 out_dir: impl AsRef<Path>,
521 opts: &GridfmOptions,
522) -> Result<GridfmOutputs> {
523 let snap = GridfmSnapshot::new(net, scenario);
524 write_gridfm_batch(std::slice::from_ref(&snap), out_dir, opts)
525}
526
527pub fn write_gridfm_batch(
540 snapshots: &[GridfmSnapshot],
541 out_dir: impl AsRef<Path>,
542 opts: &GridfmOptions,
543) -> Result<GridfmOutputs> {
544 if opts.cost_options_default() {
545 return write_gridfm_batch_inner(
546 snapshots,
547 out_dir.as_ref(),
548 opts,
549 CostPolicyBatchReport::default(),
550 );
551 }
552
553 let (nets, cost_report) = policy_adjusted_snapshots(snapshots, opts)?;
554 let adjusted: Vec<_> = nets
555 .iter()
556 .zip(snapshots)
557 .map(|(net, snap)| GridfmSnapshot::new(net, snap.scenario))
558 .collect();
559 write_gridfm_batch_inner(&adjusted, out_dir.as_ref(), opts, cost_report)
560}
561
562fn write_gridfm_batch_inner(
563 snapshots: &[GridfmSnapshot],
564 out_dir: &Path,
565 opts: &GridfmOptions,
566 cost_report: CostPolicyBatchReport,
567) -> Result<GridfmOutputs> {
568 let views = snapshot_views(snapshots)?;
569 let tables = tables_from_views(&views, opts)?;
570
571 let net = views[0].view.network();
574 let dir = out_dir.join(crate::sanitize_stem(&net.name)).join("raw");
578 std::fs::create_dir_all(&dir)?;
579
580 let mut files = Vec::new();
581 put_parquet(&dir, "bus_data.parquet", &tables.bus, &mut files)?;
582 put_parquet(&dir, "gen_data.parquet", &tables.generator, &mut files)?;
583 put_parquet(&dir, "branch_data.parquet", &tables.branch, &mut files)?;
584 if let Some(y_bus) = &tables.y_bus {
585 put_parquet(&dir, "y_bus_data.parquet", y_bus, &mut files)?;
586 }
587
588 let dropped_zero_impedance: usize = views
591 .iter()
592 .flat_map(|v| v.view.network().branches.iter())
593 .filter(|br| br.r * br.r + br.x * br.x == 0.0)
594 .count();
595 let missing_cost_gens: usize = views
596 .iter()
597 .flat_map(|v| v.view.network().generators.iter())
598 .filter(|g| g.cost.is_none())
599 .count();
600 let unsupported_cost_gens: usize = views
601 .iter()
602 .flat_map(|v| v.view.network().generators.iter())
603 .filter(|g| g.cost.is_some() && !cost_representable(g.cost.as_ref()))
604 .count();
605 let degenerate_cost_gens = missing_cost_gens + unsupported_cost_gens;
606
607 let meta = GridfmMeta {
608 case_name: net.name.clone(),
609 base_mva: net.base_mva,
610 scenario: views[0].scenario,
611 n_scenarios: views.len(),
612 schema: "gridfm-datakit",
613 n_buses: net.buses.len(),
614 n_branches: net.branches.len(),
615 n_branches_in_service: net.branches.iter().filter(|b| b.in_service).count(),
616 n_gens: net.generators.len(),
617 reference_bus: views[0].ref_bus,
618 dropped_zero_impedance,
619 degenerate_cost_gens,
620 missing_cost_gens,
621 unsupported_cost_gens,
622 zeroed_cost_gens: degenerate_cost_gens,
623 cost_policy: opts.missing_gen_cost,
624 synthesized_gen_costs: cost_report.synthesized,
625 patched_gen_costs: cost_report.patched,
626 files: files
627 .iter()
628 .filter_map(|p| p.file_name().and_then(|s| s.to_str()).map(str::to_string))
629 .collect(),
630 powerio_version: env!("CARGO_PKG_VERSION").to_string(),
631 };
632 let meta_path = dir.join("gridfm_meta.json");
633 let json = serde_json::to_string_pretty(&meta).map_err(|e| Error::Parquet(e.to_string()))?;
634 std::fs::write(&meta_path, json)?;
635 files.push(meta_path);
636
637 Ok(GridfmOutputs {
638 dir,
639 files,
640 dropped_zero_impedance,
641 degenerate_cost_gens,
642 missing_cost_gens,
643 unsupported_cost_gens,
644 synthesized_gen_costs: cost_report.synthesized,
645 patched_gen_costs: cost_report.patched,
646 })
647}
648
649fn bus_batch(snaps: &[SnapshotView]) -> Result<RecordBatch> {
652 let total: usize = snaps.iter().map(|s| s.view.n()).sum();
653 let mut scenario = Vec::with_capacity(total);
654 let mut bus_idx = Vec::with_capacity(total);
655 let (mut pd, mut qd) = (Vec::with_capacity(total), Vec::with_capacity(total));
656 let (mut pg_col, mut qg_col) = (Vec::with_capacity(total), Vec::with_capacity(total));
657 let (mut vm, mut va) = (Vec::with_capacity(total), Vec::with_capacity(total));
658 let (mut pq, mut pv, mut refc) = (
659 Vec::with_capacity(total),
660 Vec::with_capacity(total),
661 Vec::with_capacity(total),
662 );
663 let mut vn_kv = Vec::with_capacity(total);
664 let (mut min_vm, mut max_vm) = (Vec::with_capacity(total), Vec::with_capacity(total));
665 let (mut gs, mut bs) = (Vec::with_capacity(total), Vec::with_capacity(total));
666
667 for s in snaps {
668 let view = &s.view;
669 let n = view.n();
670 let base = view.base_mva();
671 let buses = &view.network().buses;
672
673 let mut pg = vec![0.0; n];
675 let mut qg = vec![0.0; n];
676 for (_, g) in view.in_service_gens() {
677 if let Some(i) = view.bus_index(g.bus) {
678 pg[i] += g.pg;
679 qg[i] += g.qg;
680 }
681 }
682
683 scenario.resize(scenario.len() + n, s.scenario);
684 bus_idx.extend(0..n as i64);
685 pd.extend_from_slice(view.pd());
686 qd.extend_from_slice(view.qd());
687 pg_col.extend(pg);
688 qg_col.extend(qg);
689 vm.extend(buses.iter().map(|b| b.vm));
690 va.extend(buses.iter().map(|b| b.va));
691 pq.extend(buses.iter().map(|b| i64::from(b.kind == BusType::Pq)));
692 pv.extend(buses.iter().map(|b| i64::from(b.kind == BusType::Pv)));
693 refc.extend(buses.iter().map(|b| i64::from(b.kind == BusType::Ref)));
694 vn_kv.extend(buses.iter().map(|b| b.base_kv));
695 min_vm.extend(buses.iter().map(|b| b.vmin));
696 max_vm.extend(buses.iter().map(|b| b.vmax));
697 gs.extend(view.gs().iter().map(|g| g / base));
698 bs.extend(view.bs().iter().map(|b| b / base));
699 }
700
701 batch(with_scenario_pair(
702 scenario,
703 vec![
704 ("bus", i64s(bus_idx)),
705 ("Pd", f64s(pd)),
706 ("Qd", f64s(qd)),
707 ("Pg", f64s(pg_col)),
708 ("Qg", f64s(qg_col)),
709 ("Vm", f64s(vm)),
710 ("Va", f64s(va)),
711 ("PQ", i64s(pq)),
712 ("PV", i64s(pv)),
713 ("REF", i64s(refc)),
714 ("vn_kv", f64s(vn_kv)),
715 ("min_vm_pu", f64s(min_vm)),
716 ("max_vm_pu", f64s(max_vm)),
717 ("GS", f64s(gs)),
718 ("BS", f64s(bs)),
719 ],
720 ))
721}
722
723fn gen_batch(snaps: &[SnapshotView]) -> Result<RecordBatch> {
724 let total: usize = snaps.iter().map(|s| s.view.generators().len()).sum();
725 let mut scenario = Vec::with_capacity(total);
726 let mut idx = Vec::with_capacity(total);
727 let mut bus = Vec::with_capacity(total);
728 let (mut p_mw, mut q_mvar) = (Vec::with_capacity(total), Vec::with_capacity(total));
729 let (mut min_p, mut max_p) = (Vec::with_capacity(total), Vec::with_capacity(total));
730 let (mut min_q, mut max_q) = (Vec::with_capacity(total), Vec::with_capacity(total));
731 let (mut cp0, mut cp1, mut cp2) = (
732 Vec::with_capacity(total),
733 Vec::with_capacity(total),
734 Vec::with_capacity(total),
735 );
736 let mut in_service = Vec::with_capacity(total);
737 let mut is_slack = Vec::with_capacity(total);
738
739 for s in snaps {
740 let view = &s.view;
741 for (row, g) in view.generators().iter().enumerate() {
744 let i = view.bus_index(g.bus).ok_or(powerio::Error::UnknownBus {
745 bus_id: g.bus,
746 element_index: row,
747 })?;
748 scenario.push(s.scenario);
749 idx.push(row as i64);
750 bus.push(i as i64);
751 is_slack.push(i64::from(i == s.ref_bus));
752 let (c0, c1, c2) = gridfm_cost(g.cost.as_ref());
753 cp0.push(c0);
754 cp1.push(c1);
755 cp2.push(c2);
756 p_mw.push(g.pg);
757 q_mvar.push(g.qg);
758 min_p.push(g.pmin);
759 max_p.push(g.pmax);
760 min_q.push(g.qmin);
761 max_q.push(g.qmax);
762 in_service.push(i64::from(g.in_service));
763 }
764 }
765
766 batch(with_scenario_pair(
767 scenario,
768 vec![
769 ("idx", i64s(idx)),
770 ("bus", i64s(bus)),
771 ("p_mw", f64s(p_mw)),
772 ("q_mvar", f64s(q_mvar)),
773 ("min_p_mw", f64s(min_p)),
774 ("max_p_mw", f64s(max_p)),
775 ("min_q_mvar", f64s(min_q)),
776 ("max_q_mvar", f64s(max_q)),
777 ("cp0_eur", f64s(cp0)),
778 ("cp1_eur_per_mw", f64s(cp1)),
779 ("cp2_eur_per_mw2", f64s(cp2)),
780 ("in_service", i64s(in_service)),
781 ("is_slack_gen", i64s(is_slack)),
782 ],
783 ))
784}
785
786#[allow(clippy::too_many_lines, clippy::many_single_char_names)]
787fn branch_batch(snaps: &[SnapshotView], opts: &GridfmOptions) -> Result<RecordBatch> {
788 let total: usize = snaps.iter().map(|s| s.view.branches().len()).sum();
789
790 let flags = YbusFlags {
794 unity_taps: !opts.include_taps,
795 zero_shifts: !opts.include_shifts,
796 ..Default::default()
797 };
798
799 let mut scenario = Vec::with_capacity(total);
800 let mut idx = Vec::with_capacity(total);
801 let (mut from_bus, mut to_bus) = (Vec::with_capacity(total), Vec::with_capacity(total));
802 let (mut pf, mut qf, mut pt, mut qt) = (
803 Vec::with_capacity(total),
804 Vec::with_capacity(total),
805 Vec::with_capacity(total),
806 Vec::with_capacity(total),
807 );
808 let (mut yff_r, mut yff_i) = (Vec::with_capacity(total), Vec::with_capacity(total));
809 let (mut yft_r, mut yft_i) = (Vec::with_capacity(total), Vec::with_capacity(total));
810 let (mut ytf_r, mut ytf_i) = (Vec::with_capacity(total), Vec::with_capacity(total));
811 let (mut ytt_r, mut ytt_i) = (Vec::with_capacity(total), Vec::with_capacity(total));
812 let (mut r_col, mut x_col, mut b_col) = (
813 Vec::with_capacity(total),
814 Vec::with_capacity(total),
815 Vec::with_capacity(total),
816 );
817 let (mut tap, mut shift) = (Vec::with_capacity(total), Vec::with_capacity(total));
818 let (mut ang_min, mut ang_max) = (Vec::with_capacity(total), Vec::with_capacity(total));
819 let mut rate_a = Vec::with_capacity(total);
820 let mut br_status = Vec::with_capacity(total);
821
822 for s in snaps {
823 let view = &s.view;
824 let base = view.base_mva();
825 let branches = view.branches();
826 let buses = &view.network().buses;
827 let v: Vec<Complex64> = buses
829 .iter()
830 .map(|b| Complex64::from_polar(b.vm, b.va.to_radians()))
831 .collect();
832
833 scenario.resize(scenario.len() + branches.len(), s.scenario);
834 idx.extend(0..branches.len() as i64);
835
836 for (row, br) in branches.iter().enumerate() {
837 let i = view.bus_index(br.from).ok_or(powerio::Error::UnknownBus {
838 bus_id: br.from,
839 element_index: row,
840 })?;
841 let j = view.bus_index(br.to).ok_or(powerio::Error::UnknownBus {
842 bus_id: br.to,
843 element_index: row,
844 })?;
845 from_bus.push(i as i64);
846 to_bus.push(j as i64);
847
848 let shift_rad = if flags.zero_shifts {
850 0.0
851 } else {
852 view.angle_radians(br.shift)
853 };
854 let block = branch_admittance(br, flags, shift_rad, row)?;
855 let [y_ff, y_ft, y_tf, y_tt] = block.unwrap_or([Complex64::new(0.0, 0.0); 4]);
856 yff_r.push(y_ff.re);
857 yff_i.push(y_ff.im);
858 yft_r.push(y_ft.re);
859 yft_i.push(y_ft.im);
860 ytf_r.push(y_tf.re);
861 ytf_i.push(y_tf.im);
862 ytt_r.push(y_tt.re);
863 ytt_i.push(y_tt.im);
864
865 let (sf, st) = if br.in_service && block.is_some() {
866 branch_flows(&[y_ff, y_ft, y_tf, y_tt], v[i], v[j])
867 } else {
868 (Complex64::new(0.0, 0.0), Complex64::new(0.0, 0.0))
869 };
870 pf.push(sf.re * base);
871 qf.push(sf.im * base);
872 pt.push(st.re * base);
873 qt.push(st.im * base);
874
875 r_col.push(br.r);
876 x_col.push(br.x);
877 b_col.push(br.total_charging_b());
878 tap.push(br.effective_tap());
879 shift.push(br.shift);
880 ang_min.push(br.angmin);
881 ang_max.push(br.angmax);
882 rate_a.push(br.rate_a);
883 br_status.push(i64::from(br.in_service));
884 }
885 }
886
887 batch(with_scenario_pair(
888 scenario,
889 vec![
890 ("idx", i64s(idx)),
891 ("from_bus", i64s(from_bus)),
892 ("to_bus", i64s(to_bus)),
893 ("pf", f64s(pf)),
894 ("qf", f64s(qf)),
895 ("pt", f64s(pt)),
896 ("qt", f64s(qt)),
897 ("r", f64s(r_col)),
898 ("x", f64s(x_col)),
899 ("b", f64s(b_col)),
900 ("Yff_r", f64s(yff_r)),
901 ("Yff_i", f64s(yff_i)),
902 ("Yft_r", f64s(yft_r)),
903 ("Yft_i", f64s(yft_i)),
904 ("Ytf_r", f64s(ytf_r)),
905 ("Ytf_i", f64s(ytf_i)),
906 ("Ytt_r", f64s(ytt_r)),
907 ("Ytt_i", f64s(ytt_i)),
908 ("tap", f64s(tap)),
909 ("shift", f64s(shift)),
910 ("ang_min", f64s(ang_min)),
911 ("ang_max", f64s(ang_max)),
912 ("rate_a", f64s(rate_a)),
913 ("br_status", i64s(br_status)),
914 ],
915 ))
916}
917
918fn y_bus_batch(snaps: &[SnapshotView], opts: &GridfmOptions) -> Result<RecordBatch> {
919 let est: usize = snaps
923 .iter()
924 .map(|s| 4 * s.view.branches().len() + s.view.n())
925 .sum();
926 let mut scenario = Vec::with_capacity(est);
927 let mut index1 = Vec::with_capacity(est);
928 let mut index2 = Vec::with_capacity(est);
929 let mut g_vals = Vec::with_capacity(est);
930 let mut b_vals = Vec::with_capacity(est);
931
932 for s in snaps {
933 let parts = build_ybus(&s.view, &opts.build_options())?;
934 let mut entries: std::collections::BTreeMap<(usize, usize), (f64, f64)> =
940 std::collections::BTreeMap::new();
941 for (row, g_row) in parts.g.outer_iterator().enumerate() {
942 for (col, &gv) in g_row.iter() {
943 entries.entry((row, col)).or_default().0 = gv;
944 }
945 }
946 for (row, b_row) in parts.b.outer_iterator().enumerate() {
947 for (col, &bv) in b_row.iter() {
948 entries.entry((row, col)).or_default().1 = bv;
949 }
950 }
951
952 for ((row, col), (gv, bv)) in entries {
953 if gv == 0.0 && bv == 0.0 {
954 continue;
955 }
956 scenario.push(s.scenario);
957 index1.push(row as i64);
958 index2.push(col as i64);
959 g_vals.push(gv);
960 b_vals.push(bv);
961 }
962 }
963
964 batch(with_scenario_pair(
965 scenario,
966 vec![
967 ("index1", i64s(index1)),
968 ("index2", i64s(index2)),
969 ("G", f64s(g_vals)),
970 ("B", f64s(b_vals)),
971 ],
972 ))
973}
974
975fn gridfm_cost(cost: Option<&GenCost>) -> (f64, f64, f64) {
981 match cost {
982 Some(c) if c.model == 2 && c.coeffs.len() >= c.ncost => match c.ncost {
983 3 => (c.coeffs[2], c.coeffs[1], c.coeffs[0]),
984 2 => (c.coeffs[1], c.coeffs[0], 0.0),
985 1 => (c.coeffs[0], 0.0, 0.0),
986 _ => (0.0, 0.0, 0.0),
987 },
988 _ => (0.0, 0.0, 0.0),
989 }
990}
991
992fn cost_representable(cost: Option<&GenCost>) -> bool {
995 matches!(cost, Some(c) if c.model == 2 && c.coeffs.len() >= c.ncost && (1..=3).contains(&c.ncost))
996}
997
998fn put_parquet(
999 dir: &Path,
1000 name: &str,
1001 batch: &RecordBatch,
1002 files: &mut Vec<PathBuf>,
1003) -> Result<()> {
1004 let path = dir.join(name);
1005 let file = std::fs::File::create(&path)?;
1006 let props = WriterProperties::builder()
1007 .set_compression(Compression::SNAPPY)
1008 .build();
1009 let mut writer = ArrowWriter::try_new(file, batch.schema(), Some(props))
1010 .map_err(|e| Error::Parquet(e.to_string()))?;
1011 writer
1012 .write(batch)
1013 .map_err(|e| Error::Parquet(e.to_string()))?;
1014 writer.close().map_err(|e| Error::Parquet(e.to_string()))?;
1015 files.push(path);
1016 Ok(())
1017}
1018
1019fn batch(columns: Vec<(&str, ArrayRef)>) -> Result<RecordBatch> {
1022 let fields: Vec<Field> = columns
1023 .iter()
1024 .map(|(name, arr)| Field::new(*name, arr.data_type().clone(), false))
1025 .collect();
1026 let arrays: Vec<ArrayRef> = columns.into_iter().map(|(_, arr)| arr).collect();
1027 RecordBatch::try_new(Arc::new(Schema::new(fields)), arrays)
1028 .map_err(|e| Error::Parquet(e.to_string()))
1029}
1030
1031fn i64s(v: Vec<i64>) -> ArrayRef {
1032 Arc::new(Int64Array::from(v))
1033}
1034
1035fn f64s(v: Vec<f64>) -> ArrayRef {
1036 Arc::new(Float64Array::from(v))
1037}
1038
1039fn with_scenario_pair(
1043 scenario: Vec<i64>,
1044 rest: Vec<(&'static str, ArrayRef)>,
1045) -> Vec<(&'static str, ArrayRef)> {
1046 let scenario = i64s(scenario);
1047 let mut cols = Vec::with_capacity(rest.len() + 2);
1048 cols.push(("scenario", scenario.clone()));
1049 cols.push(("load_scenario_idx", scenario));
1050 cols.extend(rest);
1051 cols
1052}
1053
1054#[derive(Debug, Clone)]
1066#[non_exhaustive]
1067pub struct GridfmRead {
1068 pub network: BalancedNetwork,
1070 pub scenario: i64,
1072 pub warnings: Vec<String>,
1075}
1076
1077pub fn read_gridfm_network(
1086 tables: &GridfmTables,
1087 scenario: i64,
1088 base_mva: f64,
1089 name: &str,
1090) -> Result<GridfmRead> {
1091 let bus = bus_columns(std::slice::from_ref(&tables.bus))?;
1092 let gens = gen_columns(std::slice::from_ref(&tables.generator))?;
1093 let branch = branch_columns(std::slice::from_ref(&tables.branch))?;
1094 build_network_from_columns(&bus, &gens, &branch, scenario, base_mva, name, Vec::new())
1095}
1096
1097pub fn read_gridfm_dataset(dir: impl AsRef<Path>, scenario: i64) -> Result<GridfmRead> {
1108 let raw = resolve_raw_dir(dir.as_ref())?;
1109 let (base_mva, name, warnings) = read_meta(&raw);
1110 let bus = bus_columns(&read_parquet(&raw.join("bus_data.parquet"))?)?;
1111 let gens = gen_columns(&read_parquet(&raw.join("gen_data.parquet"))?)?;
1112 let branch = branch_columns(&read_parquet(&raw.join("branch_data.parquet"))?)?;
1113 build_network_from_columns(&bus, &gens, &branch, scenario, base_mva, &name, warnings)
1114}
1115
1116pub fn read_gridfm_scenarios(dir: impl AsRef<Path>) -> Result<Vec<GridfmRead>> {
1124 let raw = resolve_raw_dir(dir.as_ref())?;
1125 let (base_mva, name, warnings) = read_meta(&raw);
1126 let bus = bus_columns(&read_parquet(&raw.join("bus_data.parquet"))?)?;
1130 let gens = gen_columns(&read_parquet(&raw.join("gen_data.parquet"))?)?;
1131 let branch = branch_columns(&read_parquet(&raw.join("branch_data.parquet"))?)?;
1132
1133 distinct_sorted(&bus.scenario)
1134 .into_iter()
1135 .map(|s| {
1136 build_network_from_columns(&bus, &gens, &branch, s, base_mva, &name, warnings.clone())
1137 })
1138 .collect()
1139}
1140
1141pub fn gridfm_scenario_ids(dir: impl AsRef<Path>) -> Result<Vec<i64>> {
1149 let raw = resolve_raw_dir(dir.as_ref())?;
1150 let bus = bus_columns(&read_parquet(&raw.join("bus_data.parquet"))?)?;
1151 Ok(distinct_sorted(&bus.scenario))
1152}
1153
1154fn distinct_sorted(scenario: &[i64]) -> Vec<i64> {
1156 let mut ids = scenario.to_vec();
1157 ids.sort_unstable();
1158 ids.dedup();
1159 ids
1160}
1161
1162pub fn gridfm_base_case(dir: impl AsRef<Path>) -> Result<GridfmRead> {
1170 read_gridfm_dataset(dir, 0)
1171}
1172
1173struct BusColumns {
1177 scenario: Vec<i64>,
1178 bus: Vec<i64>,
1179 pv: Vec<i64>,
1180 refc: Vec<i64>,
1181 vm: Vec<f64>,
1182 va: Vec<f64>,
1183 vn_kv: Vec<f64>,
1184 min_vm: Vec<f64>,
1185 max_vm: Vec<f64>,
1186 pd: Vec<f64>,
1187 qd: Vec<f64>,
1188 gs: Vec<f64>,
1189 bs: Vec<f64>,
1190}
1191
1192fn bus_columns(batches: &[RecordBatch]) -> Result<BusColumns> {
1193 Ok(BusColumns {
1194 scenario: i64_col(batches, "scenario")?,
1195 bus: i64_col(batches, "bus")?,
1196 pv: i64_col(batches, "PV")?,
1197 refc: i64_col(batches, "REF")?,
1198 vm: f64_col(batches, "Vm")?,
1199 va: f64_col(batches, "Va")?,
1200 vn_kv: f64_col(batches, "vn_kv")?,
1201 min_vm: f64_col(batches, "min_vm_pu")?,
1202 max_vm: f64_col(batches, "max_vm_pu")?,
1203 pd: f64_col(batches, "Pd")?,
1204 qd: f64_col(batches, "Qd")?,
1205 gs: f64_col(batches, "GS")?,
1206 bs: f64_col(batches, "BS")?,
1207 })
1208}
1209
1210struct GenColumns {
1212 scenario: Vec<i64>,
1213 bus: Vec<i64>,
1214 p_mw: Vec<f64>,
1215 q_mvar: Vec<f64>,
1216 min_p: Vec<f64>,
1217 max_p: Vec<f64>,
1218 min_q: Vec<f64>,
1219 max_q: Vec<f64>,
1220 cp0: Vec<f64>,
1221 cp1: Vec<f64>,
1222 cp2: Vec<f64>,
1223 in_service: Vec<i64>,
1224}
1225
1226fn gen_columns(batches: &[RecordBatch]) -> Result<GenColumns> {
1227 Ok(GenColumns {
1228 scenario: i64_col(batches, "scenario")?,
1229 bus: i64_col(batches, "bus")?,
1230 p_mw: f64_col(batches, "p_mw")?,
1231 q_mvar: f64_col(batches, "q_mvar")?,
1232 min_p: f64_col(batches, "min_p_mw")?,
1233 max_p: f64_col(batches, "max_p_mw")?,
1234 min_q: f64_col(batches, "min_q_mvar")?,
1235 max_q: f64_col(batches, "max_q_mvar")?,
1236 cp0: f64_col(batches, "cp0_eur")?,
1237 cp1: f64_col(batches, "cp1_eur_per_mw")?,
1238 cp2: f64_col(batches, "cp2_eur_per_mw2")?,
1239 in_service: i64_col(batches, "in_service")?,
1240 })
1241}
1242
1243struct BranchColumns {
1245 scenario: Vec<i64>,
1246 from_bus: Vec<i64>,
1247 to_bus: Vec<i64>,
1248 r: Vec<f64>,
1249 x: Vec<f64>,
1250 b: Vec<f64>,
1251 tap: Vec<f64>,
1252 shift: Vec<f64>,
1253 ang_min: Vec<f64>,
1254 ang_max: Vec<f64>,
1255 rate_a: Vec<f64>,
1256 status: Vec<i64>,
1257}
1258
1259fn branch_columns(batches: &[RecordBatch]) -> Result<BranchColumns> {
1260 Ok(BranchColumns {
1261 scenario: i64_col(batches, "scenario")?,
1262 from_bus: i64_col(batches, "from_bus")?,
1263 to_bus: i64_col(batches, "to_bus")?,
1264 r: f64_col(batches, "r")?,
1265 x: f64_col(batches, "x")?,
1266 b: f64_col(batches, "b")?,
1267 tap: f64_col(batches, "tap")?,
1268 shift: f64_col(batches, "shift")?,
1269 ang_min: f64_col(batches, "ang_min")?,
1270 ang_max: f64_col(batches, "ang_max")?,
1271 rate_a: f64_col(batches, "rate_a")?,
1272 status: i64_col(batches, "br_status")?,
1273 })
1274}
1275
1276#[allow(clippy::float_cmp, clippy::too_many_lines)]
1284fn build_network_from_columns(
1285 bus: &BusColumns,
1286 gens: &GenColumns,
1287 branch: &BranchColumns,
1288 scenario: i64,
1289 base_mva: f64,
1290 name: &str,
1291 mut warnings: Vec<String>,
1292) -> Result<GridfmRead> {
1293 let bus_rows = scenario_rows(&bus.scenario, scenario);
1295 if bus_rows.is_empty() {
1296 let mut avail = bus.scenario.clone();
1297 avail.sort_unstable();
1298 avail.dedup();
1299 return Err(powerio::Error::FormatRead {
1300 format: "gridfm",
1301 message: format!("scenario {scenario} not present; available: {avail:?}"),
1302 }
1303 .into());
1304 }
1305
1306 let bus_id = &bus.bus;
1307 let pv = &bus.pv;
1308 let refc = &bus.refc;
1309 let vm = &bus.vm;
1310 let va = &bus.va;
1311 let vn_kv = &bus.vn_kv;
1312 let min_vm = &bus.min_vm;
1313 let max_vm = &bus.max_vm;
1314 let pd = &bus.pd;
1315 let qd = &bus.qd;
1316 let gs = &bus.gs;
1317 let bs = &bus.bs;
1318
1319 let mut buses = Vec::with_capacity(bus_rows.len());
1320 let mut loads = Vec::new();
1321 let mut shunts = Vec::new();
1322 let mut bus_vm: std::collections::HashMap<i64, f64> =
1326 std::collections::HashMap::with_capacity(bus_rows.len());
1327 for &r in &bus_rows {
1328 let id = dense_bus_id(bus_id[r])?;
1329 bus_vm.insert(bus_id[r], vm[r]);
1330 let kind = if refc[r] != 0 {
1333 BusType::Ref
1334 } else if pv[r] != 0 {
1335 BusType::Pv
1336 } else {
1337 BusType::Pq
1338 };
1339 let mut bus = Bus::new(id, kind, vn_kv[r]);
1340 bus.vm = vm[r];
1341 bus.va = va[r];
1342 bus.vmax = max_vm[r];
1343 bus.vmin = min_vm[r];
1344 bus.area = 0;
1345 bus.zone = 0;
1346 buses.push(bus);
1347 if pd[r] != 0.0 || qd[r] != 0.0 {
1348 loads.push(Load::new(id, pd[r], qd[r]));
1349 }
1350 if gs[r] != 0.0 || bs[r] != 0.0 {
1352 shunts.push(Shunt::new(id, gs[r] * base_mva, bs[r] * base_mva));
1353 }
1354 }
1355
1356 let gen_rows = scenario_rows(&gens.scenario, scenario);
1358 require_scenario_block(&gens.scenario, scenario, &gen_rows, "gen_data")?;
1359 let g_bus = &gens.bus;
1360 let p_mw = &gens.p_mw;
1361 let q_mvar = &gens.q_mvar;
1362 let min_p = &gens.min_p;
1363 let max_p = &gens.max_p;
1364 let min_q = &gens.min_q;
1365 let max_q = &gens.max_q;
1366 let cp0 = &gens.cp0;
1367 let cp1 = &gens.cp1;
1368 let cp2 = &gens.cp2;
1369 let g_in = &gens.in_service;
1370
1371 let mut generators = Vec::with_capacity(gen_rows.len());
1372 for &r in &gen_rows {
1373 let cost = if cp0[r] != 0.0 || cp1[r] != 0.0 || cp2[r] != 0.0 {
1378 Some(GenCost::new(2, 0.0, 0.0, vec![cp2[r], cp1[r], cp0[r]]))
1379 } else {
1380 None
1381 };
1382 let mut generator = Generator::new(dense_bus_id(g_bus[r])?);
1383 generator.pg = p_mw[r];
1384 generator.qg = q_mvar[r];
1385 generator.pmax = max_p[r];
1386 generator.pmin = min_p[r];
1387 generator.qmax = max_q[r];
1388 generator.qmin = min_q[r];
1389 generator.vg = bus_vm.get(&g_bus[r]).copied().unwrap_or(1.0);
1393 generator.mbase = base_mva;
1394 generator.in_service = g_in[r] != 0;
1395 generator.cost = cost;
1396 generators.push(generator);
1397 }
1398
1399 let br_rows = scenario_rows(&branch.scenario, scenario);
1401 require_scenario_block(&branch.scenario, scenario, &br_rows, "branch_data")?;
1402 let from_bus = &branch.from_bus;
1403 let to_bus = &branch.to_bus;
1404 let r_col = &branch.r;
1405 let x_col = &branch.x;
1406 let b_col = &branch.b;
1407 let tap = &branch.tap;
1408 let shift = &branch.shift;
1409 let ang_min = &branch.ang_min;
1410 let ang_max = &branch.ang_max;
1411 let rate_a = &branch.rate_a;
1412 let br_status = &branch.status;
1413
1414 let mut branches = Vec::with_capacity(br_rows.len());
1415 let mut unit_tap_lines = 0usize;
1423 for &row in &br_rows {
1424 let shift_v = shift[row];
1425 let tap_out = if tap[row] == 1.0 && shift_v == 0.0 {
1426 unit_tap_lines += 1;
1427 0.0
1428 } else {
1429 tap[row]
1430 };
1431 let mut branch = Branch::new(
1432 dense_bus_id(from_bus[row])?,
1433 dense_bus_id(to_bus[row])?,
1434 r_col[row],
1435 x_col[row],
1436 );
1437 branch.b = b_col[row];
1438 branch.rate_a = rate_a[row];
1439 branch.tap = tap_out;
1440 branch.shift = shift_v;
1441 branch.in_service = br_status[row] != 0;
1442 branch.angmin = ang_min[row];
1443 branch.angmax = ang_max[row];
1444 branches.push(branch);
1445 }
1446
1447 let mut net = BalancedNetwork::new(name, base_mva);
1448 net.buses = buses;
1449 net.loads = loads;
1450 net.shunts = shunts;
1451 net.branches = branches;
1452 net.generators = generators;
1453 net.source_format = SourceFormat::Gridfm;
1454 net.validate()?;
1455
1456 warnings.push(format!(
1458 "synthesized bus ids 1..={}; original bus ids are not stored in a gridfm dataset, \
1459 so a written case is renumbered",
1460 net.buses.len()
1461 ));
1462 if !net.loads.is_empty() {
1463 warnings.push(format!(
1464 "folded nodal load into {} synthetic per-bus Load(s); per-load granularity is \
1465 not recoverable",
1466 net.loads.len()
1467 ));
1468 }
1469 if !net.shunts.is_empty() {
1470 warnings.push(format!(
1471 "folded nodal shunts into {} synthetic per-bus Shunt(s); per-shunt granularity \
1472 is not recoverable",
1473 net.shunts.len()
1474 ));
1475 }
1476 if unit_tap_lines > 0 {
1477 warnings.push(format!(
1478 "{unit_tap_lines} branch(es) had unit effective tap and no phase shift and were read \
1479 as lines (raw tap 0); a unity-ratio, zero-shift transformer in the source is \
1480 indistinguishable from a line and is read as one (the power flow is identical)"
1481 ));
1482 }
1483 let no_cost_gens = net.generators.iter().filter(|g| g.cost.is_none()).count();
1484 if no_cost_gens > 0 {
1485 warnings.push(format!(
1486 "{no_cost_gens} generator(s) read with no cost: an all-zero cost triple in the dataset \
1487 is the writer's encoding for a generator with no cost, a genuine zero polynomial \
1488 cost, or a piecewise/cubic+ cost it couldn't represent — indistinguishable on read"
1489 ));
1490 }
1491 warnings.push(
1492 "HVDC, storage, areas/zones, bus names, rate_b/rate_c, generator mbase/ramp limits, \
1493 and startup/shutdown costs are absent from the gridfm schema"
1494 .to_string(),
1495 );
1496
1497 Ok(GridfmRead {
1498 network: net,
1499 scenario,
1500 warnings,
1501 })
1502}
1503
1504fn resolve_raw_dir(dir: &Path) -> Result<PathBuf> {
1510 let has_bus = |d: &Path| d.join("bus_data.parquet").is_file();
1511 if has_bus(dir) {
1512 return Ok(dir.to_path_buf());
1513 }
1514 let nested = dir.join("raw");
1515 if has_bus(&nested) {
1516 return Ok(nested);
1517 }
1518 let mut matches: Vec<PathBuf> = Vec::new();
1522 let entries = std::fs::read_dir(dir).map_err(|e| powerio::Error::FormatRead {
1523 format: "gridfm",
1524 message: format!("reading directory {}: {e}", dir.display()),
1525 })?;
1526 for entry in entries {
1527 let entry = entry.map_err(|e| powerio::Error::FormatRead {
1528 format: "gridfm",
1529 message: format!("reading an entry of {}: {e}", dir.display()),
1530 })?;
1531 let raw = entry.path().join("raw");
1532 if has_bus(&raw) {
1533 matches.push(raw);
1534 }
1535 }
1536 match matches.len() {
1537 1 => Ok(matches.pop().expect("len checked")),
1538 0 => Err(powerio::Error::FormatRead {
1539 format: "gridfm",
1540 message: format!(
1541 "no gridfm dataset under {}; expected bus_data.parquet in the directory, a \
1542 raw/ child, or a single <case>/raw/ child",
1543 dir.display()
1544 ),
1545 }
1546 .into()),
1547 n => Err(powerio::Error::FormatRead {
1548 format: "gridfm",
1549 message: format!(
1550 "{n} gridfm datasets under {}; point at the specific <case>/raw directory",
1551 dir.display()
1552 ),
1553 }
1554 .into()),
1555 }
1556}
1557
1558fn read_meta(raw: &Path) -> (f64, String, Vec<String>) {
1562 let case_from_path = || {
1563 raw.parent()
1564 .and_then(Path::file_name)
1565 .and_then(|s| s.to_str())
1566 .map_or_else(|| "gridfm".to_string(), str::to_string)
1567 };
1568 let text = match std::fs::read_to_string(raw.join("gridfm_meta.json")) {
1569 Ok(text) => text,
1570 Err(e) => {
1573 return (
1574 100.0,
1575 case_from_path(),
1576 vec![format!(
1577 "gridfm_meta.json could not be read ({e}); base_mva defaulted to 100"
1578 )],
1579 );
1580 }
1581 };
1582 let Ok(meta) = serde_json::from_str::<serde_json::Value>(&text) else {
1583 return (
1584 100.0,
1585 case_from_path(),
1586 vec!["gridfm_meta.json is not valid JSON; base_mva defaulted to 100".to_string()],
1587 );
1588 };
1589 let name = meta
1590 .get("case_name")
1591 .and_then(serde_json::Value::as_str)
1592 .map_or_else(case_from_path, str::to_string);
1593 let mut warnings = Vec::new();
1594 let base = match meta.get("base_mva").and_then(serde_json::Value::as_f64) {
1597 Some(b) if b.is_finite() && b > 0.0 => b,
1598 _ => {
1599 warnings.push(
1600 "gridfm_meta.json has no usable base_mva (absent or not a positive number); \
1601 defaulted to 100"
1602 .to_string(),
1603 );
1604 100.0
1605 }
1606 };
1607 (base, name, warnings)
1608}
1609
1610fn read_parquet(path: &Path) -> Result<Vec<RecordBatch>> {
1613 let file = std::fs::File::open(path).map_err(|e| powerio::Error::FormatRead {
1614 format: "gridfm",
1615 message: format!("opening {}: {e}", path.display()),
1616 })?;
1617 let reader = ParquetRecordBatchReaderBuilder::try_new(file)
1618 .and_then(ParquetRecordBatchReaderBuilder::build)
1619 .map_err(|e| powerio::Error::FormatRead {
1620 format: "gridfm",
1621 message: format!("reading {}: {e}", path.display()),
1622 })?;
1623 reader
1624 .collect::<std::result::Result<Vec<_>, _>>()
1625 .map_err(|e| {
1626 Error::Core(powerio::Error::FormatRead {
1627 format: "gridfm",
1628 message: format!("decoding {}: {e}", path.display()),
1629 })
1630 })
1631}
1632
1633fn scenario_rows(scen: &[i64], scenario: i64) -> Vec<usize> {
1635 scen.iter()
1636 .enumerate()
1637 .filter_map(|(i, &s)| (s == scenario).then_some(i))
1638 .collect()
1639}
1640
1641fn require_scenario_block(
1646 scen_col: &[i64],
1647 scenario: i64,
1648 rows: &[usize],
1649 table: &str,
1650) -> Result<()> {
1651 if rows.is_empty() && !scen_col.is_empty() {
1652 return Err(powerio::Error::FormatRead {
1653 format: "gridfm",
1654 message: format!(
1655 "scenario {scenario} has no {table} rows, but the table holds {} row(s) for other \
1656 scenarios — a partial or corrupt dataset",
1657 scen_col.len()
1658 ),
1659 }
1660 .into());
1661 }
1662 Ok(())
1663}
1664
1665fn dense_bus_id(v: i64) -> Result<BusId> {
1667 let idx = usize::try_from(v).map_err(|_| powerio::Error::FormatRead {
1668 format: "gridfm",
1669 message: format!("negative dense bus index {v}"),
1670 })?;
1671 Ok(BusId(idx + 1))
1672}
1673
1674fn column<'a>(b: &'a RecordBatch, name: &str) -> Result<&'a ArrayRef> {
1676 b.column_by_name(name).ok_or_else(|| {
1677 Error::Core(powerio::Error::FormatRead {
1678 format: "gridfm",
1679 message: format!("missing column `{name}`"),
1680 })
1681 })
1682}
1683
1684fn i64_col(batches: &[RecordBatch], name: &str) -> Result<Vec<i64>> {
1686 let mut out = Vec::with_capacity(batches.iter().map(RecordBatch::num_rows).sum());
1687 for b in batches {
1688 let arr = column(b, name)?;
1689 let col = arr.as_any().downcast_ref::<Int64Array>().ok_or_else(|| {
1690 powerio::Error::FormatRead {
1691 format: "gridfm",
1692 message: format!("column `{name}` is not Int64"),
1693 }
1694 })?;
1695 if col.null_count() > 0 {
1696 return Err(powerio::Error::FormatRead {
1697 format: "gridfm",
1698 message: format!("column `{name}` has nulls"),
1699 }
1700 .into());
1701 }
1702 out.extend_from_slice(col.values());
1703 }
1704 Ok(out)
1705}
1706
1707fn f64_col(batches: &[RecordBatch], name: &str) -> Result<Vec<f64>> {
1709 let mut out = Vec::with_capacity(batches.iter().map(RecordBatch::num_rows).sum());
1710 for b in batches {
1711 let arr = column(b, name)?;
1712 let col = arr.as_any().downcast_ref::<Float64Array>().ok_or_else(|| {
1713 powerio::Error::FormatRead {
1714 format: "gridfm",
1715 message: format!("column `{name}` is not Float64"),
1716 }
1717 })?;
1718 if col.null_count() > 0 {
1719 return Err(powerio::Error::FormatRead {
1720 format: "gridfm",
1721 message: format!("column `{name}` has nulls"),
1722 }
1723 .into());
1724 }
1725 out.extend_from_slice(col.values());
1726 }
1727 Ok(out)
1728}
1729
1730#[cfg(test)]
1731mod tests {
1732 use super::*;
1733 use crate::network::{Branch, BranchCharging, Bus, BusId, BusType, Generator};
1734 use arrow::array::{Float64Array, Int64Array};
1735 use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
1736
1737 const BUS_COLS: &[&str] = &[
1738 "scenario",
1739 "load_scenario_idx",
1740 "bus",
1741 "Pd",
1742 "Qd",
1743 "Pg",
1744 "Qg",
1745 "Vm",
1746 "Va",
1747 "PQ",
1748 "PV",
1749 "REF",
1750 "vn_kv",
1751 "min_vm_pu",
1752 "max_vm_pu",
1753 "GS",
1754 "BS",
1755 ];
1756 const GEN_COLS: &[&str] = &[
1757 "scenario",
1758 "load_scenario_idx",
1759 "idx",
1760 "bus",
1761 "p_mw",
1762 "q_mvar",
1763 "min_p_mw",
1764 "max_p_mw",
1765 "min_q_mvar",
1766 "max_q_mvar",
1767 "cp0_eur",
1768 "cp1_eur_per_mw",
1769 "cp2_eur_per_mw2",
1770 "in_service",
1771 "is_slack_gen",
1772 ];
1773 const BRANCH_COLS: &[&str] = &[
1774 "scenario",
1775 "load_scenario_idx",
1776 "idx",
1777 "from_bus",
1778 "to_bus",
1779 "pf",
1780 "qf",
1781 "pt",
1782 "qt",
1783 "r",
1784 "x",
1785 "b",
1786 "Yff_r",
1787 "Yff_i",
1788 "Yft_r",
1789 "Yft_i",
1790 "Ytf_r",
1791 "Ytf_i",
1792 "Ytt_r",
1793 "Ytt_i",
1794 "tap",
1795 "shift",
1796 "ang_min",
1797 "ang_max",
1798 "rate_a",
1799 "br_status",
1800 ];
1801 const YBUS_COLS: &[&str] = &[
1802 "scenario",
1803 "load_scenario_idx",
1804 "index1",
1805 "index2",
1806 "G",
1807 "B",
1808 ];
1809
1810 fn case14() -> BalancedNetwork {
1811 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case14.m");
1812 crate::parse_matpower_file(path).unwrap()
1813 }
1814
1815 fn names(b: &RecordBatch) -> Vec<String> {
1816 b.schema()
1817 .fields()
1818 .iter()
1819 .map(|f| f.name().clone())
1820 .collect()
1821 }
1822
1823 fn col_i64<'a>(b: &'a RecordBatch, name: &str) -> &'a Int64Array {
1824 b.column_by_name(name)
1825 .unwrap()
1826 .as_any()
1827 .downcast_ref()
1828 .unwrap()
1829 }
1830
1831 fn col_f64<'a>(b: &'a RecordBatch, name: &str) -> &'a Float64Array {
1832 b.column_by_name(name)
1833 .unwrap()
1834 .as_any()
1835 .downcast_ref()
1836 .unwrap()
1837 }
1838
1839 fn read(path: &Path) -> RecordBatch {
1840 let file = std::fs::File::open(path).unwrap();
1841 let mut reader = ParquetRecordBatchReaderBuilder::try_new(file)
1842 .unwrap()
1843 .build()
1844 .unwrap();
1845 reader.next().unwrap().unwrap()
1847 }
1848
1849 #[test]
1850 fn schema_and_row_counts_match_case14() {
1851 let net = case14();
1852 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
1853
1854 assert_eq!(names(&tables.bus), BUS_COLS);
1855 assert_eq!(names(&tables.generator), GEN_COLS);
1856 assert_eq!(names(&tables.branch), BRANCH_COLS);
1857 assert_eq!(names(tables.y_bus.as_ref().unwrap()), YBUS_COLS);
1858
1859 assert_eq!(tables.bus.num_rows(), net.buses.len()); assert_eq!(tables.generator.num_rows(), net.generators.len()); assert_eq!(tables.branch.num_rows(), net.branches.len()); }
1863
1864 #[test]
1865 fn branch_b_uses_terminal_charging_projection() {
1866 let mut net = BalancedNetwork::in_memory(
1867 "terminal-projection",
1868 100.0,
1869 vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
1870 Vec::new(),
1871 );
1872 let mut br = branch(1, 2, 0.01, 0.1);
1873 br.charging = Some(BranchCharging::new(0.01, 0.02, 0.03, 0.05));
1874 net.branches.push(br);
1875
1876 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
1877 let b = col_f64(&tables.branch, "b").value(0);
1878 assert!((b - 0.07).abs() < 1e-12);
1879
1880 let yff_i = col_f64(&tables.branch, "Yff_i").value(0);
1881 let ytt_i = col_f64(&tables.branch, "Ytt_i").value(0);
1882 let y_series_i = -0.1 / (0.01 * 0.01 + 0.1 * 0.1);
1883 let recovered_b = yff_i + ytt_i - 2.0 * y_series_i;
1884 assert!((recovered_b - b).abs() < 1e-12);
1885 }
1886
1887 #[test]
1888 fn parquet_round_trips_through_reader() {
1889 let net = case14();
1890 let dir = tempfile::tempdir().unwrap();
1891 let out = write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
1892
1893 let raw = dir.path().join("case14").join("raw");
1894 assert_eq!(out.dir, raw);
1895 for f in ["bus_data", "gen_data", "branch_data", "y_bus_data"] {
1896 assert!(raw.join(format!("{f}.parquet")).is_file(), "missing {f}");
1897 }
1898 assert!(raw.join("gridfm_meta.json").is_file());
1899
1900 let bus = read(&raw.join("bus_data.parquet"));
1901 assert_eq!(names(&bus), BUS_COLS);
1902 assert_eq!(bus.num_rows(), net.buses.len());
1903 assert_eq!(names(&read(&raw.join("gen_data.parquet"))), GEN_COLS);
1904 assert_eq!(names(&read(&raw.join("branch_data.parquet"))), BRANCH_COLS);
1905 assert_eq!(names(&read(&raw.join("y_bus_data.parquet"))), YBUS_COLS);
1906 }
1907
1908 #[test]
1909 fn bus_table_values_are_consistent() {
1910 let net = case14();
1911 let view = IndexedNetwork::new(&net);
1912 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
1913 let bus = &tables.bus;
1914
1915 let (pq, pv, r) = (col_i64(bus, "PQ"), col_i64(bus, "PV"), col_i64(bus, "REF"));
1917 assert_eq!(r.values().iter().sum::<i64>(), 1);
1918 for i in 0..bus.num_rows() {
1919 assert_eq!(pq.value(i) + pv.value(i) + r.value(i), 1);
1920 }
1921
1922 let base = net.base_mva;
1924 let gs = col_f64(bus, "GS");
1925 for i in 0..bus.num_rows() {
1926 assert!((gs.value(i) - view.gs()[i] / base).abs() < 1e-12);
1927 }
1928
1929 let bus_idx = col_i64(bus, "bus");
1931 for i in 0..bus.num_rows() {
1932 assert_eq!(bus_idx.value(i), i as i64);
1933 }
1934 }
1935
1936 #[test]
1937 fn branch_admittance_columns_match_build_ybus() {
1938 let net = case14();
1941 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
1942 let br = &tables.branch;
1943
1944 let yff_r = col_f64(br, "Yff_r");
1945 let yff_i = col_f64(br, "Yff_i");
1946 for (row, branch) in net.branches.iter().enumerate() {
1947 let shift_rad = branch.shift.to_radians();
1949 if let Some(block) =
1950 branch_admittance(branch, YbusFlags::default(), shift_rad, row).unwrap()
1951 {
1952 assert!((yff_r.value(row) - block[0].re).abs() < 1e-12);
1953 assert!((yff_i.value(row) - block[0].im).abs() < 1e-12);
1954 }
1955 }
1956 }
1957
1958 #[test]
1959 fn is_slack_gen_marks_the_reference_bus() {
1960 let net = case14();
1961 let view = IndexedNetwork::new(&net);
1962 let ref_bus = view.reference_bus_index().unwrap();
1963 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
1964 let g = &tables.generator;
1965
1966 let bus = col_i64(g, "bus");
1967 let slack = col_i64(g, "is_slack_gen");
1968 for i in 0..g.num_rows() {
1969 assert_eq!(slack.value(i) == 1, bus.value(i) as usize == ref_bus);
1970 }
1971 assert!(slack.values().contains(&1), "no slack generator");
1972 }
1973
1974 #[test]
1975 fn branch_flows_close_the_power_balance_on_a_solved_case() {
1976 let net = case14();
1981 let view = IndexedNetwork::new(&net);
1982 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
1983 let br = &tables.branch;
1984 let (pf, pt, status) = (
1985 col_f64(br, "pf"),
1986 col_f64(br, "pt"),
1987 col_i64(br, "br_status"),
1988 );
1989
1990 let mut loss = 0.0;
1991 for i in 0..br.num_rows() {
1992 if status.value(i) == 1 {
1993 let l = pf.value(i) + pt.value(i);
1994 assert!(l >= -1e-6, "branch {i} has negative real loss {l}");
1995 loss += l;
1996 }
1997 }
1998 assert!(loss > 1.0, "case14 has ~13 MW of real loss, got {loss}");
1999
2000 let gen_p: f64 = net
2001 .generators
2002 .iter()
2003 .filter(|g| g.in_service)
2004 .map(|g| g.pg)
2005 .sum();
2006 let load_p: f64 = net.loads.iter().map(|l| l.p).sum();
2007 let shunt_p: f64 = (0..view.n())
2009 .map(|i| view.gs()[i] * net.buses[i].vm.powi(2))
2010 .sum();
2011 assert!(
2012 (loss - (gen_p - load_p - shunt_p)).abs() < 0.5,
2013 "power balance off: loss {loss} vs gen-load-shunt {}",
2014 gen_p - load_p - shunt_p
2015 );
2016 }
2017
2018 #[test]
2019 fn zero_impedance_branch_zeros_columns_and_is_counted() {
2020 let net = BalancedNetwork::in_memory(
2024 "zeroimp",
2025 100.0,
2026 vec![
2027 bus(1, BusType::Ref),
2028 bus(2, BusType::Pq),
2029 bus(3, BusType::Pq),
2030 ],
2031 vec![branch(1, 2, 0.0, 0.0), branch(2, 3, 0.01, 0.1)],
2032 );
2033 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2034 let br = &tables.branch;
2035 for col in [
2036 "Yff_r", "Yff_i", "Yft_r", "Yft_i", "Ytf_r", "Ytf_i", "Ytt_r", "Ytt_i", "pf", "qf",
2037 "pt", "qt",
2038 ] {
2039 let v = col_f64(br, col).value(0);
2040 assert!(
2041 v == 0.0,
2042 "{col} should be 0 for the zero-impedance branch, got {v}"
2043 );
2044 }
2045
2046 let dir = tempfile::tempdir().unwrap();
2047 let out = write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2048 assert_eq!(out.dropped_zero_impedance, 1);
2049 let meta: serde_json::Value = serde_json::from_str(
2050 &std::fs::read_to_string(out.dir.join("gridfm_meta.json")).unwrap(),
2051 )
2052 .unwrap();
2053 assert_eq!(meta["dropped_zero_impedance"], 1);
2054 }
2055
2056 #[test]
2057 fn gridfm_cost_maps_every_arm_to_raw_coefficients() {
2058 assert_eq!(
2060 gridfm_cost(Some(&gencost(2, 3, vec![2.0, 3.0, 4.0]))),
2061 (4.0, 3.0, 2.0)
2062 );
2063 assert_eq!(
2064 gridfm_cost(Some(&gencost(2, 2, vec![3.0, 4.0]))),
2065 (4.0, 3.0, 0.0)
2066 );
2067 assert_eq!(
2068 gridfm_cost(Some(&gencost(2, 1, vec![4.0]))),
2069 (4.0, 0.0, 0.0)
2070 );
2071 let piecewise = gencost(1, 2, vec![0.0, 0.0, 1.0, 1.0]);
2073 let malformed = gencost(2, 3, vec![1.0]); assert_eq!(gridfm_cost(Some(&piecewise)), (0.0, 0.0, 0.0));
2075 assert_eq!(gridfm_cost(Some(&malformed)), (0.0, 0.0, 0.0));
2076 assert_eq!(gridfm_cost(None), (0.0, 0.0, 0.0));
2077 assert!(!cost_representable(Some(&piecewise)));
2078 assert!(!cost_representable(Some(&malformed)));
2079 assert!(!cost_representable(None));
2080 assert!(cost_representable(Some(&gencost(
2081 2,
2082 3,
2083 vec![1.0, 2.0, 3.0]
2084 ))));
2085 }
2086
2087 #[test]
2088 fn missing_reference_bus_errors() {
2089 let net = BalancedNetwork::in_memory(
2091 "noref",
2092 100.0,
2093 vec![bus(1, BusType::Pq), bus(2, BusType::Pq)],
2094 vec![branch(1, 2, 0.01, 0.1)],
2095 );
2096 let err = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap_err();
2097 assert!(
2098 matches!(err, Error::Core(powerio::Error::ReferenceBusCount { .. })),
2099 "got {err:?}"
2100 );
2101 }
2102
2103 #[test]
2104 fn non_finite_bus_voltage_errors_before_parquet() {
2105 let mut net = case14();
2106 net.buses[0].vm = f64::NAN;
2107 let err = gridfm_record_batches(&net, 7, &GridfmOptions::default()).unwrap_err();
2108 match err {
2109 Error::NonFiniteGridfmValue {
2110 scenario,
2111 element,
2112 row,
2113 field,
2114 value,
2115 } => {
2116 assert_eq!(scenario, 7);
2117 assert_eq!(element, "bus");
2118 assert_eq!(row, 0);
2119 assert_eq!(field, "vm");
2120 assert!(value.is_nan());
2121 }
2122 other => panic!("expected NonFiniteGridfmValue, got {other:?}"),
2123 }
2124 }
2125
2126 #[test]
2127 fn non_finite_tap_errors_even_without_y_bus_table() {
2128 let mut net = case14();
2129 net.branches[0].tap = f64::NAN;
2130 let opts = GridfmOptions {
2131 include_y_bus: false,
2132 ..Default::default()
2133 };
2134 let err = gridfm_record_batches(&net, 0, &opts).unwrap_err();
2135 assert!(
2136 matches!(
2137 err,
2138 Error::NonFiniteGridfmValue {
2139 element: "branch",
2140 row: 0,
2141 field: "tap",
2142 ..
2143 }
2144 ),
2145 "got {err:?}"
2146 );
2147 }
2148
2149 #[test]
2150 fn normalized_snapshot_is_rejected_in_release_builds() {
2151 let net = case14().to_normalized().unwrap();
2152 let err = gridfm_record_batches(&net, 3, &GridfmOptions::default()).unwrap_err();
2153 assert!(
2154 matches!(err, Error::NormalizedGridfmSnapshot { scenario: 3 }),
2155 "got {err:?}"
2156 );
2157 }
2158
2159 #[test]
2160 fn non_finite_representable_cost_errors() {
2161 let mut net = BalancedNetwork::in_memory(
2162 "badcost",
2163 100.0,
2164 vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
2165 vec![branch(1, 2, 0.01, 0.1)],
2166 );
2167 net.generators
2168 .push(gen_at(1, gencost(2, 3, vec![f64::NAN, 1.0, 0.0])));
2169
2170 let err = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap_err();
2172 assert!(
2173 matches!(
2174 err,
2175 Error::NonFiniteGridfmValue {
2176 element: "gencost",
2177 row: 0,
2178 field: "cp2",
2179 ..
2180 }
2181 ),
2182 "got {err:?}"
2183 );
2184 }
2185
2186 #[test]
2187 fn unbounded_limits_export_as_infinity() {
2188 let mut net = case14();
2192 net.generators[0].qmax = f64::INFINITY;
2193 net.generators[0].qmin = f64::NEG_INFINITY;
2194 net.generators[1].pmax = f64::INFINITY;
2195 net.branches[0].angmin = f64::NEG_INFINITY;
2196 net.branches[0].angmax = f64::INFINITY;
2197 net.branches[1].rate_a = f64::INFINITY;
2198 net.buses[0].vmax = f64::INFINITY;
2199 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2200 let qmax = col_f64(&tables.generator, "max_q_mvar");
2201 assert!(qmax.value(0).is_infinite() && qmax.value(0) > 0.0);
2202 }
2203
2204 #[test]
2205 fn nan_limit_still_errors() {
2206 let mut net = case14();
2207 net.generators[0].qmax = f64::NAN;
2208 let err = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap_err();
2209 assert!(
2210 matches!(
2211 err,
2212 Error::NonFiniteGridfmValue {
2213 element: "generator",
2214 field: "qmax",
2215 ..
2216 }
2217 ),
2218 "got {err:?}"
2219 );
2220 }
2221
2222 fn scaled(net: &BalancedNetwork, factor: f64) -> BalancedNetwork {
2225 let mut s = net.clone();
2226 for l in &mut s.loads {
2227 l.p *= factor;
2228 l.q *= factor;
2229 }
2230 for g in &mut s.generators {
2231 g.pg *= factor;
2232 g.qg *= factor;
2233 }
2234 s
2235 }
2236
2237 #[test]
2238 fn batch_stacks_scenarios_keyed_by_scenario_column() {
2239 let base = case14();
2240 let up = scaled(&base, 1.1);
2241 let down = scaled(&base, 0.9);
2242 let snaps = [
2243 GridfmSnapshot {
2244 net: &base,
2245 scenario: 0,
2246 },
2247 GridfmSnapshot {
2248 net: &up,
2249 scenario: 1,
2250 },
2251 GridfmSnapshot {
2252 net: &down,
2253 scenario: 2,
2254 },
2255 ];
2256 let tables = gridfm_record_batches_batch(&snaps, &GridfmOptions::default()).unwrap();
2257
2258 assert_eq!(names(&tables.bus), BUS_COLS);
2260 assert_eq!(names(&tables.branch), BRANCH_COLS);
2261 assert_eq!(tables.bus.num_rows(), 3 * base.buses.len());
2262 assert_eq!(tables.generator.num_rows(), 3 * base.generators.len());
2263 assert_eq!(tables.branch.num_rows(), 3 * base.branches.len());
2264
2265 let n = base.buses.len();
2268 let scen = col_i64(&tables.bus, "scenario");
2269 let lsi = col_i64(&tables.bus, "load_scenario_idx");
2270 let bus_idx = col_i64(&tables.bus, "bus");
2271 for k in 0..3 {
2272 for i in 0..n {
2273 let row = k * n + i;
2274 assert_eq!(scen.value(row), k as i64);
2275 assert_eq!(lsi.value(row), k as i64);
2276 assert_eq!(bus_idx.value(row), i as i64);
2277 }
2278 }
2279
2280 let single = gridfm_record_batches(&base, 0, &GridfmOptions::default()).unwrap();
2285 let bit_exact = |b: &RecordBatch, s: &RecordBatch, col: &str, rows: usize| {
2286 let (bb, ss) = (col_f64(b, col), col_f64(s, col));
2287 for i in 0..rows {
2288 assert_eq!(
2289 bb.value(i).to_bits(),
2290 ss.value(i).to_bits(),
2291 "scenario-0 {col}[{i}] differs from the single-case path"
2292 );
2293 }
2294 };
2295 for col in ["Pd", "Qd", "Pg", "Qg", "Vm", "Va", "GS", "BS"] {
2296 bit_exact(&tables.bus, &single.bus, col, n);
2297 }
2298 bit_exact(
2299 &tables.generator,
2300 &single.generator,
2301 "p_mw",
2302 base.generators.len(),
2303 );
2304 bit_exact(&tables.branch, &single.branch, "pf", base.branches.len());
2305
2306 let pd_batch = col_f64(&tables.bus, "Pd");
2309 let pd_single = col_f64(&single.bus, "Pd");
2310 assert!((pd_batch.value(n) - 1.1 * pd_single.value(0)).abs() < 1e-9);
2311 }
2312
2313 #[test]
2314 fn hostile_network_name_stays_inside_out_dir() {
2315 let mut net = case14();
2318 net.name = "../escape".to_string();
2319 let dir = tempfile::tempdir().unwrap();
2320 let out = write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2321 assert!(
2322 out.dir.starts_with(dir.path()),
2323 "dataset dir {:?} escaped {:?}",
2324 out.dir,
2325 dir.path()
2326 );
2327 assert!(out.dir.exists());
2328 }
2329
2330 #[test]
2331 fn batch_dataset_writes_stacked_parquet_with_scenario_count() {
2332 let base = case14();
2333 let up = scaled(&base, 1.25);
2334 let snaps = [
2335 GridfmSnapshot {
2336 net: &base,
2337 scenario: 0,
2338 },
2339 GridfmSnapshot {
2340 net: &up,
2341 scenario: 1,
2342 },
2343 ];
2344 let dir = tempfile::tempdir().unwrap();
2345 let out = write_gridfm_batch(&snaps, dir.path(), &GridfmOptions::default()).unwrap();
2346
2347 let bus = read(&out.dir.join("bus_data.parquet"));
2348 assert_eq!(bus.num_rows(), 2 * base.buses.len());
2349 let scen = col_i64(&bus, "scenario");
2350 assert_eq!(scen.value(0), 0);
2351 assert_eq!(scen.value(base.buses.len()), 1);
2352
2353 let meta: serde_json::Value = serde_json::from_str(
2354 &std::fs::read_to_string(out.dir.join("gridfm_meta.json")).unwrap(),
2355 )
2356 .unwrap();
2357 assert_eq!(meta["n_scenarios"], 2);
2358 assert_eq!(meta["scenario"], 0);
2359 }
2360
2361 #[test]
2362 fn empty_batch_errors() {
2363 let err = gridfm_record_batches_batch(&[], &GridfmOptions::default()).unwrap_err();
2364 assert!(matches!(err, Error::EmptyScenarioBatch), "got {err:?}");
2365 }
2366
2367 #[test]
2368 fn shape_mismatch_across_snapshots_errors() {
2369 let big = case14();
2370 let small = BalancedNetwork::in_memory(
2371 "small",
2372 100.0,
2373 vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
2374 vec![branch(1, 2, 0.01, 0.1)],
2375 );
2376 let snaps = [
2377 GridfmSnapshot {
2378 net: &big,
2379 scenario: 0,
2380 },
2381 GridfmSnapshot {
2382 net: &small,
2383 scenario: 1,
2384 },
2385 ];
2386 let err = gridfm_record_batches_batch(&snaps, &GridfmOptions::default()).unwrap_err();
2387 assert!(
2388 matches!(
2389 err,
2390 Error::ScenarioShapeMismatch {
2391 index: 1,
2392 reason: ScenarioMismatch::Counts { .. }
2393 }
2394 ),
2395 "got {err:?}"
2396 );
2397 }
2398
2399 #[test]
2400 fn bus_order_mismatch_is_reported_distinctly() {
2401 let base = case14();
2405 let mut reordered = base.clone();
2406 reordered.buses.swap(0, 1);
2407 let snaps = [
2408 GridfmSnapshot {
2409 net: &base,
2410 scenario: 0,
2411 },
2412 GridfmSnapshot {
2413 net: &reordered,
2414 scenario: 1,
2415 },
2416 ];
2417 let err = gridfm_record_batches_batch(&snaps, &GridfmOptions::default()).unwrap_err();
2418 assert!(
2419 matches!(
2420 err,
2421 Error::ScenarioShapeMismatch {
2422 index: 1,
2423 reason: ScenarioMismatch::BusOrder
2424 }
2425 ),
2426 "got {err:?}"
2427 );
2428 }
2429
2430 #[test]
2431 fn manifest_counts_sum_over_the_batch() {
2432 let base = case14();
2437 let mut perturbed = base.clone();
2438 perturbed.branches[0].r = 0.0;
2439 perturbed.branches[0].x = 0.0;
2440 let snaps = [
2441 GridfmSnapshot {
2442 net: &base,
2443 scenario: 0,
2444 },
2445 GridfmSnapshot {
2446 net: &perturbed,
2447 scenario: 1,
2448 },
2449 ];
2450 let dir = tempfile::tempdir().unwrap();
2451 let out = write_gridfm_batch(&snaps, dir.path(), &GridfmOptions::default()).unwrap();
2452 assert_eq!(out.dropped_zero_impedance, 1);
2453 let meta: serde_json::Value = serde_json::from_str(
2454 &std::fs::read_to_string(out.dir.join("gridfm_meta.json")).unwrap(),
2455 )
2456 .unwrap();
2457 assert_eq!(meta["dropped_zero_impedance"], 1);
2458 }
2459
2460 #[test]
2461 fn y_bus_table_is_absent_when_disabled() {
2462 let net = case14();
2463 let opts = GridfmOptions {
2464 include_y_bus: false,
2465 ..Default::default()
2466 };
2467 let tables = gridfm_record_batches(&net, 0, &opts).unwrap();
2468 assert!(tables.y_bus.is_none(), "y_bus should not be built");
2469
2470 let dir = tempfile::tempdir().unwrap();
2471 let out = write_gridfm_dataset(&net, 0, dir.path(), &opts).unwrap();
2472 assert!(
2473 !out.dir.join("y_bus_data.parquet").exists(),
2474 "y_bus_data.parquet should not be written"
2475 );
2476 }
2477
2478 #[test]
2479 fn numbered_snapshots_stamps_base_plus_k_and_checks_overflow() {
2480 let net = case14();
2483 let snaps = numbered_snapshots(&[&net, &net, &net], 5).unwrap();
2484 assert_eq!(snaps.len(), 3);
2485 assert_eq!(snaps[0].scenario, 5);
2486 assert_eq!(snaps[1].scenario, 6);
2487 assert_eq!(snaps[2].scenario, 7);
2488
2489 let err = numbered_snapshots(&[&net, &net], i64::MAX).unwrap_err();
2492 assert!(
2493 matches!(err, Error::ScenarioIdOverflow { index: 1, .. }),
2494 "got {err:?}"
2495 );
2496 }
2497
2498 #[test]
2499 fn out_of_service_generator_is_listed_but_excluded_from_bus_aggregate() {
2500 let mut net = BalancedNetwork::in_memory(
2505 "genoutage",
2506 100.0,
2507 vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
2508 vec![branch(1, 2, 0.01, 0.1)],
2509 );
2510 let mut g_on = gen_at(1, gencost(2, 3, vec![0.0, 1.0, 0.0]));
2511 g_on.pg = 50.0;
2512 let mut g_off = gen_at(2, gencost(2, 3, vec![0.0, 1.0, 0.0]));
2513 g_off.pg = 30.0;
2514 g_off.in_service = false;
2515 net.generators.push(g_on);
2516 net.generators.push(g_off);
2517
2518 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2519
2520 let g = &tables.generator;
2523 assert_eq!(g.num_rows(), 2);
2524 let in_service = col_i64(g, "in_service");
2525 assert_eq!(in_service.value(0), 1, "in-service gen flagged 1");
2526 assert_eq!(in_service.value(1), 0, "out-of-service gen flagged 0");
2527 assert!(
2528 (col_f64(g, "p_mw").value(1) - 30.0).abs() < 1e-12,
2529 "gen_data keeps the out-of-service setpoint"
2530 );
2531
2532 let pg = col_f64(&tables.bus, "Pg");
2535 assert!(
2536 (pg.value(0) - 50.0).abs() < 1e-12,
2537 "in-service gen folded into bus Pg"
2538 );
2539 assert!(
2540 pg.value(1) == 0.0,
2541 "out-of-service gen excluded from bus Pg, got {}",
2542 pg.value(1)
2543 );
2544 }
2545
2546 #[test]
2547 fn out_of_service_branch_zeros_flows_but_keeps_admittance() {
2548 let mut net = BalancedNetwork::in_memory(
2554 "outage",
2555 100.0,
2556 vec![
2557 bus(1, BusType::Ref),
2558 bus(2, BusType::Pq),
2559 bus(3, BusType::Pq),
2560 ],
2561 vec![branch(1, 2, 0.01, 0.1), branch(2, 3, 0.02, 0.2)],
2562 );
2563 net.buses[1].va = -3.0;
2564 net.buses[2].va = -6.0;
2565 net.branches[0].in_service = false; let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2568 let br = &tables.branch;
2569 let status = col_i64(br, "br_status");
2570 assert_eq!(status.value(0), 0, "tripped branch reports br_status 0");
2571 assert_eq!(status.value(1), 1, "in-service branch reports br_status 1");
2572
2573 for col in ["pf", "qf", "pt", "qt"] {
2574 let v = col_f64(br, col).value(0);
2575 assert!(
2576 v == 0.0,
2577 "{col} must be zero on the out-of-service branch, got {v}"
2578 );
2579 }
2580 assert!(
2583 col_f64(br, "pf").value(1).abs() > 1e-6,
2584 "in-service branch should carry nonzero flow"
2585 );
2586 assert!(
2589 col_f64(br, "Yff_i").value(0).abs() > 0.0,
2590 "out-of-service branch keeps its physical Y** admittances"
2591 );
2592 }
2593
2594 fn bus(id: usize, kind: BusType) -> Bus {
2595 Bus::new(BusId(id), kind, 1.0)
2596 }
2597
2598 fn branch(from: usize, to: usize, r: f64, x: f64) -> Branch {
2599 Branch::new(BusId(from), BusId(to), r, x)
2600 }
2601
2602 fn gencost(model: u8, ncost: usize, coeffs: Vec<f64>) -> GenCost {
2603 GenCost::with_ncost(model, 0.0, 0.0, ncost, coeffs)
2604 }
2605
2606 fn gen_at(bus: usize, cost: GenCost) -> Generator {
2607 let mut generator = Generator::new(BusId(bus));
2608 generator.pmax = 100.0;
2609 generator.qmax = 50.0;
2610 generator.qmin = -50.0;
2611 generator.mbase = 100.0;
2612 generator.cost = Some(cost);
2613 generator
2614 }
2615
2616 #[test]
2617 fn degenerate_cost_gen_zeros_columns_and_is_counted() {
2618 let mut net = BalancedNetwork::in_memory(
2621 "degen",
2622 100.0,
2623 vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
2624 vec![branch(1, 2, 0.01, 0.1)],
2625 );
2626 net.generators
2627 .push(gen_at(1, gencost(1, 2, vec![0.0, 0.0, 1.0, 1.0]))); net.generators
2629 .push(gen_at(2, gencost(2, 3, vec![0.01, 5.0, 0.0]))); let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2632 let g = &tables.generator;
2633 let (cp0, cp1, cp2) = (
2634 col_f64(g, "cp0_eur"),
2635 col_f64(g, "cp1_eur_per_mw"),
2636 col_f64(g, "cp2_eur_per_mw2"),
2637 );
2638 assert_eq!((cp0.value(0), cp1.value(0), cp2.value(0)), (0.0, 0.0, 0.0));
2639 assert_eq!((cp0.value(1), cp1.value(1), cp2.value(1)), (0.0, 5.0, 0.01));
2640
2641 let dir = tempfile::tempdir().unwrap();
2642 let out = write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2643 assert_eq!(out.degenerate_cost_gens, 1);
2644 assert_eq!(out.missing_cost_gens, 0);
2645 assert_eq!(out.unsupported_cost_gens, 1);
2646 let meta: serde_json::Value = serde_json::from_str(
2647 &std::fs::read_to_string(out.dir.join("gridfm_meta.json")).unwrap(),
2648 )
2649 .unwrap();
2650 assert_eq!(meta["degenerate_cost_gens"], 1);
2651 assert_eq!(meta["missing_cost_gens"], 0);
2652 assert_eq!(meta["unsupported_cost_gens"], 1);
2653 assert_eq!(meta["zeroed_cost_gens"], 1);
2654 }
2655
2656 #[test]
2657 fn missing_gridfm_costs_are_split_and_fill_policy_reduces_missing_count() {
2658 let mut net = BalancedNetwork::in_memory(
2659 "missing-cost",
2660 100.0,
2661 vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
2662 vec![branch(1, 2, 0.01, 0.1)],
2663 );
2664 let mut missing = gen_at(1, gencost(2, 3, vec![1.0, 2.0, 3.0]));
2665 missing.cost = None;
2666 net.generators.push(missing);
2667 net.generators
2668 .push(gen_at(2, gencost(1, 2, vec![0.0, 0.0, 1.0, 1.0])));
2669
2670 let dir = tempfile::tempdir().unwrap();
2671 let out = write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2672 assert_eq!(out.degenerate_cost_gens, 2);
2673 assert_eq!(out.missing_cost_gens, 1);
2674 assert_eq!(out.unsupported_cost_gens, 1);
2675
2676 let opts = GridfmOptions {
2677 missing_gen_cost: MissingGenCostPolicy::zero(),
2678 ..Default::default()
2679 };
2680 let dir = tempfile::tempdir().unwrap();
2681 let out = write_gridfm_dataset(&net, 0, dir.path(), &opts).unwrap();
2682 assert_eq!(out.synthesized_gen_costs, 1);
2683 assert_eq!(out.missing_cost_gens, 0);
2684 assert_eq!(out.unsupported_cost_gens, 1);
2685 assert_eq!(out.degenerate_cost_gens, 1);
2686 let meta: serde_json::Value = serde_json::from_str(
2687 &std::fs::read_to_string(out.dir.join("gridfm_meta.json")).unwrap(),
2688 )
2689 .unwrap();
2690 assert_eq!(meta["cost_policy"]["mode"], "fill");
2691 assert_eq!(meta["synthesized_gen_costs"], 1);
2692 }
2693
2694 #[test]
2695 fn scenario_id_and_tap_toggle_take_effect() {
2696 let net = case14();
2697
2698 let bus = gridfm_record_batches(&net, 7, &GridfmOptions::default())
2700 .unwrap()
2701 .bus;
2702 assert_eq!(col_i64(&bus, "scenario").value(0), 7);
2703 assert_eq!(col_i64(&bus, "load_scenario_idx").value(0), 7);
2704
2705 let on = gridfm_record_batches(&net, 0, &GridfmOptions::default())
2707 .unwrap()
2708 .branch;
2709 let off = gridfm_record_batches(
2710 &net,
2711 0,
2712 &GridfmOptions {
2713 include_taps: false,
2714 ..Default::default()
2715 },
2716 )
2717 .unwrap()
2718 .branch;
2719 let tap = col_f64(&on, "tap");
2720 let xfmr = (0..on.num_rows())
2721 .find(|&i| (tap.value(i) - 1.0).abs() > 1e-9)
2722 .expect("case14 has off-nominal transformers");
2723 assert!(
2726 (col_f64(&on, "Yff_i").value(xfmr) - col_f64(&off, "Yff_i").value(xfmr)).abs() > 1e-9,
2727 "taps off should change the transformer's Yff"
2728 );
2729 }
2730
2731 #[allow(clippy::type_complexity)]
2738 fn fingerprint(
2739 net: &BalancedNetwork,
2740 ) -> (
2741 usize,
2742 usize,
2743 usize,
2744 usize,
2745 f64,
2746 f64,
2747 f64,
2748 f64,
2749 f64,
2750 f64,
2751 f64,
2752 ) {
2753 (
2754 net.buses.len(),
2755 net.branches.len(),
2756 net.generators.len(),
2757 net.buses.iter().filter(|b| b.kind == BusType::Ref).count(),
2758 net.loads.iter().map(|l| l.p).sum(),
2759 net.loads.iter().map(|l| l.q).sum(),
2760 net.generators.iter().map(|g| g.pg).sum(),
2761 net.branches.iter().map(|b| b.r).sum(),
2762 net.branches.iter().map(|b| b.x).sum(),
2763 net.branches.iter().map(|b| b.b).sum(),
2764 net.base_mva,
2765 )
2766 }
2767
2768 fn assert_fingerprint_close(got: &BalancedNetwork, want: &BalancedNetwork) {
2769 let (g, w) = (fingerprint(got), fingerprint(want));
2770 assert_eq!(
2771 (g.0, g.1, g.2, g.3),
2772 (w.0, w.1, w.2, w.3),
2773 "bus/branch/gen/ref counts differ"
2774 );
2775 for (a, b, label) in [
2776 (g.4, w.4, "load P"),
2777 (g.5, w.5, "load Q"),
2778 (g.6, w.6, "gen P"),
2779 (g.7, w.7, "sum r"),
2780 (g.8, w.8, "sum x"),
2781 (g.9, w.9, "sum b"),
2782 (g.10, w.10, "base_mva"),
2783 ] {
2784 assert!((a - b).abs() < 1e-9, "{label} differs: {a} vs {b}");
2785 }
2786 }
2787
2788 #[test]
2789 fn read_round_trips_power_flow_fingerprint() {
2790 let net = case14();
2791 let dir = tempfile::tempdir().unwrap();
2792 write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2793
2794 let read = read_gridfm_dataset(dir.path().join("case14").join("raw"), 0).unwrap();
2795 assert_eq!(read.scenario, 0);
2796 assert_eq!(read.network.source_format, SourceFormat::Gridfm);
2797 assert_eq!(read.network.name, "case14");
2798 assert!(read.network.source.is_none());
2799 assert_fingerprint_close(&read.network, &net);
2800 read.network.validate().unwrap();
2802 }
2803
2804 #[test]
2805 fn read_gridfm_network_pure_path_matches_disk() {
2806 let net = case14();
2809 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2810 let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
2811 assert_fingerprint_close(&read.network, &net);
2812 }
2813
2814 #[test]
2815 fn read_recovers_shunt_at_base_mva() {
2816 let net = case14();
2819 let want_b: f64 = net.shunts.iter().map(|s| s.b).sum();
2820 assert!(want_b.abs() > 1.0, "fixture should have a real shunt");
2821
2822 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2823 let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
2824 let got_b: f64 = read.network.shunts.iter().map(|s| s.b).sum();
2825 assert!(
2826 (got_b - want_b).abs() < 1e-9,
2827 "shunt b not recovered at base_mva: {got_b} vs {want_b}"
2828 );
2829 }
2830
2831 #[test]
2832 fn read_scenarios_yields_distinct_networks() {
2833 let base = case14();
2836 let up = scaled(&base, 1.1);
2837 let snaps = [
2838 GridfmSnapshot {
2839 net: &base,
2840 scenario: 0,
2841 },
2842 GridfmSnapshot {
2843 net: &up,
2844 scenario: 1,
2845 },
2846 ];
2847 let dir = tempfile::tempdir().unwrap();
2848 let out = write_gridfm_batch(&snaps, dir.path(), &GridfmOptions::default()).unwrap();
2849
2850 let reads = read_gridfm_scenarios(&out.dir).unwrap();
2851 assert_eq!(reads.len(), 2);
2852 assert_eq!((reads[0].scenario, reads[1].scenario), (0, 1));
2853
2854 let load0: f64 = reads[0].network.loads.iter().map(|l| l.p).sum();
2855 let load1: f64 = reads[1].network.loads.iter().map(|l| l.p).sum();
2856 assert!(load0 > 0.0);
2857 assert!(
2858 (load1 - 1.1 * load0).abs() < 1e-6,
2859 "scenario 1 load should be 1.1× scenario 0: {load1} vs {load0}"
2860 );
2861
2862 let base_case = gridfm_base_case(&out.dir).unwrap();
2863 assert_fingerprint_close(&base_case.network, &reads[0].network);
2864 }
2865
2866 #[test]
2867 fn read_resolves_lenient_directory_layouts() {
2868 let net = case14();
2871 let dir = tempfile::tempdir().unwrap();
2872 write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2873 let out = dir.path(); let case_dir = out.join("case14");
2875 let raw_dir = case_dir.join("raw");
2876 for d in [raw_dir.clone(), case_dir, out.to_path_buf()] {
2877 let read = read_gridfm_dataset(&d, 0)
2878 .unwrap_or_else(|e| panic!("failed to resolve {}: {e}", d.display()));
2879 assert_eq!(read.network.buses.len(), net.buses.len());
2880 }
2881 }
2882
2883 #[test]
2884 fn read_missing_scenario_errors() {
2885 let net = case14();
2886 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2887 let err = read_gridfm_network(&tables, 99, net.base_mva, &net.name).unwrap_err();
2888 assert!(
2889 matches!(
2890 err,
2891 Error::Core(powerio::Error::FormatRead {
2892 format: "gridfm",
2893 ..
2894 })
2895 ),
2896 "got {err:?}"
2897 );
2898 }
2899
2900 #[test]
2901 fn read_no_dataset_errors() {
2902 let dir = tempfile::tempdir().unwrap();
2904 let err = read_gridfm_dataset(dir.path(), 0).unwrap_err();
2905 assert!(
2906 matches!(
2907 err,
2908 Error::Core(powerio::Error::FormatRead {
2909 format: "gridfm",
2910 ..
2911 })
2912 ),
2913 "got {err:?}"
2914 );
2915 let missing = dir.path().join("does-not-exist");
2917 let err = read_gridfm_dataset(&missing, 0).unwrap_err();
2918 assert!(
2919 matches!(
2920 err,
2921 Error::Core(powerio::Error::FormatRead {
2922 format: "gridfm",
2923 ..
2924 })
2925 ),
2926 "got {err:?}"
2927 );
2928 }
2929
2930 #[test]
2931 fn read_defaults_unusable_base_mva_to_100() {
2932 let net = case14();
2936 let dir = tempfile::tempdir().unwrap();
2937 let out = write_gridfm_dataset(&net, 0, dir.path(), &GridfmOptions::default()).unwrap();
2938 let meta_path = out.dir.join("gridfm_meta.json");
2939 let mut meta: serde_json::Value =
2940 serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
2941 meta["base_mva"] = serde_json::json!(0.0);
2942 std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
2943
2944 let read = read_gridfm_dataset(&out.dir, 0).unwrap();
2945 assert!(
2946 (read.network.base_mva - 100.0).abs() < 1e-9,
2947 "base_mva should default to 100, got {}",
2948 read.network.base_mva
2949 );
2950 assert!(
2951 read.warnings.iter().any(|w| w.contains("base_mva")),
2952 "expected a base_mva warning, got {:?}",
2953 read.warnings
2954 );
2955 }
2956
2957 #[test]
2958 fn read_surfaces_fidelity_warnings() {
2959 let net = case14();
2960 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2961 let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
2962 assert!(!read.warnings.is_empty());
2963 assert!(
2964 read.warnings
2965 .iter()
2966 .any(|w| w.contains("synthesized bus ids")),
2967 "expected the bus-id synthesis warning, got {:?}",
2968 read.warnings
2969 );
2970 assert!(read.warnings.iter().any(|w| w.contains("nodal load")));
2972 assert!(read.warnings.iter().any(|w| w.contains("nodal shunts")));
2973 }
2974
2975 #[test]
2976 fn read_recovers_gen_vg_from_bus_vm() {
2977 let net = case14();
2981 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
2982 let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
2983 for g in &read.network.generators {
2984 let bus = read
2985 .network
2986 .buses
2987 .iter()
2988 .find(|b| b.id == g.bus)
2989 .expect("gen references a known bus");
2990 assert!(
2991 (g.vg - bus.vm).abs() < 1e-12,
2992 "vg should equal the bus Vm: {} vs {}",
2993 g.vg,
2994 bus.vm
2995 );
2996 }
2997 assert!(
2998 read.network
2999 .generators
3000 .iter()
3001 .any(|g| (g.vg - 1.0).abs() > 1e-3),
3002 "expected a generator with vg != 1.0 (case14's slack is at 1.06)"
3003 );
3004 }
3005
3006 #[test]
3007 fn read_maps_unit_tap_lines_back_to_zero() {
3008 let net = case14();
3014 let n_lines = net.branches.iter().filter(|b| !b.is_transformer()).count();
3015 let n_xfmr = net.branches.iter().filter(|b| b.is_transformer()).count();
3016 assert!(
3017 n_lines > 0 && n_xfmr > 0,
3018 "fixture needs both lines and transformers"
3019 );
3020
3021 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
3022 let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
3023 let read_lines = read
3024 .network
3025 .branches
3026 .iter()
3027 .filter(|b| !b.is_transformer())
3028 .count();
3029 let read_xfmr = read
3030 .network
3031 .branches
3032 .iter()
3033 .filter(|b| b.is_transformer())
3034 .count();
3035 assert_eq!(
3036 read_lines, n_lines,
3037 "lines must read back as lines (raw tap 0)"
3038 );
3039 assert_eq!(
3040 read_xfmr, n_xfmr,
3041 "transformers must keep their off-nominal ratio"
3042 );
3043 assert!(
3044 read.warnings.iter().any(|w| w.contains("read as lines")),
3045 "expected the unit-tap warning, got {:?}",
3046 read.warnings
3047 );
3048 }
3049
3050 #[test]
3051 fn read_allows_a_case_with_no_generators() {
3052 let net = BalancedNetwork::in_memory(
3056 "nogen",
3057 100.0,
3058 vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
3059 vec![branch(1, 2, 0.01, 0.1)],
3060 );
3061 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
3062 let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
3063 assert!(read.network.generators.is_empty());
3064 assert_eq!(read.network.branches.len(), 1);
3065 }
3066
3067 #[test]
3068 fn read_all_zero_cost_reads_as_none_with_ambiguity_warning() {
3069 let mut net = BalancedNetwork::in_memory(
3073 "zerocost",
3074 100.0,
3075 vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
3076 vec![branch(1, 2, 0.01, 0.1)],
3077 );
3078 net.generators
3079 .push(gen_at(1, gencost(2, 3, vec![0.0, 0.0, 0.0])));
3080 let tables = gridfm_record_batches(&net, 0, &GridfmOptions::default()).unwrap();
3081 let read = read_gridfm_network(&tables, 0, net.base_mva, &net.name).unwrap();
3082 assert!(
3083 read.network.generators[0].cost.is_none(),
3084 "all-zero cost should read back as None"
3085 );
3086 assert!(
3087 read.warnings
3088 .iter()
3089 .any(|w| w.contains("read with no cost")),
3090 "expected the no-cost ambiguity warning, got {:?}",
3091 read.warnings
3092 );
3093 }
3094
3095 #[test]
3096 fn require_scenario_block_flags_partial_tables() {
3097 assert!(require_scenario_block(&[], 0, &[], "gen_data").is_ok());
3100 assert!(require_scenario_block(&[0, 0, 1], 0, &[0, 1], "gen_data").is_ok());
3101 let err = require_scenario_block(&[0, 0], 1, &[], "branch_data").unwrap_err();
3102 assert!(
3103 matches!(
3104 err,
3105 Error::Core(powerio::Error::FormatRead {
3106 format: "gridfm",
3107 ..
3108 })
3109 ),
3110 "got {err:?}"
3111 );
3112 }
3113}