use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use anyhow::Result;
use powerio_dist::MulticonductorNetwork;
use powerio_matrix::BalancedNetwork;
use serde::{Deserialize, Serialize};
use super::anonymize;
use super::{Bucket, Domain, Ingest, balanced, catch_panic, core_delta, multiconductor};
use crate::invariants;
pub(super) const WALK_FILE: &str = "walks.json";
pub(super) const LEDGER_FILE: &str = "ledger.json";
pub const ALPHABET: [&str; 10] = [
"matpower",
"psse",
"psse34",
"psse35",
"powerworld",
"pandapower-json",
"powermodels-json",
"egret-json",
"pslf",
"surge-json",
];
pub const DIST_ALPHABET: [&str; 3] = ["dss", "pmd-json", "bmopf-json"];
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HopDelta {
pub core_changed: Option<String>,
pub ybus: Option<String>,
pub injection: Option<String>,
pub dc_terminal: Option<String>,
pub model_diffs: Vec<String>,
}
impl HopDelta {
fn of(before: &BalancedNetwork, after: &BalancedNetwork) -> Self {
let mut out = Self::default();
let (core_before, core_after) = (
invariants::transmission_core(before),
invariants::transmission_core(after),
);
if core_before != core_after {
out.core_changed = Some(core_delta(&core_before, &core_after));
}
if let Ok(Some(change)) = invariants::ybus_change(before, after) {
out.ybus = Some(change.to_string());
}
if let Some(change) = invariants::injection_change(before, after) {
out.injection = Some(change.to_string());
}
if let Some(change) = invariants::dc_terminal_change(before, after) {
out.dc_terminal = Some(change.to_string());
}
out.finish_diffs(&invariants::model_diffs(
&invariants::transmission_value(before),
&invariants::transmission_value(after),
));
out
}
fn of_dist(before: &MulticonductorNetwork, after: &MulticonductorNetwork) -> Self {
let mut out = Self::default();
let (core_before, core_after) = (
invariants::distribution_core(before),
invariants::distribution_core(after),
);
if core_before != core_after {
out.core_changed = Some(super::dist_core_delta(&core_before, &core_after));
}
out.finish_diffs(&invariants::model_diffs(
&invariants::distribution_value(before),
&invariants::distribution_value(after),
));
out
}
fn finish_diffs(&mut self, diffs: &[invariants::ModelDiff]) {
self.model_diffs = diffs
.iter()
.map(|d| anonymize::collapse_path(&d.path))
.collect();
self.model_diffs.sort();
self.model_diffs.dedup();
}
fn is_empty(&self) -> bool {
!self.electrical() && self.model_diffs.is_empty()
}
pub(super) fn electrical(&self) -> bool {
self.core_changed.as_deref().is_some_and(super::power_moved)
|| self.ybus.is_some()
|| self.injection.is_some()
|| self.dc_terminal.is_some()
}
fn signatures(&self, into: &mut BTreeSet<String>) {
for (key, present) in [
("core", self.core_changed.is_some()),
("ybus", self.ybus.is_some()),
("injection", self.injection.is_some()),
("dc", self.dc_terminal.is_some()),
] {
if present {
into.insert(key.to_string());
}
}
for path in &self.model_diffs {
into.insert(format!("path:{path}"));
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hop {
pub to: String,
pub warnings: Vec<String>,
pub failure: Option<String>,
pub leg: HopDelta,
pub path_dependent: Option<String>,
#[serde(default)]
pub path_dependent_electrical: bool,
pub emptied: Vec<String>,
pub resurrected: Vec<String>,
pub revisit_of: Option<usize>,
pub drift: Option<String>,
#[serde(default)]
pub drift_electrical: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Walk {
pub bucket: String,
pub seed: u64,
pub origin: String,
pub hops: Vec<Hop>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Walks {
pub walks: Vec<Walk>,
pub settled_buckets: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EdgeMemory {
pub visits: usize,
pub novel: usize,
pub signatures: BTreeSet<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Ledger {
pub edges: BTreeMap<String, EdgeMemory>,
pub walks: usize,
}
impl Ledger {
fn key(from: &str, to: &str) -> String {
format!("{from}>{to}")
}
fn weight(&self, from: &str, to: &str) -> f64 {
self.edges
.get(&Self::key(from, to))
.map_or(1.0, |e| (1.0 + e.novel as f64) / (1.0 + e.visits as f64))
}
fn learn(&mut self, from: &str, to: &str, signatures: &BTreeSet<String>) -> bool {
let entry = self.edges.entry(Self::key(from, to)).or_default();
entry.visits += 1;
let before = entry.signatures.len();
entry.signatures.extend(signatures.iter().cloned());
let novel = entry.signatures.len() > before;
if novel {
entry.novel += 1;
}
novel
}
}
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Self(seed | 1)
}
fn next_u64(&mut self) -> u64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
fn weighted(&mut self, weights: &[f64]) -> usize {
let total: f64 = weights.iter().sum();
if total <= 0.0 {
return weights.len().saturating_sub(1);
}
let unit = (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
let mut cursor = unit * total;
for (i, w) in weights.iter().enumerate() {
cursor -= w;
if cursor < 0.0 {
return i;
}
}
weights.len() - 1
}
}
#[derive(Clone)]
enum Net {
Balanced(BalancedNetwork),
Dist(MulticonductorNetwork),
}
impl Net {
fn delta(&self, after: &Net) -> HopDelta {
match (self, after) {
(Self::Balanced(a), Self::Balanced(b)) => HopDelta::of(a, b),
(Self::Dist(a), Self::Dist(b)) => HopDelta::of_dist(a, b),
_ => HopDelta::default(),
}
}
fn emptied(&self, after: &Net) -> Vec<String> {
match (self, after) {
(Self::Balanced(a), Self::Balanced(b)) => {
let (a, b) = (
invariants::transmission_core(a),
invariants::transmission_core(b),
);
zeroed(&[
("branches", a.branches, b.branches),
("generators", a.generators, b.generators),
("loads", a.loads, b.loads),
("shunts", a.shunts, b.shunts),
])
}
(Self::Dist(a), Self::Dist(b)) => {
let (a, b) = (
invariants::distribution_core(a),
invariants::distribution_core(b),
);
zeroed(&[
("buses", a.buses, b.buses),
("generators", a.generators, b.generators),
("loads", a.loads, b.loads),
("shunts", a.shunts, b.shunts),
])
}
_ => Vec::new(),
}
}
fn convert(&self, to: &str, warnings: &mut Vec<String>) -> std::result::Result<Net, String> {
match self {
Self::Balanced(net) => {
let Some(target) = powerio_tx::format::parse_target_format(to) else {
return Err(format!("no writer for {to}"));
};
let module = powerio_core::PioModule::new(net.clone());
let emission = match catch_panic(|| {
crate::module_io::emit_balanced_module(
&module,
target,
&powerio_tx::EmitOptions::default(),
)
}) {
Ok(Ok(emission)) => emission,
Ok(Err(err)) => return Err(format!("emit: {err}")),
Err(message) => return Err(format!("emit panicked: {message}")),
};
warnings.extend(emission.render_diagnostics());
match catch_panic(|| crate::module_io::load_balanced_memory(&emission.text, to)) {
Ok(Ok(parsed)) => {
warnings.extend(powerio_core::render_diagnostics(parsed.diagnostics()));
Ok(Self::Balanced(parsed.into_value()))
}
Ok(Err(err)) => Err(format!("readback: {err}")),
Err(message) => Err(format!("readback panicked: {message}")),
}
}
Self::Dist(net) => {
let Some(target) = powerio_dist::parse_dist_target_format(to) else {
return Err(format!("no writer for {to}"));
};
let module = powerio_core::PioModule::new(net.clone());
let emission = match catch_panic(|| {
crate::module_io::emit_multiconductor_module(&module, target)
}) {
Ok(Ok(emission)) => emission,
Ok(Err(err)) => return Err(format!("emit: {err}")),
Err(message) => return Err(format!("emit panicked: {message}")),
};
warnings.extend(emission.render_diagnostics());
if super::has_include(&emission.text) {
return Err("emitted deck redirects to include files".to_string());
}
match catch_panic(|| {
crate::module_io::load_multiconductor_memory(&emission.text, to)
}) {
Ok(Ok(parsed)) => {
warnings.extend(powerio_core::render_diagnostics(parsed.diagnostics()));
Ok(Self::Dist(parsed.into_value()))
}
Ok(Err(err)) => Err(format!("readback: {err}")),
Err(message) => Err(format!("readback panicked: {message}")),
}
}
}
}
}
fn zeroed(counts: &[(&str, usize, usize)]) -> Vec<String> {
counts
.iter()
.filter(|&&(_, had, has)| had > 0 && has == 0)
.map(|&(label, ..)| label.to_string())
.collect()
}
pub fn walk(work: &Path, walks: usize, hops: usize, seed: u64, settle: usize) -> Result<Walks> {
let ingest: Ingest = super::read_json(&work.join(super::INGEST_FILE))?;
let ledger_path = work.join(LEDGER_FILE);
let mut ledger: Ledger = super::read_json(&ledger_path).unwrap_or_default();
let mut out = Walks::default();
let mut rng = Rng::new(seed);
for bucket in &ingest.buckets {
let Some((origin_format, origin)) = first_readable(bucket) else {
continue;
};
let alphabet: &[&str] = match bucket.domain {
Domain::Transmission => &ALPHABET,
Domain::Distribution => &DIST_ALPHABET,
};
let mut direct: BTreeMap<String, Option<Net>> = BTreeMap::new();
let mut dry = 0usize;
for _ in 0..walks {
if dry >= settle {
break;
}
let walk_seed = rng.next_u64();
let path = draw_path(
&ledger,
alphabet,
&origin_format,
hops,
&mut Rng::new(walk_seed),
);
let (walk, learned) = run_walk(
bucket.id.clone(),
walk_seed,
&origin_format,
&origin,
&path,
&mut ledger,
&mut direct,
);
dry = if learned { 0 } else { dry + 1 };
ledger.walks += 1;
out.walks.push(walk);
}
if dry >= settle {
out.settled_buckets += 1;
}
}
super::write_json(&ledger_path, &ledger)?;
super::write_json(&work.join(WALK_FILE), &out)?;
Ok(out)
}
fn first_readable(bucket: &Bucket) -> Option<(String, Net)> {
bucket.members.iter().find_map(|m| {
let net = match bucket.domain {
Domain::Transmission => {
let net = balanced(&m.path)?;
Net::Balanced(net)
}
Domain::Distribution => {
let mut net = multiconductor(&m.path)?;
*net.source_format_mut() = None;
Net::Dist(net)
}
};
Some((m.format.clone(), net))
})
}
fn draw_path(
ledger: &Ledger,
alphabet: &[&str],
origin: &str,
hops: usize,
rng: &mut Rng,
) -> Vec<String> {
let mut path = Vec::with_capacity(hops);
let mut here = origin.to_string();
for _ in 0..hops {
let choices: Vec<&str> = alphabet
.iter()
.copied()
.filter(|f| *f != here.as_str())
.collect();
let weights: Vec<f64> = choices.iter().map(|to| ledger.weight(&here, to)).collect();
let pick = choices[rng.weighted(&weights)].to_string();
here.clone_from(&pick);
path.push(pick);
}
path
}
fn run_walk(
bucket: String,
seed: u64,
origin_format: &str,
origin: &Net,
path: &[String],
ledger: &mut Ledger,
direct: &mut BTreeMap<String, Option<Net>>,
) -> (Walk, bool) {
let mut walk = Walk {
bucket,
seed,
origin: origin_format.to_string(),
hops: Vec::with_capacity(path.len()),
};
let mut learned = false;
let mut current = origin.clone();
let mut here = origin_format.to_string();
let mut seen: Vec<(String, Net)> = Vec::new();
for to in path {
let mut hop = Hop {
to: to.clone(),
warnings: Vec::new(),
failure: None,
leg: HopDelta::default(),
path_dependent: None,
path_dependent_electrical: false,
emptied: Vec::new(),
resurrected: Vec::new(),
revisit_of: None,
drift: None,
drift_electrical: false,
};
let next = match current.convert(to, &mut hop.warnings) {
Ok(net) => net,
Err(message) => {
let mut signatures = BTreeSet::new();
signatures.insert(format!("fail:{}", warning_shape(&message)));
learned |= ledger.learn(&here, to, &signatures);
hop.failure = Some(message);
walk.hops.push(hop);
break;
}
};
hop.leg = current.delta(&next);
hop.emptied = origin.emptied(&next);
hop.resurrected = next.emptied(¤t);
if let Some(index) = seen.iter().position(|(f, _)| f == to) {
hop.revisit_of = Some(index);
let delta = seen[index].1.delta(&next);
if !delta.is_empty() {
hop.drift_electrical = delta.electrical();
hop.drift = Some(describe(&delta));
}
}
if !seen.is_empty() {
let baseline = direct
.entry(to.clone())
.or_insert_with(|| origin.convert(to, &mut Vec::new()).ok());
if let Some(direct_net) = baseline {
let delta = direct_net.delta(&next);
if !delta.is_empty() {
hop.path_dependent_electrical = delta.electrical();
hop.path_dependent = Some(describe(&delta));
}
}
}
let mut signatures = BTreeSet::new();
hop.leg.signatures(&mut signatures);
for warning in &hop.warnings {
signatures.insert(format!("warn:{}", warning_shape(warning)));
}
for (code, present) in [
("drift", hop.drift.is_some()),
("path-dependent", hop.path_dependent.is_some()),
("resurrection", !hop.resurrected.is_empty()),
] {
if present {
signatures.insert(code.to_string());
}
}
learned |= ledger.learn(&here, to, &signatures);
seen.push((to.clone(), next.clone()));
here.clone_from(to);
current = next;
walk.hops.push(hop);
}
(walk, learned)
}
fn describe(delta: &HopDelta) -> String {
let mut parts = Vec::new();
for value in [
delta.core_changed.as_ref(),
delta.ybus.as_ref(),
delta.injection.as_ref(),
delta.dc_terminal.as_ref(),
]
.into_iter()
.flatten()
{
parts.push(value.clone());
}
if !delta.model_diffs.is_empty() {
parts.push(delta.model_diffs.join(", "));
}
parts.join("; ")
}
fn warning_shape(warning: &str) -> String {
warning
.split_whitespace()
.take(8)
.map(|w| {
let prose = w
.chars()
.all(|c| c.is_ascii_lowercase() || "()[]{}.,:;`-_/".contains(c));
if prose { w } else { "_" }
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_seed_replays_its_path() {
let ledger = Ledger::default();
let a = draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(7));
let b = draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(7));
assert_eq!(a, b);
assert_eq!(a.len(), 6);
assert_ne!(
a,
draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(8))
);
}
#[test]
fn a_path_never_steps_to_the_format_it_is_already_in() {
let ledger = Ledger::default();
for alphabet in [&ALPHABET[..], &DIST_ALPHABET[..]] {
let path = draw_path(&ledger, alphabet, alphabet[0], 40, &mut Rng::new(3));
assert_ne!(path[0], alphabet[0]);
assert!(
path.windows(2).all(|w| w[0] != w[1]),
"a same-format step is the round trip compare already runs: {path:?}"
);
}
}
#[test]
fn the_ledger_prefers_an_edge_it_has_not_walked() {
let mut ledger = Ledger::default();
let nothing = BTreeSet::new();
for _ in 0..10 {
assert!(!ledger.learn("matpower", "psse", ¬hing));
}
assert!(ledger.weight("matpower", "psse") < 0.1);
assert!((ledger.weight("matpower", "egret-json") - 1.0).abs() < f64::EPSILON);
assert!(ledger.weight("matpower", "psse") > 0.0);
}
#[test]
fn a_visit_that_teaches_is_novel_once() {
let mut ledger = Ledger::default();
let lesson: BTreeSet<String> = ["path:.loads[#].p".to_string()].into_iter().collect();
assert!(ledger.learn("psse", "matpower", &lesson));
assert!(
!ledger.learn("psse", "matpower", &lesson),
"the same lesson twice is not a second lesson"
);
let edge = &ledger.edges["psse>matpower"];
assert_eq!((edge.visits, edge.novel), (2, 1));
}
#[test]
fn a_warning_shape_drops_what_makes_one_warning_look_like_many() {
assert_eq!(
warning_shape("3 switch(es) dropped: MATPOWER has no switch table"),
"_ switch(es) dropped: _ has no switch table"
);
for name in ["L100", "L101", "SourceBus"] {
assert_eq!(
warning_shape(&format!("line {name}: `units` has no place")),
"line _ `units` has no place"
);
}
assert_ne!(
warning_shape("bus RG60: location has no place"),
warning_shape("line L100: `units` has no place")
);
}
}