Skip to main content

powerio_tx/contingency/
resolve.rs

1//! Binding the cases of a `.con` file to the elements of a network.
2//!
3//! A `.con` statement names an element the way a PSS/E RAW file does: a bus
4//! number, a machine id, a circuit id. A [`BalancedNetwork`] names an element
5//! by its `uid`, which is a source identity or one PowerIO generated from bus
6//! numbers (`bus-4`, `3-1`) and so carries no machine or circuit id.
7//! [`PsseEquipmentIndex`] closes that gap by recomputing, for every element,
8//! the id a RAW file written from this network would state, using the writer's
9//! own allocation. A statement therefore binds to the element PSS/E would
10//! address by the same words.
11//!
12//! [`ContingencySet::resolve`] is total: every action either binds to network
13//! elements or is kept with a structured [`UnresolvedReason`]. A case holding
14//! any unresolved action is counted unresolved and reported as
15//! `BUILD.CON.CASE_UNRESOLVED`; the actions of that case that did bind stay
16//! listed, so a caller can see how far a case got.
17
18use std::borrow::Cow;
19use std::collections::BTreeMap;
20use std::sync::Arc;
21
22use powerio_core::ComponentId;
23
24use crate::diagnostics::{Diagnostic, codes};
25use crate::format::psse::{
26    detailed_source_property, quoted_circuit_id, quoted_device_id, transformer_3w_id,
27};
28use crate::network::{BalancedNetwork, BusId, BusType};
29
30use super::{ContingencyAction, ContingencySet, MAX_READER_NOTES};
31
32/// Component type strings, the same spellings the update resolver in
33/// `powerio-prob` requires of a [`ComponentId`], and the ones a
34/// [`ResolvedComponent`] states for the table its row indexes.
35const BUS: &str = "bus";
36const LOAD: &str = "load";
37const SHUNT: &str = "shunt";
38const GENERATOR: &str = "generator";
39const BRANCH: &str = "branch";
40const SWITCH: &str = "switch";
41const TRANSFORMER_3W: &str = "transformer_3w";
42
43/// The PSS/E id every element of a network would carry in a RAW file written
44/// from it, with the lookups a `.con` statement needs.
45///
46/// The ids come from the RAW writer's own allocation: an element's retained id
47/// when it has one and that id is still free on its key, compared trimmed, else
48/// the lowest positive integer still free there. That is why an id the reader
49/// dropped as a positional default (`1`) comes back, and why parallel elements
50/// stay distinct.
51///
52/// Building the index walks every table once. Resolving many sets against one
53/// network builds it once and calls [`ContingencySet::resolve_with`]. The index
54/// borrows the network it was built from, so a row it states is always a row of
55/// that network. A caller that must keep the index beside the network, for
56/// example in a `OnceLock` next to an `Arc<BalancedNetwork>`, builds a
57/// [`SharedPsseEquipmentIndex`] and reads this index from it with
58/// [`SharedPsseEquipmentIndex::index`].
59#[derive(Debug, Clone)]
60pub struct PsseEquipmentIndex<'n> {
61    net: &'n BalancedNetwork,
62    /// Owned when built by [`PsseEquipmentIndex::new`], borrowed when read
63    /// from a [`SharedPsseEquipmentIndex`].
64    tables: Cow<'n, EquipmentTables>,
65}
66
67/// The ids and rows of a [`PsseEquipmentIndex`]. They hold no reference to the
68/// network, so a [`SharedPsseEquipmentIndex`] can own them beside it.
69#[derive(Debug, Clone)]
70struct EquipmentTables {
71    machine_ids: Vec<String>,
72    circuit_ids: Vec<String>,
73    switch_ids: Vec<String>,
74    transformer_3w_ids: Vec<String>,
75    bus_rows: BTreeMap<BusId, usize>,
76    /// Keyed on the stored terminal order, as the writer keys it. A lookup
77    /// reads both orientations.
78    branch_rows: BranchRows,
79    /// System switching device rows keyed on the stored terminal pair and
80    /// the circuit id. A lookup reads both orientations.
81    switch_rows: SwitchRows,
82    machine_rows: MachineRows,
83    fixed_shunt_rows: DeviceRows,
84    switched_shunt_rows: BTreeMap<BusId, Vec<usize>>,
85    load_rows: DeviceRows,
86    /// Keyed on the three bus ids in ascending order, so a statement naming
87    /// them in any order finds the transformer.
88    transformer_3w_rows: Transformer3wRows,
89    /// Buses and branches by name, for the statements TARA states by name.
90    names: super::tara::NameIndex,
91}
92
93fn sorted_triple(buses: [BusId; 3]) -> [BusId; 3] {
94    let mut sorted = buses;
95    sorted.sort_unstable();
96    sorted
97}
98
99/// Rows of one device family keyed by bus, each with the trimmed id the RAW
100/// writer would state for it. PSS/E reads a quoted id by its trimmed text, as
101/// the `.con` reader and the RAW reader both do, and the writer's allocation
102/// gives two rows on one bus two trimmed ids, so a trimmed id names one row.
103type DeviceRows = BTreeMap<BusId, Vec<(String, usize)>>;
104
105/// Branch rows keyed by the stored terminal pair, whether the branch is a two
106/// winding transformer, and the circuit id.
107///
108/// The RAW writer allocates the line ids and the transformer ids in separate
109/// namespaces, so a line and a two winding transformer on the same terminal
110/// pair both take circuit `1`; keying the two families apart keeps each row
111/// reachable. Parallel branches of one family stored in opposite terminal
112/// orders can still take the same circuit id, so a key holds a list.
113type BranchRows = BTreeMap<(BusId, BusId, bool, String), Vec<usize>>;
114
115/// Rows of the generators, keyed by bus and machine id.
116type MachineRows = BTreeMap<(BusId, String), usize>;
117
118/// Switch rows keyed by the stored terminal pair and the circuit id. The RAW
119/// writer allocates switching device ids in a namespace of their own.
120type SwitchRows = BTreeMap<(BusId, BusId, String), Vec<usize>>;
121
122/// Three winding transformer rows keyed by their three buses in ascending
123/// order, each with the id the RAW writer would state.
124type Transformer3wRows = BTreeMap<[BusId; 3], Vec<(String, usize)>>;
125
126/// The machine id of every generator, in table order, and the row each
127/// `(bus, id)` pair names. PSS/E requires machine ids to be unique on a bus,
128/// compared by their trimmed text, and the writer's allocation preserves that,
129/// so one pair names one row.
130///
131/// The key is the trimmed id the writer allocates, which is the id a RAW file
132/// states and a `.con` statement names.
133fn machine_index(net: &BalancedNetwork, sanitized: &mut usize) -> (Vec<String>, MachineRows) {
134    let mut ids = Vec::with_capacity(net.generators().len());
135    let mut rows = BTreeMap::new();
136    let mut used = BTreeMap::new();
137    for (row, generator) in net.generators().iter().enumerate() {
138        let preferred =
139            detailed_source_property(net, GENERATOR, generator.uid.as_deref(), "psse_eqid")
140                .filter(|id| !id.is_empty());
141        let id = quoted_circuit_id(preferred, generator.bus, &mut used, sanitized);
142        rows.insert((generator.bus, id.trim().to_owned()), row);
143        ids.push(id);
144    }
145    (ids, rows)
146}
147
148/// The circuit id of every branch, aligned with `net.branches()`, and the rows
149/// each `(from, to, circuit)` key names.
150///
151/// The writer states the lines first and the two winding transformers after
152/// them, each family with its own id allocation, both keyed on the stored
153/// terminal pair. Walking the table twice reproduces that order while keeping
154/// the ids aligned with the table.
155fn branch_index(net: &BalancedNetwork, sanitized: &mut usize) -> (Vec<String>, BranchRows) {
156    let mut ids = vec![String::new(); net.branches().len()];
157    let mut rows: BranchRows = BTreeMap::new();
158    let mut line_ids = BTreeMap::new();
159    let mut transformer_ids = BTreeMap::new();
160    for transformers in [false, true] {
161        for (row, branch) in net.branches().iter().enumerate() {
162            if branch.is_transformer() != transformers {
163                continue;
164            }
165            let retained = transformers
166                .then(|| {
167                    detailed_source_property(net, "transformer", branch.uid.as_deref(), "psse_eqid")
168                })
169                .flatten();
170            let preferred = branch
171                .extras
172                .get("id")
173                .and_then(serde_json::Value::as_str)
174                .or(retained);
175            let used = if transformers {
176                &mut transformer_ids
177            } else {
178                &mut line_ids
179            };
180            let id = quoted_circuit_id(preferred, (branch.from, branch.to), used, sanitized);
181            rows.entry((branch.from, branch.to, transformers, id.trim().to_owned()))
182                .or_default()
183                .push(row);
184            ids[row] = id;
185        }
186    }
187    (ids, rows)
188}
189
190/// The circuit id of every switch, aligned with `net.switches()`, and the rows
191/// each `(from, to, circuit)` key names. The RAW writer states a switching
192/// device's retained circuit id (`@1`, `*2`, ...) when it is free on its
193/// terminal pair, as it does for a line.
194fn switch_index(net: &BalancedNetwork, sanitized: &mut usize) -> (Vec<String>, SwitchRows) {
195    let mut ids = Vec::with_capacity(net.switches().len());
196    let mut rows: SwitchRows = BTreeMap::new();
197    let mut used = BTreeMap::new();
198    for (row, switch) in net.switches().iter().enumerate() {
199        let preferred = switch
200            .extras
201            .get("psse_ckt")
202            .and_then(serde_json::Value::as_str);
203        let id = quoted_circuit_id(preferred, (switch.from, switch.to), &mut used, sanitized);
204        rows.entry((switch.from, switch.to, id.trim().to_owned()))
205            .or_default()
206            .push(row);
207        ids.push(id);
208    }
209    (ids, rows)
210}
211
212/// The rows of `net.loads()` per bus, with the id each load would carry.
213fn load_index(net: &BalancedNetwork, sanitized: &mut usize) -> DeviceRows {
214    let mut rows: DeviceRows = BTreeMap::new();
215    let mut used = BTreeMap::new();
216    for (row, load) in net.loads().iter().enumerate() {
217        let id = quoted_device_id(&load.extras, load.bus, &mut used, sanitized);
218        rows.entry(load.bus)
219            .or_default()
220            .push((id.trim().to_owned(), row));
221    }
222    rows
223}
224
225/// The fixed and switched shunt rows per bus.
226///
227/// Both families are one table here and two sections in a RAW file, with their
228/// own id allocations, and only the fixed shunt record states an id. The rows
229/// are positions in `net.shunts()`, the table a shunt `ComponentId` names.
230fn shunt_index(
231    net: &BalancedNetwork,
232    sanitized: &mut usize,
233) -> (DeviceRows, BTreeMap<BusId, Vec<usize>>) {
234    let mut fixed: DeviceRows = BTreeMap::new();
235    let mut switched: BTreeMap<BusId, Vec<usize>> = BTreeMap::new();
236    let mut used = BTreeMap::new();
237    for (row, shunt) in net.shunts().iter().enumerate() {
238        if shunt.control.is_some() {
239            switched.entry(shunt.bus).or_default().push(row);
240            continue;
241        }
242        let id = quoted_device_id(&shunt.extras, shunt.bus, &mut used, sanitized);
243        fixed
244            .entry(shunt.bus)
245            .or_default()
246            .push((id.trim().to_owned(), row));
247    }
248    (fixed, switched)
249}
250
251/// The three winding transformer rows keyed on their three buses in ascending
252/// order, so a statement naming the buses in any order finds the transformer.
253fn transformer_3w_index(
254    net: &BalancedNetwork,
255    sanitized: &mut usize,
256) -> (Vec<String>, Transformer3wRows) {
257    let mut ids = Vec::with_capacity(net.transformers_3w().len());
258    let mut rows: Transformer3wRows = BTreeMap::new();
259    let mut used = BTreeMap::new();
260    for (row, transformer) in net.transformers_3w().iter().enumerate() {
261        let id = transformer_3w_id(net, transformer, &mut used, sanitized);
262        let buses = sorted_triple([
263            transformer.windings[0].bus,
264            transformer.windings[1].bus,
265            transformer.windings[2].bus,
266        ]);
267        rows.entry(buses)
268            .or_default()
269            .push((id.trim().to_owned(), row));
270        ids.push(id);
271    }
272    (ids, rows)
273}
274
275impl EquipmentTables {
276    fn new(net: &BalancedNetwork) -> Self {
277        // The writer counts the ids sanitation changed so it can warn about
278        // them; the index states the same ids and discards the count.
279        let mut sanitized = 0usize;
280        let (machine_ids, machine_rows) = machine_index(net, &mut sanitized);
281        let (circuit_ids, branch_rows) = branch_index(net, &mut sanitized);
282        let (switch_ids, switch_rows) = switch_index(net, &mut sanitized);
283        let (fixed_shunt_rows, switched_shunt_rows) = shunt_index(net, &mut sanitized);
284        let (transformer_3w_ids, transformer_3w_rows) = transformer_3w_index(net, &mut sanitized);
285        Self {
286            machine_ids,
287            circuit_ids,
288            switch_ids,
289            transformer_3w_ids,
290            bus_rows: net
291                .buses()
292                .iter()
293                .enumerate()
294                .map(|(row, bus)| (bus.id, row))
295                .collect(),
296            branch_rows,
297            switch_rows,
298            machine_rows,
299            fixed_shunt_rows,
300            switched_shunt_rows,
301            load_rows: load_index(net, &mut sanitized),
302            transformer_3w_rows,
303            names: super::tara::NameIndex::new(net),
304        }
305    }
306}
307
308impl<'n> PsseEquipmentIndex<'n> {
309    /// Recompute every PSS/E id in `net` and index the rows by it.
310    #[must_use]
311    pub fn new(net: &'n BalancedNetwork) -> Self {
312        Self {
313            net,
314            tables: Cow::Owned(EquipmentTables::new(net)),
315        }
316    }
317
318    /// The network the index was built from. Every row it states indexes a
319    /// table of this network.
320    #[must_use]
321    pub fn network(&self) -> &'n BalancedNetwork {
322        self.net
323    }
324
325    /// The machine id of every generator, aligned with `net.generators()`.
326    #[must_use]
327    pub fn machine_ids(&self) -> &[String] {
328        &self.tables.machine_ids
329    }
330
331    /// The circuit id of every branch, aligned with `net.branches()`.
332    #[must_use]
333    pub fn circuit_ids(&self) -> &[String] {
334        &self.tables.circuit_ids
335    }
336
337    /// The circuit id of every switch, aligned with `net.switches()`.
338    #[must_use]
339    pub fn switch_ids(&self) -> &[String] {
340        &self.tables.switch_ids
341    }
342
343    /// The circuit id of every three winding transformer, aligned with
344    /// `net.transformers_3w()`.
345    #[must_use]
346    pub fn transformer_3w_ids(&self) -> &[String] {
347        &self.tables.transformer_3w_ids
348    }
349
350    /// The row of `bus` in `net.buses()`.
351    #[must_use]
352    pub fn bus_row(&self, bus: BusId) -> Option<usize> {
353        self.tables.bus_rows.get(&bus).copied()
354    }
355
356    /// The rows of `net.branches()` joining `from` and `to` on `circuit`, in
357    /// either orientation. A self-loop is counted once. More than one row
358    /// means the statement names several branches and binds to none of them.
359    ///
360    /// The lines answer first and the two winding transformers only when no
361    /// line carries the circuit id, because the RAW writer allocates the two
362    /// families apart and a `.con` statement names a branch without saying
363    /// which table it sits in.
364    #[must_use]
365    pub fn branch_rows(&self, from: BusId, to: BusId, circuit: &str) -> Vec<usize> {
366        let lines = self.family_rows(from, to, circuit, false);
367        if lines.is_empty() {
368            self.family_rows(from, to, circuit, true)
369        } else {
370            lines
371        }
372    }
373
374    /// The rows of `net.switches()` joining `from` and `to` on `circuit`, in
375    /// either orientation. A `.con` file opens a system switching device with
376    /// the branch statement, `OPEN BRANCH FROM BUS i TO BUS j CIRCUIT '@1'`,
377    /// so a statement that names no line or two winding transformer is read
378    /// against the switches.
379    #[must_use]
380    pub fn switch_rows(&self, from: BusId, to: BusId, circuit: &str) -> Vec<usize> {
381        let circuit = circuit.trim();
382        let mut rows = Vec::new();
383        if let Some(forward) = self.tables.switch_rows.get(&(from, to, circuit.to_owned())) {
384            rows.extend_from_slice(forward);
385        }
386        if from != to
387            && let Some(reverse) = self.tables.switch_rows.get(&(to, from, circuit.to_owned()))
388        {
389            rows.extend_from_slice(reverse);
390        }
391        rows
392    }
393
394    /// The rows of one branch family joining `from` and `to` on `circuit`, in
395    /// either orientation.
396    fn family_rows(&self, from: BusId, to: BusId, circuit: &str, transformer: bool) -> Vec<usize> {
397        let circuit = circuit.trim();
398        let mut rows = Vec::new();
399        if let Some(forward) =
400            self.tables
401                .branch_rows
402                .get(&(from, to, transformer, circuit.to_owned()))
403        {
404            rows.extend_from_slice(forward);
405        }
406        if from != to
407            && let Some(reverse) =
408                self.tables
409                    .branch_rows
410                    .get(&(to, from, transformer, circuit.to_owned()))
411        {
412            rows.extend_from_slice(reverse);
413        }
414        rows
415    }
416
417    /// The row of `net.generators()` for machine `id` at `bus`. PSS/E requires
418    /// machine ids to be unique on a bus and the writer's allocation preserves
419    /// that, so at most one row matches. `id` is matched trimmed, against the
420    /// trimmed id the writer allocates.
421    #[must_use]
422    pub fn machine_row(&self, bus: BusId, id: &str) -> Option<usize> {
423        self.tables
424            .machine_rows
425            .get(&(bus, id.trim().to_owned()))
426            .copied()
427    }
428
429    /// The rows of `net.shunts()` holding a fixed shunt at `bus`: the one with
430    /// this id, or every fixed shunt there when no id is stated.
431    #[must_use]
432    pub fn fixed_shunt_rows(&self, bus: BusId, id: Option<&str>) -> Vec<usize> {
433        select_rows(self.tables.fixed_shunt_rows.get(&bus), id)
434    }
435
436    /// The rows of `net.shunts()` holding a switched shunt at `bus`, every one
437    /// of them. The `.con` grammar states `REMOVE SWSHUNT FROM BUS i` and
438    /// carries no id, as the RAW switched shunt record itself does not, so the
439    /// statement addresses every switched shunt at the bus.
440    #[must_use]
441    pub fn switched_shunt_rows(&self, bus: BusId) -> Vec<usize> {
442        self.tables
443            .switched_shunt_rows
444            .get(&bus)
445            .cloned()
446            .unwrap_or_default()
447    }
448
449    /// The rows of `net.loads()` at `bus`: the one with this id, or every load
450    /// there when no id is stated.
451    #[must_use]
452    pub fn load_rows(&self, bus: BusId, id: Option<&str>) -> Vec<usize> {
453        select_rows(self.tables.load_rows.get(&bus), id)
454    }
455
456    /// The row of `net.transformers_3w()` on these three buses and circuit id.
457    /// The buses match in any order, because a `.con` statement need not state
458    /// them in winding order. When several transformers on the same three
459    /// buses carry the same id, the first in table order is returned;
460    /// [`PsseEquipmentIndex::transformer_3w_rows`] states all of them.
461    #[must_use]
462    pub fn transformer_3w_row(&self, buses: [BusId; 3], circuit: &str) -> Option<usize> {
463        self.transformer_3w_rows(buses, circuit).first().copied()
464    }
465
466    /// The rows of `net.transformers_3w()` on these three buses and circuit
467    /// id, in table order. The buses match in any order. More than one row
468    /// means the statement names several transformers and binds to none of
469    /// them, which happens when two transformers on the same three buses are
470    /// stored in different winding orders and take the same id.
471    #[must_use]
472    pub fn transformer_3w_rows(&self, buses: [BusId; 3], circuit: &str) -> Vec<usize> {
473        let circuit = circuit.trim();
474        self.tables
475            .transformer_3w_rows
476            .get(&sorted_triple(buses))
477            .into_iter()
478            .flatten()
479            .filter(|(id, _)| id == circuit)
480            .map(|(_, row)| *row)
481            .collect()
482    }
483}
484
485/// A [`PsseEquipmentIndex`] that owns its network through an [`Arc`], so it
486/// can be built once and kept: in a struct beside the network, in a
487/// `OnceLock`, or across threads.
488///
489/// [`SharedPsseEquipmentIndex::index`] hands out the borrowed index without
490/// rebuilding anything, and every entry point that takes a
491/// [`PsseEquipmentIndex`] ([`ContingencySet::resolve_with`],
492/// [`ContingencySet::expand_with`](super::ContingencySet::expand_with),
493/// [`MonitoredSet::resolve_with`](super::MonitoredSet::resolve_with))
494/// takes it, with the same result as an index built by
495/// [`PsseEquipmentIndex::new`] on the same network.
496#[derive(Debug, Clone)]
497pub struct SharedPsseEquipmentIndex {
498    net: Arc<BalancedNetwork>,
499    tables: EquipmentTables,
500}
501
502impl SharedPsseEquipmentIndex {
503    /// Recompute every PSS/E id in `net` and index the rows by it, keeping the
504    /// network. Takes an `Arc<BalancedNetwork>` to share a network the caller
505    /// already holds, or a `BalancedNetwork` to own it.
506    #[must_use]
507    pub fn new(net: impl Into<Arc<BalancedNetwork>>) -> Self {
508        let net = net.into();
509        let tables = EquipmentTables::new(&net);
510        Self { net, tables }
511    }
512
513    /// The network the index was built from.
514    #[must_use]
515    pub fn network(&self) -> &Arc<BalancedNetwork> {
516        &self.net
517    }
518
519    /// The index as a [`PsseEquipmentIndex`], borrowing the network and the
520    /// tables held here. Nothing is recomputed or copied.
521    #[must_use]
522    pub fn index(&self) -> PsseEquipmentIndex<'_> {
523        PsseEquipmentIndex {
524            net: &self.net,
525            tables: Cow::Borrowed(&self.tables),
526        }
527    }
528}
529
530/// The rows of one bus's devices whose id matches, or all of them when the
531/// statement names no id. An id matches trimmed, against the trimmed id the
532/// writer allocates.
533fn select_rows(at_bus: Option<&Vec<(String, usize)>>, id: Option<&str>) -> Vec<usize> {
534    let Some(devices) = at_bus else {
535        return Vec::new();
536    };
537    match id {
538        None => devices.iter().map(|(_, row)| *row).collect(),
539        Some(wanted) => {
540            let wanted = wanted.trim();
541            devices
542                .iter()
543                .filter(|(id, _)| id == wanted)
544                .map(|(_, row)| *row)
545                .collect()
546        }
547    }
548}
549
550/// What a whole contingency set bound to.
551#[derive(Debug, Clone, Default, PartialEq)]
552#[non_exhaustive]
553pub struct ContingencyResolution {
554    /// One entry per case of the set, in the set's order.
555    pub cases: Vec<ResolvedCase>,
556    /// Cases whose every action bound.
557    pub resolved: usize,
558    /// Cases holding at least one action that did not bind.
559    pub unresolved: usize,
560    /// Actions the reader kept as text, counted over every case.
561    pub unrecognized_statements: usize,
562}
563
564impl ContingencyResolution {
565    /// One `BUILD.CON.CASE_UNRESOLVED` note per unresolved case, naming the
566    /// case and the first action of it that did not bind.
567    ///
568    /// The notes stop at the reader's budget: the first case past it records
569    /// one `BUILD.CON.NOTES_TRUNCATED` in place of its note and the cases
570    /// after that record nothing, so a set resolved against the wrong network
571    /// cannot grow the note list without limit. Every case is still counted.
572    #[must_use]
573    pub fn diagnostics(&self) -> Vec<Diagnostic> {
574        let mut notes = Vec::new();
575        for case in self.cases.iter().filter(|case| !case.is_resolved()) {
576            if notes.len() == MAX_READER_NOTES {
577                notes.push(Diagnostic::of(
578                    &codes::BUILD_CON_NOTES_TRUNCATED,
579                    "further resolution notes suppressed",
580                ));
581                break;
582            }
583            let first = &case.unresolved[0];
584            notes.push(Diagnostic::of(
585                &codes::BUILD_CON_CASE_UNRESOLVED,
586                format!(
587                    "contingency '{}': {}",
588                    case.name,
589                    describe(&first.action, first.reason)
590                ),
591            ));
592        }
593        notes
594    }
595}
596
597/// What one case bound to.
598#[derive(Debug, Clone, Default, PartialEq)]
599#[non_exhaustive]
600pub struct ResolvedCase {
601    pub name: String,
602    /// The elements the case's actions bound to, in action order. An action
603    /// naming several elements contributes all of them.
604    pub components: Vec<ResolvedComponent>,
605    /// The actions that bound to nothing, with the reason each one did not.
606    pub unresolved: Vec<UnresolvedAction>,
607}
608
609impl ResolvedCase {
610    /// Whether every action of the case bound.
611    #[must_use]
612    pub fn is_resolved(&self) -> bool {
613        self.unresolved.is_empty()
614    }
615}
616
617/// One network element a case's action bound to.
618#[derive(Debug, Clone, PartialEq, Eq)]
619#[non_exhaustive]
620pub struct ResolvedComponent {
621    /// The component type naming the table `row` indexes: `bus`, `load`,
622    /// `shunt`, `generator`, `branch`, `switch`, or `transformer_3w`. Every
623    /// component states it, including one the network states no identity for.
624    pub component_type: &'static str,
625    /// The element's identity, when the network states one for the row: its
626    /// `uid`, under `component_type`. A row carrying no `uid`, or one
627    /// [`ComponentId`] does not accept, states `None`. A caller that needs
628    /// persistent identities calls `assign_missing_component_ids` on the
629    /// network before building the index, which gives every row a `uid`.
630    pub id: Option<ComponentId>,
631    /// The element's position in the table `component_type` names.
632    pub row: usize,
633    /// The element's own in service flag as the network states it now, before
634    /// the case is applied. For a bus it is whether the bus type is anything
635    /// other than isolated; for a switch, whether it is closed.
636    pub in_service: bool,
637}
638
639/// One action that bound to nothing, kept with the reason.
640#[derive(Debug, Clone, PartialEq)]
641#[non_exhaustive]
642pub struct UnresolvedAction {
643    pub action: ContingencyAction,
644    pub reason: UnresolvedReason,
645}
646
647/// Why an action bound to nothing.
648#[derive(Debug, Clone, Copy, PartialEq, Eq)]
649#[non_exhaustive]
650pub enum UnresolvedReason {
651    NoSuchBus,
652    NoSuchBranch,
653    /// The terminal pair and circuit id name more than one branch, so the
654    /// statement does not say which one opens.
655    AmbiguousBranch {
656        matches: usize,
657    },
658    /// The three buses and circuit id name more than one three winding
659    /// transformer, so the statement does not say which one opens.
660    AmbiguousTransformer3w {
661        matches: usize,
662    },
663    NoSuchMachine,
664    NoSuchShunt,
665    NoSuchLoad,
666    NoSuchTransformer3w,
667    /// The reader kept this statement as text, so it names no element.
668    Unrecognized,
669    /// A quoted bus name and base kV that names no bus.
670    NoSuchBusName,
671    /// A quoted bus name that names more than one bus, exactly or without
672    /// regard to case and whitespace.
673    AmbiguousBusName {
674        matches: usize,
675    },
676    /// A quoted branch name that names no branch.
677    NoSuchBranchName,
678    /// A quoted branch name that names more than one branch.
679    AmbiguousBranchName {
680        matches: usize,
681    },
682}
683
684impl UnresolvedReason {
685    /// The snake_case name of the reason, for reports and bindings.
686    #[must_use]
687    pub fn name(&self) -> &'static str {
688        match self {
689            Self::NoSuchBus => "no_such_bus",
690            Self::NoSuchBranch => "no_such_branch",
691            Self::AmbiguousBranch { .. } => "ambiguous_branch",
692            Self::AmbiguousTransformer3w { .. } => "ambiguous_transformer_3w",
693            Self::NoSuchMachine => "no_such_machine",
694            Self::NoSuchShunt => "no_such_shunt",
695            Self::NoSuchLoad => "no_such_load",
696            Self::NoSuchTransformer3w => "no_such_transformer_3w",
697            Self::Unrecognized => "unrecognized",
698            Self::NoSuchBusName => "no_such_bus_name",
699            Self::AmbiguousBusName { .. } => "ambiguous_bus_name",
700            Self::NoSuchBranchName => "no_such_branch_name",
701            Self::AmbiguousBranchName { .. } => "ambiguous_branch_name",
702        }
703    }
704}
705
706/// A one line account of an action that did not bind.
707fn describe(action: &ContingencyAction, reason: UnresolvedReason) -> String {
708    match (action, reason) {
709        (ContingencyAction::OpenBranch { from, to, circuit }, UnresolvedReason::NoSuchBranch) => {
710            format!("no branch {from} to {to} circuit {circuit}")
711        }
712        (
713            ContingencyAction::OpenBranch { from, to, circuit },
714            UnresolvedReason::AmbiguousBranch { matches },
715        ) => format!("branch {from} to {to} circuit {circuit} names {matches} branches"),
716        (
717            ContingencyAction::OpenThreeWinding { buses, circuit },
718            UnresolvedReason::AmbiguousTransformer3w { matches },
719        ) => format!(
720            "three winding transformer on buses {} {} {} circuit {circuit} names {matches} transformers",
721            buses[0], buses[1], buses[2]
722        ),
723        (ContingencyAction::OpenThreeWinding { buses, circuit }, _) => format!(
724            "no three winding transformer on buses {} {} {} circuit {circuit}",
725            buses[0], buses[1], buses[2]
726        ),
727        (
728            ContingencyAction::RemoveMachine { bus, id }
729            | ContingencyAction::AddMachine { bus, id },
730            _,
731        ) => format!("no machine {id} at bus {bus}"),
732        (ContingencyAction::RemoveShunt { bus, id }, _) => match id {
733            Some(id) => format!("no fixed shunt {id} at bus {bus}"),
734            None => format!("no fixed shunt at bus {bus}"),
735        },
736        (ContingencyAction::RemoveSwitchedShunt { bus }, _) => {
737            format!("no switched shunt at bus {bus}")
738        }
739        (ContingencyAction::RemoveLoad { bus, id }, _) => match id {
740            Some(id) => format!("no load {id} at bus {bus}"),
741            None => format!("no load at bus {bus}"),
742        },
743        (ContingencyAction::Unrecognized { text }, UnresolvedReason::NoSuchBusName) => {
744            format!("a bus name names no bus: {text}")
745        }
746        (
747            ContingencyAction::Unrecognized { text },
748            UnresolvedReason::AmbiguousBusName { matches },
749        ) => {
750            format!("a bus name names {matches} buses: {text}")
751        }
752        (ContingencyAction::Unrecognized { text }, UnresolvedReason::NoSuchBranchName) => {
753            format!("the branch name names no branch: {text}")
754        }
755        (
756            ContingencyAction::Unrecognized { text },
757            UnresolvedReason::AmbiguousBranchName { matches },
758        ) => format!("the branch name names {matches} branches: {text}"),
759        (ContingencyAction::Unrecognized { text }, _) => {
760            format!("statement kept as text: {text}")
761        }
762        (
763            ContingencyAction::DisconnectBus { bus }
764            | ContingencyAction::ChangeLoad { bus, .. }
765            | ContingencyAction::ChangeGeneration { bus, .. },
766            _,
767        ) => format!("no bus {bus}"),
768        (ContingencyAction::OpenBranch { from, to, circuit }, _) => {
769            format!("branch {from} to {to} circuit {circuit} did not bind")
770        }
771    }
772}
773
774impl ContingencySet {
775    /// Bind every case to the elements of `net`, building the equipment index
776    /// once.
777    ///
778    /// Resolution reports rather than refuses: an action that names no element
779    /// is kept with its reason and the case is counted unresolved, while the
780    /// actions of that case that did bind stay listed.
781    ///
782    /// An element the network already states out of service binds like any
783    /// other, with `in_service` false, because outaging it changes nothing.
784    /// A case with no actions resolves to no components.
785    #[must_use]
786    pub fn resolve(&self, net: &BalancedNetwork) -> ContingencyResolution {
787        self.resolve_with(&PsseEquipmentIndex::new(net))
788    }
789
790    /// [`ContingencySet::resolve`] against an index built once, for a caller
791    /// resolving several sets against one network.
792    ///
793    /// The network is the one the index borrows, so the rows it states always
794    /// index that network's tables.
795    #[must_use]
796    pub fn resolve_with(&self, index: &PsseEquipmentIndex<'_>) -> ContingencyResolution {
797        let mut out = ContingencyResolution::default();
798        for case in &self.cases {
799            let mut resolved = ResolvedCase {
800                name: case.name.clone(),
801                ..ResolvedCase::default()
802            };
803            for action in &case.actions {
804                if matches!(action, ContingencyAction::Unrecognized { .. }) {
805                    out.unrecognized_statements += 1;
806                }
807                match bind(index, action) {
808                    Ok(components) => resolved.components.extend(components),
809                    Err(reason) => resolved.unresolved.push(UnresolvedAction {
810                        action: action.clone(),
811                        reason,
812                    }),
813                }
814            }
815            if resolved.is_resolved() {
816                out.resolved += 1;
817            } else {
818                out.unresolved += 1;
819            }
820            out.cases.push(resolved);
821        }
822        out
823    }
824}
825
826/// The elements one action names, or why it names none.
827///
828/// [`ContingencyAction::DisconnectBus`] binds to the bus alone: which elements
829/// at that bus leave service depends on what the consumer models, so expanding
830/// the bus is the consumer's work. [`ContingencyAction::ChangeLoad`] and
831/// [`ContingencyAction::ChangeGeneration`] bind to the bus alone for the same
832/// reason; the amount to move rides on the action itself and is not applied
833/// here.
834fn bind(
835    index: &PsseEquipmentIndex<'_>,
836    action: &ContingencyAction,
837) -> Result<Vec<ResolvedComponent>, UnresolvedReason> {
838    let net = index.network();
839    match action {
840        ContingencyAction::OpenBranch { from, to, circuit } => {
841            let rows = index.branch_rows(*from, *to, circuit);
842            let (rows, component): (_, fn(&BalancedNetwork, usize) -> ResolvedComponent) =
843                if rows.is_empty() {
844                    (index.switch_rows(*from, *to, circuit), switch_component)
845                } else {
846                    (rows, branch_component)
847                };
848            match rows.as_slice() {
849                [] => Err(UnresolvedReason::NoSuchBranch),
850                [row] => Ok(vec![component(net, *row)]),
851                many => Err(UnresolvedReason::AmbiguousBranch {
852                    matches: many.len(),
853                }),
854            }
855        }
856        ContingencyAction::OpenThreeWinding { buses, circuit } => {
857            let rows = index.transformer_3w_rows(*buses, circuit);
858            match rows.as_slice() {
859                [] => Err(UnresolvedReason::NoSuchTransformer3w),
860                [row] => Ok(vec![transformer_3w_component(net, *row)]),
861                many => Err(UnresolvedReason::AmbiguousTransformer3w {
862                    matches: many.len(),
863                }),
864            }
865        }
866        ContingencyAction::RemoveMachine { bus, id }
867        | ContingencyAction::AddMachine { bus, id } => index
868            .machine_row(*bus, id)
869            .map(|row| vec![generator_component(net, row)])
870            .ok_or(UnresolvedReason::NoSuchMachine),
871        ContingencyAction::RemoveShunt { bus, id } => {
872            let rows = index.fixed_shunt_rows(*bus, id.as_deref());
873            non_empty(rows, UnresolvedReason::NoSuchShunt).map(|rows| {
874                rows.into_iter()
875                    .map(|row| shunt_component(net, row))
876                    .collect()
877            })
878        }
879        ContingencyAction::RemoveSwitchedShunt { bus } => {
880            let rows = index.switched_shunt_rows(*bus);
881            non_empty(rows, UnresolvedReason::NoSuchShunt).map(|rows| {
882                rows.into_iter()
883                    .map(|row| shunt_component(net, row))
884                    .collect()
885            })
886        }
887        ContingencyAction::RemoveLoad { bus, id } => {
888            let rows = index.load_rows(*bus, id.as_deref());
889            non_empty(rows, UnresolvedReason::NoSuchLoad).map(|rows| {
890                rows.into_iter()
891                    .map(|row| load_component(net, row))
892                    .collect()
893            })
894        }
895        ContingencyAction::DisconnectBus { bus }
896        | ContingencyAction::ChangeLoad { bus, .. }
897        | ContingencyAction::ChangeGeneration { bus, .. } => index
898            .bus_row(*bus)
899            .map(|row| vec![bus_component(net, row)])
900            .ok_or(UnresolvedReason::NoSuchBus),
901        // A TARA form the reader kept as text: buses or a branch named by
902        // name, or the action ahead of a dispatch block.
903        ContingencyAction::Unrecognized { text } => {
904            match super::tara::read_text_action(&index.tables.names, text) {
905                None => Err(UnresolvedReason::Unrecognized),
906                Some(Err(reason)) => Err(reason),
907                Some(Ok(super::tara::TextAction::Action(action))) => bind(index, &action),
908                Some(Ok(super::tara::TextAction::Branch(row))) => {
909                    Ok(vec![branch_component(net, row)])
910                }
911            }
912        }
913    }
914}
915
916fn non_empty(rows: Vec<usize>, reason: UnresolvedReason) -> Result<Vec<usize>, UnresolvedReason> {
917    if rows.is_empty() {
918        Err(reason)
919    } else {
920        Ok(rows)
921    }
922}
923
924/// The identity of one table row: its `uid` under `component_type`, when the
925/// row carries a `uid` [`ComponentId::new`] accepts, else none. A row without
926/// one has no identity to state, and a position stated in its place would name
927/// an identity the network does not hold. A caller that feeds these identities
928/// to an update batch calls `assign_missing_component_ids` on the network
929/// first, which gives every row a `uid`.
930fn component_id(component_type: &str, uid: Option<&str>) -> Option<ComponentId> {
931    let local = uid.filter(|uid| !uid.is_empty())?;
932    ComponentId::new(component_type, local).ok()
933}
934
935fn bus_component(net: &BalancedNetwork, row: usize) -> ResolvedComponent {
936    let bus = &net.buses()[row];
937    ResolvedComponent {
938        component_type: BUS,
939        id: component_id(BUS, bus.uid.as_deref()),
940        row,
941        in_service: bus.kind != BusType::Isolated,
942    }
943}
944
945fn load_component(net: &BalancedNetwork, row: usize) -> ResolvedComponent {
946    let load = &net.loads()[row];
947    ResolvedComponent {
948        component_type: LOAD,
949        id: component_id(LOAD, load.uid.as_deref()),
950        row,
951        in_service: load.in_service,
952    }
953}
954
955fn shunt_component(net: &BalancedNetwork, row: usize) -> ResolvedComponent {
956    let shunt = &net.shunts()[row];
957    ResolvedComponent {
958        component_type: SHUNT,
959        id: component_id(SHUNT, shunt.uid.as_deref()),
960        row,
961        in_service: shunt.in_service,
962    }
963}
964
965fn generator_component(net: &BalancedNetwork, row: usize) -> ResolvedComponent {
966    let generator = &net.generators()[row];
967    ResolvedComponent {
968        component_type: GENERATOR,
969        id: component_id(GENERATOR, generator.uid.as_deref()),
970        row,
971        in_service: generator.in_service,
972    }
973}
974
975fn branch_component(net: &BalancedNetwork, row: usize) -> ResolvedComponent {
976    let branch = &net.branches()[row];
977    ResolvedComponent {
978        component_type: BRANCH,
979        id: component_id(BRANCH, branch.uid.as_deref()),
980        row,
981        in_service: branch.in_service,
982    }
983}
984
985fn switch_component(net: &BalancedNetwork, row: usize) -> ResolvedComponent {
986    let switch = &net.switches()[row];
987    ResolvedComponent {
988        component_type: SWITCH,
989        id: component_id(SWITCH, switch.uid.as_deref()),
990        row,
991        in_service: switch.closed,
992    }
993}
994
995fn transformer_3w_component(net: &BalancedNetwork, row: usize) -> ResolvedComponent {
996    let transformer = &net.transformers_3w()[row];
997    ResolvedComponent {
998        component_type: TRANSFORMER_3W,
999        id: component_id(TRANSFORMER_3W, transformer.uid.as_deref()),
1000        row,
1001        in_service: transformer.in_service,
1002    }
1003}