1use std::collections::{BTreeMap, BTreeSet};
41use std::path::Path;
42
43use anyhow::Result;
44use powerio_dist::MulticonductorNetwork;
45use powerio_matrix::BalancedNetwork;
46use serde::{Deserialize, Serialize};
47
48use super::anonymize;
49use super::{Bucket, Domain, Ingest, balanced, catch_panic, core_delta, multiconductor};
50use crate::invariants;
51
52pub(super) const WALK_FILE: &str = "walks.json";
53pub(super) const LEDGER_FILE: &str = "ledger.json";
54
55pub const ALPHABET: [&str; 10] = [
63 "matpower",
64 "psse",
65 "psse34",
66 "psse35",
67 "powerworld",
68 "pandapower-json",
69 "powermodels-json",
70 "egret-json",
71 "pslf",
72 "surge-json",
73];
74
75pub const DIST_ALPHABET: [&str; 3] = ["dss", "pmd-json", "bmopf-json"];
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
85pub struct HopDelta {
86 pub core_changed: Option<String>,
87 pub ybus: Option<String>,
88 pub injection: Option<String>,
89 pub dc_terminal: Option<String>,
90 pub model_diffs: Vec<String>,
91}
92
93impl HopDelta {
94 fn of(before: &BalancedNetwork, after: &BalancedNetwork) -> Self {
95 let mut out = Self::default();
96 let (core_before, core_after) = (
97 invariants::transmission_core(before),
98 invariants::transmission_core(after),
99 );
100 if core_before != core_after {
101 out.core_changed = Some(core_delta(&core_before, &core_after));
102 }
103 if let Ok(Some(change)) = invariants::ybus_change(before, after) {
104 out.ybus = Some(change.to_string());
105 }
106 if let Some(change) = invariants::injection_change(before, after) {
107 out.injection = Some(change.to_string());
108 }
109 if let Some(change) = invariants::dc_terminal_change(before, after) {
110 out.dc_terminal = Some(change.to_string());
111 }
112 out.finish_diffs(&invariants::model_diffs(
113 &invariants::transmission_value(before),
114 &invariants::transmission_value(after),
115 ));
116 out
117 }
118
119 fn of_dist(before: &MulticonductorNetwork, after: &MulticonductorNetwork) -> Self {
122 let mut out = Self::default();
123 let (core_before, core_after) = (
124 invariants::distribution_core(before),
125 invariants::distribution_core(after),
126 );
127 if core_before != core_after {
128 out.core_changed = Some(super::dist_core_delta(&core_before, &core_after));
129 }
130 out.finish_diffs(&invariants::model_diffs(
131 &invariants::distribution_value(before),
132 &invariants::distribution_value(after),
133 ));
134 out
135 }
136
137 fn finish_diffs(&mut self, diffs: &[invariants::ModelDiff]) {
138 self.model_diffs = diffs
139 .iter()
140 .map(|d| anonymize::collapse_path(&d.path))
141 .collect();
142 self.model_diffs.sort();
143 self.model_diffs.dedup();
144 }
145
146 fn is_empty(&self) -> bool {
147 !self.electrical() && self.model_diffs.is_empty()
148 }
149
150 pub(super) fn electrical(&self) -> bool {
159 self.core_changed.as_deref().is_some_and(super::power_moved)
160 || self.ybus.is_some()
161 || self.injection.is_some()
162 || self.dc_terminal.is_some()
163 }
164
165 fn signatures(&self, into: &mut BTreeSet<String>) {
169 for (key, present) in [
170 ("core", self.core_changed.is_some()),
171 ("ybus", self.ybus.is_some()),
172 ("injection", self.injection.is_some()),
173 ("dc", self.dc_terminal.is_some()),
174 ] {
175 if present {
176 into.insert(key.to_string());
177 }
178 }
179 for path in &self.model_diffs {
180 into.insert(format!("path:{path}"));
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct Hop {
189 pub to: String,
191 pub warnings: Vec<String>,
192 pub failure: Option<String>,
193 pub leg: HopDelta,
197 pub path_dependent: Option<String>,
201 #[serde(default)]
206 pub path_dependent_electrical: bool,
207 pub emptied: Vec<String>,
210 pub resurrected: Vec<String>,
212 pub revisit_of: Option<usize>,
215 pub drift: Option<String>,
218 #[serde(default)]
221 pub drift_electrical: bool,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct Walk {
226 pub bucket: String,
227 pub seed: u64,
229 pub origin: String,
230 pub hops: Vec<Hop>,
231}
232
233#[derive(Debug, Clone, Default, Serialize, Deserialize)]
234pub struct Walks {
235 pub walks: Vec<Walk>,
236 pub settled_buckets: usize,
239}
240
241#[derive(Debug, Clone, Default, Serialize, Deserialize)]
243pub struct EdgeMemory {
244 pub visits: usize,
245 pub novel: usize,
247 pub signatures: BTreeSet<String>,
248}
249
250#[derive(Debug, Clone, Default, Serialize, Deserialize)]
252pub struct Ledger {
253 pub edges: BTreeMap<String, EdgeMemory>,
255 pub walks: usize,
256}
257
258impl Ledger {
259 fn key(from: &str, to: &str) -> String {
260 format!("{from}>{to}")
261 }
262
263 fn weight(&self, from: &str, to: &str) -> f64 {
269 self.edges
270 .get(&Self::key(from, to))
271 .map_or(1.0, |e| (1.0 + e.novel as f64) / (1.0 + e.visits as f64))
272 }
273
274 fn learn(&mut self, from: &str, to: &str, signatures: &BTreeSet<String>) -> bool {
276 let entry = self.edges.entry(Self::key(from, to)).or_default();
277 entry.visits += 1;
278 let before = entry.signatures.len();
279 entry.signatures.extend(signatures.iter().cloned());
280 let novel = entry.signatures.len() > before;
281 if novel {
282 entry.novel += 1;
283 }
284 novel
285 }
286}
287
288struct Rng(u64);
290
291impl Rng {
292 fn new(seed: u64) -> Self {
293 Self(seed | 1)
295 }
296
297 fn next_u64(&mut self) -> u64 {
298 self.0 ^= self.0 >> 12;
299 self.0 ^= self.0 << 25;
300 self.0 ^= self.0 >> 27;
301 self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
302 }
303
304 fn weighted(&mut self, weights: &[f64]) -> usize {
307 let total: f64 = weights.iter().sum();
308 if total <= 0.0 {
309 return weights.len().saturating_sub(1);
310 }
311 let unit = (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
313 let mut cursor = unit * total;
314 for (i, w) in weights.iter().enumerate() {
315 cursor -= w;
316 if cursor < 0.0 {
317 return i;
318 }
319 }
320 weights.len() - 1
321 }
322}
323
324#[derive(Clone)]
326enum Net {
327 Balanced(BalancedNetwork),
328 Dist(MulticonductorNetwork),
329}
330
331impl Net {
332 fn delta(&self, after: &Net) -> HopDelta {
333 match (self, after) {
334 (Self::Balanced(a), Self::Balanced(b)) => HopDelta::of(a, b),
335 (Self::Dist(a), Self::Dist(b)) => HopDelta::of_dist(a, b),
336 _ => HopDelta::default(),
338 }
339 }
340
341 fn emptied(&self, after: &Net) -> Vec<String> {
343 match (self, after) {
344 (Self::Balanced(a), Self::Balanced(b)) => {
345 let (a, b) = (
346 invariants::transmission_core(a),
347 invariants::transmission_core(b),
348 );
349 zeroed(&[
350 ("branches", a.branches, b.branches),
351 ("generators", a.generators, b.generators),
352 ("loads", a.loads, b.loads),
353 ("shunts", a.shunts, b.shunts),
354 ])
355 }
356 (Self::Dist(a), Self::Dist(b)) => {
357 let (a, b) = (
358 invariants::distribution_core(a),
359 invariants::distribution_core(b),
360 );
361 zeroed(&[
362 ("buses", a.buses, b.buses),
363 ("generators", a.generators, b.generators),
364 ("loads", a.loads, b.loads),
365 ("shunts", a.shunts, b.shunts),
366 ])
367 }
368 _ => Vec::new(),
369 }
370 }
371
372 fn convert(&self, to: &str, warnings: &mut Vec<String>) -> std::result::Result<Net, String> {
375 match self {
376 Self::Balanced(net) => {
377 let Some(target) = powerio_tx::format::parse_target_format(to) else {
378 return Err(format!("no writer for {to}"));
379 };
380 let module = powerio_core::PioModule::new(net.clone());
381 let emission = match catch_panic(|| {
382 crate::module_io::emit_balanced_module(
383 &module,
384 target,
385 &powerio_tx::EmitOptions::default(),
386 )
387 }) {
388 Ok(Ok(emission)) => emission,
389 Ok(Err(err)) => return Err(format!("emit: {err}")),
390 Err(message) => return Err(format!("emit panicked: {message}")),
391 };
392 warnings.extend(emission.render_diagnostics());
393 match catch_panic(|| crate::module_io::load_balanced_memory(&emission.text, to)) {
394 Ok(Ok(parsed)) => {
395 warnings.extend(powerio_core::render_diagnostics(parsed.diagnostics()));
396 Ok(Self::Balanced(parsed.into_value()))
397 }
398 Ok(Err(err)) => Err(format!("readback: {err}")),
399 Err(message) => Err(format!("readback panicked: {message}")),
400 }
401 }
402 Self::Dist(net) => {
403 let Some(target) = powerio_dist::parse_dist_target_format(to) else {
404 return Err(format!("no writer for {to}"));
405 };
406 let module = powerio_core::PioModule::new(net.clone());
407 let emission = match catch_panic(|| {
408 crate::module_io::emit_multiconductor_module(&module, target)
409 }) {
410 Ok(Ok(emission)) => emission,
411 Ok(Err(err)) => return Err(format!("emit: {err}")),
412 Err(message) => return Err(format!("emit panicked: {message}")),
413 };
414 warnings.extend(emission.render_diagnostics());
415 if super::has_include(&emission.text) {
418 return Err("emitted deck redirects to include files".to_string());
419 }
420 match catch_panic(|| {
421 crate::module_io::load_multiconductor_memory(&emission.text, to)
422 }) {
423 Ok(Ok(parsed)) => {
424 warnings.extend(powerio_core::render_diagnostics(parsed.diagnostics()));
425 Ok(Self::Dist(parsed.into_value()))
426 }
427 Ok(Err(err)) => Err(format!("readback: {err}")),
428 Err(message) => Err(format!("readback panicked: {message}")),
429 }
430 }
431 }
432 }
433}
434
435fn zeroed(counts: &[(&str, usize, usize)]) -> Vec<String> {
436 counts
437 .iter()
438 .filter(|&&(_, had, has)| had > 0 && has == 0)
439 .map(|&(label, ..)| label.to_string())
440 .collect()
441}
442
443pub fn walk(work: &Path, walks: usize, hops: usize, seed: u64, settle: usize) -> Result<Walks> {
453 let ingest: Ingest = super::read_json(&work.join(super::INGEST_FILE))?;
454 let ledger_path = work.join(LEDGER_FILE);
455 let mut ledger: Ledger = super::read_json(&ledger_path).unwrap_or_default();
456
457 let mut out = Walks::default();
458 let mut rng = Rng::new(seed);
459
460 for bucket in &ingest.buckets {
461 let Some((origin_format, origin)) = first_readable(bucket) else {
462 continue;
463 };
464 let alphabet: &[&str] = match bucket.domain {
465 Domain::Transmission => &ALPHABET,
466 Domain::Distribution => &DIST_ALPHABET,
467 };
468 let mut direct: BTreeMap<String, Option<Net>> = BTreeMap::new();
471 let mut dry = 0usize;
475 for _ in 0..walks {
476 if dry >= settle {
477 break;
478 }
479 let walk_seed = rng.next_u64();
480 let path = draw_path(
481 &ledger,
482 alphabet,
483 &origin_format,
484 hops,
485 &mut Rng::new(walk_seed),
486 );
487 let (walk, learned) = run_walk(
488 bucket.id.clone(),
489 walk_seed,
490 &origin_format,
491 &origin,
492 &path,
493 &mut ledger,
494 &mut direct,
495 );
496 dry = if learned { 0 } else { dry + 1 };
497 ledger.walks += 1;
498 out.walks.push(walk);
499 }
500 if dry >= settle {
501 out.settled_buckets += 1;
502 }
503 }
504
505 super::write_json(&ledger_path, &ledger)?;
506 super::write_json(&work.join(WALK_FILE), &out)?;
507 Ok(out)
508}
509
510fn first_readable(bucket: &Bucket) -> Option<(String, Net)> {
518 bucket.members.iter().find_map(|m| {
519 let net = match bucket.domain {
520 Domain::Transmission => {
521 let net = balanced(&m.path)?;
522 Net::Balanced(net)
523 }
524 Domain::Distribution => {
525 let mut net = multiconductor(&m.path)?;
526 *net.source_format_mut() = None;
527 Net::Dist(net)
528 }
529 };
530 Some((m.format.clone(), net))
531 })
532}
533
534fn draw_path(
537 ledger: &Ledger,
538 alphabet: &[&str],
539 origin: &str,
540 hops: usize,
541 rng: &mut Rng,
542) -> Vec<String> {
543 let mut path = Vec::with_capacity(hops);
544 let mut here = origin.to_string();
545 for _ in 0..hops {
546 let choices: Vec<&str> = alphabet
547 .iter()
548 .copied()
549 .filter(|f| *f != here.as_str())
550 .collect();
551 let weights: Vec<f64> = choices.iter().map(|to| ledger.weight(&here, to)).collect();
552 let pick = choices[rng.weighted(&weights)].to_string();
553 here.clone_from(&pick);
554 path.push(pick);
555 }
556 path
557}
558
559fn run_walk(
562 bucket: String,
563 seed: u64,
564 origin_format: &str,
565 origin: &Net,
566 path: &[String],
567 ledger: &mut Ledger,
568 direct: &mut BTreeMap<String, Option<Net>>,
569) -> (Walk, bool) {
570 let mut walk = Walk {
571 bucket,
572 seed,
573 origin: origin_format.to_string(),
574 hops: Vec::with_capacity(path.len()),
575 };
576 let mut learned = false;
577 let mut current = origin.clone();
578 let mut here = origin_format.to_string();
579 let mut seen: Vec<(String, Net)> = Vec::new();
582
583 for to in path {
584 let mut hop = Hop {
585 to: to.clone(),
586 warnings: Vec::new(),
587 failure: None,
588 leg: HopDelta::default(),
589 path_dependent: None,
590 path_dependent_electrical: false,
591 emptied: Vec::new(),
592 resurrected: Vec::new(),
593 revisit_of: None,
594 drift: None,
595 drift_electrical: false,
596 };
597 let next = match current.convert(to, &mut hop.warnings) {
598 Ok(net) => net,
599 Err(message) => {
600 let mut signatures = BTreeSet::new();
603 signatures.insert(format!("fail:{}", warning_shape(&message)));
604 learned |= ledger.learn(&here, to, &signatures);
605 hop.failure = Some(message);
606 walk.hops.push(hop);
607 break;
609 }
610 };
611
612 hop.leg = current.delta(&next);
613 hop.emptied = origin.emptied(&next);
614 hop.resurrected = next.emptied(¤t);
615
616 if let Some(index) = seen.iter().position(|(f, _)| f == to) {
617 hop.revisit_of = Some(index);
618 let delta = seen[index].1.delta(&next);
619 if !delta.is_empty() {
620 hop.drift_electrical = delta.electrical();
621 hop.drift = Some(describe(&delta));
622 }
623 }
624
625 if !seen.is_empty() {
631 let baseline = direct
632 .entry(to.clone())
633 .or_insert_with(|| origin.convert(to, &mut Vec::new()).ok());
634 if let Some(direct_net) = baseline {
635 let delta = direct_net.delta(&next);
636 if !delta.is_empty() {
637 hop.path_dependent_electrical = delta.electrical();
638 hop.path_dependent = Some(describe(&delta));
639 }
640 }
641 }
642
643 let mut signatures = BTreeSet::new();
644 hop.leg.signatures(&mut signatures);
645 for warning in &hop.warnings {
646 signatures.insert(format!("warn:{}", warning_shape(warning)));
647 }
648 for (code, present) in [
649 ("drift", hop.drift.is_some()),
650 ("path-dependent", hop.path_dependent.is_some()),
651 ("resurrection", !hop.resurrected.is_empty()),
652 ] {
653 if present {
654 signatures.insert(code.to_string());
655 }
656 }
657 learned |= ledger.learn(&here, to, &signatures);
658
659 seen.push((to.clone(), next.clone()));
660 here.clone_from(to);
661 current = next;
662 walk.hops.push(hop);
663 }
664
665 (walk, learned)
666}
667
668fn describe(delta: &HopDelta) -> String {
670 let mut parts = Vec::new();
671 for value in [
672 delta.core_changed.as_ref(),
673 delta.ybus.as_ref(),
674 delta.injection.as_ref(),
675 delta.dc_terminal.as_ref(),
676 ]
677 .into_iter()
678 .flatten()
679 {
680 parts.push(value.clone());
681 }
682 if !delta.model_diffs.is_empty() {
683 parts.push(delta.model_diffs.join(", "));
684 }
685 parts.join("; ")
686}
687
688fn warning_shape(warning: &str) -> String {
700 warning
701 .split_whitespace()
702 .take(8)
703 .map(|w| {
704 let prose = w
705 .chars()
706 .all(|c| c.is_ascii_lowercase() || "()[]{}.,:;`-_/".contains(c));
707 if prose { w } else { "_" }
708 })
709 .collect::<Vec<_>>()
710 .join(" ")
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716
717 #[test]
718 fn a_seed_replays_its_path() {
719 let ledger = Ledger::default();
720 let a = draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(7));
721 let b = draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(7));
722 assert_eq!(a, b);
723 assert_eq!(a.len(), 6);
724 assert_ne!(
725 a,
726 draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(8))
727 );
728 }
729
730 #[test]
731 fn a_path_never_steps_to_the_format_it_is_already_in() {
732 let ledger = Ledger::default();
733 for alphabet in [&ALPHABET[..], &DIST_ALPHABET[..]] {
734 let path = draw_path(&ledger, alphabet, alphabet[0], 40, &mut Rng::new(3));
735 assert_ne!(path[0], alphabet[0]);
736 assert!(
737 path.windows(2).all(|w| w[0] != w[1]),
738 "a same-format step is the round trip compare already runs: {path:?}"
739 );
740 }
741 }
742
743 #[test]
744 fn the_ledger_prefers_an_edge_it_has_not_walked() {
745 let mut ledger = Ledger::default();
746 let nothing = BTreeSet::new();
747 for _ in 0..10 {
750 assert!(!ledger.learn("matpower", "psse", ¬hing));
751 }
752 assert!(ledger.weight("matpower", "psse") < 0.1);
753 assert!((ledger.weight("matpower", "egret-json") - 1.0).abs() < f64::EPSILON);
754 assert!(ledger.weight("matpower", "psse") > 0.0);
757 }
758
759 #[test]
760 fn a_visit_that_teaches_is_novel_once() {
761 let mut ledger = Ledger::default();
762 let lesson: BTreeSet<String> = ["path:.loads[#].p".to_string()].into_iter().collect();
763 assert!(ledger.learn("psse", "matpower", &lesson));
764 assert!(
765 !ledger.learn("psse", "matpower", &lesson),
766 "the same lesson twice is not a second lesson"
767 );
768 let edge = &ledger.edges["psse>matpower"];
769 assert_eq!((edge.visits, edge.novel), (2, 1));
770 }
771
772 #[test]
773 fn a_warning_shape_drops_what_makes_one_warning_look_like_many() {
774 assert_eq!(
778 warning_shape("3 switch(es) dropped: MATPOWER has no switch table"),
779 "_ switch(es) dropped: _ has no switch table"
780 );
781 for name in ["L100", "L101", "SourceBus"] {
782 assert_eq!(
783 warning_shape(&format!("line {name}: `units` has no place")),
784 "line _ `units` has no place"
785 );
786 }
787 assert_ne!(
788 warning_shape("bus RG60: location has no place"),
789 warning_shape("line L100: `units` has no place")
790 );
791 }
792}