Skip to main content

powerio_tx/operations/
subset.rs

1//! Row provenance and detailed topology for electrical cuts.
2
3use std::collections::{HashMap, HashSet};
4use std::sync::Arc;
5
6use powerio_core::ComponentId;
7
8use crate::network::{
9    BalancedNetwork, CalculatedBus, DetailedConnectivity, TerminalReference, TopologyEndpoint,
10};
11
12/// A bus cut and its source-to-result row maps.
13///
14/// Each map has one entry per row of the corresponding **source** table.
15/// `Some(row)` is a zero-based row in `network`; `None` means the source row
16/// was removed. Bus IDs and component identities are preserved, not renumbered.
17/// A dead winding's retained stub has a bus row, but its excluded injections
18/// have no result row. Service status does not otherwise filter the cut.
19#[derive(Debug, Clone)]
20#[non_exhaustive]
21pub struct BusSubset {
22    pub network: BalancedNetwork,
23    pub bus_rows: Vec<Option<usize>>,
24    pub load_rows: Vec<Option<usize>>,
25    pub shunt_rows: Vec<Option<usize>>,
26    pub static_var_compensator_rows: Vec<Option<usize>>,
27    pub branch_rows: Vec<Option<usize>>,
28    pub switch_rows: Vec<Option<usize>>,
29    pub generator_rows: Vec<Option<usize>>,
30    pub storage_rows: Vec<Option<usize>>,
31    pub hvdc_rows: Vec<Option<usize>>,
32    pub transformer_3w_rows: Vec<Option<usize>>,
33    pub area_rows: Vec<Option<usize>>,
34}
35
36pub(super) fn filter_rows<T: Clone>(
37    source: &[T],
38    keep: impl Fn(&T) -> bool,
39) -> (Vec<T>, Vec<Option<usize>>) {
40    let mut result = Vec::new();
41    let rows = source
42        .iter()
43        .map(|value| {
44            keep(value).then(|| {
45                let row = result.len();
46                result.push(value.clone());
47                row
48            })
49        })
50        .collect();
51    (result, rows)
52}
53
54/// Remove topology tied to removed calculation records and nodes. Unassigned
55/// topology and its containers remain: absence of a calculated bus is valid
56/// source data, not evidence that the record belongs to an excluded bus.
57#[expect(clippy::too_many_lines)]
58pub(crate) fn filter_connectivity(source: &BalancedNetwork, cut: &mut BalancedNetwork) {
59    let Some(original) = source.detailed_connectivity().as_deref() else {
60        return;
61    };
62    let mut detail = original.clone();
63    let kept: HashSet<_> = cut.buses().iter().map(|bus| bus.id).collect();
64    let mut removed = HashSet::<ComponentId>::new();
65    macro_rules! removed_records {
66        ($table:ident, $kind:literal) => {{
67            let ids: HashSet<_> = cut
68                .$table()
69                .iter()
70                .filter_map(|v| v.uid.as_deref())
71                .collect();
72            removed.extend(
73                source
74                    .$table()
75                    .iter()
76                    .filter_map(|v| v.uid.as_deref())
77                    .filter(|uid| !ids.contains(uid))
78                    .filter_map(|uid| ComponentId::new($kind, uid).ok()),
79            );
80        }};
81    }
82    removed_records!(buses, "bus");
83    removed_records!(loads, "load");
84    removed_records!(shunts, "shunt");
85    removed_records!(static_var_compensators, "static_var_compensator");
86    removed_records!(branches, "branch");
87    removed_records!(switches, "switch");
88    removed_records!(generators, "generator");
89    removed_records!(storage, "storage");
90    removed_records!(hvdc, "hvdc");
91    removed_records!(transformers_3w, "transformer_3w");
92
93    for level in &mut detail.voltage_levels {
94        level.buses.retain(|bus| kept.contains(bus));
95    }
96    for bus in &detail.calculated_buses {
97        if !kept.contains(&bus.calculated_bus) {
98            removed.extend(bus.nodes.iter().cloned());
99        }
100    }
101    detail
102        .calculated_buses
103        .retain(|bus| kept.contains(&bus.calculated_bus));
104    detail.bus_breaker_buses.retain(|bus| {
105        let keep = bus.calculated_bus.is_none_or(|id| kept.contains(&id));
106        if !keep {
107            removed.insert(bus.component.clone());
108        }
109        keep
110    });
111    detail.connectivity_nodes.retain(|node| {
112        let keep = !removed.contains(&node.component)
113            && node.calculated_bus.is_none_or(|id| kept.contains(&id));
114        if !keep {
115            removed.insert(node.component.clone());
116        }
117        keep
118    });
119    for bus in &mut detail.calculated_buses {
120        bus.nodes.retain(|node| !removed.contains(node));
121    }
122    detail.busbar_sections.retain(|busbar| {
123        let keep = !removed.contains(&busbar.node);
124        if !keep {
125            removed.insert(busbar.component.clone());
126        }
127        keep
128    });
129    let endpoint = |value: &TopologyEndpoint| match value {
130        TopologyEndpoint::Bus(id) | TopologyEndpoint::Node(id) => id.clone(),
131    };
132    detail.switches.retain(|switch| {
133        let keep = !removed.contains(&switch.component)
134            && !removed.contains(&endpoint(&switch.endpoint1))
135            && !removed.contains(&endpoint(&switch.endpoint2));
136        if !keep {
137            removed.insert(switch.component.clone());
138        }
139        keep
140    });
141    detail
142        .internal_connections
143        .retain(|edge| !removed.contains(&edge.node1) && !removed.contains(&edge.node2));
144    // A retained calculation record can have a connectable bus outside the cut.
145    // Keep its terminal, clearing only the out-of-scope connection reference.
146    let source_equipment: HashSet<_> = original
147        .terminals
148        .iter()
149        .filter(|t| t.bus.is_some() || t.node.is_some() || t.connectable_bus.is_some())
150        .map(|t| t.equipment.clone())
151        .collect();
152    detail.terminals.retain_mut(|terminal| {
153        if removed.contains(&terminal.equipment) {
154            return false;
155        }
156        let mut lost_connection = false;
157        for connection in [&mut terminal.bus, &mut terminal.node] {
158            if connection.as_ref().is_some_and(|id| removed.contains(id)) {
159                *connection = None;
160                lost_connection = true;
161            }
162        }
163        if terminal
164            .connectable_bus
165            .as_ref()
166            .is_some_and(|id| removed.contains(id))
167        {
168            terminal.connectable_bus = None;
169        }
170        if lost_connection {
171            terminal.connected = false;
172        }
173        true
174    });
175    // Detailed-only equipment whose entire AC connection was cut must not be
176    // emitted again as part of the selected electrical network.
177    let attached: HashSet<_> = detail
178        .terminals
179        .iter()
180        .filter(|t| t.bus.is_some() || t.node.is_some() || t.connectable_bus.is_some())
181        .map(|t| t.equipment.clone())
182        .collect();
183    macro_rules! detailed_equipment {
184        ($table:ident) => {
185            detail.$table.retain(|value| {
186                let keep = !removed.contains(&value.component)
187                    && (!source_equipment.contains(&value.component)
188                        || attached.contains(&value.component));
189                if !keep {
190                    removed.insert(value.component.clone());
191                }
192                keep
193            });
194        };
195    }
196    detailed_equipment!(junctions);
197    detailed_equipment!(boundary_lines);
198    detailed_equipment!(voltage_source_converters);
199    detailed_equipment!(line_commutated_converters);
200    detail.boundary_lines.retain(|line| {
201        let keep = !line
202            .calculation_load
203            .as_ref()
204            .is_some_and(|id| removed.contains(id))
205            && !line
206                .calculation_generator
207                .as_ref()
208                .is_some_and(|id| removed.contains(id));
209        if !keep {
210            removed.insert(line.component.clone());
211        }
212        keep
213    });
214    detail.tie_lines.retain(|line| {
215        let keep = !removed.contains(&line.boundary_line1)
216            && !removed.contains(&line.boundary_line2)
217            && !line
218                .calculation_branch
219                .as_ref()
220                .is_some_and(|id| removed.contains(id));
221        if !keep {
222            removed.insert(line.component.clone());
223        }
224        keep
225    });
226    detail.terminals.retain(|t| !removed.contains(&t.equipment));
227    for terminal in &original.terminals {
228        if removed.contains(&terminal.equipment)
229            && let Some(id) = &terminal.component
230        {
231            removed.insert(id.clone());
232        }
233    }
234    let terminals: HashSet<_> = detail
235        .terminals
236        .iter()
237        .map(|t| (t.equipment.clone(), t.terminal))
238        .collect();
239    let clear_reference = |reference: &mut Option<TerminalReference>| {
240        if reference
241            .as_ref()
242            .is_some_and(|r| !terminals.contains(&(r.equipment.clone(), r.terminal)))
243        {
244            *reference = None;
245        }
246    };
247    detail.tap_changers.retain(|tap| {
248        let keep = !removed.contains(&tap.transformer);
249        if !keep && let Some(id) = &tap.component {
250            removed.insert(id.clone());
251        }
252        keep
253    });
254    for tap in &mut detail.tap_changers {
255        clear_reference(&mut tap.regulation_terminal);
256    }
257    for converter in &mut detail.voltage_source_converters {
258        clear_reference(&mut converter.pcc_terminal);
259    }
260    for converter in &mut detail.line_commutated_converters {
261        clear_reference(&mut converter.pcc_terminal);
262    }
263    // Do not emit emptied voltage levels: XIIDM requires topology in each
264    // emitted level. Keep levels still used by disconnected source records.
265    let used_levels: HashSet<_> = detail
266        .bus_breaker_buses
267        .iter()
268        .map(|b| b.voltage_level.clone())
269        .chain(
270            detail
271                .connectivity_nodes
272                .iter()
273                .map(|n| n.voltage_level.clone()),
274        )
275        .chain(detail.terminals.iter().map(|t| t.voltage_level.clone()))
276        .chain(
277            detail
278                .boundary_lines
279                .iter()
280                .map(|b| b.voltage_level.clone()),
281        )
282        .collect();
283    let originally_empty: HashSet<_> = original
284        .voltage_levels
285        .iter()
286        .filter(|level| level.buses.is_empty())
287        .map(|level| &level.component)
288        .collect();
289    detail.voltage_levels.retain(|level| {
290        let keep = !level.buses.is_empty()
291            || used_levels.contains(&level.component)
292            || originally_empty.contains(&level.component);
293        if !keep {
294            removed.insert(level.component.clone());
295        }
296        keep
297    });
298    detail
299        .operational_limit_groups
300        .retain(|g| !removed.contains(&g.equipment));
301    detail
302        .equipment_reactive_limits
303        .retain(|g| !removed.contains(&g.equipment));
304    detail
305        .omitted_fields
306        .retain(|f| !removed.contains(&f.component));
307    detail
308        .component_metadata
309        .retain(|m| !removed.contains(&m.component));
310    for metadata in &mut detail.component_metadata {
311        if metadata
312            .equipment_container
313            .as_ref()
314            .is_some_and(|id| removed.contains(id))
315        {
316            metadata.equipment_container = None;
317        }
318    }
319    macro_rules! clear_removed_containers {
320        ($($table:ident),+) => {$(
321            for record in &mut detail.$table {
322                if record.equipment_container.as_ref().is_some_and(|id| removed.contains(id)) {
323                    record.equipment_container = None;
324                }
325            }
326        )+};
327    }
328    clear_removed_containers!(
329        dc_grounds,
330        dc_busbars,
331        dc_lines,
332        dc_series_devices,
333        dc_switches
334    );
335    for subnetwork in &mut detail.subnetworks {
336        subnetwork.components.retain(|id| !removed.contains(id));
337    }
338    if !removed.is_empty() || cut.buses().len() != source.buses().len() {
339        preserve_orphaned_buses(cut, &mut detail);
340    }
341    *cut.detailed_connectivity_mut() = Some(Arc::new(detail));
342}
343
344// Cutting the last switch/terminal off a node must not erase its calculation
345// bus on fresh XIIDM emission. An explicit calculated-bus record can carry
346// that otherwise unanchored node without inventing electrical equipment.
347fn preserve_orphaned_buses(cut: &BalancedNetwork, detail: &mut DetailedConnectivity) {
348    let mut anchored: HashSet<_> = detail
349        .calculated_buses
350        .iter()
351        .flat_map(|b| b.nodes.iter().cloned())
352        .chain(detail.busbar_sections.iter().map(|b| b.node.clone()))
353        .chain(detail.terminals.iter().filter_map(|t| t.node.clone()))
354        .chain(
355            detail
356                .internal_connections
357                .iter()
358                .flat_map(|e| [e.node1.clone(), e.node2.clone()]),
359        )
360        .collect();
361    for switch in &detail.switches {
362        for end in [&switch.endpoint1, &switch.endpoint2] {
363            if let TopologyEndpoint::Node(node) = end {
364                anchored.insert(node.clone());
365            }
366        }
367    }
368    let mut nodes_by_bus: HashMap<_, Vec<_>> = HashMap::new();
369    for node in &detail.connectivity_nodes {
370        if let Some(bus) = node.calculated_bus {
371            nodes_by_bus.entry(bus).or_default().push(node);
372        }
373    }
374    for bus in cut.buses() {
375        let Some(nodes) = nodes_by_bus.get(&bus.id) else {
376            continue;
377        };
378        if nodes.iter().any(|node| anchored.contains(&node.component))
379            || nodes
380                .iter()
381                .any(|node| node.voltage_level != nodes[0].voltage_level)
382        {
383            continue;
384        }
385        detail.calculated_buses.push(CalculatedBus {
386            voltage_level: nodes[0].voltage_level.clone(),
387            calculated_bus: bus.id,
388            nodes: nodes.iter().map(|node| node.component.clone()).collect(),
389            voltage_kv: Some(bus.vm * bus.base_kv),
390            angle_degrees: Some(if cut.is_normalized() {
391                bus.va.to_degrees()
392            } else {
393                bus.va
394            }),
395        });
396    }
397}