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 {
126 let mut out = Self::default();
127 let (core_before, core_after) = (
128 invariants::distribution_core(before),
129 invariants::distribution_core(after),
130 );
131 if core_before != core_after {
132 out.core_changed = Some(super::dist_core_delta(&core_before, &core_after));
133 }
134 let mut clean_before = before.clone();
135 let mut clean_after = after.clone();
136 clean_before.warnings.clear();
137 clean_after.warnings.clear();
138 out.finish_diffs(&invariants::model_diffs(
139 &invariants::distribution_value(&clean_before),
140 &invariants::distribution_value(&clean_after),
141 ));
142 out
143 }
144
145 fn finish_diffs(&mut self, diffs: &[invariants::ModelDiff]) {
146 self.model_diffs = diffs
147 .iter()
148 .map(|d| anonymize::collapse_path(&d.path))
149 .collect();
150 self.model_diffs.sort();
151 self.model_diffs.dedup();
152 }
153
154 fn is_empty(&self) -> bool {
155 !self.electrical() && self.model_diffs.is_empty()
156 }
157
158 pub(super) fn electrical(&self) -> bool {
167 self.core_changed.as_deref().is_some_and(super::power_moved)
168 || self.ybus.is_some()
169 || self.injection.is_some()
170 || self.dc_terminal.is_some()
171 }
172
173 fn signatures(&self, into: &mut BTreeSet<String>) {
177 for (key, present) in [
178 ("core", self.core_changed.is_some()),
179 ("ybus", self.ybus.is_some()),
180 ("injection", self.injection.is_some()),
181 ("dc", self.dc_terminal.is_some()),
182 ] {
183 if present {
184 into.insert(key.to_string());
185 }
186 }
187 for path in &self.model_diffs {
188 into.insert(format!("path:{path}"));
189 }
190 }
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct Hop {
197 pub to: String,
199 pub warnings: Vec<String>,
200 pub failure: Option<String>,
201 pub leg: HopDelta,
205 pub path_dependent: Option<String>,
209 #[serde(default)]
214 pub path_dependent_electrical: bool,
215 pub emptied: Vec<String>,
218 pub resurrected: Vec<String>,
220 pub revisit_of: Option<usize>,
223 pub drift: Option<String>,
226 #[serde(default)]
229 pub drift_electrical: bool,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct Walk {
234 pub bucket: String,
235 pub seed: u64,
237 pub origin: String,
238 pub hops: Vec<Hop>,
239}
240
241#[derive(Debug, Clone, Default, Serialize, Deserialize)]
242pub struct Walks {
243 pub walks: Vec<Walk>,
244 pub settled_buckets: usize,
247}
248
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
251pub struct EdgeMemory {
252 pub visits: usize,
253 pub novel: usize,
255 pub signatures: BTreeSet<String>,
256}
257
258#[derive(Debug, Clone, Default, Serialize, Deserialize)]
260pub struct Ledger {
261 pub edges: BTreeMap<String, EdgeMemory>,
263 pub walks: usize,
264}
265
266impl Ledger {
267 fn key(from: &str, to: &str) -> String {
268 format!("{from}>{to}")
269 }
270
271 fn weight(&self, from: &str, to: &str) -> f64 {
277 self.edges
278 .get(&Self::key(from, to))
279 .map_or(1.0, |e| (1.0 + e.novel as f64) / (1.0 + e.visits as f64))
280 }
281
282 fn learn(&mut self, from: &str, to: &str, signatures: &BTreeSet<String>) -> bool {
284 let entry = self.edges.entry(Self::key(from, to)).or_default();
285 entry.visits += 1;
286 let before = entry.signatures.len();
287 entry.signatures.extend(signatures.iter().cloned());
288 let novel = entry.signatures.len() > before;
289 if novel {
290 entry.novel += 1;
291 }
292 novel
293 }
294}
295
296struct Rng(u64);
298
299impl Rng {
300 fn new(seed: u64) -> Self {
301 Self(seed | 1)
303 }
304
305 fn next_u64(&mut self) -> u64 {
306 self.0 ^= self.0 >> 12;
307 self.0 ^= self.0 << 25;
308 self.0 ^= self.0 >> 27;
309 self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
310 }
311
312 fn weighted(&mut self, weights: &[f64]) -> usize {
315 let total: f64 = weights.iter().sum();
316 if total <= 0.0 {
317 return weights.len().saturating_sub(1);
318 }
319 let unit = (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
321 let mut cursor = unit * total;
322 for (i, w) in weights.iter().enumerate() {
323 cursor -= w;
324 if cursor < 0.0 {
325 return i;
326 }
327 }
328 weights.len() - 1
329 }
330}
331
332#[derive(Clone)]
334enum Net {
335 Balanced(BalancedNetwork),
336 Dist(MulticonductorNetwork),
337}
338
339impl Net {
340 fn delta(&self, after: &Net) -> HopDelta {
341 match (self, after) {
342 (Self::Balanced(a), Self::Balanced(b)) => HopDelta::of(a, b),
343 (Self::Dist(a), Self::Dist(b)) => HopDelta::of_dist(a, b),
344 _ => HopDelta::default(),
346 }
347 }
348
349 fn emptied(&self, after: &Net) -> Vec<String> {
351 match (self, after) {
352 (Self::Balanced(a), Self::Balanced(b)) => {
353 let (a, b) = (
354 invariants::transmission_core(a),
355 invariants::transmission_core(b),
356 );
357 zeroed(&[
358 ("branches", a.branches, b.branches),
359 ("generators", a.generators, b.generators),
360 ("loads", a.loads, b.loads),
361 ("shunts", a.shunts, b.shunts),
362 ])
363 }
364 (Self::Dist(a), Self::Dist(b)) => {
365 let (a, b) = (
366 invariants::distribution_core(a),
367 invariants::distribution_core(b),
368 );
369 zeroed(&[
370 ("buses", a.buses, b.buses),
371 ("generators", a.generators, b.generators),
372 ("loads", a.loads, b.loads),
373 ("shunts", a.shunts, b.shunts),
374 ])
375 }
376 _ => Vec::new(),
377 }
378 }
379
380 fn convert(&self, to: &str, warnings: &mut Vec<String>) -> std::result::Result<Net, String> {
383 match self {
384 Self::Balanced(net) => {
385 let Some(target) = powerio_matrix::target_format_from_name(to) else {
386 return Err(format!("no writer for {to}"));
387 };
388 let written = match catch_panic(|| powerio_matrix::write_as(net, target)) {
389 Ok(Ok(conversion)) => conversion,
390 Ok(Err(err)) => return Err(format!("write: {err}")),
391 Err(message) => return Err(format!("write panicked: {message}")),
392 };
393 warnings.extend(written.warnings.iter().cloned());
394 match catch_panic(|| powerio_matrix::parse_str(&written.text, to)) {
395 Ok(Ok(parsed)) => {
396 warnings.extend(parsed.warnings.iter().cloned());
397 Ok(Self::Balanced(parsed.network))
398 }
399 Ok(Err(err)) => Err(format!("readback: {err}")),
400 Err(message) => Err(format!("readback panicked: {message}")),
401 }
402 }
403 Self::Dist(net) => {
404 let Some(target) = powerio_dist::dist_target_from_name(to) else {
405 return Err(format!("no writer for {to}"));
406 };
407 let written = match catch_panic(|| net.to_format(target)) {
408 Ok(conversion) => conversion,
409 Err(message) => return Err(format!("write panicked: {message}")),
410 };
411 warnings.extend(written.warnings.iter().cloned());
412 if super::has_include(&written.text) {
415 return Err("written deck redirects to include files".to_string());
416 }
417 match catch_panic(|| powerio_dist::parse_str(&written.text, to)) {
418 Ok(Ok(parsed)) => {
419 warnings.extend(parsed.warnings.iter().cloned());
420 Ok(Self::Dist(parsed))
421 }
422 Ok(Err(err)) => Err(format!("readback: {err}")),
423 Err(message) => Err(format!("readback panicked: {message}")),
424 }
425 }
426 }
427 }
428}
429
430fn zeroed(counts: &[(&str, usize, usize)]) -> Vec<String> {
431 counts
432 .iter()
433 .filter(|&&(_, had, has)| had > 0 && has == 0)
434 .map(|&(label, ..)| label.to_string())
435 .collect()
436}
437
438pub fn walk(work: &Path, walks: usize, hops: usize, seed: u64, settle: usize) -> Result<Walks> {
448 let ingest: Ingest = super::read_json(&work.join(super::INGEST_FILE))?;
449 let ledger_path = work.join(LEDGER_FILE);
450 let mut ledger: Ledger = super::read_json(&ledger_path).unwrap_or_default();
451
452 let mut out = Walks::default();
453 let mut rng = Rng::new(seed);
454
455 for bucket in &ingest.buckets {
456 let Some((origin_format, origin)) = first_readable(bucket) else {
457 continue;
458 };
459 let alphabet: &[&str] = match bucket.domain {
460 Domain::Transmission => &ALPHABET,
461 Domain::Distribution => &DIST_ALPHABET,
462 };
463 let mut direct: BTreeMap<String, Option<Net>> = BTreeMap::new();
466 let mut dry = 0usize;
470 for _ in 0..walks {
471 if dry >= settle {
472 break;
473 }
474 let walk_seed = rng.next_u64();
475 let path = draw_path(
476 &ledger,
477 alphabet,
478 &origin_format,
479 hops,
480 &mut Rng::new(walk_seed),
481 );
482 let (walk, learned) = run_walk(
483 bucket.id.clone(),
484 walk_seed,
485 &origin_format,
486 &origin,
487 &path,
488 &mut ledger,
489 &mut direct,
490 );
491 dry = if learned { 0 } else { dry + 1 };
492 ledger.walks += 1;
493 out.walks.push(walk);
494 }
495 if dry >= settle {
496 out.settled_buckets += 1;
497 }
498 }
499
500 super::write_json(&ledger_path, &ledger)?;
501 super::write_json(&work.join(WALK_FILE), &out)?;
502 Ok(out)
503}
504
505fn first_readable(bucket: &Bucket) -> Option<(String, Net)> {
513 bucket.members.iter().find_map(|m| {
514 let net = match bucket.domain {
515 Domain::Transmission => {
516 let mut net = balanced(&m.path)?;
517 net.source = None;
518 Net::Balanced(net)
519 }
520 Domain::Distribution => {
521 let mut net = multiconductor(&m.path)?;
522 net.source = None;
523 net.source_format = None;
524 Net::Dist(net)
525 }
526 };
527 Some((m.format.clone(), net))
528 })
529}
530
531fn draw_path(
534 ledger: &Ledger,
535 alphabet: &[&str],
536 origin: &str,
537 hops: usize,
538 rng: &mut Rng,
539) -> Vec<String> {
540 let mut path = Vec::with_capacity(hops);
541 let mut here = origin.to_string();
542 for _ in 0..hops {
543 let choices: Vec<&str> = alphabet
544 .iter()
545 .copied()
546 .filter(|f| *f != here.as_str())
547 .collect();
548 let weights: Vec<f64> = choices.iter().map(|to| ledger.weight(&here, to)).collect();
549 let pick = choices[rng.weighted(&weights)].to_string();
550 here.clone_from(&pick);
551 path.push(pick);
552 }
553 path
554}
555
556fn run_walk(
559 bucket: String,
560 seed: u64,
561 origin_format: &str,
562 origin: &Net,
563 path: &[String],
564 ledger: &mut Ledger,
565 direct: &mut BTreeMap<String, Option<Net>>,
566) -> (Walk, bool) {
567 let mut walk = Walk {
568 bucket,
569 seed,
570 origin: origin_format.to_string(),
571 hops: Vec::with_capacity(path.len()),
572 };
573 let mut learned = false;
574 let mut current = origin.clone();
575 let mut here = origin_format.to_string();
576 let mut seen: Vec<(String, Net)> = Vec::new();
579
580 for to in path {
581 let mut hop = Hop {
582 to: to.clone(),
583 warnings: Vec::new(),
584 failure: None,
585 leg: HopDelta::default(),
586 path_dependent: None,
587 path_dependent_electrical: false,
588 emptied: Vec::new(),
589 resurrected: Vec::new(),
590 revisit_of: None,
591 drift: None,
592 drift_electrical: false,
593 };
594 let next = match current.convert(to, &mut hop.warnings) {
595 Ok(net) => net,
596 Err(message) => {
597 let mut signatures = BTreeSet::new();
600 signatures.insert(format!("fail:{}", warning_shape(&message)));
601 learned |= ledger.learn(&here, to, &signatures);
602 hop.failure = Some(message);
603 walk.hops.push(hop);
604 break;
606 }
607 };
608
609 hop.leg = current.delta(&next);
610 hop.emptied = origin.emptied(&next);
611 hop.resurrected = next.emptied(¤t);
612
613 if let Some(index) = seen.iter().position(|(f, _)| f == to) {
614 hop.revisit_of = Some(index);
615 let delta = seen[index].1.delta(&next);
616 if !delta.is_empty() {
617 hop.drift_electrical = delta.electrical();
618 hop.drift = Some(describe(&delta));
619 }
620 }
621
622 if !seen.is_empty() {
628 let baseline = direct
629 .entry(to.clone())
630 .or_insert_with(|| origin.convert(to, &mut Vec::new()).ok());
631 if let Some(direct_net) = baseline {
632 let delta = direct_net.delta(&next);
633 if !delta.is_empty() {
634 hop.path_dependent_electrical = delta.electrical();
635 hop.path_dependent = Some(describe(&delta));
636 }
637 }
638 }
639
640 let mut signatures = BTreeSet::new();
641 hop.leg.signatures(&mut signatures);
642 for warning in &hop.warnings {
643 signatures.insert(format!("warn:{}", warning_shape(warning)));
644 }
645 for (code, present) in [
646 ("drift", hop.drift.is_some()),
647 ("path-dependent", hop.path_dependent.is_some()),
648 ("resurrection", !hop.resurrected.is_empty()),
649 ] {
650 if present {
651 signatures.insert(code.to_string());
652 }
653 }
654 learned |= ledger.learn(&here, to, &signatures);
655
656 seen.push((to.clone(), next.clone()));
657 here.clone_from(to);
658 current = next;
659 walk.hops.push(hop);
660 }
661
662 (walk, learned)
663}
664
665fn describe(delta: &HopDelta) -> String {
667 let mut parts = Vec::new();
668 for value in [
669 delta.core_changed.as_ref(),
670 delta.ybus.as_ref(),
671 delta.injection.as_ref(),
672 delta.dc_terminal.as_ref(),
673 ]
674 .into_iter()
675 .flatten()
676 {
677 parts.push(value.clone());
678 }
679 if !delta.model_diffs.is_empty() {
680 parts.push(delta.model_diffs.join(", "));
681 }
682 parts.join("; ")
683}
684
685fn warning_shape(warning: &str) -> String {
697 warning
698 .split_whitespace()
699 .take(8)
700 .map(|w| {
701 let prose = w
702 .chars()
703 .all(|c| c.is_ascii_lowercase() || "()[]{}.,:;`-_/".contains(c));
704 if prose { w } else { "_" }
705 })
706 .collect::<Vec<_>>()
707 .join(" ")
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713
714 #[test]
715 fn a_seed_replays_its_path() {
716 let ledger = Ledger::default();
717 let a = draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(7));
718 let b = draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(7));
719 assert_eq!(a, b);
720 assert_eq!(a.len(), 6);
721 assert_ne!(
722 a,
723 draw_path(&ledger, &ALPHABET, "matpower", 6, &mut Rng::new(8))
724 );
725 }
726
727 #[test]
728 fn a_path_never_steps_to_the_format_it_is_already_in() {
729 let ledger = Ledger::default();
730 for alphabet in [&ALPHABET[..], &DIST_ALPHABET[..]] {
731 let path = draw_path(&ledger, alphabet, alphabet[0], 40, &mut Rng::new(3));
732 assert_ne!(path[0], alphabet[0]);
733 assert!(
734 path.windows(2).all(|w| w[0] != w[1]),
735 "a same-format step is the round trip compare already runs: {path:?}"
736 );
737 }
738 }
739
740 #[test]
741 fn the_ledger_prefers_an_edge_it_has_not_walked() {
742 let mut ledger = Ledger::default();
743 let nothing = BTreeSet::new();
744 for _ in 0..10 {
747 assert!(!ledger.learn("matpower", "psse", ¬hing));
748 }
749 assert!(ledger.weight("matpower", "psse") < 0.1);
750 assert!((ledger.weight("matpower", "egret-json") - 1.0).abs() < f64::EPSILON);
751 assert!(ledger.weight("matpower", "psse") > 0.0);
754 }
755
756 #[test]
757 fn a_visit_that_teaches_is_novel_once() {
758 let mut ledger = Ledger::default();
759 let lesson: BTreeSet<String> = ["path:.loads[#].p".to_string()].into_iter().collect();
760 assert!(ledger.learn("psse", "matpower", &lesson));
761 assert!(
762 !ledger.learn("psse", "matpower", &lesson),
763 "the same lesson twice is not a second lesson"
764 );
765 let edge = &ledger.edges["psse>matpower"];
766 assert_eq!((edge.visits, edge.novel), (2, 1));
767 }
768
769 #[test]
770 fn a_warning_shape_drops_what_makes_one_warning_look_like_many() {
771 assert_eq!(
775 warning_shape("3 switch(es) dropped: MATPOWER has no switch table"),
776 "_ switch(es) dropped: _ has no switch table"
777 );
778 for name in ["L100", "L101", "SourceBus"] {
779 assert_eq!(
780 warning_shape(&format!("line {name}: `units` has no place")),
781 "line _ `units` has no place"
782 );
783 }
784 assert_ne!(
785 warning_shape("bus RG60: location has no place"),
786 warning_shape("line L100: `units` has no place")
787 );
788 }
789}