1#![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#[derive(Debug, Clone)]
89pub struct GridfmOptions {
90 pub include_y_bus: bool,
94 pub include_taps: bool,
97 pub include_shifts: bool,
99 pub missing_gen_cost: MissingGenCostPolicy,
101 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 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#[derive(Debug, Clone, Copy)]
154pub struct GridfmSnapshot<'a> {
155 net: &'a BalancedNetwork,
157 scenario: i64,
159}
160
161impl<'a> GridfmSnapshot<'a> {
162 #[must_use]
166 pub fn new(net: &'a BalancedNetwork, scenario: i64) -> Self {
167 Self { net, scenario }
168 }
169}
170
171#[derive(Debug, Clone)]
180#[non_exhaustive]
181pub struct GridfmTables {
182 pub bus: RecordBatch,
183 pub generator: RecordBatch,
184 pub branch: RecordBatch,
185 pub y_bus: Option<RecordBatch>,
188}
189
190#[derive(Debug, Clone)]
193pub struct GridfmOutputs {
194 pub dir: PathBuf,
195 pub files: Vec<PathBuf>,
196 pub dropped_zero_impedance: usize,
199 pub degenerate_cost_gens: usize,
202 pub missing_cost_gens: usize,
204 pub unsupported_cost_gens: usize,
206 pub synthesized_gen_costs: usize,
208 pub patched_gen_costs: usize,
210 pub diagnostics: Vec<powerio_core::Diagnostic>,
212}
213
214#[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 scenario: i64,
238 n_scenarios: usize,
240 schema: &'static str,
241 n_buses: usize,
243 n_branches: usize,
244 n_branches_in_service: usize,
247 n_gens: usize,
248 reference_bus: usize,
252 dropped_zero_impedance: usize,
255 degenerate_cost_gens: usize,
259 missing_cost_gens: usize,
261 unsupported_cost_gens: usize,
263 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
272pub 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
288pub 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
322fn 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
338struct SnapshotView<'a> {
340 view: IndexedNetwork<'a>,
341 scenario: i64,
342 ref_bus: usize,
343}
344
345fn 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 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 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
517fn 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
538fn 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
569pub 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
589pub 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
613pub 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
670pub 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
683pub 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 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 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 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)] fn 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)] fn 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#[allow(clippy::float_cmp)]
1136fn is_exact_unit_transformer(tap: f64, shift: f64) -> bool {
1137 tap == 1.0 && shift == 0.0
1138}
1139
1140#[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
1160fn 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 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 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 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 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 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 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 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
1496fn 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
1513fn 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
1519fn 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
1534fn 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
1554fn 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#[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 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()); assert_eq!(tables.generator.num_rows(), net.generators().len()); assert_eq!(tables.branch.num_rows(), net.branches().len()); }
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 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 #[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 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 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 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 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 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 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 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 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 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 let piecewise = gencost(1, 2, vec![0.0, 0.0, 1.0, 1.0]);
1982 let malformed = gencost(2, 3, vec![1.0]); 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; 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 assert!(
2629 col_f64(br, "pf").value(1).abs() > 1e-6,
2630 "in-service branch should carry nonzero flow"
2631 );
2632 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 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]))); net.generators_mut()
2675 .push(gen_at(2, gencost(2, 3, vec![0.01, 5.0, 0.0]))); 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 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 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 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 }