Skip to main content

powerio_tx/contingency/
expand.rs

1//! Expanding a `.con` file's automatic specifications into explicit cases.
2//!
3//! A `SINGLE BRANCH IN SUBSYSTEM 'WOA'` line states a rule, not a list. The
4//! elements it names are whatever the network holds inside the subsystem the
5//! `.sub` file states, so expanding needs all three files. Expansion produces
6//! ordinary [`ContingencyCase`] values, which resolve and write like any case
7//! read from a file.
8//!
9//! PSS/E's own generated case names are not publicly documented. The names
10//! here are PowerIO's convention and are listed in `FORMAT.md`.
11
12use std::collections::BTreeSet;
13
14use super::sub::SubsystemSet;
15use super::{
16    AutomaticOrder, AutomaticSpec, AutomaticTarget, ContingencyAction, ContingencyCase,
17    ContingencySet, PsseEquipmentIndex, SkipRule,
18};
19use crate::diagnostics::{Diagnostic, codes};
20use crate::network::{BalancedNetwork, BusId, Transformer3W};
21
22/// Output of an expansion: the set with its automatic specifications turned
23/// into cases, plus the notes on the specifications that produced no case.
24///
25/// Two findings are noted, one note each: a specification naming a subsystem
26/// the subsystem set does not state earns `BUILD.CON.SUBSYSTEM_UNKNOWN`, and a
27/// specification whose subsystem is stated but holds fewer in service elements
28/// than its order needs, one for `SINGLE` and two for `DOUBLE`, earns
29/// `BUILD.CON.SPECIFICATION_EMPTY`. Nothing else is noted.
30#[derive(Debug, Clone)]
31#[non_exhaustive]
32pub struct Expanded {
33    pub set: ContingencySet,
34    pub diagnostics: Vec<Diagnostic>,
35}
36
37impl ContingencySet {
38    /// Expand every automatic specification against `net` and `subsystems`.
39    ///
40    /// The expanded set keeps the header and the statements kept as text, puts
41    /// the explicit cases first and the generated cases after them, and holds
42    /// no automatic specification that expanded. A specification naming a
43    /// subsystem `subsystems` does not state stays in
44    /// [`ContingencySet::automatic`] and earns a `BUILD.CON.SUBSYSTEM_UNKNOWN`
45    /// note; the `SKIP` rules stay with it, because it still needs them. A
46    /// specification whose subsystem holds fewer elements it names than its
47    /// order needs expands into no case and earns a
48    /// `BUILD.CON.SPECIFICATION_EMPTY` note whose message states both counts.
49    /// A `DOUBLE` specification therefore needs two eligible elements, because
50    /// its cases are the unordered pairs of them.
51    ///
52    /// Only elements the network states in service expand into cases, because
53    /// outaging an element already out of service changes nothing.
54    #[must_use]
55    pub fn expand(&self, net: &BalancedNetwork, subsystems: &SubsystemSet) -> Expanded {
56        self.expand_with(&PsseEquipmentIndex::new(net), subsystems)
57    }
58
59    /// [`ContingencySet::expand`] against an index built once, for a caller
60    /// expanding several sets over one network.
61    ///
62    /// The network is the one the index borrows, so the rows it reads always
63    /// index that network's tables.
64    #[must_use]
65    pub fn expand_with(
66        &self,
67        index: &PsseEquipmentIndex<'_>,
68        subsystems: &SubsystemSet,
69    ) -> Expanded {
70        let net = index.network();
71        let mut diagnostics = Vec::new();
72        let mut kept = Vec::new();
73        let mut generated = Vec::new();
74        for spec in &self.automatic {
75            let Some(subsystem) = subsystems.get(&spec.subsystem) else {
76                diagnostics.push(Diagnostic::of(
77                    &codes::BUILD_CON_SUBSYSTEM_UNKNOWN,
78                    format!(
79                        "{}: the subsystem set states no subsystem '{}'",
80                        describe(spec),
81                        spec.subsystem
82                    ),
83                ));
84                kept.push(spec.clone());
85                continue;
86            };
87            let buses = subsystem.select_buses(net);
88            let singles = single_cases(net, index, spec, &buses, &self.skips);
89            let needed = match spec.order {
90                AutomaticOrder::Single => 1,
91                AutomaticOrder::Double => 2,
92            };
93            let eligible = singles.len();
94            if eligible < needed {
95                let plural = if eligible == 1 { "element" } else { "elements" };
96                diagnostics.push(Diagnostic::of(
97                    &codes::BUILD_CON_SPECIFICATION_EMPTY,
98                    format!(
99                        "{}: subsystem '{}' holds {eligible} in service {plural} this specification names, and its order needs {needed}",
100                        describe(spec),
101                        spec.subsystem
102                    ),
103                ));
104            }
105            match spec.order {
106                AutomaticOrder::Single => generated.extend(singles),
107                AutomaticOrder::Double => generated.extend(double_cases(&singles)),
108            }
109        }
110        let mut cases = self.cases.clone();
111        cases.extend(generated);
112        // The rules are the expansion's own input, so they are dropped only
113        // once every specification that could read them has expanded. A set
114        // stating rules and no specification keeps them.
115        let skips = if kept.is_empty() && !self.automatic.is_empty() {
116            Vec::new()
117        } else {
118            self.skips.clone()
119        };
120        Expanded {
121            set: ContingencySet {
122                header: self.header.clone(),
123                cases,
124                automatic: kept,
125                skips,
126                retained: self.retained.clone(),
127            },
128            diagnostics,
129        }
130    }
131}
132
133/// A specification as its own line states it, for a note that names it.
134fn describe(spec: &AutomaticSpec) -> String {
135    let order = match spec.order {
136        AutomaticOrder::Single => "SINGLE",
137        AutomaticOrder::Double => "DOUBLE",
138    };
139    let target = match spec.target {
140        AutomaticTarget::Branch => "BRANCH",
141        AutomaticTarget::Unit => "UNIT",
142        AutomaticTarget::Tie => "TIE",
143    };
144    format!("{order} {target}")
145}
146
147/// The one element cases a specification names, in table order.
148fn single_cases(
149    net: &BalancedNetwork,
150    index: &PsseEquipmentIndex,
151    spec: &AutomaticSpec,
152    buses: &BTreeSet<BusId>,
153    skips: &[SkipRule],
154) -> Vec<ContingencyCase> {
155    let mut cases = Vec::new();
156    match spec.target {
157        AutomaticTarget::Branch | AutomaticTarget::Tie => {
158            let tie = spec.target == AutomaticTarget::Tie;
159            for (row, branch) in net.branches().iter().enumerate() {
160                let inside = usize::from(buses.contains(&branch.from))
161                    + usize::from(buses.contains(&branch.to));
162                let wanted = if tie { inside == 1 } else { inside == 2 };
163                if !branch.in_service || !wanted {
164                    continue;
165                }
166                let circuit = index.circuit_ids()[row].trim();
167                if skipped(skips, branch.from, branch.to, circuit) {
168                    continue;
169                }
170                cases.push(ContingencyCase {
171                    name: format!("L_{}_{}_{circuit}", branch.from.0, branch.to.0),
172                    actions: vec![ContingencyAction::OpenBranch {
173                        from: branch.from,
174                        to: branch.to,
175                        circuit: circuit.to_owned(),
176                    }],
177                });
178            }
179            if spec.low_voltage_3w && !tie {
180                cases.extend(three_winding_cases(net, index, buses));
181            }
182        }
183        AutomaticTarget::Unit => {
184            for (row, generator) in net.generators().iter().enumerate() {
185                if !generator.in_service || !buses.contains(&generator.bus) {
186                    continue;
187                }
188                let id = index.machine_ids()[row].trim();
189                cases.push(ContingencyCase {
190                    name: format!("G_{}_{id}", generator.bus.0),
191                    actions: vec![ContingencyAction::RemoveMachine {
192                        bus: generator.bus,
193                        id: id.to_owned(),
194                    }],
195                });
196            }
197        }
198    }
199    cases
200}
201
202/// The three winding transformer cases `3WLOWVOLTAGE` adds: one per in service
203/// transformer whose lowest voltage winding sits inside the subsystem.
204fn three_winding_cases(
205    net: &BalancedNetwork,
206    index: &PsseEquipmentIndex,
207    buses: &BTreeSet<BusId>,
208) -> Vec<ContingencyCase> {
209    let mut cases = Vec::new();
210    for (row, transformer) in net.transformers_3w().iter().enumerate() {
211        if !transformer.in_service || !buses.contains(&low_voltage_bus(net, index, transformer)) {
212            continue;
213        }
214        let circuit = index.transformer_3w_ids()[row].trim();
215        let terminals = [
216            transformer.windings[0].bus,
217            transformer.windings[1].bus,
218            transformer.windings[2].bus,
219        ];
220        cases.push(ContingencyCase {
221            name: format!(
222                "T_{}_{}_{}_{circuit}",
223                terminals[0].0, terminals[1].0, terminals[2].0
224            ),
225            actions: vec![ContingencyAction::OpenThreeWinding {
226                buses: terminals,
227                circuit: circuit.to_owned(),
228            }],
229        });
230    }
231    cases
232}
233
234/// One case per unordered pair of single cases, the second case's actions
235/// following the first's.
236fn double_cases(singles: &[ContingencyCase]) -> Vec<ContingencyCase> {
237    let mut cases = Vec::new();
238    for (position, first) in singles.iter().enumerate() {
239        for second in &singles[position + 1..] {
240            let mut actions = first.actions.clone();
241            actions.extend(second.actions.iter().cloned());
242            cases.push(ContingencyCase {
243                name: format!("{}+{}", first.name, second.name),
244                actions,
245            });
246        }
247    }
248    cases
249}
250
251/// Whether a `SKIP` rule names this branch. A rule matches in either terminal
252/// order, as a `.con` statement does.
253fn skipped(skips: &[SkipRule], from: BusId, to: BusId, circuit: &str) -> bool {
254    skips.iter().any(|rule| {
255        rule.circuit.trim() == circuit
256            && ((rule.from == from && rule.to == to) || (rule.from == to && rule.to == from))
257    })
258}
259
260/// The bus of the winding with the lowest voltage. A winding stating no
261/// nominal kV defers to its terminal bus base kV, which is the same rule the
262/// RAW reader states. Ties take the earlier winding.
263pub(super) fn low_voltage_bus(
264    net: &BalancedNetwork,
265    index: &PsseEquipmentIndex,
266    transformer: &Transformer3W,
267) -> BusId {
268    let kv_of = |winding: &crate::network::Winding| {
269        if winding.nominal_kv > 0.0 {
270            return winding.nominal_kv;
271        }
272        index
273            .bus_row(winding.bus)
274            .map_or(0.0, |row| net.buses()[row].base_kv)
275    };
276    let mut lowest = &transformer.windings[0];
277    for winding in &transformer.windings[1..] {
278        if kv_of(winding) < kv_of(lowest) {
279            lowest = winding;
280        }
281    }
282    lowest.bus
283}