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] = [
65 "matpower",
66 "psse",
67 "psse34",
68 "psse35",
69 "powerworld",
70 "pandapower-json",
71 "powermodels-json",
72 "egret-json",
73 "pslf",
74 "surge-json",
75];
76
77pub const DIST_ALPHABET: [&str; 3] = ["dss", "pmd-json", "bmopf-json"];
83
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
87pub struct HopDelta {
88 pub core_changed: Option<String>,
89 pub ybus: Option<String>,
90 pub injection: Option<String>,
91 pub dc_terminal: Option<String>,
92 pub model_diffs: Vec<String>,
93}
94
95impl HopDelta {
96 fn of(before: &BalancedNetwork, after: &BalancedNetwork) -> Self {
97 let mut out = Self::default();
98 let (core_before, core_after) = (
99 invariants::transmission_core(before),
100 invariants::transmission_core(after),
101 );
102 if core_before != core_after {
103 out.core_changed = Some(core_delta(&core_before, &core_after));
104 }
105 if let Ok(Some(change)) = invariants::ybus_change(before, after) {
106 out.ybus = Some(change.to_string());
107 }
108 if let Some(change) = invariants::injection_change(before, after) {
109 out.injection = Some(change.to_string());
110 }
111 if let Some(change) = invariants::dc_terminal_change(before, after) {
112 out.dc_terminal = Some(change.to_string());
113 }
114 out.finish_diffs(&invariants::model_diffs(
115 &invariants::transmission_value(before),
116 &invariants::transmission_value(after),
117 ));
118 out
119 }
120
121 fn of_dist(before: &MulticonductorNetwork, after: &MulticonductorNetwork) -> Self {
124 let mut out = Self::default();
125 let (core_before, core_after) = (
126 invariants::distribution_core(before),
127 invariants::distribution_core(after),
128 );
129 if core_before != core_after {
130 out.core_changed = Some(super::dist_core_delta(&core_before, &core_after));
131 }
132 out.finish_diffs(&invariants::model_diffs(
133 &invariants::distribution_value(before),
134 &invariants::distribution_value(after),
135 ));
136 out
137 }
138
139 fn finish_diffs(&mut self, diffs: &[invariants::ModelDiff]) {
140 self.model_diffs = diffs
141 .iter()
142 .map(|d| anonymize::collapse_path(&d.path))
143 .collect();
144 self.model_diffs.sort();
145 self.model_diffs.dedup();
146 }
147
148 fn is_empty(&self) -> bool {
149 !self.electrical() && self.model_diffs.is_empty()
150 }
151
152 pub(super) fn electrical(&self) -> bool {
161 self.core_changed.as_deref().is_some_and(super::power_moved)
162 || self.ybus.is_some()
163 || self.injection.is_some()
164 || self.dc_terminal.is_some()
165 }
166
167 fn signatures(&self, into: &mut BTreeSet<String>) {
171 for (key, present) in [
172 ("core", self.core_changed.is_some()),
173 ("ybus", self.ybus.is_some()),
174 ("injection", self.injection.is_some()),
175 ("dc", self.dc_terminal.is_some()),
176 ] {
177 if present {
178 into.insert(key.to_string());
179 }
180 }
181 for path in &self.model_diffs {
182 into.insert(format!("path:{path}"));
183 }
184 }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Hop {
191 pub to: String,
193 pub warnings: Vec<String>,
194 pub failure: Option<String>,
195 pub leg: HopDelta,
199 pub path_dependent: Option<String>,
203 #[serde(default)]
208 pub path_dependent_electrical: bool,
209 pub emptied: Vec<String>,
212 pub resurrected: Vec<String>,
214 pub revisit_of: Option<usize>,
217 pub drift: Option<String>,
220 #[serde(default)]
223 pub drift_electrical: bool,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct Walk {
228 pub bucket: String,
229 pub seed: u64,
231 pub origin: String,
232 pub hops: Vec<Hop>,
233}
234
235#[derive(Debug, Clone, Default, Serialize, Deserialize)]
236pub struct Walks {
237 pub walks: Vec<Walk>,
238 pub settled_buckets: usize,
241}
242
243#[derive(Debug, Clone, Default, Serialize, Deserialize)]
245pub struct EdgeMemory {
246 pub visits: usize,
247 pub novel: usize,
249 pub signatures: BTreeSet<String>,
250}
251
252#[derive(Debug, Clone, Default, Serialize, Deserialize)]
254pub struct Ledger {
255 pub edges: BTreeMap<String, EdgeMemory>,
257 pub walks: usize,
258}
259
260impl Ledger {
261 fn key(from: &str, to: &str) -> String {
262 format!("{from}>{to}")
263 }
264
265 fn weight(&self, from: &str, to: &str) -> f64 {
271 self.edges
272 .get(&Self::key(from, to))
273 .map_or(1.0, |e| (1.0 + e.novel as f64) / (1.0 + e.visits as f64))
274 }
275
276 fn learn(&mut self, from: &str, to: &str, signatures: &BTreeSet<String>) -> bool {
278 let entry = self.edges.entry(Self::key(from, to)).or_default();
279 entry.visits += 1;
280 let before = entry.signatures.len();
281 entry.signatures.extend(signatures.iter().cloned());
282 let novel = entry.signatures.len() > before;
283 if novel {
284 entry.novel += 1;
285 }
286 novel
287 }
288}
289
290struct Rng(u64);
292
293impl Rng {
294 fn new(seed: u64) -> Self {
295 Self(seed | 1)
297 }
298
299 fn next_u64(&mut self) -> u64 {
300 self.0 ^= self.0 >> 12;
301 self.0 ^= self.0 << 25;
302 self.0 ^= self.0 >> 27;
303 self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
304 }
305
306 fn weighted(&mut self, weights: &[f64]) -> usize {
309 let total: f64 = weights.iter().sum();
310 if total <= 0.0 {
311 return weights.len().saturating_sub(1);
312 }
313 let unit = (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
315 let mut cursor = unit * total;
316 for (i, w) in weights.iter().enumerate() {
317 cursor -= w;
318 if cursor < 0.0 {
319 return i;
320 }
321 }
322 weights.len() - 1
323 }
324}
325
326#[derive(Clone)]
328enum Net {
329 Balanced(BalancedNetwork),
330 Dist(MulticonductorNetwork),
331}
332
333impl Net {
334 fn delta(&self, after: &Net) -> HopDelta {
335 match (self, after) {
336 (Self::Balanced(a), Self::Balanced(b)) => HopDelta::of(a, b),
337 (Self::Dist(a), Self::Dist(b)) => HopDelta::of_dist(a, b),
338 _ => HopDelta::default(),
340 }
341 }
342
343 fn emptied(&self, after: &Net) -> Vec<String> {
345 match (self, after) {
346 (Self::Balanced(a), Self::Balanced(b)) => {
347 let (a, b) = (
348 invariants::transmission_core(a),
349 invariants::transmission_core(b),
350 );
351 zeroed(&[
352 ("branches", a.branches, b.branches),
353 ("generators", a.generators, b.generators),
354 ("loads", a.loads, b.loads),
355 ("shunts", a.shunts, b.shunts),
356 ])
357 }
358 (Self::Dist(a), Self::Dist(b)) => {
359 let (a, b) = (
360 invariants::distribution_core(a),
361 invariants::distribution_core(b),
362 );
363 zeroed(&[
364 ("buses", a.buses, b.buses),
365 ("generators", a.generators, b.generators),
366 ("loads", a.loads, b.loads),
367 ("shunts", a.shunts, b.shunts),
368 ])
369 }
370 _ => Vec::new(),
371 }
372 }
373
374 fn convert(&self, to: &str, warnings: &mut Vec<String>) -> std::result::Result<Net, String> {
377 match self {
378 Self::Balanced(net) => {
379 let Some(target) = powerio_matrix::target_format_from_name(to) else {
380 return Err(format!("no writer for {to}"));
381 };
382 let written = match catch_panic(|| powerio_matrix::write_network(net, target)) {
383 Ok(Ok(conversion)) => conversion,
384 Ok(Err(err)) => return Err(format!("write: {err}")),
385 Err(message) => return Err(format!("write panicked: {message}")),
386 };
387 warnings.extend(written.rendered_diagnostics());
388 match catch_panic(|| crate::compat::parse_str(&written.text, to)) {
389 Ok(Ok(parsed)) => {
390 warnings.extend(parsed.rendered_diagnostics());
391 Ok(Self::Balanced(parsed.network))
392 }
393 Ok(Err(err)) => Err(format!("readback: {err}")),
394 Err(message) => Err(format!("readback panicked: {message}")),
395 }
396 }
397 Self::Dist(net) => {
398 let Some(target) = powerio_dist::dist_target_from_name(to) else {
399 return Err(format!("no writer for {to}"));
400 };
401 let written = match catch_panic(|| powerio_dist::write_network(net, target)) {
402 Ok(conversion) => conversion,
403 Err(message) => return Err(format!("write panicked: {message}")),
404 };
405 warnings.extend(written.rendered_diagnostics());
406 if super::has_include(&written.text) {
409 return Err("written deck redirects to include files".to_string());
410 }
411 match catch_panic(|| crate::compat::dist_parse_str(&written.text, to)) {
412 Ok(Ok(parsed)) => {
413 warnings.extend(parsed.warnings.iter().cloned());
414 Ok(Self::Dist(parsed.network))
415 }
416 Ok(Err(err)) => Err(format!("readback: {err}")),
417 Err(message) => Err(format!("readback panicked: {message}")),
418 }
419 }
420 }
421 }
422}
423
424fn zeroed(counts: &[(&str, usize, usize)]) -> Vec<String> {
425 counts
426 .iter()
427 .filter(|&&(_, had, has)| had > 0 && has == 0)
428 .map(|&(label, ..)| label.to_string())
429 .collect()
430}
431
432pub fn walk(work: &Path, walks: usize, hops: usize, seed: u64, settle: usize) -> Result<Walks> {
442 let ingest: Ingest = super::read_json(&work.join(super::INGEST_FILE))?;
443 let ledger_path = work.join(LEDGER_FILE);
444 let mut ledger: Ledger = super::read_json(&ledger_path).unwrap_or_default();
445
446 let mut out = Walks::default();
447 let mut rng = Rng::new(seed);
448
449 for bucket in &ingest.buckets {
450 let Some((origin_format, origin)) = first_readable(bucket) else {
451 continue;
452 };
453 let alphabet: &[&str] = match bucket.domain {
454 Domain::Transmission => &ALPHABET,
455 Domain::Distribution => &DIST_ALPHABET,
456 };
457 let mut direct: BTreeMap<String, Option<Net>> = BTreeMap::new();
460 let mut dry = 0usize;
464 for _ in 0..walks {
465 if dry >= settle {
466 break;
467 }
468 let walk_seed = rng.next_u64();
469 let path = draw_path(
470 &ledger,
471 alphabet,
472 &origin_format,
473 hops,
474 &mut Rng::new(walk_seed),
475 );
476 let (walk, learned) = run_walk(
477 bucket.id.clone(),
478 walk_seed,
479 &origin_format,
480 &origin,
481 &path,
482 &mut ledger,
483 &mut direct,
484 );
485 dry = if learned { 0 } else { dry + 1 };
486 ledger.walks += 1;
487 out.walks.push(walk);
488 }
489 if dry >= settle {
490 out.settled_buckets += 1;
491 }
492 }
493
494 super::write_json(&ledger_path, &ledger)?;
495 super::write_json(&work.join(WALK_FILE), &out)?;
496 Ok(out)
497}
498
499fn first_readable(bucket: &Bucket) -> Option<(String, Net)> {
507 bucket.members.iter().find_map(|m| {
508 let net = match bucket.domain {
509 Domain::Transmission => {
510 let net = balanced(&m.path)?;
511 Net::Balanced(net)
512 }
513 Domain::Distribution => {
514 let mut net = multiconductor(&m.path)?;
515 *net.source_format_mut() = None;
516 Net::Dist(net)
517 }
518 };
519 Some((m.format.clone(), net))
520 })
521}
522
523fn draw_path(
526 ledger: &Ledger,
527 alphabet: &[&str],
528 origin: &str,
529 hops: usize,
530 rng: &mut Rng,
531) -> Vec<String> {
532 let mut path = Vec::with_capacity(hops);
533 let mut here = origin.to_string();
534 for _ in 0..hops {
535 let choices: Vec<&str> = alphabet
536 .iter()
537 .copied()
538 .filter(|f| *f != here.as_str())
539 .collect();
540 let weights: Vec<f64> = choices.iter().map(|to| ledger.weight(&here, to)).collect();
541 let pick = choices[rng.weighted(&weights)].to_string();
542 here.clone_from(&pick);
543 path.push(pick);
544 }
545 path
546}
547
548fn run_walk(
551 bucket: String,
552 seed: u64,
553 origin_format: &str,
554 origin: &Net,
555 path: &[String],
556 ledger: &mut Ledger,
557 direct: &mut BTreeMap<String, Option<Net>>,
558) -> (Walk, bool) {
559 let mut walk = Walk {
560 bucket,
561 seed,
562 origin: origin_format.to_string(),
563 hops: Vec::with_capacity(path.len()),
564 };
565 let mut learned = false;
566 let mut current = origin.clone();
567 let mut here = origin_format.to_string();
568 let mut seen: Vec<(String, Net)> = Vec::new();
571
572 for to in path {
573 let mut hop = Hop {
574 to: to.clone(),
575 warnings: Vec::new(),
576 failure: None,
577 leg: HopDelta::default(),
578 path_dependent: None,
579 path_dependent_electrical: false,
580 emptied: Vec::new(),
581 resurrected: Vec::new(),
582 revisit_of: None,
583 drift: None,
584 drift_electrical: false,
585 };
586 let next = match current.convert(to, &mut hop.warnings) {
587 Ok(net) => net,
588 Err(message) => {
589 let mut signatures = BTreeSet::new();
592 signatures.insert(format!("fail:{}", warning_shape(&message)));
593 learned |= ledger.learn(&here, to, &signatures);
594 hop.failure = Some(message);
595 walk.hops.push(hop);
596 break;
598 }
599 };
600
601 hop.leg = current.delta(&next);
602 hop.emptied = origin.emptied(&next);
603 hop.resurrected = next.emptied(¤t);
604
605 if let Some(index) = seen.iter().position(|(f, _)| f == to) {
606 hop.revisit_of = Some(index);
607 let delta = seen[index].1.delta(&next);
608 if !delta.is_empty() {
609 hop.drift_electrical = delta.electrical();
610 hop.drift = Some(describe(&delta));
611 }
612 }
613
614 if !seen.is_empty() {
620 let baseline = direct
621 .entry(to.clone())
622 .or_insert_with(|| origin.convert(to, &mut Vec::new()).ok());
623 if let Some(direct_net) = baseline {
624 let delta = direct_net.delta(&next);
625 if !delta.is_empty() {
626 hop.path_dependent_electrical = delta.electrical();
627 hop.path_dependent = Some(describe(&delta));
628 }
629 }
630 }
631
632 let mut signatures = BTreeSet::new();
633 hop.leg.signatures(&mut signatures);
634 for warning in &hop.warnings {
635 signatures.insert(format!("warn:{}", warning_shape(warning)));
636 }
637 for (code, present) in [
638 ("drift", hop.drift.is_some()),
639 ("path-dependent", hop.path_dependent.is_some()),
640 ("resurrection", !hop.resurrected.is_empty()),
641 ] {
642 if present {
643 signatures.insert(code.to_string());
644 }
645 }
646 learned |= ledger.learn(&here, to, &signatures);
647
648 seen.push((to.clone(), next.clone()));
649 here.clone_from(to);
650 current = next;
651 walk.hops.push(hop);
652 }
653
654 (walk, learned)
655}
656
657fn describe(delta: &HopDelta) -> String {
659 let mut parts = Vec::new();
660 for value in [
661 delta.core_changed.as_ref(),
662 delta.ybus.as_ref(),
663 delta.injection.as_ref(),
664 delta.dc_terminal.as_ref(),
665 ]
666 .into_iter()
667 .flatten()
668 {
669 parts.push(value.clone());
670 }
671 if !delta.model_diffs.is_empty() {
672 parts.push(delta.model_diffs.join(", "));
673 }
674 parts.join("; ")
675}
676
677fn warning_shape(warning: &str) -> String {
689 warning
690 .split_whitespace()
691 .take(8)
692 .map(|w| {
693 let prose = w
694 .chars()
695 .all(|c| c.is_ascii_lowercase() || "()[]{}.,:;`-_/".contains(c));
696 if prose { w } else { "_" }
697 })
698 .collect::<Vec<_>>()
699 .join(" ")
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 #[test]
707 fn a_seed_replays_its_path() {
708 let ledger = Ledger::default();
709 let a = draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(7));
710 let b = draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(7));
711 assert_eq!(a, b);
712 assert_eq!(a.len(), 6);
713 assert_ne!(
714 a,
715 draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(8))
716 );
717 }
718
719 #[test]
720 fn a_path_never_steps_to_the_format_it_is_already_in() {
721 let ledger = Ledger::default();
722 for alphabet in [&ALPHABET[..], &DIST_ALPHABET[..]] {
723 let path = draw_path(&ledger, alphabet, alphabet[0], 40, &mut Rng::new(3));
724 assert_ne!(path[0], alphabet[0]);
725 assert!(
726 path.windows(2).all(|w| w[0] != w[1]),
727 "a same-format step is the round trip compare already runs: {path:?}"
728 );
729 }
730 }
731
732 #[test]
733 fn the_ledger_prefers_an_edge_it_has_not_walked() {
734 let mut ledger = Ledger::default();
735 let nothing = BTreeSet::new();
736 for _ in 0..10 {
739 assert!(!ledger.learn("matpower", "psse", ¬hing));
740 }
741 assert!(ledger.weight("matpower", "psse") < 0.1);
742 assert!((ledger.weight("matpower", "egret-json") - 1.0).abs() < f64::EPSILON);
743 assert!(ledger.weight("matpower", "psse") > 0.0);
746 }
747
748 #[test]
749 fn a_visit_that_teaches_is_novel_once() {
750 let mut ledger = Ledger::default();
751 let lesson: BTreeSet<String> = ["path:.loads[#].p".to_string()].into_iter().collect();
752 assert!(ledger.learn("psse", "matpower", &lesson));
753 assert!(
754 !ledger.learn("psse", "matpower", &lesson),
755 "the same lesson twice is not a second lesson"
756 );
757 let edge = &ledger.edges["psse>matpower"];
758 assert_eq!((edge.visits, edge.novel), (2, 1));
759 }
760
761 #[test]
762 fn a_warning_shape_drops_what_makes_one_warning_look_like_many() {
763 assert_eq!(
767 warning_shape("3 switch(es) dropped: MATPOWER has no switch table"),
768 "_ switch(es) dropped: _ has no switch table"
769 );
770 for name in ["L100", "L101", "SourceBus"] {
771 assert_eq!(
772 warning_shape(&format!("line {name}: `units` has no place")),
773 "line _ `units` has no place"
774 );
775 }
776 assert_ne!(
777 warning_shape("bus RG60: location has no place"),
778 warning_shape("line L100: `units` has no place")
779 );
780 }
781}