use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::Context;
use clap::{Parser, Subcommand, ValueEnum};
use powerio_matrix::format::routing::{Detection, SourceFormat as DetectedFormat};
use powerio_matrix::io::gridfm::{GridfmOptions, numbered_snapshots, write_gridfm_batch};
use powerio_matrix::matrix::{BuildOptions, DcConvention, Scheme, Units, sddm_check};
use powerio_matrix::opf_pipeline::{DcOpfOptions, write_dcopf_bundle};
use powerio_matrix::pipeline::{MatrixKind, Pipeline, RhsKind};
use powerio_matrix::synth::{SynthSpec, Topology};
use powerio_matrix::{MissingGenCostPolicy, WriteOptions};
use powerio_pkg::{
DiagnosticSeverity, DiagnosticStage, NetworkPackage, Origin, SourceDescriptor,
StructuredDiagnostic, ValidationSummary,
};
use serde_json::json;
mod tui;
const SUMMARY_SCHEMA_VERSION: &str = "0.1";
#[derive(Parser, Debug)]
#[command(name = "powerio", version, about)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand, Debug)]
enum Command {
Tui {
#[arg(short, long)]
data_dir: Option<PathBuf>,
#[arg(short, long)]
out_dir: Option<PathBuf>,
},
Batch {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(short, long, value_delimiter = ',', default_values = ["bprime"])]
matrices: Vec<MatrixKindArg>,
#[arg(long, value_enum, default_value = "bx")]
scheme: SchemeArg,
#[arg(long, value_enum, default_value = "none")]
rhs: RhsArg,
#[arg(long, default_value_t = 0xC0FFEE)]
seed: u64,
},
Gen {
#[arg(long, value_enum)]
topology: TopologyArg,
#[arg(long, default_value_t = 64)]
n: usize,
#[arg(long, default_value_t = 0.1)]
r_over_x: f64,
#[arg(long, default_value_t = 0.05)]
mean_x: f64,
#[arg(long, default_value_t = 0xC0FFEE)]
seed: u64,
#[arg(short, long)]
output: PathBuf,
#[arg(short, long, value_delimiter = ',', default_values = ["bprime"])]
matrices: Vec<MatrixKindArg>,
},
Verify {
input: PathBuf,
#[arg(long, value_enum, default_value = "bprime")]
kind: MatrixKindArg,
#[arg(long, value_enum, default_value = "bx")]
scheme: SchemeArg,
},
#[command(name = "dcopf", visible_alias = "dc-opf")]
DcOpf {
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(long, value_enum, default_value = "paper-pure")]
convention: DcConvArg,
#[arg(long, value_enum, default_value = "per-unit")]
units: UnitsArg,
#[arg(long, value_enum, default_value = "require")]
missing_gen_cost: MissingGenCostArg,
#[arg(long)]
default_gen_cost: Option<String>,
#[arg(long)]
gen_cost_csv: Option<PathBuf>,
},
Sensitivities {
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(long, value_enum, default_value = "paper-pure")]
convention: DcConvArg,
},
Summary {
input: PathBuf,
#[arg(long, value_enum)]
from: Option<FormatArg>,
#[arg(long, default_value_t = 0)]
scenario: i64,
},
#[command(visible_alias = "pkg")]
Package {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, value_enum)]
from: Option<FormatArg>,
#[arg(long, default_value_t = 0)]
scenario: i64,
},
Gridfm {
#[arg(required = true, num_args = 1..)]
inputs: Vec<PathBuf>,
#[arg(short, long)]
output: PathBuf,
#[arg(long, value_enum)]
from: Option<FormatArg>,
#[arg(long, default_value_t = 0)]
scenario: i64,
#[arg(long, value_enum, default_value = "preserve")]
missing_gen_cost: MissingGenCostArg,
#[arg(long)]
default_gen_cost: Option<String>,
#[arg(long)]
gen_cost_csv: Option<PathBuf>,
},
Convert {
input: PathBuf,
#[arg(long, value_enum)]
to: FormatArg,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, value_enum)]
from: Option<FormatArg>,
#[arg(long, default_value_t = 0)]
scenario: i64,
#[arg(long, value_enum, default_value = "preserve")]
missing_gen_cost: MissingGenCostArg,
#[arg(long)]
default_gen_cost: Option<String>,
#[arg(long)]
gen_cost_csv: Option<PathBuf>,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum MissingGenCostArg {
Preserve,
Require,
Zero,
Quadratic,
}
#[derive(Clone, Copy, Debug)]
struct GenCostCliOptions<'a> {
missing_gen_cost: MissingGenCostArg,
default_gen_cost: Option<&'a str>,
gen_cost_csv: Option<&'a Path>,
}
impl<'a> GenCostCliOptions<'a> {
const fn new(
missing_gen_cost: MissingGenCostArg,
default_gen_cost: Option<&'a str>,
gen_cost_csv: Option<&'a Path>,
) -> Self {
Self {
missing_gen_cost,
default_gen_cost,
gen_cost_csv,
}
}
#[cfg(test)]
const fn preserve() -> Self {
Self {
missing_gen_cost: MissingGenCostArg::Preserve,
default_gen_cost: None,
gen_cost_csv: None,
}
}
fn write_options(self) -> anyhow::Result<WriteOptions> {
write_options(
self.missing_gen_cost,
self.default_gen_cost,
self.gen_cost_csv,
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum FormatArg {
#[value(name = "matpower", alias = "m")]
Matpower,
#[value(name = "powermodels-json", alias = "powermodels", alias = "pm")]
PowerModelsJson,
#[value(name = "egret-json", alias = "egret")]
EgretJson,
#[value(name = "psse", alias = "raw")]
Psse,
#[value(name = "psse34")]
Psse34,
#[value(name = "psse35")]
Psse35,
#[value(name = "powerworld", alias = "aux")]
PowerWorld,
#[value(name = "pandapower-json", alias = "pandapower", alias = "pp")]
PandapowerJson,
#[value(name = "powerio-json", alias = "powerio", alias = "json")]
PowerioJson,
#[value(name = "pypsa-csv", alias = "pypsa")]
PypsaCsv,
#[value(name = "pslf", alias = "epc")]
Pslf,
#[value(name = "goc3-json", alias = "goc3", alias = "go3", alias = "c3")]
Goc3Json,
#[value(name = "surge-json", alias = "surge")]
SurgeJson,
#[value(name = "gridfm")]
Gridfm,
#[value(name = "pwb")]
Pwb,
#[value(name = "dss", alias = "opendss")]
Dss,
#[value(name = "pmd-json", alias = "pmd", alias = "engineering")]
PmdJson,
#[value(name = "bmopf-json", alias = "bmopf")]
BmopfJson,
}
impl FormatArg {
fn transmission(self) -> Option<powerio_matrix::TargetFormat> {
use powerio_matrix::TargetFormat;
Some(match self {
FormatArg::Matpower => TargetFormat::Matpower,
FormatArg::PowerModelsJson => TargetFormat::PowerModelsJson,
FormatArg::EgretJson => TargetFormat::EgretJson,
FormatArg::Psse => TargetFormat::Psse { rev: 33 },
FormatArg::Psse34 => TargetFormat::Psse { rev: 34 },
FormatArg::Psse35 => TargetFormat::Psse { rev: 35 },
FormatArg::PowerWorld => TargetFormat::PowerWorld,
FormatArg::PandapowerJson => TargetFormat::PandapowerJson,
FormatArg::PowerioJson => TargetFormat::PowerioJson,
FormatArg::Pslf => TargetFormat::Pslf,
FormatArg::Goc3Json => TargetFormat::Goc3Json,
FormatArg::SurgeJson => TargetFormat::SurgeJson,
FormatArg::PypsaCsv
| FormatArg::Gridfm
| FormatArg::Pwb
| FormatArg::Dss
| FormatArg::PmdJson
| FormatArg::BmopfJson => return None,
})
}
fn distribution(self) -> Option<powerio_dist::DistTargetFormat> {
use powerio_dist::DistTargetFormat;
match self {
FormatArg::Dss => Some(DistTargetFormat::Dss),
FormatArg::PmdJson => Some(DistTargetFormat::PmdJson),
FormatArg::BmopfJson => Some(DistTargetFormat::BmopfJson),
FormatArg::Matpower
| FormatArg::PowerModelsJson
| FormatArg::EgretJson
| FormatArg::Psse
| FormatArg::Psse34
| FormatArg::Psse35
| FormatArg::PowerWorld
| FormatArg::PandapowerJson
| FormatArg::PowerioJson
| FormatArg::PypsaCsv
| FormatArg::Pslf
| FormatArg::Goc3Json
| FormatArg::SurgeJson
| FormatArg::Gridfm
| FormatArg::Pwb => None,
}
}
fn name(self) -> &'static str {
match self {
FormatArg::Matpower => "matpower",
FormatArg::PowerModelsJson => "powermodels-json",
FormatArg::EgretJson => "egret-json",
FormatArg::Psse => "psse",
FormatArg::Psse34 => "psse34",
FormatArg::Psse35 => "psse35",
FormatArg::PowerWorld => "powerworld",
FormatArg::PandapowerJson => "pandapower-json",
FormatArg::PowerioJson => "powerio-json",
FormatArg::PypsaCsv => "pypsa-csv",
FormatArg::Pslf => "pslf",
FormatArg::Goc3Json => "goc3-json",
FormatArg::SurgeJson => "surge-json",
FormatArg::Gridfm => "gridfm",
FormatArg::Pwb => "pwb",
FormatArg::Dss => "dss",
FormatArg::PmdJson => "pmd-json",
FormatArg::BmopfJson => "bmopf-json",
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum MatrixKindArg {
#[value(name = "bprime", alias = "b1", alias = "b")]
BPrime,
#[value(name = "bdoubleprime", alias = "b2", alias = "bpp")]
BDoublePrime,
#[value(name = "ybus_real", alias = "g")]
YbusG,
#[value(name = "ybus_imag", alias = "negB", alias = "b_lap")]
YbusB,
#[value(name = "lacpf")]
Lacpf,
#[value(name = "adjacency", alias = "adj")]
Adjacency,
}
impl From<MatrixKindArg> for MatrixKind {
fn from(value: MatrixKindArg) -> Self {
match value {
MatrixKindArg::BPrime => Self::BPrime,
MatrixKindArg::BDoublePrime => Self::BDoublePrime,
MatrixKindArg::YbusG => Self::YbusG,
MatrixKindArg::YbusB => Self::YbusB,
MatrixKindArg::Lacpf => Self::Lacpf,
MatrixKindArg::Adjacency => Self::Adjacency,
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum SchemeArg {
Bx,
Xb,
}
impl From<SchemeArg> for Scheme {
fn from(value: SchemeArg) -> Self {
match value {
SchemeArg::Bx => Self::Bx,
SchemeArg::Xb => Self::Xb,
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum DcConvArg {
PaperPure,
Matpower,
}
impl From<DcConvArg> for DcConvention {
fn from(value: DcConvArg) -> Self {
match value {
DcConvArg::PaperPure => Self::PaperPure,
DcConvArg::Matpower => Self::Matpower,
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum UnitsArg {
PerUnit,
Native,
}
impl From<UnitsArg> for Units {
fn from(value: UnitsArg) -> Self {
match value {
UnitsArg::PerUnit => Self::PerUnit,
UnitsArg::Native => Self::Native,
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum RhsArg {
None,
Random,
Injection,
}
impl From<RhsArg> for RhsKind {
fn from(value: RhsArg) -> Self {
match value {
RhsArg::None => Self::None,
RhsArg::Random => Self::Random,
RhsArg::Injection => Self::Injection,
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum TopologyArg {
Tree,
Lattice,
Pegase,
}
impl From<TopologyArg> for Topology {
fn from(value: TopologyArg) -> Self {
match value {
TopologyArg::Tree => Self::Tree,
TopologyArg::Lattice => Self::Lattice2D,
TopologyArg::Pegase => Self::PegaseLike,
}
}
}
fn main() -> anyhow::Result<()> {
install_tracing();
let cli = Cli::parse();
match cli.command.unwrap_or_else(default_command) {
Command::Tui { data_dir, out_dir } => tui::run(tui::TuiOptions { data_dir, out_dir }),
Command::Batch {
input,
output,
matrices,
scheme,
rhs,
seed,
} => run_batch(&input, &output, matrices, scheme.into(), rhs.into(), seed),
Command::Gen {
topology,
n,
r_over_x,
mean_x,
seed,
output,
matrices,
} => run_gen_cli(topology, n, r_over_x, mean_x, seed, &output, matrices),
Command::Verify {
input,
kind,
scheme,
} => run_verify(&input, kind.into(), scheme.into()),
Command::DcOpf {
input,
output,
convention,
units,
missing_gen_cost,
default_gen_cost,
gen_cost_csv,
} => run_dcopf(
&input,
&output,
convention.into(),
units.into(),
missing_gen_cost,
default_gen_cost.as_deref(),
gen_cost_csv.as_deref(),
),
Command::Sensitivities {
input,
output,
convention,
} => run_sensitivities(&input, &output, convention.into()),
Command::Summary {
input,
from,
scenario,
} => run_summary(&input, from, scenario),
Command::Package {
input,
output,
from,
scenario,
} => run_package(&input, output.as_deref(), from, scenario),
Command::Gridfm {
inputs,
output,
from,
scenario,
missing_gen_cost,
default_gen_cost,
gen_cost_csv,
} => run_gridfm(
&inputs,
&output,
from,
scenario,
missing_gen_cost,
default_gen_cost.as_deref(),
gen_cost_csv.as_deref(),
),
Command::Convert {
input,
to,
output,
from,
scenario,
missing_gen_cost,
default_gen_cost,
gen_cost_csv,
} => run_convert(
&input,
to,
output.as_deref(),
from,
scenario,
GenCostCliOptions::new(
missing_gen_cost,
default_gen_cost.as_deref(),
gen_cost_csv.as_deref(),
),
),
}
}
fn default_command() -> Command {
Command::Tui {
data_dir: None,
out_dir: None,
}
}
fn run_gen_cli(
topology: TopologyArg,
n: usize,
r_over_x: f64,
mean_x: f64,
seed: u64,
output: &Path,
matrices: Vec<MatrixKindArg>,
) -> anyhow::Result<()> {
run_gen(topology.into(), n, r_over_x, mean_x, seed, output, matrices)
}
fn install_tracing() {
use tracing_subscriber::EnvFilter;
let _ = tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_writer(std::io::stderr)
.try_init();
}
fn run_batch(
input: &Path,
output: &Path,
matrices: Vec<MatrixKindArg>,
scheme: Scheme,
rhs: RhsKind,
seed: u64,
) -> anyhow::Result<()> {
let cases: Vec<PathBuf> = if input.is_file() {
vec![input.to_path_buf()]
} else {
walkdir::WalkDir::new(input)
.max_depth(2)
.into_iter()
.filter_map(std::result::Result::ok)
.filter(|e| e.file_type().is_file())
.filter(|e| {
e.path()
.extension()
.is_some_and(|x| x.eq_ignore_ascii_case("m"))
})
.map(|e| e.path().to_path_buf())
.collect()
};
if cases.is_empty() {
anyhow::bail!("no `.m` files found under {}", input.display());
}
let pipeline = Pipeline {
matrices: matrices.into_iter().map(MatrixKind::from).collect(),
options: BuildOptions {
scheme,
..Default::default()
},
rhs,
rng_seed: seed,
source_file: None,
};
for case_path in &cases {
let mpc = powerio_matrix::parse_matpower_file(case_path)
.with_context(|| format!("parse {}", case_path.display()))?;
let mut p = pipeline.clone();
p.source_file = Some(case_path.clone());
let outputs = p
.run(&mpc, output)
.with_context(|| format!("export {}", case_path.display()))?;
tracing::info!(
case = %outputs.case_name,
n = outputs.metadata.n_buses,
files = outputs.files.len(),
"exported"
);
}
Ok(())
}
fn run_gen(
topology: Topology,
n: usize,
r_over_x: f64,
mean_x: f64,
seed: u64,
output: &Path,
matrices: Vec<MatrixKindArg>,
) -> anyhow::Result<()> {
let spec = SynthSpec {
topology,
n,
r_over_x,
mean_x,
seed,
};
let case = powerio_matrix::synth::generate(&spec);
let pipeline = Pipeline {
matrices: matrices.into_iter().map(MatrixKind::from).collect(),
..Default::default()
};
let outputs = pipeline.run(&case, output)?;
tracing::info!(
case = %outputs.case_name,
n = outputs.metadata.n_buses,
files = outputs.files.len(),
"synthesized"
);
Ok(())
}
fn run_sensitivities(input: &Path, output: &Path, convention: DcConvention) -> anyhow::Result<()> {
let mpc = powerio_matrix::parse_matpower_file(input)
.with_context(|| format!("parse {}", input.display()))?;
std::fs::create_dir_all(output)?;
let view = powerio_matrix::IndexedNetwork::new(&mpc);
let (ptdf, lodf) = powerio_matrix::build_ptdf_lodf(&view, convention)
.with_context(|| format!("DC sensitivities for {}", input.display()))?;
let ptdf_path = output.join(format!("{}_ptdf.mtx", view.name()));
let lodf_path = output.join(format!("{}_lodf.mtx", view.name()));
powerio_matrix::io::mtx::write_mtx(&ptdf, &ptdf_path)?;
powerio_matrix::io::mtx::write_mtx(&lodf, &lodf_path)?;
tracing::info!(
case = %view.name(),
ptdf = %ptdf_path.display(),
lodf = %lodf_path.display(),
"wrote DC sensitivities"
);
Ok(())
}
fn missing_gen_cost_policy(
arg: MissingGenCostArg,
default_gen_cost: Option<&str>,
) -> anyhow::Result<MissingGenCostPolicy> {
match arg {
MissingGenCostArg::Preserve => {
if default_gen_cost.is_some() {
anyhow::bail!("--default-gen-cost is only valid with --missing-gen-cost quadratic");
}
Ok(MissingGenCostPolicy::Preserve)
}
MissingGenCostArg::Require => {
if default_gen_cost.is_some() {
anyhow::bail!("--default-gen-cost is only valid with --missing-gen-cost quadratic");
}
Ok(MissingGenCostPolicy::Require)
}
MissingGenCostArg::Zero => {
if default_gen_cost.is_some() {
anyhow::bail!("--default-gen-cost is only valid with --missing-gen-cost quadratic");
}
Ok(MissingGenCostPolicy::zero())
}
MissingGenCostArg::Quadratic => {
let value = default_gen_cost
.context("--missing-gen-cost quadratic requires --default-gen-cost C2,C1,C0")?;
let [c2, c1, c0] = parse_cost_triple(value)?;
Ok(MissingGenCostPolicy::quadratic(c2, c1, c0))
}
}
}
fn parse_cost_triple(value: &str) -> anyhow::Result<[f64; 3]> {
let parts: Vec<_> = value.split(',').map(str::trim).collect();
if parts.len() != 3 {
anyhow::bail!("--default-gen-cost expects exactly three comma-separated values: C2,C1,C0");
}
let mut out = [0.0; 3];
for (slot, part) in out.iter_mut().zip(parts) {
*slot = part
.parse::<f64>()
.with_context(|| format!("parse --default-gen-cost value `{part}`"))?;
if !slot.is_finite() {
anyhow::bail!("--default-gen-cost values must be finite");
}
}
Ok(out)
}
fn write_options(
arg: MissingGenCostArg,
default_gen_cost: Option<&str>,
gen_cost_csv: Option<&Path>,
) -> anyhow::Result<WriteOptions> {
let missing_gen_cost = missing_gen_cost_policy(arg, default_gen_cost)?;
let gen_cost_patches = match gen_cost_csv {
Some(path) => {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading generator cost CSV {}", path.display()))?;
powerio_matrix::parse_gen_cost_csv(&text)
.with_context(|| format!("parsing generator cost CSV {}", path.display()))?
}
None => Vec::new(),
};
Ok(WriteOptions {
missing_gen_cost,
gen_cost_patches,
})
}
fn run_dcopf(
input: &Path,
output: &Path,
convention: DcConvention,
units: Units,
missing_gen_cost: MissingGenCostArg,
default_gen_cost: Option<&str>,
gen_cost_csv: Option<&Path>,
) -> anyhow::Result<()> {
let mpc = powerio_matrix::parse_matpower_file(input)
.with_context(|| format!("parse {}", input.display()))?;
let cost_opts = write_options(missing_gen_cost, default_gen_cost, gen_cost_csv)?;
let opts = DcOpfOptions {
convention,
units,
missing_gen_cost: cost_opts.missing_gen_cost,
gen_cost_patches: cost_opts.gen_cost_patches,
};
let outputs = write_dcopf_bundle(&mpc, output, &opts)
.with_context(|| format!("export DC OPF bundle for {}", input.display()))?;
tracing::info!(
case = %mpc.name,
dir = %outputs.dir.display(),
files = outputs.files.len(),
"wrote DC OPF bundle"
);
Ok(())
}
fn run_gridfm(
inputs: &[PathBuf],
output: &Path,
from: Option<FormatArg>,
base_scenario: i64,
missing_gen_cost: MissingGenCostArg,
default_gen_cost: Option<&str>,
gen_cost_csv: Option<&Path>,
) -> anyhow::Result<()> {
if from == Some(FormatArg::Gridfm) {
anyhow::bail!(
"the `gridfm` subcommand writes a gridfm dataset from classical cases; \
to read a gridfm dataset back, use `convert --from gridfm`"
);
}
let nets = inputs
.iter()
.map(|p| read_network(p, from))
.collect::<anyhow::Result<Vec<_>>>()?;
let net_refs: Vec<_> = nets.iter().collect();
let snapshots = numbered_snapshots(&net_refs, base_scenario)?;
let cost_opts = write_options(missing_gen_cost, default_gen_cost, gen_cost_csv)?;
let opts = GridfmOptions {
missing_gen_cost: cost_opts.missing_gen_cost,
gen_cost_patches: cost_opts.gen_cost_patches,
..Default::default()
};
let outputs = write_gridfm_batch(&snapshots, output, &opts)
.with_context(|| format!("export gridfm dataset for {} scenario(s)", snapshots.len()))?;
if outputs.dropped_zero_impedance > 0 || outputs.degenerate_cost_gens > 0 {
tracing::warn!(
zeroed_branches = outputs.dropped_zero_impedance,
degenerate_cost_gens = outputs.degenerate_cost_gens,
missing_cost_gens = outputs.missing_cost_gens,
unsupported_cost_gens = outputs.unsupported_cost_gens,
"gridfm: some columns were zeroed; see gridfm_meta.json"
);
}
tracing::info!(
case = %nets[0].name,
scenarios = snapshots.len(),
dir = %outputs.dir.display(),
files = outputs.files.len(),
"wrote gridfm dataset"
);
Ok(())
}
fn run_verify(input: &Path, kind: MatrixKind, scheme: Scheme) -> anyhow::Result<()> {
let mpc = powerio_matrix::parse_matpower_file(input)?;
let opts = BuildOptions {
scheme,
..Default::default()
};
let view = powerio_matrix::IndexedNetwork::new(&mpc);
let matrix = powerio_matrix::build_kind(&view, kind, &opts)?;
let stats = powerio_matrix::matrix_stats_for_kind(&matrix, &view, kind, &opts);
let sddm = sddm_check(&matrix);
println!(
"{} ({}): n={} nnz={} min_diag={:.3e} max_diag={:.3e} dd_margin={:.3e} M-sign={} ‖A‖_F={:.3e} skipped_zero_impedance={} SDDM={}",
kind.label(),
mpc.name,
stats.n,
stats.nnz,
stats.min_diag,
stats.max_diag,
stats.min_dd_margin,
stats.m_matrix_sign,
stats.frobenius_norm,
stats.skipped_zero_impedance,
sddm
);
Ok(())
}
fn run_summary(input: &Path, from: Option<FormatArg>, scenario: i64) -> anyhow::Result<()> {
let value =
if from == Some(FormatArg::Gridfm) || (from.is_none() && looks_like_gridfm_dir(input)) {
let read = powerio_matrix::read_gridfm_dataset(input, scenario)
.with_context(|| format!("reading gridfm dataset {}", input.display()))?;
transmission_summary_json(&read.network, &read.warnings)
} else if from.is_some_and(|f| f.distribution().is_some())
|| (from.is_none() && looks_like_distribution_input(input)?)
{
let net = powerio_dist::parse_file(input, from.map(FormatArg::name))
.with_context(|| format!("reading {}", input.display()))?;
distribution_summary_json(&net)
} else {
let parsed = powerio_matrix::parse_file(input, from.map(FormatArg::name))
.with_context(|| format!("reading {}", input.display()))?;
transmission_summary_json(&parsed.network, &parsed.warnings)
};
println!("{}", serde_json::to_string_pretty(&value)?);
Ok(())
}
fn run_package(
input: &Path,
output: Option<&Path>,
from: Option<FormatArg>,
scenario: i64,
) -> anyhow::Result<()> {
let text = package_text(input, from, scenario)?;
match output {
Some(p) if p.as_os_str() != "-" => {
std::fs::write(p, &text).with_context(|| format!("writing {}", p.display()))?;
eprintln!("wrote {}", p.display());
}
_ => print!("{text}"),
}
Ok(())
}
fn package_text(input: &Path, from: Option<FormatArg>, scenario: i64) -> anyhow::Result<String> {
let pkg = build_package(input, from, scenario)?;
let text = pkg
.to_json_pretty()
.context("serializing .pio.json package")?;
NetworkPackage::from_json(&text).context("validating .pio.json package readback")?;
Ok(text)
}
fn build_package(
input: &Path,
from: Option<FormatArg>,
scenario: i64,
) -> anyhow::Result<NetworkPackage> {
if from == Some(FormatArg::Gridfm) || (from.is_none() && looks_like_gridfm_dir(input)) {
let read = powerio_matrix::read_gridfm_dataset(input, scenario)
.with_context(|| format!("reading gridfm dataset {}", input.display()))?;
let mut pkg = NetworkPackage::from_balanced(read.network);
add_read_warning_diagnostics(&mut pkg, "READ.GRIDFM.FIDELITY_WARNING", &read.warnings);
set_package_source(&mut pkg, input, PackageSourceKind::Folder, "gridfm", false);
pkg.run_sane_validation();
return Ok(pkg);
}
if from.is_some_and(|f| f.distribution().is_some())
|| (from.is_none() && looks_like_distribution_input(input)?)
{
let net = powerio_dist::parse_file(input, from.map(FormatArg::name))
.with_context(|| format!("reading {}", input.display()))?;
let format = net
.source_format
.map(powerio_dist::DistSourceFormat::name)
.or_else(|| from.map(FormatArg::name))
.unwrap_or("unknown");
let retained_source = net.source.is_some();
let mut pkg = NetworkPackage::from_multiconductor(net);
set_package_source(
&mut pkg,
input,
package_source_kind(input, format),
format,
retained_source,
);
pkg.run_sane_validation();
return Ok(pkg);
}
let parsed = powerio_matrix::parse_file(input, from.map(FormatArg::name))
.with_context(|| format!("reading {}", input.display()))?;
let format = parsed.network.source_format.name();
let retained_source = parsed.network.source.is_some();
let mut pkg = NetworkPackage::from_balanced(parsed.network);
add_read_warning_diagnostics(
&mut pkg,
"READ.TRANSMISSION.PARSE_WARNING",
&parsed.warnings,
);
set_package_source(
&mut pkg,
input,
package_source_kind(input, format),
format,
retained_source,
);
pkg.run_sane_validation();
Ok(pkg)
}
fn add_read_warning_diagnostics(pkg: &mut NetworkPackage, code: &str, warnings: &[String]) {
pkg.diagnostics.extend(warnings.iter().map(|w| {
StructuredDiagnostic::new(
code,
DiagnosticSeverity::Warning,
DiagnosticStage::Read,
w.clone(),
)
}));
pkg.validation = ValidationSummary::from_diagnostics(&pkg.diagnostics);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PackageSourceKind {
File,
BinaryFile,
Folder,
}
impl PackageSourceKind {
fn as_str(self) -> &'static str {
match self {
Self::File => "file",
Self::BinaryFile => "binary_file",
Self::Folder => "folder",
}
}
}
fn package_source_kind(input: &Path, format: &str) -> PackageSourceKind {
if input.is_dir() {
PackageSourceKind::Folder
} else if format == "powerworld-pwb" {
PackageSourceKind::BinaryFile
} else {
PackageSourceKind::File
}
}
fn set_package_source(
pkg: &mut NetworkPackage,
input: &Path,
kind: PackageSourceKind,
format: &str,
retained_source: bool,
) {
let path = input.display().to_string();
pkg.origin = match kind {
PackageSourceKind::File => Origin::File {
path: path.clone(),
format: format.to_owned(),
hash: None,
retained_source,
},
PackageSourceKind::BinaryFile => Origin::BinaryFile {
path: path.clone(),
format: format.to_owned(),
hash: None,
decoded_sections: Vec::new(),
},
PackageSourceKind::Folder => Origin::Folder {
path: path.clone(),
format: format.to_owned(),
file_hashes: BTreeMap::new(),
},
};
pkg.sources = vec![SourceDescriptor {
id: "src0".to_owned(),
kind: kind.as_str().to_owned(),
path: Some(path),
format: Some(format.to_owned()),
hash: None,
}];
}
fn transmission_summary_json(
net: &powerio_matrix::Network,
warnings: &[String],
) -> serde_json::Value {
let view = powerio_matrix::IndexedNetwork::new(net);
json!({
"schema": "powerio.summary",
"schema_version": SUMMARY_SCHEMA_VERSION,
"domain": "transmission",
"model": "balanced",
"name": net.name,
"source_format": format!("{:?}", net.source_format),
"json_format": "powerio-json",
"base_mva": net.base_mva,
"elements": {
"buses": net.buses.len(),
"branches": net.branches.len(),
"generators": net.generators.len(),
"loads": net.loads.len(),
"shunts": net.shunts.len(),
"lines": serde_json::Value::Null,
"transformers": serde_json::Value::Null,
"sources": serde_json::Value::Null,
},
"topology": {
"connected_components": view.n_connected_components(),
"is_radial": view.is_radial(),
"reference_buses": view.reference_bus_indices(),
"connectivity_report": view.connectivity_report(),
},
"warnings": warnings,
})
}
fn distribution_summary_json(net: &powerio_dist::DistNetwork) -> serde_json::Value {
json!({
"schema": "powerio.summary",
"schema_version": SUMMARY_SCHEMA_VERSION,
"domain": "distribution",
"model": "multiconductor",
"name": net.name,
"source_format": net.source_format.map(powerio_dist::DistSourceFormat::name),
"json_format": "bmopf-json",
"base_mva": serde_json::Value::Null,
"elements": {
"buses": net.buses.len(),
"branches": serde_json::Value::Null,
"generators": net.generators.len(),
"loads": net.loads.len(),
"shunts": serde_json::Value::Null,
"lines": net.lines.len(),
"transformers": net.transformers.len(),
"sources": net.sources.len(),
},
"topology": {
"connected_components": serde_json::Value::Null,
"is_radial": serde_json::Value::Null,
"reference_buses": serde_json::Value::Null,
"connectivity_report": serde_json::Value::Null,
},
"warnings": net.warnings,
})
}
fn looks_like_gridfm_dir(input: &Path) -> bool {
input.join("bus_data.parquet").is_file()
|| input.join("raw").join("bus_data.parquet").is_file()
|| std::fs::read_dir(input).is_ok_and(|entries| {
entries
.filter_map(std::result::Result::ok)
.filter(|e| e.path().join("raw").join("bus_data.parquet").is_file())
.take(2)
.count()
== 1
})
}
fn looks_like_distribution_input(input: &Path) -> anyhow::Result<bool> {
Ok(infer_input_family(input)?.unwrap_or(false))
}
fn infer_input_family(input: &Path) -> anyhow::Result<Option<bool>> {
let ext = input
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
match ext.as_deref() {
Some("m" | "raw" | "aux" | "epc" | "pwb") => return Ok(Some(false)),
Some("dss") => return Ok(Some(true)),
Some("json") => {}
_ => return Ok(None),
}
let text = std::fs::read_to_string(input)
.with_context(|| format!("reading JSON format markers from {}", input.display()))?;
match powerio_matrix::format::routing::classify_json_text(&text) {
Detection::Known(DetectedFormat::Distribution(_)) => Ok(Some(true)),
Detection::Known(DetectedFormat::Transmission(_)) => Ok(Some(false)),
Detection::Ambiguous => anyhow::bail!(
"ambiguous JSON markers in {}; pass --from to choose a format",
input.display()
),
Detection::Unknown => anyhow::bail!(
"cannot infer JSON format for {}; pass --from to choose a format",
input.display()
),
Detection::Known(_) => anyhow::bail!(
"unrecognized JSON format family in {}; pass --from to choose a format",
input.display()
),
}
}
fn run_convert(
input: &std::path::Path,
to: FormatArg,
output: Option<&std::path::Path>,
from: Option<FormatArg>,
scenario: i64,
gen_cost_options: GenCostCliOptions<'_>,
) -> anyhow::Result<()> {
if matches!(to, FormatArg::Gridfm) {
anyhow::bail!("`convert` cannot write a gridfm dataset; use the `gridfm` subcommand");
}
if matches!(to, FormatArg::Pwb) {
anyhow::bail!(
"`convert` cannot write PowerWorld .pwb binary cases; use `--to powerworld` for AUX text"
);
}
if to == FormatArg::PypsaCsv {
return convert_to_pypsa_folder(input, output, from, scenario, gen_cost_options);
}
let input_is_dist = if let Some(f) = from {
Some(f.distribution().is_some())
} else {
infer_input_family(input)?
};
if input_is_dist.is_some_and(|dist| dist != to.transmission().is_none()) {
anyhow::bail!(
"no conversion path between the transmission and distribution format families \
({} to `{}`)",
from.map_or_else(
|| format!("`{}` input", input.display()),
|f| format!("`{}`", f.name())
),
to.name()
);
}
let (text, warnings) = if let Some(target) = to.transmission() {
let options = gen_cost_options.write_options()?;
let net = if matches!(from, Some(FormatArg::Gridfm)) {
let read = powerio_matrix::read_gridfm_dataset(input, scenario)
.with_context(|| format!("reading gridfm dataset {}", input.display()))?;
for w in &read.warnings {
eprintln!("fidelity: {w}");
}
read.network
} else {
read_network(input, from)?
};
let conv = powerio_matrix::write_as_with_options(&net, target, &options)
.with_context(|| format!("serializing to {target}"))?;
(conv.text, conv.warnings)
} else {
let net = powerio_dist::parse_file(input, from.map(FormatArg::name))
.with_context(|| format!("reading {}", input.display()))?;
for w in &net.warnings {
eprintln!("parse: {w}");
}
let target = to
.distribution()
.expect("the family check routed a transmission target here");
let conv = net.to_format(target);
(conv.text, conv.warnings)
};
for w in &warnings {
eprintln!("fidelity: {w}");
}
match output {
Some(p) if p.as_os_str() != "-" => {
std::fs::write(p, &text).with_context(|| format!("writing {}", p.display()))?;
eprintln!("wrote {}", p.display());
}
_ => print!("{text}"),
}
Ok(())
}
fn convert_to_pypsa_folder(
input: &std::path::Path,
output: Option<&std::path::Path>,
from: Option<FormatArg>,
scenario: i64,
gen_cost_options: GenCostCliOptions<'_>,
) -> anyhow::Result<()> {
let Some(out_dir) = output else {
anyhow::bail!("`--to pypsa-csv` requires `-o <output-dir>`");
};
if out_dir.as_os_str() == "-" {
anyhow::bail!("`--to pypsa-csv` writes a directory and cannot write to stdout");
}
let mut net = if from == Some(FormatArg::Gridfm) {
let read = powerio_matrix::read_gridfm_dataset(input, scenario)
.with_context(|| format!("reading gridfm dataset {}", input.display()))?;
for w in &read.warnings {
eprintln!("fidelity: {w}");
}
read.network
} else {
read_network(input, from)?
};
let options = gen_cost_options.write_options()?;
let report = net.apply_gen_cost_policy(&options.gen_cost_patches, options.missing_gen_cost)?;
if report.patched > 0 {
eprintln!(
"fidelity: generator cost patch applied to {} generator(s)",
report.patched
);
}
if report.synthesized > 0 {
eprintln!(
"fidelity: generator cost synthesized for {} generator(s)",
report.synthesized
);
}
let outputs = powerio_matrix::write_pypsa_csv_folder(&net, out_dir)
.with_context(|| format!("writing PyPSA CSV folder {}", out_dir.display()))?;
for w in &outputs.warnings {
eprintln!("fidelity: {w}");
}
eprintln!("wrote {}", outputs.dir.display());
Ok(())
}
fn read_network(
input: &std::path::Path,
from: Option<FormatArg>,
) -> anyhow::Result<powerio_matrix::Network> {
if let Some(f) = from {
if matches!(f, FormatArg::Gridfm) {
anyhow::bail!(
"gridfm datasets are read by `convert --from gridfm` or the `gridfm` \
subcommand, not this command"
);
}
if f.distribution().is_some() {
anyhow::bail!(
"`{}` is a distribution format; this command reads transmission cases \
(use `convert` to bridge dss, pmd-json, and bmopf-json)",
f.name()
);
}
}
let parsed = powerio_matrix::parse_file(input, from.map(FormatArg::name))
.with_context(|| format!("reading {}", input.display()))?;
for w in &parsed.warnings {
eprintln!("fidelity: {w}");
}
Ok(parsed.network)
}
#[cfg(test)]
mod tests {
use super::{
Cli, Command, FormatArg, GenCostCliOptions, build_package, distribution_summary_json,
infer_input_family, looks_like_distribution_input, package_text, run_convert, run_package,
transmission_summary_json,
};
use clap::Parser;
use powerio_pkg::{MappingKind, NetworkPackage, Origin, ValidationStatus};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
fn data(path: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("tests")
.join("data")
.join(path)
}
#[test]
fn summary_json_matches_canonical_transmission_shape() {
let parsed = powerio_matrix::parse_file(data("case9.m"), None).unwrap();
let value = transmission_summary_json(&parsed.network, &parsed.warnings);
assert_eq!(value["schema"], "powerio.summary");
assert_eq!(value["schema_version"], "0.1");
assert_eq!(value["domain"], "transmission");
assert_eq!(value["model"], "balanced");
assert_eq!(value["json_format"], "powerio-json");
assert_eq!(value["elements"]["buses"], 9);
assert_eq!(value["topology"]["connected_components"], 1);
}
#[test]
fn summary_json_matches_canonical_distribution_shape() {
let net = powerio_dist::parse_file(data("dist/micro/xfmr_single_phase.dss"), None).unwrap();
let value = distribution_summary_json(&net);
assert_eq!(value["schema"], "powerio.summary");
assert_eq!(value["schema_version"], "0.1");
assert_eq!(value["domain"], "distribution");
assert_eq!(value["model"], "multiconductor");
assert_eq!(value["json_format"], "bmopf-json");
assert_eq!(value["elements"]["buses"], 2);
assert!(value["topology"]["connected_components"].is_null());
}
#[test]
fn distribution_json_shape_check_uses_shared_classifier() {
let tmp = std::env::temp_dir().join(format!(
"powerio-summary-routing-{}.json",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&tmp, r#"{"bus":{"a":{"terminal_names":["1"]}}}"#).unwrap();
assert!(looks_like_distribution_input(&tmp).unwrap());
std::fs::write(
&tmp,
std::fs::read_to_string(data("egret/case9.json")).unwrap(),
)
.unwrap();
assert!(!looks_like_distribution_input(&tmp).unwrap());
let _ = std::fs::remove_file(tmp);
}
#[test]
fn package_visible_alias_parses() {
let cli = Cli::try_parse_from(["powerio", "pkg", "case9.m"]).unwrap();
match cli.command.unwrap() {
Command::Package { input, .. } => assert_eq!(input, Path::new("case9.m")),
other => panic!("expected package command, got {other:?}"),
}
}
#[test]
fn package_text_matches_balanced_shape_and_provenance() {
let input = data("case9.m");
let text = package_text(&input, None, 0).unwrap();
let pkg = NetworkPackage::from_json(&text).unwrap();
assert_eq!(pkg.model_kind, powerio_pkg::ModelKind::Balanced);
assert!(pkg.kind_is_consistent());
assert_eq!(pkg.as_balanced().unwrap().buses.len(), 9);
match &pkg.origin {
Origin::File {
path,
format,
retained_source,
..
} => {
assert_eq!(path, &input.display().to_string());
assert_eq!(format, "matpower");
assert!(*retained_source);
}
other => panic!("expected file origin, got {other:?}"),
}
assert_eq!(pkg.sources.len(), 1);
assert_eq!(pkg.sources[0].id, "src0");
assert_eq!(pkg.sources[0].kind, "file");
assert_eq!(
pkg.sources[0].path.as_deref(),
Some(input.to_str().unwrap())
);
assert_eq!(pkg.sources[0].format.as_deref(), Some("matpower"));
assert!(
pkg.source_maps.iter().any(|entry| {
entry.mapping_kind == MappingKind::Exact
&& entry.element_path == "/model/balanced_network/buses/0/vm"
&& entry.source_ref.source_id == "src0"
&& entry.source_ref.record.as_deref() == Some("bus")
&& entry.source_ref.field.as_deref() == Some("vm")
}),
"expected balanced source map entries: {:?}",
pkg.source_maps
);
}
#[test]
fn package_command_writes_output_file() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let output = std::env::temp_dir().join(format!("powerio-package-{stamp}.pio.json"));
run_package(&data("case9.m"), Some(&output), None, 0).unwrap();
let text = std::fs::read_to_string(&output).unwrap();
let pkg = NetworkPackage::from_json(&text).unwrap();
assert_eq!(pkg.model_kind, powerio_pkg::ModelKind::Balanced);
assert_eq!(pkg.sources[0].format.as_deref(), Some("matpower"));
let _ = std::fs::remove_file(output);
}
#[test]
fn package_helper_returns_stdout_text() {
let text = package_text(&data("case9.m"), None, 0).unwrap();
assert!(text.contains("\"schema\""));
let pkg = NetworkPackage::from_json(&text).unwrap();
assert_eq!(pkg.summary.elements["buses"], 9);
}
#[test]
fn package_text_includes_validation_passes() {
let text = package_text(&data("case9.m"), None, 0).unwrap();
let pkg = NetworkPackage::from_json(&text).unwrap();
assert!(
pkg.validation
.passes
.iter()
.any(|p| p.name == "balanced.structure" && p.status == ValidationStatus::Ok),
"missing balanced validation pass: {:?}",
pkg.validation.passes
);
let pretty = pkg.to_json_pretty().unwrap();
let back = NetworkPackage::from_json(&pretty).unwrap();
assert_eq!(back.validation.passes, pkg.validation.passes);
}
#[test]
fn package_distribution_fixture_keeps_defaulted_source_maps() {
let input = data("dist/micro/xfmr_single_phase.dss");
let pkg = build_package(&input, None, 0).unwrap();
assert_eq!(pkg.model_kind, powerio_pkg::ModelKind::Multiconductor);
match &pkg.origin {
Origin::File { path, format, .. } => {
assert_eq!(path, &input.display().to_string());
assert_eq!(format, "dss");
}
other => panic!("expected file origin, got {other:?}"),
}
assert_eq!(
pkg.sources[0].path.as_deref(),
Some(input.to_str().unwrap())
);
assert!(
pkg.source_maps
.iter()
.any(|e| e.mapping_kind == MappingKind::Defaulted),
"expected defaulted source map entries: {:?}",
pkg.source_maps
);
}
#[test]
fn package_rejects_non_finite_payload_before_writing() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let input = std::env::temp_dir().join(format!("powerio-package-bad-{stamp}.m"));
let output = std::env::temp_dir().join(format!("powerio-package-bad-{stamp}.pio.json"));
std::fs::write(
&input,
"\
function mpc = bad
mpc.version = '2';
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;
2 1 0 0 0 0 1 1 0 230 1 1.1 0.9;
];
mpc.branch = [
1 2 0.01 0.1 0 0 0 0 0 0 1 NaN Inf;
];
",
)
.unwrap();
let err = run_package(&input, Some(&output), None, 0).unwrap_err();
assert!(
err.to_string()
.contains("validating .pio.json package readback"),
"{err}"
);
assert!(!output.exists());
let _ = std::fs::remove_file(input);
let _ = std::fs::remove_file(output);
}
#[test]
fn convert_rejects_transmission_json_to_distribution_without_format() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let input = std::env::temp_dir().join(format!("powerio-convert-pm-{stamp}.json"));
let output = std::env::temp_dir().join(format!("powerio-convert-pm-{stamp}.dss"));
let parsed = powerio_matrix::parse_file(data("case9.m"), None).unwrap();
let conv = powerio_matrix::write_as(
&parsed.network,
powerio_matrix::TargetFormat::PowerModelsJson,
)
.unwrap();
std::fs::write(&input, conv.text).unwrap();
assert_eq!(infer_input_family(&input).unwrap(), Some(false));
let err = run_convert(
&input,
FormatArg::Dss,
Some(&output),
None,
0,
GenCostCliOptions::preserve(),
)
.unwrap_err();
assert!(err.to_string().contains("no conversion path"), "{err}");
assert!(!output.exists());
let _ = std::fs::remove_file(input);
let _ = std::fs::remove_file(output);
}
#[test]
fn convert_accepts_pypsa_csv_as_transmission_input() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let input = std::env::temp_dir().join(format!("powerio-convert-pypsa-{stamp}"));
let output = std::env::temp_dir().join(format!("powerio-convert-pypsa-{stamp}.m"));
let parsed = powerio_matrix::parse_file(data("case9.m"), None).unwrap();
powerio_matrix::write_pypsa_csv_folder(&parsed.network, &input).unwrap();
run_convert(
&input,
FormatArg::Matpower,
Some(&output),
Some(FormatArg::PypsaCsv),
0,
GenCostCliOptions::preserve(),
)
.unwrap();
let text = std::fs::read_to_string(&output).unwrap();
assert!(text.contains("mpc.bus"));
let _ = std::fs::remove_dir_all(input);
let _ = std::fs::remove_file(output);
}
#[test]
fn convert_rejects_pwb_target_before_family_routing() {
let err = run_convert(
&data("dist/micro/xfmr_single_phase.dss"),
FormatArg::Pwb,
None,
None,
0,
GenCostCliOptions::preserve(),
)
.unwrap_err();
assert!(
err.to_string().contains("cannot write PowerWorld .pwb"),
"{err}"
);
}
}