use std::io::Read as _;
use std::path::{Path, PathBuf};
use anyhow::Context;
use clap::{Parser, Subcommand, ValueEnum};
use powerio_core::ErrorCategory;
use powerio_matrix::io::gridfm::{GridfmOptions, emit_gridfm_batch, number_snapshots};
use powerio_matrix::matrix::{BranchSusceptanceFormula, BuildOptions, Scheme, check_sddm};
use powerio_matrix::pipeline::{MatrixKind, Pipeline, RhsKind};
use powerio_matrix::synth::{SynthSpec, Topology};
use powerio_matrix::{
DcOpfAssemblyOptions, DcOpfBundleMetadata, DcOpfBundleOptions, Units, emit_dcopf_bundle,
};
use powerio_matrix::{SensitivityOptions, SensitivitySolver};
use powerio_tx::{EmitOptions, MissingGenCostPolicy};
use serde_json::json;
mod cases;
mod codes;
mod module_io;
mod tui;
use powerio_tx::format::routing::SourceFormat as DetectedFormat;
#[derive(Parser, Debug)]
#[command(name = "powerio", version, about)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(long, global = true, value_enum, default_value_t = DiagnosticsFormat::Text)]
diagnostics_format: DiagnosticsFormat,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
enum DiagnosticsFormat {
#[default]
Text,
Json,
}
#[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)]
from: Option<FormatArg>,
#[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(long, value_enum)]
from: Option<FormatArg>,
#[arg(short, long)]
output: PathBuf,
#[arg(long, value_enum, default_value = "series-susceptance")]
formula: BranchSusceptanceFormulaArg,
#[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(long, value_enum)]
from: Option<FormatArg>,
#[arg(short, long)]
output: PathBuf,
#[arg(long, value_enum, default_value = "series-susceptance")]
formula: BranchSusceptanceFormulaArg,
#[arg(long, value_enum, default_value = "auto")]
solver: SensitivitySolverArg,
#[arg(long, default_value_t = 1e-12)]
drop_tolerance: f64,
},
Summary {
input: PathBuf,
#[arg(long, value_enum)]
from: Option<FormatArg>,
#[arg(long, default_value_t = 0)]
scenario: i64,
},
Serialize {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, value_enum)]
from: Option<FormatArg>,
},
Corpus {
#[command(subcommand)]
action: CorpusCommand,
},
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>,
},
Geo {
#[command(subcommand)]
command: GeoCommand,
},
}
#[derive(Subcommand, Debug)]
enum CorpusCommand {
Ingest {
corpus: PathBuf,
#[arg(short, long)]
work: PathBuf,
#[arg(long)]
max_bytes: Option<u64>,
},
Compare {
#[arg(short, long)]
work: PathBuf,
},
Walk {
#[arg(short, long)]
work: PathBuf,
#[arg(long, default_value_t = 8)]
walks: usize,
#[arg(long, default_value_t = 6)]
hops: usize,
#[arg(long, default_value_t = 0x5EED)]
seed: u64,
#[arg(long, default_value_t = 12)]
settle: usize,
},
Report {
#[arg(short, long)]
work: PathBuf,
#[arg(short, long, default_value = "findings.jsonl")]
output: PathBuf,
#[arg(long)]
summary: Option<PathBuf>,
},
}
#[derive(Subcommand, Debug)]
enum GeoCommand {
Extract {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, value_enum)]
from: Option<FormatArg>,
},
Apply {
input: PathBuf,
layer: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, value_enum)]
to: Option<FormatArg>,
#[arg(long, value_enum)]
from: Option<FormatArg>,
},
Convert {
input: PathBuf,
#[arg(short, long)]
output: 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 emit_options(self) -> anyhow::Result<EmitOptions> {
emit_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 = "psse-rawx")]
PsseRawx,
#[value(name = "rawx")]
RawxInput,
#[value(name = "xiidm")]
Xiidm,
#[value(name = "iidm")]
IidmInput,
#[value(name = "jiidm")]
Jiidm,
#[value(name = "cgmes")]
Cgmes,
#[value(name = "ucte", alias = "uct")]
Ucte,
#[value(name = "powerworld", alias = "aux")]
PowerWorld,
#[value(name = "pandapower-json", alias = "pandapower", alias = "pp")]
PandapowerJson,
#[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 = "opfdata-json",
alias = "opfdata",
alias = "deepmind-opfdata-json",
alias = "deepmind-opfdata",
alias = "gridopt-json",
alias = "gridopt"
)]
DeepMindOpfDataJson,
#[value(name = "gridfm")]
Gridfm,
#[value(name = "pwb")]
Pwb,
#[value(name = "ieee-cdf", alias = "cdf")]
IeeeCdf,
#[value(name = "dss", alias = "opendss")]
Dss,
#[value(name = "pmd-json", alias = "pmd", alias = "engineering")]
PmdJson,
#[value(name = "bmopf-json", alias = "bmopf")]
BmopfJson,
#[value(name = "bmopf-json@0.1.0")]
Bmopf010,
#[value(name = "bmopf-json@0.2.0")]
Bmopf020,
}
impl FormatArg {
fn transmission(self) -> Option<powerio_tx::TargetFormat> {
use powerio_tx::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::PsseRawx | FormatArg::RawxInput => TargetFormat::PsseRawx,
FormatArg::Xiidm | FormatArg::IidmInput => TargetFormat::Xiidm,
FormatArg::Jiidm => TargetFormat::Jiidm,
FormatArg::Cgmes => TargetFormat::Cgmes,
FormatArg::Ucte => TargetFormat::Ucte,
FormatArg::PowerWorld => TargetFormat::PowerWorld,
FormatArg::PandapowerJson => TargetFormat::PandapowerJson,
FormatArg::Pslf => TargetFormat::Pslf,
FormatArg::Goc3Json => TargetFormat::Goc3Json,
FormatArg::SurgeJson => TargetFormat::SurgeJson,
FormatArg::DeepMindOpfDataJson => TargetFormat::DeepMindOpfDataJson,
FormatArg::PypsaCsv
| FormatArg::Gridfm
| FormatArg::Pwb
| FormatArg::IeeeCdf
| FormatArg::Dss
| FormatArg::PmdJson
| FormatArg::BmopfJson
| FormatArg::Bmopf010
| FormatArg::Bmopf020 => 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 | FormatArg::Bmopf010 | FormatArg::Bmopf020 => {
Some(DistTargetFormat::BmopfJson)
}
FormatArg::Matpower
| FormatArg::PowerModelsJson
| FormatArg::EgretJson
| FormatArg::Psse
| FormatArg::Psse34
| FormatArg::Psse35
| FormatArg::PsseRawx
| FormatArg::RawxInput
| FormatArg::Xiidm
| FormatArg::IidmInput
| FormatArg::Jiidm
| FormatArg::Cgmes
| FormatArg::Ucte
| FormatArg::PowerWorld
| FormatArg::PandapowerJson
| FormatArg::PypsaCsv
| FormatArg::Pslf
| FormatArg::Goc3Json
| FormatArg::SurgeJson
| FormatArg::DeepMindOpfDataJson
| FormatArg::Gridfm
| FormatArg::Pwb
| FormatArg::IeeeCdf => 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::PsseRawx => "psse-rawx",
FormatArg::RawxInput => "rawx",
FormatArg::Xiidm => "xiidm",
FormatArg::IidmInput => "iidm",
FormatArg::Jiidm => "jiidm",
FormatArg::Cgmes => "cgmes",
FormatArg::Ucte => "ucte",
FormatArg::PowerWorld => "powerworld",
FormatArg::PandapowerJson => "pandapower-json",
FormatArg::PypsaCsv => "pypsa-csv",
FormatArg::Pslf => "pslf",
FormatArg::Goc3Json => "goc3-json",
FormatArg::SurgeJson => "surge-json",
FormatArg::DeepMindOpfDataJson => "opfdata-json",
FormatArg::Gridfm => "gridfm",
FormatArg::Pwb => "pwb",
FormatArg::IeeeCdf => "ieee-cdf",
FormatArg::Dss => "dss",
FormatArg::PmdJson => "pmd-json",
FormatArg::BmopfJson => "bmopf-json",
FormatArg::Bmopf010 => "bmopf-json@0.1.0",
FormatArg::Bmopf020 => "bmopf-json@0.2.0",
}
}
fn is_input_spelling(self) -> bool {
matches!(self, Self::RawxInput | Self::IidmInput)
}
}
#[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 BranchSusceptanceFormulaArg {
#[value(name = "series-susceptance")]
SeriesSusceptance,
#[value(name = "tap-adjusted-reactance")]
TapAdjustedReactance,
#[value(name = "reactance-only")]
ReactanceOnly,
}
impl From<BranchSusceptanceFormulaArg> for BranchSusceptanceFormula {
fn from(value: BranchSusceptanceFormulaArg) -> Self {
match value {
BranchSusceptanceFormulaArg::SeriesSusceptance => Self::SeriesSusceptance,
BranchSusceptanceFormulaArg::TapAdjustedReactance => Self::TapAdjustedReactance,
BranchSusceptanceFormulaArg::ReactanceOnly => Self::ReactanceOnly,
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum SensitivitySolverArg {
Auto,
Dense,
Sparse,
}
impl From<SensitivitySolverArg> for SensitivitySolver {
fn from(value: SensitivitySolverArg) -> Self {
match value {
SensitivitySolverArg::Auto => Self::Auto,
SensitivitySolverArg::Dense => Self::Dense,
SensitivitySolverArg::Sparse => Self::Sparse,
}
}
}
#[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,
}
}
}
#[expect(clippy::too_many_lines)]
fn main() -> std::process::ExitCode {
let cli = Cli::parse();
install_tracing(cli.diagnostics_format);
let _ = DIAGNOSTICS_FORMAT.set(cli.diagnostics_format);
let result: anyhow::Result<()> = 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,
from,
kind,
scheme,
} => run_verify(&input, from, kind.into(), scheme.into()),
Command::DcOpf {
input,
from,
output,
formula,
units,
missing_gen_cost,
default_gen_cost,
gen_cost_csv,
} => run_dcopf(
&input,
from,
&output,
formula.into(),
units.into(),
missing_gen_cost,
default_gen_cost.as_deref(),
gen_cost_csv.as_deref(),
),
Command::Sensitivities {
input,
from,
output,
formula,
solver,
drop_tolerance,
} => run_sensitivities(&input, from, &output, formula, solver, drop_tolerance),
Command::Summary {
input,
from,
scenario,
} => run_summary(&input, from, scenario),
Command::Corpus { action } => run_corpus(action),
Command::Serialize {
input,
output,
from,
} => run_serialize(&input, output.as_deref(), from),
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(),
),
),
Command::Geo { command } => run_geo(command),
};
let status = match &result {
Ok(()) => 0,
Err(error) => exit_status(error),
};
match diagnostics_format() {
DiagnosticsFormat::Text => {
if let Err(error) = &result {
print_error_chain(error);
}
}
DiagnosticsFormat::Json => {
if let Err(error) = &result {
report_diagnostics(&failure_diagnostics(error));
}
print_collected_json();
}
}
std::process::ExitCode::from(status)
}
fn cli_failure(
info: &'static powerio_core::DiagnosticInfo,
message: impl Into<String>,
) -> anyhow::Error {
anyhow::Error::new(powerio_core::Error::new(info, message))
}
macro_rules! fail_with {
($code:ident, $($arg:tt)*) => { return Err(cli_failure(&codes::$code, format!($($arg)*))) };
}
macro_rules! failure {
($code:ident, $($arg:tt)*) => { cli_failure(&codes::$code, format!($($arg)*)) };
}
fn exit_status(error: &anyhow::Error) -> u8 {
match error_category(error) {
Some(ErrorCategory::Request) => 2,
Some(ErrorCategory::Io) => 3,
Some(ErrorCategory::Parse) => 4,
Some(ErrorCategory::Data) => 5,
Some(ErrorCategory::Output) => 6,
None => 1,
}
}
fn error_category(error: &anyhow::Error) -> Option<ErrorCategory> {
error
.chain()
.find_map(|cause| classified(cause).map(|(_, category)| category))
}
fn classified(
cause: &(dyn std::error::Error + 'static),
) -> Option<(&'static powerio_core::DiagnosticInfo, ErrorCategory)> {
if let Some(error) = cause.downcast_ref::<powerio_core::Error>() {
return error.info().map(|info| (info, error.category()));
}
if let Some(error) = cause.downcast_ref::<powerio_matrix::Error>() {
return Some((error.code(), error.category()));
}
if let Some(error) = cause.downcast_ref::<powerio_prob::Error>() {
return Some((error.code(), error.category()));
}
if let Some(error) = cause.downcast_ref::<powerio_tx::Error>() {
return Some((error.code(), error.category()));
}
if let Some(error) = cause.downcast_ref::<powerio_dist::Error>() {
return Some((error.code(), error.category()));
}
None
}
static DIAGNOSTICS_FORMAT: std::sync::OnceLock<DiagnosticsFormat> = std::sync::OnceLock::new();
static COLLECTED_DIAGNOSTICS: std::sync::Mutex<Vec<powerio_core::Diagnostic>> =
std::sync::Mutex::new(Vec::new());
fn diagnostics_format() -> DiagnosticsFormat {
DIAGNOSTICS_FORMAT.get().copied().unwrap_or_default()
}
fn collected_diagnostics() -> std::sync::MutexGuard<'static, Vec<powerio_core::Diagnostic>> {
COLLECTED_DIAGNOSTICS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn report_diagnostics(diagnostics: &[powerio_core::Diagnostic]) {
match diagnostics_format() {
DiagnosticsFormat::Text => {
for line in powerio_core::render_diagnostics(diagnostics) {
eprintln!("{line}");
}
}
DiagnosticsFormat::Json => collected_diagnostics().extend(diagnostics.iter().cloned()),
}
}
fn report_progress(line: impl std::fmt::Display) {
if diagnostics_format() == DiagnosticsFormat::Text {
eprintln!("{line}");
}
}
fn report_written(path: &Path) {
report_progress(format!("wrote {}", path.display()));
}
fn print_collected_json() {
let collected = std::mem::take(&mut *collected_diagnostics());
match powerio::serialize_diagnostics(&collected) {
Ok(text) => eprintln!("{text}"),
Err(encode_error) => {
for line in powerio_core::render_diagnostics(&collected) {
eprintln!("{line}");
}
eprintln!("Error: could not encode the diagnostics as JSON: {encode_error}");
}
}
}
fn failure_frames(error: &anyhow::Error) -> Vec<(bool, String)> {
let mut frames = Vec::new();
let mut previous: Option<String> = None;
for cause in error.chain() {
let text = cause.to_string();
let repeats_the_frame_above = previous
.as_deref()
.is_some_and(|above| above.ends_with(&text));
if !repeats_the_frame_above {
let is_powerio_error = classified(cause).is_some();
frames.push((is_powerio_error, text.clone()));
}
previous = Some(text);
}
frames
}
fn failure_diagnostics(error: &anyhow::Error) -> Vec<powerio_core::Diagnostic> {
use powerio_core::{Diagnostic, DiagnosticCode, DiagnosticId, DiagnosticSeverity};
let failure = error
.chain()
.find_map(|cause| cause.downcast_ref::<powerio_core::Error>());
let mut frames = failure_frames(error);
let mut records: Vec<Diagnostic> = match failure {
Some(failure) if !failure.diagnostics().is_empty() => failure.diagnostics().to_vec(),
_ => {
let code = error
.chain()
.find_map(|cause| classified(cause).map(|(code, _)| code))
.unwrap_or(&codes::BIND_CLI_UNCLASSIFIED);
let (_, message) = frames.remove(0);
vec![Diagnostic::of(code, message)]
}
};
let primary_id = if let Some(id) = records[0].id() {
id.clone()
} else {
let Ok(id) = DiagnosticId::new("failure") else {
return records;
};
records[0] = records[0].clone().with_id(id.clone());
id
};
let Ok(primary_code) = DiagnosticCode::new(records[0].code()) else {
return records;
};
for (is_powerio_error, text) in frames {
if is_powerio_error {
continue;
}
let note = Diagnostic::new(primary_code.clone(), DiagnosticSeverity::Note, text);
if let Ok(note) = note.with_related(primary_id.clone()) {
records.push(note);
}
}
records
}
fn is_stdin(input: &Path) -> bool {
input.as_os_str() == "-"
}
fn stdin_source() -> anyhow::Result<powerio_core::Source> {
let mut bytes = Vec::new();
std::io::stdin()
.lock()
.read_to_end(&mut bytes)
.map_err(|cause| {
powerio_core::Error::new(
&powerio_core::codes::READ_IO_READ,
"cannot read standard input",
)
.with_cause(cause)
})?;
powerio_core::Source::from_memory("<stdin>", bytes)
.context("creating the standard input source")
}
fn stdin_format(from: Option<FormatArg>) -> anyhow::Result<FormatArg> {
match from {
None => Err(cli_failure(
&codes::REQUEST_CLI_FORMAT_REQUIRED,
"reading a case from standard input needs `--from <format>`; a stream has no \
file name to infer the format from",
)),
Some(FormatArg::Gridfm) => Err(cli_failure(
&codes::REQUEST_CLI_FORMAT_REQUIRED,
"a gridfm dataset is a directory and cannot be read from standard input",
)),
Some(format) => Ok(format),
}
}
fn print_error_chain(error: &anyhow::Error) {
let mut prefix = "Error";
for (_, text) in failure_frames(error) {
eprintln!("{prefix}: {text}");
prefix = "Caused by";
}
}
fn read_text_file(path: &Path) -> anyhow::Result<String> {
let source = powerio_core::Source::open(path)?;
let buffer = source.primary_buffer()?;
String::from_utf8(buffer.content_bytes().to_vec()).map_err(|cause| {
powerio_core::Error::new(
&powerio_core::codes::READ_IO_READ,
format!("{} is not UTF-8 text", path.display()),
)
.with_cause(cause)
.into()
})
}
fn run_geo(command: GeoCommand) -> anyhow::Result<()> {
match command {
GeoCommand::Extract {
input,
output,
from,
} => run_geo_extract(&input, output.as_deref(), from),
GeoCommand::Apply {
input,
layer,
output,
to,
from,
} => run_geo_apply(&input, &layer, output.as_deref(), to, from),
GeoCommand::Convert { input, output } => run_geo_convert(&input, output.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(diagnostics_format: DiagnosticsFormat) {
use tracing_subscriber::EnvFilter;
if diagnostics_format == DiagnosticsFormat::Json {
return;
}
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 (found, scanned) = if input.is_file() {
(vec![input.to_path_buf()], false)
} else {
(cases::discover_cases(input, Some(output)), true)
};
if found.is_empty() {
fail_with!(
REQUEST_CLI_NO_CASES,
"no case files ({}) found under {}",
cases::CASE_EXTENSIONS_LABEL,
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,
};
let mut exported = 0usize;
for case_path in &found {
let loaded = match cases::load_network(case_path) {
Ok(loaded) => loaded,
Err(e) if scanned => {
tracing::warn!(case = %case_path.display(), error = format!("{e:#}"), "skipping");
continue;
}
Err(e) => return Err(e),
};
for w in &loaded.warnings {
tracing::warn!(case = %case_path.display(), "{w}");
}
let mut p = pipeline.clone();
p.source_file = Some(case_path.clone());
let outputs = p
.run(&loaded.network, output)
.with_context(|| format!("export {}", case_path.display()))?;
exported += 1;
tracing::info!(
case = %outputs.case_name,
n = outputs.metadata.n_buses,
files = outputs.files.len(),
"exported"
);
}
if exported == 0 {
fail_with!(
REQUEST_CLI_NO_CASES,
"no files under {} loaded as case files ({})",
input.display(),
cases::CASE_EXTENSIONS_LABEL
);
}
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,
from: Option<FormatArg>,
output: &Path,
formula: BranchSusceptanceFormulaArg,
solver: SensitivitySolverArg,
drop_tolerance: f64,
) -> anyhow::Result<()> {
let mpc = balanced_case(input, from).with_context(|| format!("parse {}", input.display()))?;
std::fs::create_dir_all(output).map_err(|cause| {
powerio_core::Error::new(
&powerio_core::codes::EMIT_IO_STAGING,
format!("cannot create the output directory {}", output.display()),
)
.with_cause(cause)
})?;
let view = powerio_matrix::IndexedNetwork::new(&mpc);
let options = SensitivityOptions {
formula: formula.into(),
solver: solver.into(),
drop_tolerance,
..Default::default()
};
let stem = powerio_matrix::sanitize_stem(view.name());
let ptdf_path = output.join(format!("{stem}_ptdf.mtx"));
let lodf_path = output.join(format!("{stem}_lodf.mtx"));
let meta_path = output.join(format!("{stem}_sensitivity_meta.json"));
let metadata = powerio_matrix::io::emit_sensitivity_mtx_with_options(
&view, &options, &ptdf_path, &lodf_path,
)
.with_context(|| format!("DC sensitivities for {}", input.display()))?;
let meta = json!({
"case": view.name(),
"branch_susceptance_formula": options.formula.formula_name(),
"files": {
"ptdf": ptdf_path.file_name().and_then(|s| s.to_str()).unwrap_or(""),
"lodf": lodf_path.file_name().and_then(|s| s.to_str()).unwrap_or("")
},
"sensitivity": &metadata
});
commit_output_file(&meta_path, serde_json::to_vec_pretty(&meta)?)
.with_context(|| format!("writing {}", meta_path.display()))?;
tracing::info!(
case = %view.name(),
ptdf = %ptdf_path.display(),
lodf = %lodf_path.display(),
metadata = %meta_path.display(),
solver = metadata.solver_path.as_str(),
ptdf_dropped = metadata.ptdf.dropped_entries,
lodf_dropped = metadata.lodf.dropped_entries,
"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() {
fail_with!(
REQUEST_CLI_OPTION_INVALID,
"--default-gen-cost is only valid with --missing-gen-cost quadratic"
);
}
Ok(MissingGenCostPolicy::Preserve)
}
MissingGenCostArg::Require => {
if default_gen_cost.is_some() {
fail_with!(
REQUEST_CLI_OPTION_INVALID,
"--default-gen-cost is only valid with --missing-gen-cost quadratic"
);
}
Ok(MissingGenCostPolicy::Require)
}
MissingGenCostArg::Zero => {
if default_gen_cost.is_some() {
fail_with!(
REQUEST_CLI_OPTION_INVALID,
"--default-gen-cost is only valid with --missing-gen-cost quadratic"
);
}
Ok(MissingGenCostPolicy::zero())
}
MissingGenCostArg::Quadratic => {
let Some(value) = default_gen_cost else {
fail_with!(
REQUEST_CLI_OPTION_INVALID,
"--missing-gen-cost quadratic requires --default-gen-cost C2,C1,C0"
);
};
let [c2, c1, c0] = parse_cost_triple(value)?;
Ok(MissingGenCostPolicy::calc_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 {
fail_with!(
REQUEST_CLI_OPTION_INVALID,
"--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 = match part.parse::<f64>() {
Ok(value) => value,
Err(_) => fail_with!(
REQUEST_CLI_OPTION_INVALID,
"--default-gen-cost value `{part}` is not a number"
),
};
if !slot.is_finite() {
fail_with!(
REQUEST_CLI_OPTION_INVALID,
"--default-gen-cost values must be finite"
);
}
}
Ok(out)
}
fn emit_options(
arg: MissingGenCostArg,
default_gen_cost: Option<&str>,
gen_cost_csv: Option<&Path>,
) -> anyhow::Result<EmitOptions> {
let missing_gen_cost = missing_gen_cost_policy(arg, default_gen_cost)?;
let gen_cost_patches = match gen_cost_csv {
Some(path) => {
let text = read_text_file(path)?;
powerio_tx::parse_gen_cost_csv(&text)
.with_context(|| format!("parsing generator cost CSV {}", path.display()))?
}
None => Vec::new(),
};
Ok(EmitOptions {
missing_gen_cost,
gen_cost_patches,
})
}
#[allow(clippy::too_many_arguments)]
fn run_dcopf(
input: &Path,
from: Option<FormatArg>,
output: &Path,
formula: BranchSusceptanceFormula,
units: Units,
missing_gen_cost: MissingGenCostArg,
default_gen_cost: Option<&str>,
gen_cost_csv: Option<&Path>,
) -> anyhow::Result<()> {
let mpc = balanced_case(input, from).with_context(|| format!("parse {}", input.display()))?;
let cost_opts = emit_options(missing_gen_cost, default_gen_cost, gen_cost_csv)?;
let mut policy_network = mpc.clone();
let cost_report = policy_network
.apply_gen_cost_policy(&cost_opts.gen_cost_patches, cost_opts.missing_gen_cost)?;
let instance = powerio_prob::DcOpfInstance::from_network(policy_network)
.with_context(|| format!("build DC OPF instance for {}", input.display()))?
.with_branch_susceptance_formula(formula);
let mut assembly = DcOpfAssemblyOptions::default();
assembly.units = units;
let bundle_options = DcOpfBundleOptions {
assembly,
metadata: DcOpfBundleMetadata {
cost_policy: cost_opts.missing_gen_cost,
cost_report,
},
};
let outputs = emit_dcopf_bundle(&instance, output, &bundle_options)
.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) {
fail_with!(
REQUEST_CLI_OPTION_INVALID,
"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 = number_snapshots(&net_refs, base_scenario)?;
let cost_opts = emit_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 = emit_gridfm_batch(&snapshots, output, &opts)
.with_context(|| format!("export gridfm dataset for {} scenario(s)", snapshots.len()))?;
report_diagnostics(&outputs.diagnostics);
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,
from: Option<FormatArg>,
kind: MatrixKind,
scheme: Scheme,
) -> anyhow::Result<()> {
let mpc = balanced_case(input, from)?;
let opts = BuildOptions {
scheme,
..Default::default()
};
let view = powerio_matrix::IndexedNetwork::new(&mpc);
let matrix = powerio_matrix::calc_matrix(&view, kind, &opts)?;
let stats = powerio_matrix::calc_matrix_stats_for_kind(&matrix, &view, kind, &opts);
let sddm = check_sddm(&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_corpus(action: CorpusCommand) -> anyhow::Result<()> {
match action {
CorpusCommand::Ingest {
corpus,
work,
max_bytes,
} => {
let out = powerio_cli::corpus::ingest(&corpus, &work, max_bytes)?;
let members: usize = out.buckets.iter().map(|b| b.members.len()).sum();
println!(
"{} files seen, {members} cases in {} buckets, {} unreadable, {} skipped",
out.files_seen,
out.buckets.len(),
out.unreadable.len(),
out.skipped
);
}
CorpusCommand::Compare { work } => {
let out = powerio_cli::corpus::compare(&work)?;
println!("{} comparisons", out.comparisons.len());
}
CorpusCommand::Walk {
work,
walks,
hops,
seed,
settle,
} => {
let out = powerio_cli::corpus::walk::walk(&work, walks, hops, seed, settle)?;
let steps: usize = out.walks.iter().map(|w| w.hops.len()).sum();
println!(
"{} walks, {steps} hops, {} bucket(s) settled early",
out.walks.len(),
out.settled_buckets
);
}
CorpusCommand::Report {
work,
output,
summary,
} => {
let n = powerio_cli::corpus::report(&work, &output, summary.as_deref())?;
println!("{n} findings written to {}", output.display());
}
}
Ok(())
}
fn parse_gridfm_scenario(
input: &Path,
scenario: i64,
) -> anyhow::Result<(
powerio_matrix::BalancedNetwork,
Vec<powerio_core::Diagnostic>,
)> {
let module = powerio::parse_with_options(input, &parse_options(Some("gridfm"))?)
.with_context(|| format!("parsing gridfm dataset {}", input.display()))?;
let scenario_id = scenario.to_string();
let powerio::PioValue::ScenarioSet(scenarios) = module.value() else {
fail_with!(
VALIDATE_CLI_INPUT_LACKS_DATA,
"gridfm produced {}; expected powerio.ScenarioSet<powerio.BalancedNetwork>",
module.value().type_name()
);
};
let Some(value) = scenarios.get(&scenario_id) else {
fail_with!(
REQUEST_CLI_OPTION_INVALID,
"gridfm scenario {scenario} does not exist"
);
};
let powerio::PioValue::BalancedNetwork(network) = value else {
fail_with!(
VALIDATE_CLI_INPUT_LACKS_DATA,
"gridfm scenario {scenario} contains {}; expected powerio.BalancedNetwork",
value.type_name()
);
};
Ok((network.clone(), module.diagnostics().to_vec()))
}
fn run_summary(input: &Path, from: Option<FormatArg>, scenario: i64) -> anyhow::Result<()> {
if is_stdin(input) {
stdin_format(from)?;
}
let value =
if from == Some(FormatArg::Gridfm) || (from.is_none() && looks_like_gridfm_dir(input)) {
let (network, diagnostics) = parse_gridfm_scenario(input, scenario)?;
transmission_summary_json(&network, &powerio_core::render_diagnostics(&diagnostics))
} else {
match parse_family_case(input, from)? {
FamilyCase::Distribution(module) => {
let diagnostics = powerio_core::render_diagnostics(module.diagnostics());
distribution_summary_json(module.value(), &diagnostics)
}
FamilyCase::Transmission(module) => {
let diagnostics = powerio_core::render_diagnostics(module.diagnostics());
transmission_summary_json(module.value(), &diagnostics)
}
}
};
println!("{}", serde_json::to_string_pretty(&value)?);
Ok(())
}
fn run_serialize(
input: &Path,
output: Option<&Path>,
from: Option<FormatArg>,
) -> anyhow::Result<()> {
let module = load_module(input, from)?;
report_diagnostics(module.diagnostics());
let parse_errors = parse_error_count(module.diagnostics());
let text = serialize_module_text(&module)?;
powerio::deserialize(
powerio::Source::from_memory("module.pio.json", text.as_bytes().to_vec())
.context("creating the PowerIO IR validation source")?,
)
.context("reading back the PowerIO IR just serialized")?;
write_conversion_output(&text, &[], output)?;
fail_on_reported_errors(parse_errors, 0)
}
fn parse_options(format: Option<&str>) -> anyhow::Result<powerio::ParseOptions> {
let mut options = powerio::ParseOptions::default();
if let Some(format) = format {
options = options.format(format).map_err(anyhow::Error::new)?;
}
Ok(options)
}
fn load_module(
input: &Path,
from: Option<FormatArg>,
) -> anyhow::Result<powerio_core::PioModule<powerio::PioValue>> {
if is_stdin(input) {
let format = stdin_format(from)?;
return powerio::parse_with_options(stdin_source()?, &parse_options(Some(format.name()))?)
.context("parsing standard input");
}
powerio::parse_with_options(input, &parse_options(from.map(FormatArg::name))?)
.with_context(|| format!("parsing {}", input.display()))
}
fn deserialize_module(input: &Path) -> anyhow::Result<powerio_core::PioModule<powerio::PioValue>> {
let source = powerio_core::Source::open(input)
.with_context(|| format!("reading {}", input.display()))?;
powerio::deserialize(source)
.with_context(|| format!("deserializing PowerIO IR from {}", input.display()))
}
fn serialize_module_text(
module: &powerio_core::PioModule<powerio::PioValue>,
) -> anyhow::Result<String> {
let result = powerio::serialize(
module,
powerio::Destination::memory("module.pio.json")
.context("creating the PowerIO IR memory destination")?,
)
.context("serializing PowerIO IR")?;
let powerio::EmittedOutput::Memory { mut artifacts } = result.into_output() else {
anyhow::bail!("a PowerIO IR memory destination returned path output")
};
let artifact = artifacts
.pop()
.filter(|_| artifacts.is_empty())
.context("PowerIO IR serialization did not return exactly one artifact")?;
String::from_utf8(artifact.into_bytes()).context("PowerIO IR is not valid UTF-8")
}
#[cfg(test)]
fn serialize_input(input: &Path, from: Option<FormatArg>) -> anyhow::Result<(String, Vec<String>)> {
let module = load_module(input, from)?;
let errors = module_error_lines(&module);
let text = serialize_module_text(&module)?;
powerio::deserialize(
powerio::Source::from_memory("module.pio.json", text.as_bytes().to_vec())
.context("creating the PowerIO IR validation source")?,
)
.context("validating PowerIO IR deserialization")?;
Ok((text, errors))
}
#[cfg(test)]
fn module_error_lines(module: &powerio_core::PioModule<powerio::PioValue>) -> Vec<String> {
module
.diagnostics()
.iter()
.filter(|d| d.severity() >= powerio_core::DiagnosticSeverity::Error)
.map(|d| format!("{}: {}", d.code(), d.message()))
.collect()
}
fn transmission_summary_json(
net: &powerio_matrix::BalancedNetwork,
warnings: &[String],
) -> serde_json::Value {
let view = powerio_matrix::IndexedNetwork::new(net);
json!({
"schema": "powerio.summary",
powerio::version::VERSION_KEY: powerio::VERSION,
"domain": "transmission",
"model": "balanced",
"name": net.name(),
"source_format": net.source_format().name(),
"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.calc_island_count(),
"is_radial": view.is_radial(),
"reference_buses": view.reference_bus_indices(),
"connectivity_report": view.calc_connectivity_report(),
},
"warnings": warnings,
})
}
fn distribution_summary_json(
net: &powerio_dist::MulticonductorNetwork,
warnings: &[String],
) -> serde_json::Value {
json!({
"schema": "powerio.summary",
powerio::version::VERSION_KEY: powerio::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": 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
})
}
#[allow(clippy::too_many_lines)]
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 to.is_input_spelling() {
return Err(cli_failure(
&codes::REQUEST_CLI_OUTPUT_REQUIRED,
format!(
"`{}` is accepted for input only; use `--to {}`",
to.name(),
if to == FormatArg::RawxInput {
"psse-rawx"
} else {
"xiidm"
}
),
));
}
if matches!(to, FormatArg::Gridfm) {
return Err(cli_failure(
&codes::REQUEST_CLI_TARGET_UNSUPPORTED,
"`convert` cannot write a gridfm dataset; use the `gridfm` subcommand",
));
}
if matches!(to, FormatArg::Pwb) {
return Err(cli_failure(
&codes::REQUEST_CLI_OUTPUT_REQUIRED,
"`convert` cannot write PowerWorld .pwb binary cases; use `--to powerworld` for AUX text",
));
}
if matches!(to, FormatArg::IeeeCdf) {
return Err(cli_failure(
&codes::REQUEST_CLI_TARGET_UNSUPPORTED,
"`convert` cannot write IEEE CDF cases; the format is read only",
));
}
if is_stdin(input) {
stdin_format(from)?;
}
if from == Some(FormatArg::Gridfm) {
if to == FormatArg::PypsaCsv {
return convert_to_pypsa_folder(input, output, from, scenario, gen_cost_options);
}
if to == FormatArg::Cgmes {
return convert_to_cgmes(input, output, from, scenario, gen_cost_options);
}
let target = to.transmission().ok_or_else(|| {
cli_failure(
&codes::REQUEST_CLI_FAMILY_MISMATCH,
format!(
"no conversion path between the transmission and distribution format families \
(`gridfm` to `{}`)",
to.name()
),
)
})?;
let options = gen_cost_options.emit_options()?;
let (network, diagnostics) = parse_gridfm_scenario(input, scenario)?;
report_diagnostics(&diagnostics);
let emission = module_io::emit_balanced_module(
&powerio_core::PioModule::new(network),
target,
&options,
)
.with_context(|| format!("emitting {target}"))?;
report_diagnostics(&emission.diagnostics);
write_conversion_output(&emission.text, &emission.sidecars, output)?;
return Ok(());
}
if from.is_none() && cases::powerio_ir_text(input)?.is_some() {
let module = deserialize_module(input)?;
return convert_parsed_module(module, to, output, &gen_cost_options);
}
let module = load_module(input, from)?;
convert_parsed_module(module, to, output, &gen_cost_options)
}
fn convert_parsed_module(
module: powerio::PioModule<powerio::PioValue>,
to: FormatArg,
output: Option<&Path>,
gen_cost_options: &GenCostCliOptions<'_>,
) -> anyhow::Result<()> {
match &module.value() {
powerio::PioValue::BalancedNetwork(_) => {
let module = module.map_value(|value| match value {
powerio::PioValue::BalancedNetwork(network) => network,
_ => unreachable!("the value variant was checked"),
});
convert_balanced_module(&module, to, output, gen_cost_options)
}
powerio::PioValue::MulticonductorNetwork(_) => {
let module = module.map_value(|value| match value {
powerio::PioValue::MulticonductorNetwork(network) => network,
_ => unreachable!("the value variant was checked"),
});
convert_multiconductor_module(&module, to, output)
}
_ => convert_typed_module(&module, to, output),
}
}
fn convert_balanced_module(
module: &powerio::PioModule<powerio::BalancedNetwork>,
to: FormatArg,
output: Option<&Path>,
gen_cost_options: &GenCostCliOptions<'_>,
) -> anyhow::Result<()> {
let options = gen_cost_options.emit_options()?;
if to == FormatArg::PypsaCsv {
let Some(output) = output else {
fail_with!(
REQUEST_CLI_OUTPUT_REQUIRED,
"`--to pypsa-csv` requires `-o <output-dir>`"
);
};
if output.as_os_str() == "-" {
fail_with!(
REQUEST_CLI_OUTPUT_REQUIRED,
"`--to pypsa-csv` writes a directory and cannot write to stdout"
);
}
let result = powerio_tx::__emit_pypsa_csv_with_options(
module,
&options,
powerio_core::Destination::path(output),
)
.with_context(|| format!("emitting PyPSA CSV folder {}", output.display()))?;
return finish_path_emission(module.diagnostics(), &result, output);
}
if to == FormatArg::Cgmes {
let Some(output) = output else {
fail_with!(
REQUEST_CLI_OUTPUT_REQUIRED,
"`--to cgmes` requires `-o <output-path>`"
);
};
if output.as_os_str() == "-" {
fail_with!(
REQUEST_CLI_OUTPUT_REQUIRED,
"`--to cgmes` cannot write a profile set to stdout"
);
}
let result = powerio_tx::emit_with_options(
module,
powerio_tx::TargetFormat::Cgmes,
&options,
powerio_core::Destination::path(output),
)
.with_context(|| format!("emitting CGMES to {}", output.display()))?;
return finish_path_emission(module.diagnostics(), &result, output);
}
let target = to.transmission().ok_or_else(|| {
failure!(
REQUEST_CLI_FAMILY_MISMATCH,
"no conversion path between the transmission and distribution format families \
(balanced network to `{}`)",
to.name()
)
})?;
let emission = module_io::emit_balanced_module(module, target, &options)
.with_context(|| format!("emitting {target}"))?;
finish_memory_emission(module.diagnostics(), &emission, output)
}
fn convert_multiconductor_module(
module: &powerio::PioModule<powerio::MulticonductorNetwork>,
to: FormatArg,
output: Option<&Path>,
) -> anyhow::Result<()> {
let target = to.distribution().ok_or_else(|| {
failure!(
REQUEST_CLI_FAMILY_MISMATCH,
"no conversion path between the transmission and distribution format families \
(multiconductor network to `{}`)",
to.name()
)
})?;
let emission = if matches!(to, FormatArg::Bmopf010 | FormatArg::Bmopf020) {
let result = powerio::emit(module, to.name(), powerio::Destination::memory("output")?)?;
module_io::unpack_memory_emission(result, None)?
} else {
module_io::emit_multiconductor_module(module, target)
.with_context(|| format!("emitting {}", target.name()))?
};
finish_memory_emission(module.diagnostics(), &emission, output)
}
fn convert_typed_module(
module: &powerio::PioModule<powerio::PioValue>,
to: FormatArg,
output: Option<&Path>,
) -> anyhow::Result<()> {
let format = powerio::resolve_format(to.name()).ok_or_else(|| {
failure!(
REQUEST_CLI_OUTPUT_REQUIRED,
"unknown target format `{}`",
to.name()
)
})?;
if format.is_directory {
let Some(output) = output else {
fail_with!(
REQUEST_CLI_OUTPUT_REQUIRED,
"`--to {}` requires `-o <output-path>`",
format.token
);
};
if output.as_os_str() == "-" {
fail_with!(
REQUEST_CLI_OUTPUT_REQUIRED,
"`--to {}` cannot write to stdout",
format.token
);
}
let result = powerio::emit(
module,
format.token,
powerio_core::Destination::path(output),
)
.with_context(|| format!("emitting {} to {}", format.token, output.display()))?;
return finish_path_emission(module.diagnostics(), &result, output);
}
let emission = module_io::emit_module(module, format.token)
.with_context(|| format!("emitting {}", format.token))?;
finish_memory_emission(module.diagnostics(), &emission, output)
}
fn finish_memory_emission(
parse_diagnostics: &[powerio::Diagnostic],
emission: &module_io::MemoryEmission,
output: Option<&Path>,
) -> anyhow::Result<()> {
let mut diagnostics = parse_diagnostics.to_vec();
diagnostics.extend(emission.diagnostics.iter().cloned());
report_diagnostics(&diagnostics);
write_conversion_output(&emission.text, &emission.sidecars, output)?;
fail_on_reported_errors(
parse_error_count(parse_diagnostics),
parse_error_count(&emission.diagnostics),
)
}
fn finish_path_emission(
parse_diagnostics: &[powerio::Diagnostic],
result: &powerio::EmitResult,
output: &Path,
) -> anyhow::Result<()> {
let mut diagnostics = parse_diagnostics.to_vec();
diagnostics.extend(result.diagnostics().iter().cloned());
report_diagnostics(&diagnostics);
report_written(output);
fail_on_reported_errors(
parse_error_count(parse_diagnostics),
parse_error_count(result.diagnostics()),
)
}
fn parse_error_count(diagnostics: &[powerio_core::Diagnostic]) -> usize {
diagnostics
.iter()
.filter(|d| d.severity() >= powerio_core::DiagnosticSeverity::Error)
.count()
}
fn fail_on_reported_errors(parse_errors: usize, emit_errors: usize) -> anyhow::Result<()> {
if parse_errors > 0 {
return Err(cli_failure(
&codes::PARSE_CLI_ERRORS_REPORTED,
format!("the reader reported {parse_errors} error(s); the output is incomplete"),
));
}
if emit_errors > 0 {
return Err(cli_failure(
&codes::EMIT_CLI_ERRORS_REPORTED,
format!("the writer reported {emit_errors} error(s); the output is incomplete"),
));
}
Ok(())
}
fn commit_output_file(path: &std::path::Path, bytes: Vec<u8>) -> anyhow::Result<()> {
let artifact = powerio_core::MemoryArtifact::new(
powerio_core::ArtifactPath::new("case").expect("static placeholder name"),
bytes,
);
powerio_core::Destination::path(path)
.__commit_artifacts(
false,
powerio_core::Fidelity::Canonical,
vec![artifact],
Vec::new(),
)
.map(|_| ())
.map_err(anyhow::Error::new)
}
fn write_conversion_output(
text: &str,
sidecars: &[module_io::MemorySidecar],
output: Option<&std::path::Path>,
) -> anyhow::Result<()> {
match output {
Some(p) if p.as_os_str() != "-" => {
let mut committed: Vec<std::path::PathBuf> = Vec::new();
let result = (|| -> anyhow::Result<()> {
commit_output_file(p, text.as_bytes().to_vec())
.with_context(|| format!("writing {}", p.display()))?;
committed.push(p.to_path_buf());
report_written(p);
let base = p.parent().unwrap_or_else(|| std::path::Path::new("."));
for sidecar in sidecars {
if !is_relative_component_path(&sidecar.path) {
fail_with!(
EMIT_CLI_SIDECAR_PATH,
"sidecar `{}` is not a relative path under the output directory",
sidecar.path
);
}
let path = base.join(&sidecar.path);
commit_output_file(&path, sidecar.bytes.clone())
.with_context(|| format!("writing {}", path.display()))?;
committed.push(path.clone());
report_written(&path);
}
Ok(())
})();
if result.is_err() {
for created in &committed {
let _ = std::fs::remove_file(created);
}
}
result?;
}
_ => {
for sidecar in sidecars {
report_diagnostics(&[powerio_core::Diagnostic::of(
&powerio_dist::diagnostics::codes::EMIT_MULTICONDUCTOR_SIDECAR_DROPPED,
format!(
"sidecar `{}` was not written because the output is standard output",
sidecar.path
),
)]);
}
print!("{text}");
}
}
Ok(())
}
fn is_relative_component_path(path: &str) -> bool {
!path.is_empty()
&& std::path::Path::new(path)
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)))
}
fn run_geo_extract(
input: &std::path::Path,
output: Option<&std::path::Path>,
from: Option<FormatArg>,
) -> anyhow::Result<()> {
if from.is_none()
&& input
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("pwd"))
{
let module =
powerio::parse(input).with_context(|| format!("reading {}", input.display()))?;
let powerio::PioValue::GeoLayer(layer) = module.value() else {
fail_with!(
VALIDATE_CLI_INPUT_LACKS_DATA,
"{} did not read as a display file",
input.display()
);
};
if layer.features.is_empty() {
fail_with!(
VALIDATE_CLI_INPUT_LACKS_DATA,
"{} carries no substation symbols",
input.display()
);
}
return write_conversion_output(&layer.to_geojson(), &[], output);
}
let layer = match parse_family_case(input, from)? {
FamilyCase::Distribution(module) => {
report_diagnostics(module.diagnostics());
powerio::dist_geo::to_dist_geo_layer(module.value())
}
FamilyCase::Transmission(module) => {
report_diagnostics(module.diagnostics());
module.value().to_geo_layer()
}
};
if layer.features.is_empty() {
fail_with!(
VALIDATE_CLI_INPUT_LACKS_DATA,
"{} carries no coordinates to extract",
input.display()
);
}
write_conversion_output(&layer.to_geojson(), &[], output)
}
fn run_geo_apply(
input: &std::path::Path,
layer_path: &std::path::Path,
output: Option<&std::path::Path>,
to: Option<FormatArg>,
from: Option<FormatArg>,
) -> anyhow::Result<()> {
if let Some(format) = to.filter(|format| format.is_input_spelling()) {
fail_with!(
REQUEST_CLI_OUTPUT_REQUIRED,
"`{}` is accepted for input only; use `--to {}`",
format.name(),
if format == FormatArg::RawxInput {
"psse-rawx"
} else {
"xiidm"
}
);
}
let text = read_text_file(layer_path)?;
let parsed =
powerio::geo::GeoLayer::parse(&text, layer_path.file_name().and_then(|n| n.to_str()))
.with_context(|| format!("parsing layer {}", layer_path.display()))?;
report_diagnostics(&parsed.diagnostics);
let (text, sidecars, diagnostics) = match parse_family_case(input, from)? {
FamilyCase::Distribution(module) => {
report_diagnostics(module.diagnostics());
let dynamic = module.map_value(powerio::PioValue::from);
let (placed, report) = powerio::apply_geo_layer(&dynamic, &parsed.layer)?;
report_geo_apply(&report);
let placed = placed.map_value(|value| match value {
powerio::PioValue::MulticonductorNetwork(network) => network,
_ => unreachable!("apply_geo_layer preserves the network type"),
});
let target = match to {
Some(f) => f.distribution().ok_or_else(|| {
failure!(
REQUEST_CLI_TARGET_UNSUPPORTED,
"`{}` is not a distribution text target; a distribution case writes \
back to dss, pmd-json, or bmopf-json",
f.name()
)
})?,
None => placed
.value()
.source_format()
.map(|f| f.name().parse())
.transpose()?
.ok_or_else(|| {
failure!(
REQUEST_CLI_TARGET_UNSUPPORTED,
"the input carries no source format; pass --to"
)
})?,
};
let emission = module_io::emit_multiconductor_module(&placed, target)?;
(emission.text, emission.sidecars, emission.diagnostics)
}
FamilyCase::Transmission(module) => {
report_diagnostics(module.diagnostics());
let dynamic = module.map_value(powerio::PioValue::from);
let (placed, report) = powerio::apply_geo_layer(&dynamic, &parsed.layer)?;
report_geo_apply(&report);
let placed = placed.map_value(|value| match value {
powerio::PioValue::BalancedNetwork(network) => network,
_ => unreachable!("apply_geo_layer preserves the network type"),
});
let target = match to {
Some(f) => f.transmission().ok_or_else(|| {
failure!(
REQUEST_CLI_TARGET_UNSUPPORTED,
"`{}` is not a transmission text target here; apply writes a single \
case file (use `convert` for pypsa-csv and gridfm outputs)",
f.name()
)
})?,
None => powerio_tx::format::parse_target_format(&format!(
"{:?}",
placed.value().source_format()
))
.ok_or_else(|| {
failure!(
REQUEST_CLI_TARGET_UNSUPPORTED,
"`{:?}` has no write target; pass --to to choose one",
placed.value().source_format()
)
})?,
};
let emission = module_io::emit_balanced_module(
&placed,
target,
&powerio_tx::EmitOptions::default(),
)
.with_context(|| format!("emitting {target}"))?;
(emission.text, emission.sidecars, emission.diagnostics)
}
};
report_diagnostics(&diagnostics);
write_conversion_output(&text, &sidecars, output)
}
fn report_geo_apply(report: &powerio::GeoApplyReport) {
report_progress(format!(
"applied: {} bus point(s), {} branch route(s), {} unmatched feature(s)",
report.matched_buses, report.matched_branches, report.unmatched_features
));
report_progress(format!(
"unplaced: {} bus(es) with no location, {} branch(es) with no route",
report.unlocated_buses, report.unlocated_branches
));
if report.unlocated_branches > 0 {
report_progress(
"note: a branch with no route renders from its bus endpoints; only a source \
stating intermediate geometry gives it one",
);
}
for note in &report.notes {
report_progress(format!("note: {note}"));
}
}
fn run_geo_convert(
input: &std::path::Path,
output: Option<&std::path::Path>,
) -> anyhow::Result<()> {
let text = read_text_file(input)?;
let parsed = powerio::geo::GeoLayer::parse(&text, input.file_name().and_then(|n| n.to_str()))
.with_context(|| format!("parsing {}", input.display()))?;
report_diagnostics(&parsed.diagnostics);
let text = parsed
.layer
.to_geojson_checked()
.map_err(anyhow::Error::new)
.with_context(|| format!("converting {}", input.display()))?;
write_conversion_output(&text, &[], output)
}
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 {
fail_with!(
REQUEST_CLI_OUTPUT_REQUIRED,
"`--to pypsa-csv` requires `-o <output-dir>`"
);
};
if out_dir.as_os_str() == "-" {
fail_with!(
REQUEST_CLI_OUTPUT_REQUIRED,
"`--to pypsa-csv` writes a directory and cannot write to stdout"
);
}
let net = if from == Some(FormatArg::Gridfm) {
let (network, diagnostics) = parse_gridfm_scenario(input, scenario)?;
report_diagnostics(&diagnostics);
network
} else {
read_network(input, from)?
};
let options = gen_cost_options.emit_options()?;
let result = powerio_tx::__emit_pypsa_csv_with_options(
&powerio_core::PioModule::new(net),
&options,
powerio_core::Destination::path(out_dir),
)
.with_context(|| format!("emitting PyPSA CSV folder {}", out_dir.display()))?;
report_diagnostics(result.diagnostics());
report_written(out_dir);
Ok(())
}
fn convert_to_cgmes(
input: &std::path::Path,
output: Option<&std::path::Path>,
from: Option<FormatArg>,
scenario: i64,
gen_cost_options: GenCostCliOptions<'_>,
) -> anyhow::Result<()> {
let Some(output) = output else {
fail_with!(
REQUEST_CLI_OUTPUT_REQUIRED,
"`--to cgmes` requires `-o <output-path>`"
);
};
if output.as_os_str() == "-" {
fail_with!(
REQUEST_CLI_OUTPUT_REQUIRED,
"`--to cgmes` cannot write a profile set to stdout"
);
}
let module = if from == Some(FormatArg::Gridfm) {
let (network, diagnostics) = parse_gridfm_scenario(input, scenario)?;
report_diagnostics(&diagnostics);
powerio_core::PioModule::new(network)
} else {
match parse_family_case(input, from)? {
FamilyCase::Transmission(module) => *module,
FamilyCase::Distribution(_) => fail_with!(
REQUEST_CLI_FAMILY_MISMATCH,
"{} is a distribution case; CGMES represents a balanced transmission network",
input.display()
),
}
};
let parse_errors = parse_error_count(module.diagnostics());
report_diagnostics(module.diagnostics());
let options = gen_cost_options.emit_options()?;
let result = powerio_tx::emit_with_options(
&module,
powerio_tx::TargetFormat::Cgmes,
&options,
powerio_core::Destination::path(output),
)
.with_context(|| format!("emitting CGMES to {}", output.display()))?;
report_diagnostics(result.diagnostics());
report_written(output);
fail_on_reported_errors(parse_errors, 0)
}
enum FamilyCase {
Transmission(Box<powerio_core::PioModule<powerio::BalancedNetwork>>),
Distribution(Box<powerio_core::PioModule<powerio::MulticonductorNetwork>>),
}
fn balanced_case(
input: &Path,
from: Option<FormatArg>,
) -> anyhow::Result<powerio_matrix::BalancedNetwork> {
match parse_family_case(input, from)? {
FamilyCase::Transmission(module) => {
report_diagnostics(module.diagnostics());
Ok(module.into_value())
}
FamilyCase::Distribution(_) => Err(cli_failure(
&codes::REQUEST_CLI_FAMILY_MISMATCH,
format!(
"{} is a distribution case; this command needs a transmission network",
input.display()
),
)),
}
}
fn ir_family_case(input: &Path) -> anyhow::Result<FamilyCase> {
let module = deserialize_module(input)?;
match &module.value() {
powerio::PioValue::BalancedNetwork(_) => {
let module = module.map_value(|value| match value {
powerio::PioValue::BalancedNetwork(network) => network,
_ => unreachable!("the value variant was checked"),
});
Ok(FamilyCase::Transmission(Box::new(module)))
}
powerio::PioValue::MulticonductorNetwork(_) => {
let module = module.map_value(|value| match value {
powerio::PioValue::MulticonductorNetwork(network) => network,
_ => unreachable!("the value variant was checked"),
});
Ok(FamilyCase::Distribution(Box::new(module)))
}
other => fail_with!(
REQUEST_CLI_FAMILY_MISMATCH,
"{} stores {}; this command requires powerio.BalancedNetwork or \
powerio.MulticonductorNetwork",
input.display(),
other.type_name()
),
}
}
fn parse_family_case(input: &Path, from: Option<FormatArg>) -> anyhow::Result<FamilyCase> {
if is_stdin(input) {
let f = stdin_format(from)?;
let source = module_io::declare_format(stdin_source()?, Some(f.name()))
.context("declaring the standard input format")?;
return if f.distribution().is_some() {
let net = powerio_dist::parse(source).context("reading standard input")?;
Ok(FamilyCase::Distribution(Box::new(net)))
} else {
let parsed = powerio_tx::parse(source).context("reading standard input")?;
Ok(FamilyCase::Transmission(Box::new(parsed)))
};
}
if let Some(f) = from {
if f == FormatArg::Gridfm {
return Err(cli_failure(
&codes::REQUEST_CLI_OPTION_INVALID,
"gridfm datasets are read by `convert --from gridfm` or the `gridfm` \
subcommand, not this command",
));
}
return if f.distribution().is_some() {
let net = module_io::load_multiconductor_module(input, Some(f.name()))
.with_context(|| format!("reading {}", input.display()))?;
Ok(FamilyCase::Distribution(Box::new(net)))
} else {
let parsed = module_io::load_balanced_module(input, Some(f.name()))
.with_context(|| format!("reading {}", input.display()))?;
Ok(FamilyCase::Transmission(Box::new(parsed)))
};
}
if cases::powerio_ir_text(input)?.is_some() {
return ir_family_case(input);
}
if let Some(case) = cases::classified_json(input)? {
return parse_classified_case(&case, input);
}
if cases::looks_like_distribution_input(input)? {
let net = module_io::load_multiconductor_module(input, None)
.with_context(|| format!("reading {}", input.display()))?;
Ok(FamilyCase::Distribution(Box::new(net)))
} else {
let parsed = module_io::load_balanced_module(input, None)
.with_context(|| format!("reading {}", input.display()))?;
Ok(FamilyCase::Transmission(Box::new(parsed)))
}
}
fn parse_classified_case(case: &cases::ClassifiedCase, input: &Path) -> anyhow::Result<FamilyCase> {
match case.format {
DetectedFormat::Distribution(_) => {
let format = powerio_dist::classify_distribution_json(&case.text)
.with_context(|| format!("reading {}", input.display()))?;
let net = module_io::load_multiconductor_memory(&case.text, format.name())
.with_context(|| format!("reading {}", input.display()))?;
Ok(FamilyCase::Distribution(Box::new(net)))
}
DetectedFormat::Transmission(format) => {
let stem = input.file_stem().and_then(|s| s.to_str());
let parsed = module_io::load_balanced_memory_named(&case.text, format.name(), stem)
.with_context(|| format!("reading {}", input.display()))?;
Ok(FamilyCase::Transmission(Box::new(parsed)))
}
_ => unreachable!("classified_json returns transmission or distribution formats only"),
}
}
fn reject_nontransmission_from(from: Option<FormatArg>) -> anyhow::Result<()> {
if let Some(f) = from {
if matches!(f, FormatArg::Gridfm) {
fail_with!(
REQUEST_CLI_OPTION_INVALID,
"gridfm datasets are read by `convert --from gridfm` or the `gridfm` \
subcommand, not this command"
);
}
if f.distribution().is_some() {
fail_with!(
REQUEST_CLI_FAMILY_MISMATCH,
"`{}` is a distribution format; this command reads transmission cases \
(use `convert` to bridge dss, pmd-json, and bmopf-json)",
f.name()
);
}
}
Ok(())
}
fn read_network(
input: &std::path::Path,
from: Option<FormatArg>,
) -> anyhow::Result<powerio_matrix::BalancedNetwork> {
reject_nontransmission_from(from)?;
let parsed = module_io::load_balanced_module(input, from.map(FormatArg::name))
.with_context(|| format!("reading {}", input.display()))?;
report_diagnostics(parsed.diagnostics());
Ok(parsed.into_value())
}
#[cfg(test)]
mod tests {
use super::cases::{infer_input_family, looks_like_distribution_input};
use super::{
BranchSusceptanceFormulaArg, Cli, Command, FamilyCase, FormatArg, GenCostCliOptions,
distribution_summary_json, parse_family_case, run_convert, run_serialize, serialize_input,
transmission_summary_json,
};
use clap::Parser;
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)
}
fn deserialize_module(text: &str) -> powerio::PioModule<powerio::PioValue> {
let source =
powerio::Source::from_memory("module.pio.json", text.as_bytes().to_vec()).unwrap();
powerio::deserialize(source).unwrap()
}
#[test]
fn summary_json_matches_canonical_transmission_shape() {
let parsed = crate::module_io::load_balanced_module(data("case9.m"), None).unwrap();
let diagnostics = powerio_core::render_diagnostics(parsed.diagnostics());
let value = transmission_summary_json(parsed.value(), &diagnostics);
assert_eq!(value["schema"], "powerio.summary");
assert_eq!(value[powerio::version::VERSION_KEY], powerio::VERSION);
assert_eq!(value["domain"], "transmission");
assert_eq!(value["model"], "balanced");
assert_eq!(value["elements"]["buses"], 9);
assert_eq!(value["topology"]["connected_components"], 1);
}
#[test]
fn opfdata_alias_routes_through_the_transmission_hub() {
let cli = Cli::try_parse_from([
"powerio",
"convert",
"example_0.json",
"--from",
"opfdata",
"--to",
"matpower",
])
.unwrap();
match cli.command {
Some(Command::Convert { from, to, .. }) => {
assert_eq!(from, Some(FormatArg::DeepMindOpfDataJson));
assert_eq!(to, FormatArg::Matpower);
}
other => panic!("unexpected command: {other:?}"),
}
let parsed =
crate::module_io::load_balanced_module(data("opfdataset/example_0.json"), None)
.unwrap();
assert_eq!(
parsed.value().source_format(),
powerio_matrix::SourceFormat::DeepMindOpfDataJson
);
}
#[test]
fn summary_json_matches_canonical_distribution_shape() {
let net = crate::module_io::load_multiconductor_module(
&data("dist/micro/xfmr_single_phase.dss"),
None,
)
.unwrap();
let diagnostics = powerio_core::render_diagnostics(net.diagnostics());
let value = distribution_summary_json(net.value(), &diagnostics);
assert_eq!(value["schema"], "powerio.summary");
assert_eq!(value[powerio::version::VERSION_KEY], powerio::VERSION);
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 family_case_routes_json_by_classifier_without_from() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let egret = dir.join("myegret.json");
std::fs::write(
&egret,
std::fs::read_to_string(data("egret/case9.json")).unwrap(),
)
.unwrap();
match parse_family_case(&egret, None).unwrap() {
FamilyCase::Transmission(parsed) => {
assert_eq!(parsed.value().buses().len(), 9);
assert_eq!(parsed.value().name(), "myegret");
}
FamilyCase::Distribution(_) => panic!("egret JSON classified as distribution"),
}
let dist = dir.join("feeder.json");
std::fs::write(
&dist,
r#"{
"bus":{"a":{"terminal_names":["1"]}},
"voltage_source":{"source":{
"bus":"a",
"terminal_map":["1"],
"v_magnitude":[1.0],
"v_angle":[0.0]
}},
"meta":{"version":"0.1.0"}
}"#,
)
.unwrap();
match parse_family_case(&dist, None).unwrap() {
FamilyCase::Distribution(net) => assert_eq!(net.value().buses().len(), 1),
FamilyCase::Transmission(_) => panic!("BMOPF JSON classified as transmission"),
}
}
#[test]
fn family_case_applies_the_distribution_readers_own_refusal() {
let tmp = tempfile::tempdir().unwrap();
let orphan = tmp.path().join("orphan.json");
std::fs::write(&orphan, r#"{"linecode":{"lc1":{"nphases":1}}}"#).unwrap();
let text = match parse_family_case(&orphan, None) {
Err(err) => format!("{err:#}"),
Ok(_) => panic!("a linecode-only document parsed as a distribution case"),
};
assert!(
text.contains("not a recognized distribution document"),
"{text}"
);
}
#[test]
fn serialized_module_reads_back_as_a_family_case() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("powerio-cli-stored-{stamp}.pio.json"));
let (text, _) = serialize_input(&data("case9.m"), None).unwrap();
std::fs::write(&path, text).unwrap();
match super::ir_family_case(&path).unwrap() {
super::FamilyCase::Transmission(parsed) => {
assert_eq!(parsed.value().buses().len(), 9);
}
super::FamilyCase::Distribution(_) => panic!("case9 is transmission"),
}
let net = super::balanced_case(&path, None).unwrap();
assert_eq!(net.buses().len(), 9);
let _ = std::fs::remove_file(path);
}
#[test]
fn serialize_command_parses() {
let cli = Cli::try_parse_from(["powerio", "serialize", "case9.m"]).unwrap();
match cli.command.unwrap() {
Command::Serialize { input, .. } => assert_eq!(input, Path::new("case9.m")),
other => panic!("expected serialize command, got {other:?}"),
}
}
#[test]
fn ir_text_matches_module_shape_and_provenance() {
let input = data("case9.m");
let (text, _) = serialize_input(&input, None).unwrap();
let doc: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(doc["schema"], powerio::IR_SCHEMA_NAME);
assert_eq!(doc["version"], powerio::IR_VERSION);
assert_eq!(doc["value"]["type"], "powerio.BalancedNetwork");
assert_eq!(doc["value"]["data"]["buses"].as_array().unwrap().len(), 9);
let sources = doc["sources"].as_array().unwrap();
assert!(
sources.iter().any(|s| {
s["name"]
.as_str()
.is_some_and(|name| name.ends_with("case9.m"))
}),
"expected the case file among the module sources: {sources:?}"
);
let module = deserialize_module(&text);
assert!(matches!(
&module.value(),
powerio::PioValue::BalancedNetwork(_)
));
}
#[test]
fn serialize_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_serialize(&data("case9.m"), Some(&output), None).unwrap();
let text = std::fs::read_to_string(&output).unwrap();
let module = deserialize_module(&text);
assert!(matches!(
&module.value(),
powerio::PioValue::BalancedNetwork(_)
));
let _ = std::fs::remove_file(output);
}
#[test]
fn module_helper_returns_stdout_text() {
let (text, _) = serialize_input(&data("case9.m"), None).unwrap();
let doc: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(doc["producer"]["name"], "powerio");
assert_eq!(doc["value"]["data"]["buses"].as_array().unwrap().len(), 9);
}
#[test]
fn ir_text_round_trips_through_deserialize() {
let (text, _) = serialize_input(&data("case9.m"), None).unwrap();
let module = deserialize_module(&text);
let again = super::serialize_module_text(&module).unwrap();
assert_eq!(
text, again,
"the PowerIO IR document is serialization stable"
);
}
#[test]
fn ir_distribution_fixture_stores_the_multiconductor_value() {
let input = data("dist/micro/xfmr_single_phase.dss");
let (text, _) = serialize_input(&input, None).unwrap();
let doc: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(doc["value"]["type"], "powerio.MulticonductorNetwork");
let sources = doc["sources"].as_array().unwrap();
assert!(
sources.iter().any(|s| {
s["name"]
.as_str()
.is_some_and(|name| name.ends_with("xfmr_single_phase.dss"))
}),
"expected the dss file among the module sources: {sources:?}"
);
}
#[test]
#[allow(clippy::float_cmp)]
fn ir_carries_a_nonfinite_payload_as_string_spellings() {
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();
run_serialize(&input, Some(&output), None).unwrap();
let text = std::fs::read_to_string(&output).unwrap();
assert!(text.contains("\"angmin\": \"NaN\""), "{text}");
assert!(text.contains("\"angmax\": \"Infinity\""), "{text}");
let module = deserialize_module(&text);
let powerio::PioValue::BalancedNetwork(network) = &module.value() else {
panic!(
"expected a balanced network, found {}",
module.value().type_name()
);
};
assert!(network.branches()[0].angmin.is_nan());
assert_eq!(network.branches()[0].angmax, f64::INFINITY);
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 = crate::module_io::load_balanced_module(data("case9.m"), None).unwrap();
let emission = crate::module_io::emit_balanced_module(
&parsed,
powerio_tx::TargetFormat::PowerModelsJson,
&powerio_tx::EmitOptions::default(),
)
.unwrap();
std::fs::write(&input, emission.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 = crate::module_io::load_balanced_module(data("case9.m"), None).unwrap();
powerio_tx::__emit_pypsa_csv(&parsed, powerio_core::Destination::path(&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_writes_distribution_sidecars_next_to_output_file() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("powerio-convert-sidecar-{stamp}"));
std::fs::create_dir_all(&dir).unwrap();
let input = dir.join("geo.bmopf.json");
let output = dir.join("geo.dss");
let mut bus = powerio_dist::DistBus::new("sourcebus", vec!["1".to_owned(), "4".to_owned()]);
bus.grounded = vec!["4".to_owned()];
bus.location = Some(powerio_dist::DistLocation {
x: -80.0,
y: 35.0,
kind: None,
});
let mut net = powerio_dist::MulticonductorNetwork::default();
*net.geo_mut() = Some(powerio_dist::DistGeoMeta {
space: powerio_dist::CoordinateSpace::Geographic { crs: None },
kind: Some(powerio_dist::DistCoordsKind::Source),
});
*net.buses_mut() = vec![bus];
net.sources_mut().push(powerio_dist::VoltageSource::new(
"source",
"sourcebus",
vec!["1".to_owned(), "4".to_owned()],
vec![7200.0, 0.0],
vec![0.0, 0.0],
));
let mut options = powerio_dist::EmitOptions::default();
options.bmopf.sideload_coordinates = true;
let module = powerio_core::PioModule::new(net);
let bmopf = crate::module_io::emit_multiconductor_module_with_options(
&module,
powerio_dist::DistTargetFormat::BmopfJson,
&options,
)
.unwrap();
std::fs::write(&input, bmopf.text).unwrap();
run_convert(
&input,
FormatArg::Dss,
Some(&output),
Some(FormatArg::BmopfJson),
0,
GenCostCliOptions::preserve(),
)
.unwrap();
let dss = std::fs::read_to_string(&output).unwrap();
let coords = std::fs::read_to_string(dir.join("buscoords.csv")).unwrap();
assert!(dss.contains("Buscoords buscoords.csv"), "{dss}");
assert!(coords.contains("sourcebus,-80,35"), "{coords}");
let _ = std::fs::remove_dir_all(dir);
}
#[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}"
);
}
#[test]
fn convert_rejects_ieee_cdf_target() {
let err = run_convert(
&data("case9.m"),
FormatArg::IeeeCdf,
None,
None,
0,
GenCostCliOptions::preserve(),
)
.unwrap_err();
assert!(err.to_string().contains("cannot write IEEE CDF"), "{err}");
}
#[test]
fn sidecar_paths_must_stay_under_the_output_directory() {
for bad in [
"../escape.csv",
"a/../../escape.csv",
"/etc/passwd",
"",
"..",
] {
assert!(
!super::is_relative_component_path(bad),
"{bad:?} was accepted as a sidecar path"
);
}
for good in ["buscoords.csv", "sub/dir/buscoords.csv", "a.b.c"] {
assert!(
super::is_relative_component_path(good),
"{good:?} was rejected as a sidecar path"
);
}
}
#[test]
fn formula_tokens_are_the_stable_formula_names() {
use clap::ValueEnum;
use powerio_matrix::matrix::BranchSusceptanceFormula;
for arg in BranchSusceptanceFormulaArg::value_variants() {
let primary = arg.to_possible_value().unwrap().get_name().to_string();
let parsed = BranchSusceptanceFormula::from_formula_name(&primary.replace('-', "_"))
.unwrap_or_else(|| panic!("{primary} is not a formula name"));
assert_eq!(parsed, BranchSusceptanceFormula::from(*arg));
}
for alias in ["series", "series-impedance", "matpower"] {
assert!(
BranchSusceptanceFormulaArg::from_str(alias, false).is_err(),
"{alias} must not remain a 1.0 formula alias"
);
}
}
}