Skip to main content

surge_network/network/
case_diff.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Network case diff utility.
3//!
4//! Compares two [`Network`] objects and produces a structured diff showing
5//! buses, branches, and generators that were added, removed, or modified.
6//! Useful for verifying parser round-trips and inspecting contingency effects.
7
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10
11use crate::network::{Branch, Generator, Load, Network};
12/// Kind of change detected for an element.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum DiffKind {
15    Added,
16    Removed,
17    Modified,
18}
19
20/// Diff entry for a bus. Modified fields are `Some` with `(old, new)`.
21#[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/// Diff entry for a branch. Modified fields are `Some` with `(old, new)`.
32#[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/// Diff entry for a generator. Modified fields are `Some` with `(old, new)`.
48#[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    /// Cost change shown as debug strings when the cost curves differ.
61    pub cost: Option<(String, String)>,
62}
63
64/// Diff entry for a load. Modified fields are `Some` with `(old, new)`.
65#[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/// Structured diff between two networks.
76#[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/// Compare two `f64` values; return `Some((a, b))` if they differ.
86#[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
95/// Produce a structured diff between two [`Network`] objects.
96///
97/// Buses are matched by `bus_number`, branches by `(from_bus, to_bus, circuit)`,
98/// and generators by canonical generator ID, synthesizing missing IDs
99/// deterministically from bus order when needed.
100pub 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    /// Build a minimal 2-bus, 1-branch, 1-gen network via JSON round-trip.
521    /// This avoids listing every struct field explicitly.
522    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}