1use std::path::{Path, PathBuf};
9
10use rand::SeedableRng;
11use rand::distr::{Distribution, StandardUniform};
12use rand_chacha::ChaCha8Rng;
13use serde::{Deserialize, Serialize};
14
15use crate::Result;
16use crate::indexed::IndexedNetwork;
17use crate::io::meta::{CaseMetadata, MatrixMetadata};
18use crate::matrix::{
19 BuildOptions, MatrixStats, ZeroImpedanceRule, ZeroImpedanceSkips, calc_adjacency_matrix,
20 calc_admittance_matrix, calc_bdoubleprime_matrix, calc_bprime_matrix, calc_lacpf_matrix,
21 calc_zero_impedance_skips, check_sddm, negate_into,
22};
23use crate::network::BalancedNetwork;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[non_exhaustive]
27pub enum MatrixKind {
28 BPrime,
30 BDoublePrime,
32 YbusG,
34 YbusB,
36 Lacpf,
38 Adjacency,
40}
41
42impl MatrixKind {
43 pub const ALL: &'static [MatrixKind] = &[
44 Self::BPrime,
45 Self::BDoublePrime,
46 Self::YbusG,
47 Self::YbusB,
48 Self::Lacpf,
49 Self::Adjacency,
50 ];
51
52 pub fn slug(self) -> &'static str {
53 match self {
54 Self::BPrime => "bprime",
55 Self::BDoublePrime => "bdoubleprime",
56 Self::YbusG => "ybus_real",
57 Self::YbusB => "ybus_imag",
58 Self::Lacpf => "lacpf",
59 Self::Adjacency => "adjacency",
60 }
61 }
62
63 pub fn label(self) -> &'static str {
64 match self {
65 Self::BPrime => "MATPOWER Bp (FDPF)",
66 Self::BDoublePrime => "MATPOWER Bpp (FDPF)",
67 Self::YbusG => "Re(Y_bus)",
68 Self::YbusB => "-Im(Y_bus)",
69 Self::Lacpf => "LACPF block (2n×2n)",
70 Self::Adjacency => "adjacency (0/1)",
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
77pub enum RhsKind {
78 #[default]
79 None,
80 Random,
82 Injection,
84}
85
86#[derive(Debug, Clone)]
87pub struct Pipeline {
88 pub matrices: Vec<MatrixKind>,
89 pub options: BuildOptions,
90 pub rhs: RhsKind,
91 pub rng_seed: u64,
92 pub source_file: Option<PathBuf>,
93}
94
95impl Default for Pipeline {
96 fn default() -> Self {
97 Self {
98 matrices: vec![MatrixKind::BPrime],
99 options: BuildOptions::default(),
100 rhs: RhsKind::None,
101 rng_seed: 0x00C0_FFEE,
102 source_file: None,
103 }
104 }
105}
106
107#[derive(Debug, Clone)]
108pub struct PipelineOutputs {
109 pub case_name: String,
110 pub files: Vec<PathBuf>,
111 pub metadata: CaseMetadata,
112}
113
114const MAX_STEM_LEN: usize = 120;
119
120const DIGEST_LEN: usize = 16;
124
125const WINDOWS_RESERVED: &[&str] = &[
129 "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
130 "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
131];
132
133pub fn sanitize_stem(name: &str) -> String {
151 let mut stem: String = name
155 .chars()
156 .skip_while(|&c| c == '.')
157 .map(|c| {
158 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
159 c
160 } else {
161 '_'
162 }
163 })
164 .collect();
165 while stem.ends_with('.') {
166 stem.pop();
167 }
168 let pre_dot = stem.split('.').next().unwrap_or("");
169 if WINDOWS_RESERVED
170 .iter()
171 .any(|r| pre_dot.eq_ignore_ascii_case(r))
172 {
173 stem.insert(0, '_');
174 }
175 if stem.is_empty() {
176 stem.push_str("case");
177 }
178 if stem == name && stem.len() <= MAX_STEM_LEN && !ends_with_digest(&stem) {
179 return stem;
180 }
181 stem.truncate(MAX_STEM_LEN - DIGEST_LEN - 1);
184 stem.push('-');
185 stem.push_str(&sha256_hex(name.as_bytes())[..DIGEST_LEN]);
186 stem
187}
188
189fn ends_with_digest(stem: &str) -> bool {
193 stem.len() > DIGEST_LEN
194 && stem.as_bytes()[stem.len() - DIGEST_LEN - 1] == b'-'
195 && stem[stem.len() - DIGEST_LEN..]
196 .bytes()
197 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
198}
199
200impl Pipeline {
201 pub fn run(&self, net: &BalancedNetwork, out_dir: impl AsRef<Path>) -> Result<PipelineOutputs> {
208 let out_dir = out_dir.as_ref();
209 let view = IndexedNetwork::new(net);
210 let stem = sanitize_stem(view.name());
215
216 let mut inventory: Vec<(String, Vec<u8>)> = Vec::new();
217 let mut matrices_meta = Vec::new();
218 let mut ybus_cache = None;
219
220 for &kind in &self.matrices {
221 let name = format!("{stem}_{}.mtx", kind.slug());
222 let matrix = self.build_for_run(&view, kind, &mut ybus_cache)?;
223 let stats = calc_matrix_stats_for_kind(&matrix, &view, kind, &self.options);
224 let sddm = check_sddm(&matrix);
225 matrices_meta.push(MatrixMetadata {
226 kind: kind.slug().to_string(),
227 file: name.clone(),
228 stats,
229 sddm,
230 });
231 inventory.push((name, crate::io::mtx::to_mtx_bytes(&matrix)?));
232
233 if let Some(rhs) = self.build_rhs(&view, kind) {
235 inventory.push((
236 format!("{stem}_{}_rhs.mtx", kind.slug()),
237 crate::io::mtx::to_vector_mtx_bytes(&rhs)?,
238 ));
239 }
240 }
241
242 let base = view.per_unit_base();
244 let shunt: Vec<f64> = view.bs().iter().map(|&b| b / base).collect();
245 inventory.push((
246 format!("{stem}_shunt.mtx"),
247 crate::io::mtx::to_vector_mtx_bytes(&shunt)?,
248 ));
249
250 let metadata = CaseMetadata {
251 case_name: view.name().to_string(),
252 source_file: self
253 .source_file
254 .as_ref()
255 .and_then(|p| p.to_str())
256 .map(str::to_string),
257 source_sha256: self
258 .source_file
259 .as_ref()
260 .and_then(|p| std::fs::read(p).ok())
261 .map(|b| sha256_hex(&b)),
262 base_mva: view.base_mva(),
263 n_buses: view.n(),
264 n_branches: view.branches().len(),
265 build_options: self.options.clone(),
266 matrices: matrices_meta,
267 powerio_version: env!("CARGO_PKG_VERSION").to_string(),
268 };
269 inventory.push((
270 format!("{stem}_meta.json"),
271 crate::io::meta::meta_json_bytes(&metadata)?,
272 ));
273
274 std::fs::create_dir_all(out_dir)?;
275 let mut files = Vec::new();
276 for (name, bytes) in inventory {
277 let target = out_dir.join(&name);
278 if let Err(error) = crate::io::mtx::commit_one_file(&target, bytes) {
279 for created in &files {
280 let _ = std::fs::remove_file(created);
281 }
282 return Err(error);
283 }
284 files.push(target);
285 }
286
287 Ok(PipelineOutputs {
288 case_name: view.name().to_string(),
289 files,
290 metadata,
291 })
292 }
293
294 fn build_for_run(
295 &self,
296 case: &IndexedNetwork,
297 kind: MatrixKind,
298 ybus_cache: &mut Option<YbusCache>,
299 ) -> Result<sprs::CsMat<f64>> {
300 match kind {
301 MatrixKind::YbusG => take_ybus_g(case, &self.options, ybus_cache),
302 MatrixKind::YbusB => take_ybus_b(case, &self.options, ybus_cache),
303 _ => calc_matrix(case, kind, &self.options),
304 }
305 }
306
307 fn build_rhs(&self, case: &IndexedNetwork, kind: MatrixKind) -> Option<Vec<f64>> {
308 if matches!(self.rhs, RhsKind::None)
310 || matches!(kind, MatrixKind::Lacpf | MatrixKind::Adjacency)
311 {
312 return None;
313 }
314 let n = case.n();
315 let v = match self.rhs {
316 RhsKind::Random => {
317 let mut rng = ChaCha8Rng::seed_from_u64(self.rng_seed.wrapping_add(kind as u64));
318 let dist = StandardUniform;
319 let mut v: Vec<f64> = (0..n)
320 .map(|_| {
321 let u: f64 = dist.sample(&mut rng);
322 u - 0.5
323 })
324 .collect();
325 let mean = v.iter().sum::<f64>() / n as f64;
326 for x in &mut v {
327 *x -= mean; }
329 v
330 }
331 RhsKind::Injection => {
332 let base = case.per_unit_base();
333 match kind {
334 MatrixKind::BPrime | MatrixKind::YbusG | MatrixKind::YbusB => {
335 case.pd().iter().map(|&p| -p / base).collect()
336 }
337 MatrixKind::BDoublePrime => case.qd().iter().map(|&q| -q / base).collect(),
338 MatrixKind::Lacpf | MatrixKind::Adjacency => unreachable!(),
339 }
340 }
341 RhsKind::None => unreachable!(),
342 };
343 Some(v)
344 }
345}
346
347struct YbusCache {
348 g: Option<sprs::CsMat<f64>>,
349 b: Option<sprs::CsMat<f64>>,
350}
351
352fn fill_ybus_cache(
353 view: &IndexedNetwork,
354 opts: &BuildOptions,
355 ybus_cache: &mut Option<YbusCache>,
356) -> Result<()> {
357 let parts = calc_admittance_matrix(view, opts)?;
358 *ybus_cache = Some(YbusCache {
359 g: Some(parts.g),
360 b: Some(parts.b),
361 });
362 Ok(())
363}
364
365fn take_ybus_g(
366 view: &IndexedNetwork,
367 opts: &BuildOptions,
368 ybus_cache: &mut Option<YbusCache>,
369) -> Result<sprs::CsMat<f64>> {
370 if ybus_cache.as_ref().is_none_or(|c| c.g.is_none()) {
371 fill_ybus_cache(view, opts, ybus_cache)?;
372 }
373 Ok(ybus_cache
374 .as_mut()
375 .and_then(|c| c.g.take())
376 .expect("Ybus cache was just filled, so the real part must be present"))
377}
378
379fn take_ybus_b(
380 view: &IndexedNetwork,
381 opts: &BuildOptions,
382 ybus_cache: &mut Option<YbusCache>,
383) -> Result<sprs::CsMat<f64>> {
384 if ybus_cache.as_ref().is_none_or(|c| c.b.is_none()) {
385 fill_ybus_cache(view, opts, ybus_cache)?;
386 }
387 let b = ybus_cache
388 .as_mut()
389 .and_then(|c| c.b.take())
390 .expect("Ybus cache was just filled, so the imaginary part must be present");
391 Ok(negate_into(b))
392}
393
394pub fn calc_matrix(
399 view: &IndexedNetwork,
400 kind: MatrixKind,
401 opts: &BuildOptions,
402) -> Result<sprs::CsMat<f64>> {
403 match kind {
404 MatrixKind::BPrime => calc_bprime_matrix(view, opts),
405 MatrixKind::BDoublePrime => calc_bdoubleprime_matrix(view, opts),
406 MatrixKind::YbusG => calc_admittance_matrix(view, opts).map(|p| p.g),
407 MatrixKind::YbusB => calc_admittance_matrix(view, opts).map(|p| negate_into(p.b)),
408 MatrixKind::Lacpf => calc_lacpf_matrix(view, opts),
409 MatrixKind::Adjacency => calc_adjacency_matrix(view),
410 }
411}
412
413pub fn select_zero_impedance_rule_for_kind(
414 kind: MatrixKind,
415 opts: &BuildOptions,
416) -> Option<ZeroImpedanceRule> {
417 match kind {
418 MatrixKind::BPrime => Some(match opts.scheme {
419 crate::matrix::Scheme::Bx => ZeroImpedanceRule::Series,
420 crate::matrix::Scheme::Xb => ZeroImpedanceRule::Reactance,
421 }),
422 MatrixKind::BDoublePrime => Some(match opts.scheme {
423 crate::matrix::Scheme::Bx => ZeroImpedanceRule::Reactance,
424 crate::matrix::Scheme::Xb => ZeroImpedanceRule::Series,
425 }),
426 MatrixKind::YbusG | MatrixKind::YbusB | MatrixKind::Lacpf => {
427 Some(ZeroImpedanceRule::Series)
428 }
429 MatrixKind::Adjacency => None,
430 }
431}
432
433pub fn calc_zero_impedance_skips_for_kind(
434 view: &IndexedNetwork,
435 kind: MatrixKind,
436 opts: &BuildOptions,
437) -> ZeroImpedanceSkips {
438 if !opts.skip_zero_impedance {
439 return ZeroImpedanceSkips::default();
440 }
441 select_zero_impedance_rule_for_kind(kind, opts)
442 .map_or_else(ZeroImpedanceSkips::default, |rule| {
443 calc_zero_impedance_skips(view, rule)
444 })
445}
446
447pub fn calc_matrix_stats_for_kind(
448 matrix: &sprs::CsMat<f64>,
449 view: &IndexedNetwork,
450 kind: MatrixKind,
451 opts: &BuildOptions,
452) -> MatrixStats {
453 MatrixStats::from_csr(matrix)
454 .with_zero_impedance_skips(calc_zero_impedance_skips_for_kind(view, kind, opts))
455}
456
457fn sha256_hex(bytes: &[u8]) -> String {
458 use sha2::{Digest, Sha256};
459 use std::fmt::Write as _;
460 let mut hasher = Sha256::new();
461 hasher.update(bytes);
462 let digest = hasher.finalize();
463 let mut out = String::with_capacity(digest.len() * 2);
464 for byte in digest {
465 let _ = write!(out, "{byte:02x}");
466 }
467 out
468}
469
470#[cfg(test)]
471mod tests {
472 use super::{Pipeline, sanitize_stem};
473 use std::path::Path;
474
475 #[test]
476 fn a_pipeline_run_never_replaces_an_existing_entry() {
477 let spec = crate::synth::SynthSpec {
478 n: 8,
479 ..Default::default()
480 };
481 let net = crate::synth::generate(&spec);
482 let pipeline = Pipeline::default();
483
484 let base = tempfile::tempdir().unwrap();
486 let fresh = base.path().join("matrices");
487 let outputs = pipeline.run(&net, &fresh).unwrap();
488 assert_ne!(outputs.files, [] as [std::path::PathBuf; 0]);
489 for file in &outputs.files {
490 assert!(file.is_file(), "{file:?}");
491 }
492
493 let shared = base.path().join("shared");
496 std::fs::create_dir_all(&shared).unwrap();
497 std::fs::write(shared.join("unrelated.m"), b"unrelated").unwrap();
498 pipeline.run(&net, &shared).unwrap();
499 assert_eq!(
500 std::fs::read(shared.join("unrelated.m")).unwrap(),
501 b"unrelated"
502 );
503
504 let meta_name = outputs
507 .files
508 .iter()
509 .find_map(|file| {
510 let name = file.file_name()?.to_str()?;
511 name.ends_with("_meta.json").then(|| name.to_owned())
512 })
513 .expect("the run produces a metadata file");
514 let blocked = base.path().join("blocked");
515 std::fs::create_dir_all(&blocked).unwrap();
516 std::fs::write(blocked.join(&meta_name), b"precious").unwrap();
517 let error = pipeline.run(&net, &blocked).unwrap_err();
518 assert!(matches!(error, crate::Error::Commit(_)), "{error:?}");
519 assert_eq!(
520 std::fs::read(blocked.join(&meta_name)).unwrap(),
521 b"precious"
522 );
523 let residue: Vec<_> = std::fs::read_dir(&blocked)
524 .unwrap()
525 .filter_map(std::result::Result::ok)
526 .filter(|entry| entry.file_name().to_str() != Some(meta_name.as_str()))
527 .map(|entry| entry.file_name())
528 .collect();
529 assert!(residue.is_empty(), "{residue:?}");
530
531 #[cfg(unix)]
534 {
535 let designated = base.path().join("designated.json");
536 std::fs::write(&designated, b"designated").unwrap();
537 let linked = base.path().join("linked");
538 std::fs::create_dir_all(&linked).unwrap();
539 std::os::unix::fs::symlink(&designated, linked.join(&meta_name)).unwrap();
540 let error = pipeline.run(&net, &linked).unwrap_err();
541 assert!(matches!(error, crate::Error::Commit(_)), "{error:?}");
542 assert!(
543 std::fs::symlink_metadata(linked.join(&meta_name))
544 .unwrap()
545 .file_type()
546 .is_symlink()
547 );
548 assert_eq!(std::fs::read(&designated).unwrap(), b"designated");
549 assert_eq!(std::fs::metadata(&designated).unwrap().len(), 10);
550 }
551 }
552
553 #[test]
554 fn sanitize_stem_confines_names_to_out_dir() {
555 for name in [
558 "../../etc/passwd",
559 "/abs/path",
560 "..",
561 ".",
562 "..\\..\\win",
563 "",
564 "a/b/c",
565 ] {
566 let stem = sanitize_stem(name);
567 let joined = Path::new("out").join(&stem);
568 assert_eq!(
569 joined.components().count(),
570 2,
571 "{name:?} -> {stem:?} escaped out_dir as {joined:?}"
572 );
573 assert_ne!(stem, "");
574 assert!(stem != "." && stem != "..");
575 }
576 }
577
578 #[test]
579 fn sanitize_stem_keeps_ordinary_names() {
580 assert_eq!(sanitize_stem("case118"), "case118");
581 assert_eq!(sanitize_stem("ieee-13_bus.v2"), "ieee-13_bus.v2");
582 }
583
584 #[test]
585 fn sanitize_stem_separates_names_that_sanitize_alike() {
586 assert_ne!(sanitize_stem("a/b"), sanitize_stem("a_b"));
588 assert_ne!(sanitize_stem("a/b"), sanitize_stem("a\\b"));
589 assert_eq!(sanitize_stem("a_b"), "a_b");
590 }
591
592 #[test]
593 fn a_safe_name_cannot_impersonate_a_disambiguated_stem() {
594 let unsafe_stem = sanitize_stem(".foo");
599 assert_ne!(sanitize_stem(&unsafe_stem), unsafe_stem);
600 assert_ne!(sanitize_stem(&unsafe_stem), sanitize_stem(".foo"));
601 assert_eq!(sanitize_stem("ieee-13"), "ieee-13");
603 assert_eq!(sanitize_stem("case-deadbeef"), "case-deadbeef");
604 }
605
606 #[test]
607 fn sanitize_stem_applies_windows_filename_rules() {
608 let trailing = sanitize_stem("case.");
610 assert!(!trailing.ends_with('.'), "{trailing:?}");
611 for name in ["con", "CON", "aux.4", "lpt9"] {
612 let stem = sanitize_stem(name);
613 let pre_dot = stem.split('.').next().unwrap();
614 assert!(
615 !super::WINDOWS_RESERVED
616 .iter()
617 .any(|r| pre_dot.eq_ignore_ascii_case(r)),
618 "{name:?} -> {stem:?} is still a reserved device name"
619 );
620 }
621 }
622
623 #[test]
624 fn sanitize_stem_caps_the_length() {
625 let long = "x".repeat(4096);
626 assert!(sanitize_stem(&long).len() <= super::MAX_STEM_LEN);
628 }
629}