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