use std::collections::{HashMap, HashSet};
use surge_network::Network;
#[derive(Debug)]
pub enum MergeError {
Empty,
MultipleBoundaryMatch { cn_mrid: String, count: usize },
BusNumberOverflow,
}
impl std::fmt::Display for MergeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "no networks provided for merging"),
Self::MultipleBoundaryMatch { cn_mrid, count } => {
write!(
f,
"boundary CN mRID {cn_mrid} appears in {count} networks (max 2 supported)"
)
}
Self::BusNumberOverflow => write!(f, "bus renumbering would overflow u32"),
}
}
}
impl std::error::Error for MergeError {}
#[derive(Debug, Clone, Default)]
pub struct MergeReport {
pub input_count: usize,
pub boundary_points_stitched: usize,
pub boundary_points_unmatched: usize,
pub bus_offsets: Vec<u32>,
pub total_buses: usize,
pub total_branches: usize,
pub equivalent_branches_removed: usize,
pub warnings: Vec<String>,
}
pub fn merge_networks(networks: Vec<Network>) -> Result<(Network, MergeReport), MergeError> {
if networks.is_empty() {
return Err(MergeError::Empty);
}
let mut report = MergeReport {
input_count: networks.len(),
..Default::default()
};
if networks.len() == 1 {
let net = networks.into_iter().next().expect("networks.len() == 1");
report.bus_offsets = vec![0];
report.total_buses = net.buses.len();
report.total_branches = net.branches.len();
return Ok((net, report));
}
let mut offsets: Vec<u32> = Vec::with_capacity(networks.len());
let mut current_offset: u32 = 0;
for net in &networks {
offsets.push(current_offset);
let max_bus = net.buses.iter().map(|b| b.number).max().unwrap_or(0);
current_offset = current_offset
.checked_add(max_bus)
.ok_or(MergeError::BusNumberOverflow)?;
}
report.bus_offsets = offsets.clone();
let mut renamed_networks: Vec<Network> = Vec::with_capacity(networks.len());
let mut bus_maps: Vec<HashMap<u32, u32>> = Vec::with_capacity(networks.len());
for (i, mut net) in networks.into_iter().enumerate() {
let map = renumber_network(&mut net, offsets[i]);
bus_maps.push(map);
renamed_networks.push(net);
}
let boundary_map = find_boundary_pairs(&renamed_networks);
for (cn_mrid, entries) in &boundary_map {
if entries.len() > 2 {
return Err(MergeError::MultipleBoundaryMatch {
cn_mrid: cn_mrid.clone(),
count: entries.len(),
});
}
}
let mut stitch_pairs: Vec<(u32, u32)> = Vec::new();
for entries in boundary_map.values() {
if entries.len() == 2 {
let (_, bus_a) = entries[0];
let (_, bus_b) = entries[1];
stitch_pairs.push((bus_a, bus_b));
report.boundary_points_stitched += 1;
} else {
report.boundary_points_unmatched += 1;
}
}
let mut merged = Network::default();
let names: Vec<&str> = renamed_networks.iter().map(|n| n.name.as_str()).collect();
merged.name = format!("Merged: {}", names.join(" + "));
let base_mva = renamed_networks[0].base_mva;
for (i, net) in renamed_networks.iter().enumerate().skip(1) {
if (net.base_mva - base_mva).abs() > 1e-6 {
report.warnings.push(format!(
"base_mva mismatch: network[0]={base_mva}, network[{i}]={}",
net.base_mva
));
}
}
merged.base_mva = base_mva;
merged.freq_hz = renamed_networks[0].freq_hz;
for net in renamed_networks.into_iter() {
merged.buses.extend(net.buses);
merged.branches.extend(net.branches);
merged.generators.extend(net.generators);
merged.loads.extend(net.loads);
merged.power_injections.extend(net.power_injections);
merged
.market_data
.dispatchable_loads
.extend(net.market_data.dispatchable_loads);
merged
.controls
.switched_shunts
.extend(net.controls.switched_shunts);
merged
.controls
.switched_shunts_opf
.extend(net.controls.switched_shunts_opf);
merged.controls.oltc_specs.extend(net.controls.oltc_specs);
merged.controls.par_specs.extend(net.controls.par_specs);
merged.hvdc.links.extend(net.hvdc.links);
merged.hvdc.dc_grids.extend(net.hvdc.dc_grids);
merged.facts_devices.extend(net.facts_devices);
merged.fixed_shunts.extend(net.fixed_shunts);
merged.induction_machines.extend(net.induction_machines);
merged
.metadata
.multi_section_line_groups
.extend(net.metadata.multi_section_line_groups);
merged.breaker_ratings.extend(net.breaker_ratings);
merged.cim.measurements.extend(net.cim.measurements);
merged
.cim
.grounding_impedances
.extend(net.cim.grounding_impedances);
merged.flowgates.extend(net.flowgates);
merged.interfaces.extend(net.interfaces);
merged.nomograms.extend(net.nomograms);
merged
.market_data
.pumped_hydro_units
.extend(net.market_data.pumped_hydro_units);
merged
.market_data
.combined_cycle_plants
.extend(net.market_data.combined_cycle_plants);
merged
.market_data
.outage_schedule
.extend(net.market_data.outage_schedule);
merged
.market_data
.reserve_zones
.extend(net.market_data.reserve_zones);
merged
.metadata
.impedance_corrections
.extend(net.metadata.impedance_corrections);
for area in net.area_schedules {
if !merged.area_schedules.iter().any(|a| a.name == area.name) {
merged.area_schedules.push(area);
}
}
for region in net.metadata.regions {
if !merged
.metadata
.regions
.iter()
.any(|r| r.name == region.name)
{
merged.metadata.regions.push(region);
}
}
for owner in net.metadata.owners {
if !merged.metadata.owners.iter().any(|o| o.name == owner.name) {
merged.metadata.owners.push(owner);
}
}
merged
.metadata
.scheduled_area_transfers
.extend(net.metadata.scheduled_area_transfers);
merged
.cim
.boundary_data
.boundary_points
.extend(net.cim.boundary_data.boundary_points);
merged
.cim
.boundary_data
.model_authority_sets
.extend(net.cim.boundary_data.model_authority_sets);
merged
.cim
.boundary_data
.equivalent_networks
.extend(net.cim.boundary_data.equivalent_networks);
merged
.cim
.boundary_data
.equivalent_branches
.extend(net.cim.boundary_data.equivalent_branches);
merged
.cim
.boundary_data
.equivalent_shunts
.extend(net.cim.boundary_data.equivalent_shunts);
merged
.cim
.per_length_phase_impedances
.extend(net.cim.per_length_phase_impedances);
merged.cim.mutual_couplings.extend(net.cim.mutual_couplings);
merged.cim.geo_locations.extend(net.cim.geo_locations);
merged.conditional_limits.extend(net.conditional_limits);
}
let mut stitched_bus_set: HashSet<u32> = HashSet::new();
for &(bus_a, bus_b) in &stitch_pairs {
stitch_buses(&mut merged, bus_a, bus_b);
stitched_bus_set.insert(bus_a);
stitched_bus_set.insert(bus_b);
}
let eq_removed = remove_duplicate_equivalents(&mut merged, &stitched_bus_set);
report.equivalent_branches_removed = eq_removed;
report.total_buses = merged.buses.len();
report.total_branches = merged.branches.len();
Ok((merged, report))
}
fn renumber_network(network: &mut Network, offset: u32) -> HashMap<u32, u32> {
if offset == 0 {
return network.buses.iter().map(|b| (b.number, b.number)).collect();
}
let mut map: HashMap<u32, u32> = HashMap::new();
let dc_grid_offset = offset;
let remap =
|bus: u32, m: &mut HashMap<u32, u32>| -> u32 { *m.entry(bus).or_insert(bus + offset) };
for bus in &mut network.buses {
let new_num = bus.number + offset;
map.insert(bus.number, new_num);
bus.number = new_num;
}
for br in &mut network.branches {
br.from_bus = remap(br.from_bus, &mut map);
br.to_bus = remap(br.to_bus, &mut map);
}
for g in &mut network.generators {
g.bus = remap(g.bus, &mut map);
}
for load in &mut network.loads {
load.bus = remap(load.bus, &mut map);
}
for injection in &mut network.power_injections {
injection.bus = remap(injection.bus, &mut map);
}
for fs in &mut network.fixed_shunts {
fs.bus = remap(fs.bus, &mut map);
}
for fd in &mut network.facts_devices {
fd.bus_from = remap(fd.bus_from, &mut map);
if fd.bus_to != 0 {
fd.bus_to = remap(fd.bus_to, &mut map);
}
}
for link in &mut network.hvdc.links {
match link {
surge_network::network::HvdcLink::Lcc(dcl) => {
dcl.rectifier.bus = remap(dcl.rectifier.bus, &mut map);
dcl.inverter.bus = remap(dcl.inverter.bus, &mut map);
}
surge_network::network::HvdcLink::Vsc(vsc) => {
vsc.converter1.bus = remap(vsc.converter1.bus, &mut map);
vsc.converter2.bus = remap(vsc.converter2.bus, &mut map);
}
}
}
for dc_grid in &mut network.hvdc.dc_grids {
dc_grid.id += dc_grid_offset;
for conv in &mut dc_grid.converters {
*conv.ac_bus_mut() = remap(conv.ac_bus(), &mut map);
*conv.dc_bus_mut() = remap(conv.dc_bus(), &mut map);
}
for dcb in &mut dc_grid.buses {
dcb.bus_id = remap(dcb.bus_id, &mut map);
}
for dcbr in &mut dc_grid.branches {
dcbr.from_bus = remap(dcbr.from_bus, &mut map);
dcbr.to_bus = remap(dcbr.to_bus, &mut map);
}
}
for area in &mut network.area_schedules {
area.slack_bus = remap(area.slack_bus, &mut map);
}
for oltc in &mut network.controls.oltc_specs {
oltc.from_bus = remap(oltc.from_bus, &mut map);
oltc.to_bus = remap(oltc.to_bus, &mut map);
}
for par in &mut network.controls.par_specs {
par.from_bus = remap(par.from_bus, &mut map);
par.to_bus = remap(par.to_bus, &mut map);
}
for msg in &mut network.metadata.multi_section_line_groups {
msg.from_bus = remap(msg.from_bus, &mut map);
msg.to_bus = remap(msg.to_bus, &mut map);
for db in &mut msg.dummy_buses {
*db = remap(*db, &mut map);
}
}
for im in &mut network.induction_machines {
im.bus = remap(im.bus, &mut map);
}
for m in &mut network.cim.measurements {
m.bus = remap(m.bus, &mut map);
}
for br in &mut network.breaker_ratings {
br.bus = remap(br.bus, &mut map);
}
for gi in &mut network.cim.grounding_impedances {
gi.bus = remap(gi.bus, &mut map);
}
for bp in &mut network.cim.boundary_data.boundary_points {
if let Some(ref mut b) = bp.bus {
*b = remap(*b, &mut map);
}
}
for eb in &mut network.cim.boundary_data.equivalent_branches {
if let Some(ref mut b) = eb.from_bus {
*b = remap(*b, &mut map);
}
if let Some(ref mut b) = eb.to_bus {
*b = remap(*b, &mut map);
}
}
for es in &mut network.cim.boundary_data.equivalent_shunts {
if let Some(ref mut b) = es.bus {
*b = remap(*b, &mut map);
}
}
for ls in network.cim.operational_limits.limit_sets.values_mut() {
ls.bus = remap(ls.bus, &mut map);
}
map
}
fn find_boundary_pairs(networks: &[Network]) -> HashMap<String, Vec<(usize, u32)>> {
let mut cn_map: HashMap<String, Vec<(usize, u32)>> = HashMap::new();
for (net_idx, net) in networks.iter().enumerate() {
for bp in &net.cim.boundary_data.boundary_points {
if let (Some(cn_mrid), Some(bus)) = (&bp.connectivity_node_mrid, bp.bus) {
cn_map
.entry(cn_mrid.clone())
.or_default()
.push((net_idx, bus));
}
}
}
cn_map
}
fn stitch_buses(merged: &mut Network, bus_a: u32, bus_b: u32) {
let (vmax_b, vmin_b, bs_b, gs_b) = merged
.buses
.iter()
.find(|b| b.number == bus_b)
.map(|b| {
(
b.voltage_max_pu,
b.voltage_min_pu,
b.shunt_susceptance_mvar,
b.shunt_conductance_mw,
)
})
.unwrap_or((1.1, 0.9, 0.0, 0.0));
if let Some(a) = merged.buses.iter_mut().find(|b| b.number == bus_a) {
if vmax_b < a.voltage_max_pu {
a.voltage_max_pu = vmax_b;
}
if vmin_b > a.voltage_min_pu {
a.voltage_min_pu = vmin_b;
}
a.shunt_susceptance_mvar += bs_b;
a.shunt_conductance_mw += gs_b;
}
merged.buses.retain(|b| b.number != bus_b);
let remap = |bus: &mut u32| {
if *bus == bus_b {
*bus = bus_a;
}
};
for br in &mut merged.branches {
remap(&mut br.from_bus);
remap(&mut br.to_bus);
}
for g in &mut merged.generators {
remap(&mut g.bus);
}
for load in &mut merged.loads {
remap(&mut load.bus);
}
for injection in &mut merged.power_injections {
remap(&mut injection.bus);
}
for fs in &mut merged.fixed_shunts {
remap(&mut fs.bus);
}
for fd in &mut merged.facts_devices {
remap(&mut fd.bus_from);
if fd.bus_to != 0 {
remap(&mut fd.bus_to);
}
}
for link in &mut merged.hvdc.links {
match link {
surge_network::network::HvdcLink::Lcc(dcl) => {
remap(&mut dcl.rectifier.bus);
remap(&mut dcl.inverter.bus);
}
surge_network::network::HvdcLink::Vsc(vsc) => {
remap(&mut vsc.converter1.bus);
remap(&mut vsc.converter2.bus);
}
}
}
for dc_grid in &mut merged.hvdc.dc_grids {
for conv in &mut dc_grid.converters {
remap(conv.ac_bus_mut());
remap(conv.dc_bus_mut());
}
for dcb in &mut dc_grid.buses {
remap(&mut dcb.bus_id);
}
for dcbr in &mut dc_grid.branches {
remap(&mut dcbr.from_bus);
remap(&mut dcbr.to_bus);
}
}
for area in &mut merged.area_schedules {
remap(&mut area.slack_bus);
}
for oltc in &mut merged.controls.oltc_specs {
remap(&mut oltc.from_bus);
remap(&mut oltc.to_bus);
}
for par in &mut merged.controls.par_specs {
remap(&mut par.from_bus);
remap(&mut par.to_bus);
}
for msg in &mut merged.metadata.multi_section_line_groups {
remap(&mut msg.from_bus);
remap(&mut msg.to_bus);
for db in &mut msg.dummy_buses {
remap(db);
}
}
for im in &mut merged.induction_machines {
remap(&mut im.bus);
}
for m in &mut merged.cim.measurements {
remap(&mut m.bus);
}
for br in &mut merged.breaker_ratings {
remap(&mut br.bus);
}
for gi in &mut merged.cim.grounding_impedances {
if gi.bus == bus_b {
gi.bus = bus_a;
}
}
for bp in &mut merged.cim.boundary_data.boundary_points {
if let Some(ref mut b) = bp.bus {
remap(b);
}
}
for eb in &mut merged.cim.boundary_data.equivalent_branches {
if let Some(ref mut b) = eb.from_bus {
remap(b);
}
if let Some(ref mut b) = eb.to_bus {
remap(b);
}
}
for es in &mut merged.cim.boundary_data.equivalent_shunts {
if let Some(ref mut b) = es.bus {
remap(b);
}
}
for ls in merged.cim.operational_limits.limit_sets.values_mut() {
remap(&mut ls.bus);
}
}
fn remove_duplicate_equivalents(merged: &mut Network, stitched_buses: &HashSet<u32>) -> usize {
if stitched_buses.is_empty() {
return 0;
}
let before_br = merged.cim.boundary_data.equivalent_branches.len();
let before_sh = merged.cim.boundary_data.equivalent_shunts.len();
merged.cim.boundary_data.equivalent_branches.retain(|eb| {
let from_stitched = eb.from_bus.is_some_and(|b| stitched_buses.contains(&b));
let to_stitched = eb.to_bus.is_some_and(|b| stitched_buses.contains(&b));
!(from_stitched && to_stitched)
});
merged
.cim
.boundary_data
.equivalent_shunts
.retain(|es| !es.bus.is_some_and(|b| stitched_buses.contains(&b)));
let after_br = merged.cim.boundary_data.equivalent_branches.len();
let after_sh = merged.cim.boundary_data.equivalent_shunts.len();
(before_br - after_br) + (before_sh - after_sh)
}
#[cfg(test)]
mod tests {
use super::*;
use surge_network::network::boundary::{
BoundaryPoint, EquivalentBranchData, EquivalentShuntData,
};
use surge_network::network::{Branch, Bus, BusType, Generator, Load};
#[allow(clippy::field_reassign_with_default)]
fn make_tso_network(name: &str, bus_start: u32, boundary_cn_mrid: Option<&str>) -> Network {
let mut net = Network::new(name);
net.base_mva = 100.0;
for i in 0..3 {
let bus_num = bus_start + i;
let mut bus = Bus::default();
bus.number = bus_num;
bus.base_kv = 220.0;
bus.voltage_max_pu = 1.1;
bus.voltage_min_pu = 0.9;
bus.bus_type = if i == 0 { BusType::Slack } else { BusType::PQ };
net.buses.push(bus);
if i == 2 {
net.loads.push(Load::new(bus_num, 50.0, 0.0)); }
}
for i in 0..2 {
let mut br = Branch::default();
br.from_bus = bus_start + i;
br.to_bus = bus_start + i + 1;
br.r = 0.01;
br.x = 0.1;
br.in_service = true;
net.branches.push(br);
}
let mut g = Generator::default();
g.bus = bus_start;
g.p = 100.0;
g.pmax = 200.0;
g.in_service = true;
net.generators.push(g);
let mut load = Load::default();
load.bus = bus_start + 1;
load.active_power_demand_mw = 80.0;
load.reactive_power_demand_mvar = 20.0;
load.in_service = true;
net.loads.push(load);
if let Some(cn) = boundary_cn_mrid {
net.cim.boundary_data.boundary_points.push(BoundaryPoint {
mrid: format!("BP_{name}"),
connectivity_node_mrid: Some(cn.to_string()),
from_end_iso_code: None,
to_end_iso_code: None,
from_end_name: None,
to_end_name: None,
from_end_name_tso: None,
to_end_name_tso: None,
is_direct_current: false,
is_excluded_from_area_interchange: false,
bus: Some(bus_start + 2),
});
}
net
}
#[test]
fn test_merge_empty() {
let result = merge_networks(vec![]);
assert!(matches!(result, Err(MergeError::Empty)));
}
#[test]
fn test_merge_single_passthrough() {
let net = make_tso_network("TSO_A", 1, Some("CN_SHARED"));
let (merged, report) = merge_networks(vec![net]).unwrap();
assert_eq!(report.input_count, 1);
assert_eq!(merged.buses.len(), 3);
assert_eq!(merged.branches.len(), 2);
assert_eq!(merged.generators.len(), 1);
}
#[test]
fn test_merge_two_networks_shared_boundary() {
let net_a = make_tso_network("TSO_A", 1, Some("CN_SHARED_1"));
let net_b = make_tso_network("TSO_B", 1, Some("CN_SHARED_1"));
let (merged, report) = merge_networks(vec![net_a, net_b]).unwrap();
assert_eq!(merged.buses.len(), 5);
assert_eq!(report.boundary_points_stitched, 1);
assert_eq!(report.boundary_points_unmatched, 0);
assert_eq!(report.total_buses, 5);
assert_eq!(merged.branches.len(), 4);
assert_eq!(merged.generators.len(), 2);
assert_eq!(merged.loads.len(), 4);
let stitched_bus_num = 3_u32; let stitched = merged.buses.iter().find(|b| b.number == stitched_bus_num);
assert!(stitched.is_some());
let stitched_pd: f64 = merged
.loads
.iter()
.filter(|l| l.bus == stitched_bus_num)
.map(|l| l.active_power_demand_mw)
.sum();
assert!((stitched_pd - 100.0).abs() < 1e-6);
let bus_nums: HashSet<u32> = merged.buses.iter().map(|b| b.number).collect();
assert_eq!(bus_nums.len(), 5);
}
#[test]
fn test_merge_no_shared_boundaries() {
let net_a = make_tso_network("TSO_A", 1, Some("CN_A_ONLY"));
let net_b = make_tso_network("TSO_B", 1, Some("CN_B_ONLY"));
let (merged, report) = merge_networks(vec![net_a, net_b]).unwrap();
assert_eq!(merged.buses.len(), 6);
assert_eq!(report.boundary_points_stitched, 0);
assert_eq!(report.boundary_points_unmatched, 2);
assert_eq!(merged.branches.len(), 4);
}
#[test]
fn test_bus_renumbering_no_collision() {
let net_a = make_tso_network("TSO_A", 1, None);
let net_b = make_tso_network("TSO_B", 1, None);
let (merged, report) = merge_networks(vec![net_a, net_b]).unwrap();
assert_eq!(merged.buses.len(), 6);
let bus_nums: HashSet<u32> = merged.buses.iter().map(|b| b.number).collect();
assert_eq!(bus_nums.len(), 6);
assert_eq!(report.bus_offsets[0], 0);
assert_eq!(report.bus_offsets[1], 3);
}
#[test]
fn test_branches_remapped_at_boundary() {
let net_a = make_tso_network("TSO_A", 1, Some("CN_SHARED_1"));
let net_b = make_tso_network("TSO_B", 1, Some("CN_SHARED_1"));
let (merged, _) = merge_networks(vec![net_a, net_b]).unwrap();
let bus_nums: HashSet<u32> = merged.buses.iter().map(|b| b.number).collect();
for br in &merged.branches {
assert!(
bus_nums.contains(&br.from_bus),
"branch from_bus {} not in bus set",
br.from_bus
);
assert!(
bus_nums.contains(&br.to_bus),
"branch to_bus {} not in bus set",
br.to_bus
);
}
for g in &merged.generators {
assert!(
bus_nums.contains(&g.bus),
"generator bus {} not in bus set",
g.bus
);
}
for load in &merged.loads {
assert!(
bus_nums.contains(&load.bus),
"load bus {} not in bus set",
load.bus
);
}
}
#[test]
fn test_duplicate_equivalents_removed() {
let mut net_a = make_tso_network("TSO_A", 1, Some("CN_SHARED_1"));
let mut net_b = make_tso_network("TSO_B", 1, Some("CN_SHARED_1"));
net_a
.cim
.boundary_data
.equivalent_branches
.push(EquivalentBranchData {
mrid: "EB_A".to_string(),
network_mrid: None,
r_ohm: 1.0,
x_ohm: 10.0,
r0_ohm: None,
x0_ohm: None,
r2_ohm: None,
x2_ohm: None,
from_bus: Some(3), to_bus: Some(3), });
net_a
.cim
.boundary_data
.equivalent_shunts
.push(EquivalentShuntData {
mrid: "ES_A".to_string(),
network_mrid: None,
g_s: 0.001,
b_s: 0.01,
bus: Some(3), });
net_b
.cim
.boundary_data
.equivalent_branches
.push(EquivalentBranchData {
mrid: "EB_B".to_string(),
network_mrid: None,
r_ohm: 1.5,
x_ohm: 15.0,
r0_ohm: None,
x0_ohm: None,
r2_ohm: None,
x2_ohm: None,
from_bus: Some(3), to_bus: Some(3),
});
net_b
.cim
.boundary_data
.equivalent_shunts
.push(EquivalentShuntData {
mrid: "ES_B".to_string(),
network_mrid: None,
g_s: 0.002,
b_s: 0.02,
bus: Some(3), });
let (merged, report) = merge_networks(vec![net_a, net_b]).unwrap();
assert_eq!(report.equivalent_branches_removed, 4); assert!(merged.cim.boundary_data.equivalent_branches.is_empty());
assert!(merged.cim.boundary_data.equivalent_shunts.is_empty());
}
#[test]
fn test_merge_report_counts() {
let net_a = make_tso_network("TSO_A", 1, Some("CN_SHARED_1"));
let net_b = make_tso_network("TSO_B", 1, Some("CN_SHARED_1"));
let (_, report) = merge_networks(vec![net_a, net_b]).unwrap();
assert_eq!(report.input_count, 2);
assert_eq!(report.boundary_points_stitched, 1);
assert_eq!(report.total_buses, 5);
assert_eq!(report.total_branches, 4);
assert_eq!(report.bus_offsets.len(), 2);
}
}