1use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10
11use crate::network::{Branch, Generator, Load, Network};
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum DiffKind {
15 Added,
16 Removed,
17 Modified,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct BusDiff {
23 pub bus_number: u32,
24 pub kind: DiffKind,
25 pub bus_type: Option<(String, String)>,
26 pub voltage_magnitude_pu: Option<(f64, f64)>,
27 pub voltage_angle_rad: Option<(f64, f64)>,
28 pub base_kv: Option<(f64, f64)>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct BranchDiff {
34 pub from_bus: u32,
35 pub to_bus: u32,
36 pub circuit: String,
37 pub kind: DiffKind,
38 pub r: Option<(f64, f64)>,
39 pub x: Option<(f64, f64)>,
40 pub b: Option<(f64, f64)>,
41 pub rating_a_mva: Option<(f64, f64)>,
42 pub tap: Option<(f64, f64)>,
43 pub phase_shift_rad: Option<(f64, f64)>,
44 pub in_service: Option<(bool, bool)>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct GenDiff {
50 pub bus: u32,
51 pub id: String,
52 pub kind: DiffKind,
53 pub p: Option<(f64, f64)>,
54 pub q: Option<(f64, f64)>,
55 pub pmin: Option<(f64, f64)>,
56 pub pmax: Option<(f64, f64)>,
57 pub qmin: Option<(f64, f64)>,
58 pub qmax: Option<(f64, f64)>,
59 pub in_service: Option<(bool, bool)>,
60 pub cost: Option<(String, String)>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct LoadDiff {
67 pub bus: u32,
68 pub id: String,
69 pub kind: DiffKind,
70 pub active_power_demand_mw: Option<(f64, f64)>,
71 pub reactive_power_demand_mvar: Option<(f64, f64)>,
72 pub in_service: Option<(bool, bool)>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct CaseDiff {
78 pub bus_diffs: Vec<BusDiff>,
79 pub branch_diffs: Vec<BranchDiff>,
80 pub gen_diffs: Vec<GenDiff>,
81 pub load_diffs: Vec<LoadDiff>,
82 pub summary: String,
83}
84
85#[inline]
87fn diff_f64(a: f64, b: f64) -> Option<(f64, f64)> {
88 if (a - b).abs() > f64::EPSILON {
89 Some((a, b))
90 } else {
91 None
92 }
93}
94
95pub fn diff_networks(a: &Network, b: &Network) -> CaseDiff {
101 let bus_diffs = diff_buses(a, b);
102 let branch_diffs = diff_branches(a, b);
103 let gen_diffs = diff_gens(a, b);
104 let load_diffs = diff_loads(a, b);
105
106 let mut parts = Vec::new();
107
108 for (name, total, added, removed, modified) in [
109 (
110 "bus",
111 bus_diffs.len(),
112 bus_diffs
113 .iter()
114 .filter(|d| d.kind == DiffKind::Added)
115 .count(),
116 bus_diffs
117 .iter()
118 .filter(|d| d.kind == DiffKind::Removed)
119 .count(),
120 bus_diffs
121 .iter()
122 .filter(|d| d.kind == DiffKind::Modified)
123 .count(),
124 ),
125 (
126 "branch",
127 branch_diffs.len(),
128 branch_diffs
129 .iter()
130 .filter(|d| d.kind == DiffKind::Added)
131 .count(),
132 branch_diffs
133 .iter()
134 .filter(|d| d.kind == DiffKind::Removed)
135 .count(),
136 branch_diffs
137 .iter()
138 .filter(|d| d.kind == DiffKind::Modified)
139 .count(),
140 ),
141 (
142 "generator",
143 gen_diffs.len(),
144 gen_diffs
145 .iter()
146 .filter(|d| d.kind == DiffKind::Added)
147 .count(),
148 gen_diffs
149 .iter()
150 .filter(|d| d.kind == DiffKind::Removed)
151 .count(),
152 gen_diffs
153 .iter()
154 .filter(|d| d.kind == DiffKind::Modified)
155 .count(),
156 ),
157 (
158 "load",
159 load_diffs.len(),
160 load_diffs
161 .iter()
162 .filter(|d| d.kind == DiffKind::Added)
163 .count(),
164 load_diffs
165 .iter()
166 .filter(|d| d.kind == DiffKind::Removed)
167 .count(),
168 load_diffs
169 .iter()
170 .filter(|d| d.kind == DiffKind::Modified)
171 .count(),
172 ),
173 ] {
174 if total > 0 {
175 let mut sub = Vec::new();
176 if added > 0 {
177 sub.push(format!("{added} added"));
178 }
179 if removed > 0 {
180 sub.push(format!("{removed} removed"));
181 }
182 if modified > 0 {
183 sub.push(format!("{modified} modified"));
184 }
185 let plural = if total == 1 {
186 ""
187 } else if name == "branch" {
188 "es"
189 } else {
190 "s"
191 };
192 parts.push(format!("{total} {name}{plural} ({})", sub.join(", ")));
193 }
194 }
195
196 let summary = if parts.is_empty() {
197 "no differences".to_string()
198 } else {
199 parts.join("; ")
200 };
201
202 CaseDiff {
203 bus_diffs,
204 branch_diffs,
205 gen_diffs,
206 load_diffs,
207 summary,
208 }
209}
210
211fn diff_buses(a: &Network, b: &Network) -> Vec<BusDiff> {
212 let a_map: HashMap<u32, _> = a.buses.iter().map(|bus| (bus.number, bus)).collect();
213 let b_map: HashMap<u32, _> = b.buses.iter().map(|bus| (bus.number, bus)).collect();
214 let mut diffs = Vec::new();
215
216 for (&num, ba) in &a_map {
217 if let Some(bb) = b_map.get(&num) {
218 let bus_type = if ba.bus_type != bb.bus_type {
219 Some((format!("{:?}", ba.bus_type), format!("{:?}", bb.bus_type)))
220 } else {
221 None
222 };
223 let vm = diff_f64(ba.voltage_magnitude_pu, bb.voltage_magnitude_pu);
224 let va = diff_f64(ba.voltage_angle_rad, bb.voltage_angle_rad);
225 let base_kv = diff_f64(ba.base_kv, bb.base_kv);
226 if bus_type.is_some() || vm.is_some() || va.is_some() || base_kv.is_some() {
227 diffs.push(BusDiff {
228 bus_number: num,
229 kind: DiffKind::Modified,
230 bus_type,
231 voltage_magnitude_pu: vm,
232 voltage_angle_rad: va,
233 base_kv,
234 });
235 }
236 } else {
237 diffs.push(BusDiff {
238 bus_number: num,
239 kind: DiffKind::Removed,
240 bus_type: None,
241 voltage_magnitude_pu: None,
242 voltage_angle_rad: None,
243 base_kv: None,
244 });
245 }
246 }
247 for &num in b_map.keys() {
248 if !a_map.contains_key(&num) {
249 diffs.push(BusDiff {
250 bus_number: num,
251 kind: DiffKind::Added,
252 bus_type: None,
253 voltage_magnitude_pu: None,
254 voltage_angle_rad: None,
255 base_kv: None,
256 });
257 }
258 }
259 diffs.sort_by_key(|d| d.bus_number);
260 diffs
261}
262
263fn diff_branches(a: &Network, b: &Network) -> Vec<BranchDiff> {
264 type Key = (u32, u32, String);
265 let key_of = |br: &Branch| -> Key { (br.from_bus, br.to_bus, br.circuit.clone()) };
266
267 let a_map: HashMap<Key, _> = a.branches.iter().map(|br| (key_of(br), br)).collect();
268 let b_map: HashMap<Key, _> = b.branches.iter().map(|br| (key_of(br), br)).collect();
269 let mut diffs = Vec::new();
270
271 for (key, ba) in &a_map {
272 if let Some(bb) = b_map.get(key) {
273 let r = diff_f64(ba.r, bb.r);
274 let x = diff_f64(ba.x, bb.x);
275 let b = diff_f64(ba.b, bb.b);
276 let rating_a_mva = diff_f64(ba.rating_a_mva, bb.rating_a_mva);
277 let tap = diff_f64(ba.tap, bb.tap);
278 let phase_shift_rad = diff_f64(ba.phase_shift_rad, bb.phase_shift_rad);
279 let in_service = if ba.in_service != bb.in_service {
280 Some((ba.in_service, bb.in_service))
281 } else {
282 None
283 };
284 if r.is_some()
285 || x.is_some()
286 || b.is_some()
287 || rating_a_mva.is_some()
288 || tap.is_some()
289 || phase_shift_rad.is_some()
290 || in_service.is_some()
291 {
292 diffs.push(BranchDiff {
293 from_bus: key.0,
294 to_bus: key.1,
295 circuit: key.2.clone(),
296 kind: DiffKind::Modified,
297 r,
298 x,
299 b,
300 rating_a_mva,
301 tap,
302 phase_shift_rad,
303 in_service,
304 });
305 }
306 } else {
307 diffs.push(BranchDiff {
308 from_bus: key.0,
309 to_bus: key.1,
310 circuit: key.2.clone(),
311 kind: DiffKind::Removed,
312 r: None,
313 x: None,
314 b: None,
315 rating_a_mva: None,
316 tap: None,
317 phase_shift_rad: None,
318 in_service: None,
319 });
320 }
321 }
322 for key in b_map.keys() {
323 if !a_map.contains_key(key) {
324 diffs.push(BranchDiff {
325 from_bus: key.0,
326 to_bus: key.1,
327 circuit: key.2.clone(),
328 kind: DiffKind::Added,
329 r: None,
330 x: None,
331 b: None,
332 rating_a_mva: None,
333 tap: None,
334 phase_shift_rad: None,
335 in_service: None,
336 });
337 }
338 }
339 diffs.sort_by_key(|d| (d.from_bus, d.to_bus, d.circuit.clone()));
340 diffs
341}
342
343fn diff_gens(a: &Network, b: &Network) -> Vec<GenDiff> {
344 let a_map = canonical_generator_map(&a.generators);
345 let b_map = canonical_generator_map(&b.generators);
346 let mut diffs = Vec::new();
347
348 for (key, ga) in &a_map {
349 if let Some(gb) = b_map.get(key) {
350 let p = diff_f64(ga.p, gb.p);
351 let q = diff_f64(ga.q, gb.q);
352 let pmin = diff_f64(ga.pmin, gb.pmin);
353 let pmax = diff_f64(ga.pmax, gb.pmax);
354 let qmin = diff_f64(ga.qmin, gb.qmin);
355 let qmax = diff_f64(ga.qmax, gb.qmax);
356 let in_service = if ga.in_service != gb.in_service {
357 Some((ga.in_service, gb.in_service))
358 } else {
359 None
360 };
361 let cost = {
362 let ca = format!("{:?}", ga.cost);
363 let cb = format!("{:?}", gb.cost);
364 if ca != cb { Some((ca, cb)) } else { None }
365 };
366 if p.is_some()
367 || q.is_some()
368 || pmin.is_some()
369 || pmax.is_some()
370 || qmin.is_some()
371 || qmax.is_some()
372 || in_service.is_some()
373 || cost.is_some()
374 {
375 diffs.push(GenDiff {
376 bus: ga.bus,
377 id: key.clone(),
378 kind: DiffKind::Modified,
379 p,
380 q,
381 pmin,
382 pmax,
383 qmin,
384 qmax,
385 in_service,
386 cost,
387 });
388 }
389 } else {
390 diffs.push(GenDiff {
391 bus: ga.bus,
392 id: key.clone(),
393 kind: DiffKind::Removed,
394 p: None,
395 q: None,
396 pmin: None,
397 pmax: None,
398 qmin: None,
399 qmax: None,
400 in_service: None,
401 cost: None,
402 });
403 }
404 }
405 for key in b_map.keys() {
406 if !a_map.contains_key(key) {
407 let gb = &b_map[key];
408 diffs.push(GenDiff {
409 bus: gb.bus,
410 id: key.clone(),
411 kind: DiffKind::Added,
412 p: None,
413 q: None,
414 pmin: None,
415 pmax: None,
416 qmin: None,
417 qmax: None,
418 in_service: None,
419 cost: None,
420 });
421 }
422 }
423 diffs.sort_by_key(|d| (d.bus, d.id.clone()));
424 diffs
425}
426
427fn canonical_generator_map(generators: &[Generator]) -> HashMap<String, &Generator> {
428 let mut used_ids = HashSet::new();
429 for generator in generators {
430 let trimmed = generator.id.trim();
431 if !trimmed.is_empty() {
432 used_ids.insert(trimmed.to_string());
433 }
434 }
435
436 let mut ordinal_by_bus: HashMap<u32, usize> = HashMap::new();
437 let mut map = HashMap::new();
438
439 for generator in generators {
440 let trimmed = generator.id.trim();
441 let key = if !trimmed.is_empty() {
442 trimmed.to_string()
443 } else {
444 let ordinal = ordinal_by_bus.entry(generator.bus).or_insert(0);
445 *ordinal += 1;
446 let base = format!("gen_{}_{}", generator.bus, *ordinal);
447 let mut candidate = base.clone();
448 let mut collision = 2usize;
449 while used_ids.contains(&candidate) {
450 candidate = format!("{base}_{collision}");
451 collision += 1;
452 }
453 used_ids.insert(candidate.clone());
454 candidate
455 };
456 map.entry(key).or_insert(generator);
457 }
458
459 map
460}
461
462fn diff_loads(a: &Network, b: &Network) -> Vec<LoadDiff> {
463 type Key = (u32, String);
464 let key_of = |l: &Load| -> Key { (l.bus, l.id.clone()) };
465
466 let a_map: HashMap<Key, _> = a.loads.iter().map(|l| (key_of(l), l)).collect();
467 let b_map: HashMap<Key, _> = b.loads.iter().map(|l| (key_of(l), l)).collect();
468 let mut diffs = Vec::new();
469
470 for (key, la) in &a_map {
471 if let Some(lb) = b_map.get(key) {
472 let p = diff_f64(la.active_power_demand_mw, lb.active_power_demand_mw);
473 let q = diff_f64(la.reactive_power_demand_mvar, lb.reactive_power_demand_mvar);
474 let in_service = if la.in_service != lb.in_service {
475 Some((la.in_service, lb.in_service))
476 } else {
477 None
478 };
479 if p.is_some() || q.is_some() || in_service.is_some() {
480 diffs.push(LoadDiff {
481 bus: key.0,
482 id: key.1.clone(),
483 kind: DiffKind::Modified,
484 active_power_demand_mw: p,
485 reactive_power_demand_mvar: q,
486 in_service,
487 });
488 }
489 } else {
490 diffs.push(LoadDiff {
491 bus: key.0,
492 id: key.1.clone(),
493 kind: DiffKind::Removed,
494 active_power_demand_mw: None,
495 reactive_power_demand_mvar: None,
496 in_service: None,
497 });
498 }
499 }
500 for key in b_map.keys() {
501 if !a_map.contains_key(key) {
502 diffs.push(LoadDiff {
503 bus: key.0,
504 id: key.1.clone(),
505 kind: DiffKind::Added,
506 active_power_demand_mw: None,
507 reactive_power_demand_mvar: None,
508 in_service: None,
509 });
510 }
511 }
512 diffs.sort_by_key(|d| (d.bus, d.id.clone()));
513 diffs
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 fn minimal_network() -> Network {
523 let json = r#"{
524 "name":"test","base_mva":100.0,"freq_hz":60.0,
525 "buses":[
526 {"number":1,"name":"Bus1","bus_type":"Slack",
527 "shunt_conductance_mw":0.0,"shunt_susceptance_mvar":0.0,"area":1,"voltage_magnitude_pu":1.0,"voltage_angle_rad":0.0,"base_kv":230.0,
528 "zone":1,"voltage_max_pu":1.1,"voltage_min_pu":0.9,"island_id":0},
529 {"number":2,"name":"Bus2","bus_type":"PQ",
530 "shunt_conductance_mw":0.0,"shunt_susceptance_mvar":0.0,"area":1,"voltage_magnitude_pu":1.0,"voltage_angle_rad":0.0,"base_kv":230.0,
531 "zone":1,"voltage_max_pu":1.1,"voltage_min_pu":0.9,"island_id":0}
532 ],
533 "branches":[
534 {"from_bus":1,"to_bus":2,"circuit":"1","r":0.01,"x":0.1,"b":0.02,
535 "rating_a_mva":100.0,"rating_b_mva":100.0,"rating_c_mva":100.0,"tap":1.0,"phase_shift_rad":0.0,
536 "in_service":true}
537 ],
538 "loads":[
539 {"bus":2,"id":"load_2_1","active_power_demand_mw":50.0,"reactive_power_demand_mvar":20.0,"in_service":true}
540 ],
541 "generators":[
542 {"bus":1,"machine_id":"1","pg":100.0,"qg":0.0,"qmax":100.0,
543 "qmin":-100.0,"voltage_setpoint_pu":1.0,"machine_base_mva":100.0,"pmax":200.0,"pmin":0.0,
544 "in_service":true}
545 ]
546 }"#;
547 serde_json::from_str(json).expect("test network JSON must parse")
548 }
549
550 #[test]
551 fn test_identical_networks() {
552 let net = minimal_network();
553 let diff = diff_networks(&net, &net);
554 assert!(diff.bus_diffs.is_empty());
555 assert!(diff.branch_diffs.is_empty());
556 assert!(diff.gen_diffs.is_empty());
557 assert_eq!(diff.summary, "no differences");
558 }
559
560 #[test]
561 fn test_modified_bus_type() {
562 let a = minimal_network();
563 let mut b = a.clone();
564 b.buses[1].bus_type = crate::network::BusType::PV;
565 let diff = diff_networks(&a, &b);
566 assert_eq!(diff.bus_diffs.len(), 1);
567 assert_eq!(diff.bus_diffs[0].kind, DiffKind::Modified);
568 assert!(diff.bus_diffs[0].bus_type.is_some());
569 }
570
571 #[test]
572 fn test_branch_removed() {
573 let a = minimal_network();
574 let mut b = a.clone();
575 b.branches.clear();
576 let diff = diff_networks(&a, &b);
577 assert_eq!(diff.branch_diffs.len(), 1);
578 assert_eq!(diff.branch_diffs[0].kind, DiffKind::Removed);
579 assert!(diff.summary.contains("removed"));
580 }
581
582 #[test]
583 fn test_generator_added() {
584 let a = minimal_network();
585 let mut b = a.clone();
586 let mut g2 = b.generators[0].clone();
587 g2.bus = 2;
588 g2.machine_id = Some("1".into());
589 b.generators.push(g2);
590 let diff = diff_networks(&a, &b);
591 assert_eq!(diff.gen_diffs.len(), 1);
592 assert_eq!(diff.gen_diffs[0].kind, DiffKind::Added);
593 assert_eq!(diff.gen_diffs[0].bus, 2);
594 }
595
596 #[test]
597 fn test_generator_diff_with_missing_ids_keeps_distinct_entries() {
598 let mut a = minimal_network();
599 let mut b = minimal_network();
600
601 let g2 = Generator::new(1, 20.0, 1.0);
602 a.generators.push(g2.clone());
603 b.generators.push(g2);
604 b.generators[1].p = 25.0;
605
606 let diff = diff_networks(&a, &b);
607 assert_eq!(diff.gen_diffs.len(), 1);
608 assert_eq!(diff.gen_diffs[0].kind, DiffKind::Modified);
609 assert_eq!(diff.gen_diffs[0].bus, 1);
610 assert!(diff.gen_diffs[0].id.starts_with("gen_1_2"));
611 }
612}