Skip to main content

powerio_tx/contingency/
mod.rs

1//! PSS/E contingency description files (`.con`).
2//!
3//! A `.con` file names the outages a contingency analysis runs. It is free
4//! format text: optional header comments, then named cases, automatic
5//! specifications that expand against a subsystem, `SKIP` rules that exclude
6//! elements from that expansion, and solver-specific lines that only the tool
7//! that wrote them interprets.
8//!
9//! ```text
10//! CONTINGENCY 'L_000001ODES'
11//! OPEN LINE FROM BUS   1001 TO BUS   1064 CIRCUIT 1
12//! END
13//! SINGLE BRANCH IN SUBSYSTEM 'WOA'
14//! END
15//! ```
16//!
17//! [`ContingencySet::parse`] reads UTF-8 text and touches no filesystem.
18//! Statements outside the grammar below keep their line, with surrounding
19//! whitespace dropped, and are reported, so a file reads completely rather
20//! than failing on the first line a tool wrote for itself. [`ContingencySet::to_con`] writes the set back in
21//! one canonical spelling. Only a case that never closes, a case that starts
22//! inside another, and a block left open at end of input are errors.
23//!
24//! The grammar, its evidence, and the writer's spellings are in `FORMAT.md`
25//! next to this file. Reading holds what the file states and touches no
26//! network. [`ContingencySet::resolve`] is the separate step that binds a set
27//! to the elements of a [`crate::network::BalancedNetwork`]; `resolve.rs`
28//! holds it.
29//!
30//! The other two files a contingency analysis reads have their own modules
31//! beside this one: `sub.rs` for the subsystem description file
32//! ([`SubsystemSet`]) and `mon.rs` for the monitored element file
33//! ([`MonitoredSet`]). [`ContingencySet::expand`] turns this file's automatic
34//! specifications into explicit cases against a network and a subsystem set;
35//! `expand.rs` holds it.
36
37mod expand;
38mod lexer;
39pub mod mon;
40mod resolve;
41pub mod sub;
42
43use std::cmp::Ordering;
44
45pub use expand::Expanded;
46use lexer::{LexedLine, LineKind, lex};
47pub use mon::{
48    BranchRef, InterfaceMember, MonitorScope, MonitorStatement, MonitoredParsed,
49    MonitoredResolution, MonitoredSet, ResolvedInterface, ResolvedVoltageScope, UnresolvedMonitor,
50    UnresolvedMonitorReason,
51};
52pub use resolve::{
53    ContingencyResolution, PsseEquipmentIndex, ResolvedCase, ResolvedComponent, UnresolvedAction,
54    UnresolvedReason,
55};
56pub use sub::{
57    JoinName, SelectorGroup, Subsystem, SubsystemParsed, SubsystemSelector, SubsystemSet,
58};
59
60use crate::diagnostics::{Diagnostic, DiagnosticInfo, codes};
61use crate::network::BusId;
62use crate::{Error, Result};
63
64const FMT: &str = "psse contingency";
65
66/// Reader notes are bounded so that a file of unrecognized lines cannot grow
67/// the note list without limit.
68const MAX_READER_NOTES: usize = 16;
69
70/// Record one reader note, within the note budget the three contingency
71/// analysis readers share. A file with exactly the budget of findings gets
72/// that many notes and no marker; the first note past the budget is replaced
73/// by one marker under the reader's own truncation code, recorded once.
74fn note_within_budget(
75    diagnostics: &mut Vec<Diagnostic>,
76    info: &'static DiagnosticInfo,
77    truncated: &'static DiagnosticInfo,
78    message: String,
79) {
80    match diagnostics.len().cmp(&MAX_READER_NOTES) {
81        Ordering::Less => diagnostics.push(Diagnostic::of(info, message)),
82        Ordering::Equal => {
83            diagnostics.push(Diagnostic::of(truncated, "further reader notes suppressed"));
84        }
85        Ordering::Greater => {}
86    }
87}
88
89/// One contingency description file.
90#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
91#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
92pub struct ContingencySet {
93    /// Comment lines ahead of the first statement, as written. PSS/E leads a
94    /// generated file with its `/PSS(R)E` stamp and a `COM` banner.
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub header: Vec<String>,
97    /// Named cases, in file order.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub cases: Vec<ContingencyCase>,
100    /// Specifications that expand into cases against a named subsystem.
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub automatic: Vec<AutomaticSpec>,
103    /// Branches excluded from automatic expansion.
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub skips: Vec<SkipRule>,
106    /// File level statements outside the grammar, kept as their trimmed line.
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub retained: Vec<RetainedStatement>,
109}
110
111/// One named case: every action applies together.
112#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114pub struct ContingencyCase {
115    pub name: String,
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub actions: Vec<ContingencyAction>,
118}
119
120/// One statement inside a case.
121#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123#[serde(tag = "kind", rename_all = "snake_case")]
124#[non_exhaustive]
125pub enum ContingencyAction {
126    /// A line or two winding transformer, keyed by its terminal buses and
127    /// circuit id.
128    OpenBranch {
129        from: BusId,
130        to: BusId,
131        circuit: String,
132    },
133    /// A three winding transformer, keyed by its three buses and circuit id.
134    OpenThreeWinding { buses: [BusId; 3], circuit: String },
135    /// A machine leaves service; its dispatch leaves the balance.
136    RemoveMachine { bus: BusId, id: String },
137    /// A machine enters service.
138    AddMachine { bus: BusId, id: String },
139    /// A fixed shunt leaves service; without an id, every fixed shunt at the
140    /// bus.
141    RemoveShunt {
142        bus: BusId,
143        #[serde(default, skip_serializing_if = "Option::is_none")]
144        id: Option<String>,
145    },
146    /// The switched shunt at the bus leaves service.
147    RemoveSwitchedShunt { bus: BusId },
148    /// A load leaves service; without an id, every load at the bus.
149    RemoveLoad {
150        bus: BusId,
151        #[serde(default, skip_serializing_if = "Option::is_none")]
152        id: Option<String>,
153    },
154    /// Every element at the bus leaves service.
155    DisconnectBus { bus: BusId },
156    /// The bus load moves by the stated amount.
157    ChangeLoad { bus: BusId, change: Change },
158    /// The bus generation moves by the stated amount.
159    ChangeGeneration { bus: BusId, change: Change },
160    /// A statement outside the grammar, kept as its trimmed line. A nested
161    /// dispatch block keeps all of its trimmed lines, joined with newlines.
162    Unrecognized { text: String },
163}
164
165impl ContingencyAction {
166    /// The one line statement [`ContingencySet::to_con`] writes for this
167    /// action, without its line ending. A statement kept as text reads back
168    /// as the line the source wrote.
169    #[must_use]
170    pub fn to_con_statement(&self) -> String {
171        write_action(self)
172    }
173}
174
175/// How much a load or generation statement moves, and in what unit.
176#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
177#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
178pub struct Change {
179    /// Whether the amount adds to, subtracts from, or replaces the value.
180    pub op: ChangeOp,
181    /// How far the value moves, in `unit`. Finite: a line stating a non-finite
182    /// amount does not read as a change and keeps its text, because no written
183    /// form of one reads back.
184    pub amount: f64,
185    /// The unit the amount is stated in.
186    pub unit: ChangeUnit,
187}
188
189/// Whether a change adds to, subtracts from, or replaces the present value.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
191#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
192#[serde(rename_all = "snake_case")]
193#[non_exhaustive]
194pub enum ChangeOp {
195    Increase,
196    Decrease,
197    Set,
198}
199
200/// The unit a change is stated in.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
202#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
203#[serde(rename_all = "snake_case")]
204#[non_exhaustive]
205pub enum ChangeUnit {
206    Mw,
207    Percent,
208}
209
210/// A specification that expands into one case per element of a subsystem.
211#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
212#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
213pub struct AutomaticSpec {
214    pub order: AutomaticOrder,
215    pub target: AutomaticTarget,
216    /// The subsystem the expansion draws elements from.
217    pub subsystem: String,
218    /// `3WLOWVOLTAGE`: include the low voltage winding of a three winding
219    /// transformer in a branch expansion.
220    pub low_voltage_3w: bool,
221}
222
223/// How many elements an automatic specification outages at a time.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
225#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
226#[serde(rename_all = "snake_case")]
227#[non_exhaustive]
228pub enum AutomaticOrder {
229    Single,
230    Double,
231}
232
233/// The element family an automatic specification expands over.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
235#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
236#[serde(rename_all = "snake_case")]
237#[non_exhaustive]
238pub enum AutomaticTarget {
239    Branch,
240    Unit,
241    /// Branches crossing the subsystem border.
242    Tie,
243}
244
245/// One branch an automatic expansion leaves alone.
246#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
248pub struct SkipRule {
249    pub from: BusId,
250    pub to: BusId,
251    pub circuit: String,
252}
253
254/// A file level statement outside the grammar, kept as text.
255#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
256#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
257pub struct RetainedStatement {
258    /// The 1-based line the statement was read from, so the lowest line a
259    /// statement can carry is 1.
260    #[cfg_attr(feature = "schema", schemars(range(min = 1)))]
261    pub line: usize,
262    /// The statement's line, with leading and trailing whitespace dropped.
263    pub text: String,
264    /// Whether the line follows the file level `END`. The writer states these
265    /// after the `END` it writes, so reading the written file places them
266    /// after the terminator again rather than reading them as grammar.
267    #[serde(default)]
268    pub after_end: bool,
269}
270
271/// Output of a tolerant contingency read: the set plus the reader's notes on
272/// statements it kept as text.
273#[derive(Debug, Clone)]
274#[non_exhaustive]
275pub struct ContingencyParsed {
276    pub set: ContingencySet,
277    /// The reader's notes as structured records.
278    pub diagnostics: Vec<Diagnostic>,
279}
280
281impl ContingencyParsed {
282    fn note(&mut self, info: &'static DiagnosticInfo, message: String) {
283        note_within_budget(
284            &mut self.diagnostics,
285            info,
286            &codes::READ_CON_NOTES_TRUNCATED,
287            message,
288        );
289    }
290
291    fn unrecognized(&mut self, number: usize, text: &str) {
292        self.note(
293            &codes::READ_CON_STATEMENT_UNRECOGNIZED,
294            format!("line {number}: statement kept as text: {text}"),
295        );
296    }
297
298    /// Report a statement naming a value no written line states, which is
299    /// therefore kept as text.
300    fn unwritable(&mut self, number: usize, text: &str) {
301        self.note(
302            &codes::READ_CON_SOURCE_MALFORMED,
303            format!(
304                "line {number}: a value holding both quote characters has no written form, and the statement was kept as text: {text}"
305            ),
306        );
307    }
308
309    /// Report a dispatch block, whose lines are kept as one statement.
310    fn dispatch_block(&mut self, number: usize, text: &str) {
311        self.note(
312            &codes::READ_CON_STATEMENT_UNRECOGNIZED,
313            format!("line {number}: dispatch block kept as text: {text}"),
314        );
315    }
316}
317
318fn bad(message: String) -> Error {
319    Error::FormatRead {
320        format: FMT,
321        message,
322    }
323}
324
325/// A case whose `END` has not been read yet.
326struct OpenCase {
327    name: String,
328    opened: usize,
329    actions: Vec<ContingencyAction>,
330}
331
332/// A dispatch block whose `END` has not been read yet.
333struct OpenBlock {
334    opened: usize,
335    lines: Vec<String>,
336}
337
338impl ContingencySet {
339    /// Read a `.con` file from UTF-8 text. Keywords are case insensitive.
340    ///
341    /// A statement the grammar does not cover keeps its line, with
342    /// surrounding whitespace dropped, in
343    /// [`ContingencyAction::Unrecognized`] inside a case or in
344    /// [`ContingencySet::retained`] at file level, and is reported. A
345    /// statement naming a value that no written line states, one holding both
346    /// `'` and `"`, is kept the same way. Lines after a file level `END` are
347    /// also kept as text, marked [`RetainedStatement::after_end`], and
348    /// reported once.
349    ///
350    /// # Errors
351    /// [`Error::FormatRead`] when a `CONTINGENCY` starts before the previous
352    /// case reached `END`, or when a case, a `SKIP` block, or a dispatch block
353    /// is still open at end of input. The message names the 1-based line.
354    pub fn parse(text: &str) -> Result<ContingencyParsed> {
355        let mut reader = Reader::new();
356        for line in lex(text) {
357            reader.read_line(&line)?;
358        }
359        reader.finish()
360    }
361
362    /// Write the set as `.con` text: the header lines as written, the
363    /// automatic specifications, one `SKIP` block, the cases in order, the
364    /// file level statements kept as text, a final `END`, and then the
365    /// statements read after the file `END`. Every line ends with a newline.
366    ///
367    /// Reading the result back gives the same set, except that a statement
368    /// kept from the middle of a file is written after the cases and so reads
369    /// back from a different line. Writing that second set gives the same
370    /// text.
371    #[must_use]
372    pub fn to_con(&self) -> String {
373        use std::fmt::Write as _;
374
375        let mut out = String::new();
376        for line in &self.header {
377            out.push_str(line);
378            out.push('\n');
379        }
380        for spec in &self.automatic {
381            out.push_str(&write_automatic(spec));
382            out.push('\n');
383        }
384        if !self.skips.is_empty() {
385            out.push_str("SKIP\n");
386            for rule in &self.skips {
387                let circuit = field(&rule.circuit);
388                let _ = writeln!(
389                    out,
390                    "{:>6} TO {:>6} CIRCUIT {circuit}",
391                    rule.from.0, rule.to.0
392                );
393            }
394            out.push_str("END\n");
395        }
396        for case in &self.cases {
397            let _ = writeln!(out, "CONTINGENCY {}", quoted(&case.name));
398            for action in &case.actions {
399                out.push_str(&write_action(action));
400                out.push('\n');
401            }
402            out.push_str("END\n");
403        }
404        for statement in self.retained.iter().filter(|kept| !kept.after_end) {
405            out.push_str(&statement.text);
406            out.push('\n');
407        }
408        out.push_str("END\n");
409        for statement in self.retained.iter().filter(|kept| kept.after_end) {
410            out.push_str(&statement.text);
411            out.push('\n');
412        }
413        out
414    }
415}
416
417/// The reader's state while it walks the lines of one file.
418struct Reader {
419    parsed: ContingencyParsed,
420    case: Option<OpenCase>,
421    block: Option<OpenBlock>,
422    skip_opened: Option<usize>,
423    /// Whether a statement line has been read; comment lines before the first
424    /// one are the file header.
425    seen_statement: bool,
426    /// Whether the file level `END` has been read.
427    ended: bool,
428    noted_text_after_end: bool,
429}
430
431impl Reader {
432    fn new() -> Self {
433        Reader {
434            parsed: ContingencyParsed {
435                set: ContingencySet::default(),
436                diagnostics: Vec::new(),
437            },
438            case: None,
439            block: None,
440            skip_opened: None,
441            seen_statement: false,
442            ended: false,
443            noted_text_after_end: false,
444        }
445    }
446
447    fn read_line(&mut self, line: &LexedLine<'_>) -> Result<()> {
448        if self.block.is_some() {
449            self.read_block_line(line);
450            return Ok(());
451        }
452        if self.ended {
453            self.keep_after_end(line);
454            return Ok(());
455        }
456        if !self.take_header(line) {
457            return Ok(());
458        }
459        let upper = line.keywords();
460        let words = line.words();
461        if self.skip_opened.is_some() {
462            self.read_skip_line(line, &upper, &words);
463            return Ok(());
464        }
465        if self.case.is_some() {
466            return self.read_case_line(line, &upper, &words);
467        }
468        self.read_file_line(line, &upper, &words);
469        Ok(())
470    }
471
472    /// The set and its notes, once every line has been read.
473    fn finish(self) -> Result<ContingencyParsed> {
474        if let Some(open) = self.block {
475            return Err(bad(format!(
476                "line {}: a dispatch block has no END",
477                open.opened
478            )));
479        }
480        if let Some(open) = self.case {
481            return Err(bad(format!(
482                "line {}: CONTINGENCY '{}' has no END",
483                open.opened, open.name
484            )));
485        }
486        if let Some(opened) = self.skip_opened {
487            return Err(bad(format!("line {opened}: SKIP has no END")));
488        }
489        Ok(self.parsed)
490    }
491
492    /// Collect a comment line ahead of the first statement into the header.
493    /// Returns whether the caller should read this line as a statement.
494    fn take_header(&mut self, line: &LexedLine<'_>) -> bool {
495        if self.seen_statement {
496            return line.kind == LineKind::Statement;
497        }
498        match line.kind {
499            LineKind::Blank => false,
500            LineKind::Comment => {
501                self.parsed.set.header.push(line.text.to_owned());
502                false
503            }
504            LineKind::Statement => {
505                self.seen_statement = true;
506                true
507            }
508        }
509    }
510
511    /// Add one line to the open dispatch block, and close the block on its
512    /// `END`. The text holds every line of the block, including that `END`, so
513    /// writing it and reading it again gives the same block.
514    fn read_block_line(&mut self, line: &LexedLine<'_>) {
515        let closed = is_end(line);
516        let text = line.trimmed().to_owned();
517        if let Some(open) = self.block.as_mut() {
518            open.lines.push(text);
519        }
520        if !closed {
521            return;
522        }
523        let Some(open) = self.block.take() else {
524            return;
525        };
526        let text = open.lines.join("\n");
527        match self.case.as_mut() {
528            Some(case) => case.actions.push(ContingencyAction::Unrecognized { text }),
529            None => self.parsed.set.retained.push(RetainedStatement {
530                line: open.opened,
531                text,
532                after_end: false,
533            }),
534        }
535    }
536
537    /// Keep a statement line that follows the file level `END`, and report the
538    /// first one. A blank or comment line there states nothing and is dropped,
539    /// as one before the `END` is, so the written file reads back the same.
540    fn keep_after_end(&mut self, line: &LexedLine<'_>) {
541        if line.kind != LineKind::Statement {
542            return;
543        }
544        if !self.noted_text_after_end {
545            self.noted_text_after_end = true;
546            self.parsed.note(
547                &codes::READ_CON_TEXT_AFTER_END,
548                format!("line {}: text follows the file END", line.number),
549            );
550        }
551        self.keep_statement(line, true);
552    }
553
554    /// Keep one line as a file level statement. `after_end` marks a line the
555    /// file states after its `END`, which the writer states after the `END` it
556    /// writes.
557    fn keep_statement(&mut self, line: &LexedLine<'_>, after_end: bool) {
558        self.parsed.set.retained.push(RetainedStatement {
559            line: line.number,
560            text: line.trimmed().to_owned(),
561            after_end,
562        });
563    }
564
565    fn read_skip_line(&mut self, line: &LexedLine<'_>, upper: &[String], words: &[&str]) {
566        if is_end(line) {
567            self.skip_opened = None;
568            return;
569        }
570        match parse_skip_rule(upper, words) {
571            Some(rule) if writable(&rule.circuit) => {
572                self.parsed.set.skips.push(rule);
573                return;
574            }
575            Some(_) => self.parsed.unwritable(line.number, line.trimmed()),
576            None => self.parsed.note(
577                &codes::READ_CON_SOURCE_MALFORMED,
578                format!(
579                    "line {}: a SKIP line states no branch and was kept as text: {}",
580                    line.number,
581                    line.trimmed()
582                ),
583            ),
584        }
585        self.keep_statement(line, false);
586    }
587
588    fn read_case_line(
589        &mut self,
590        line: &LexedLine<'_>,
591        upper: &[String],
592        words: &[&str],
593    ) -> Result<()> {
594        if is_end(line) {
595            if let Some(open) = self.case.take() {
596                self.parsed.set.cases.push(ContingencyCase {
597                    name: open.name,
598                    actions: open.actions,
599                });
600            }
601            return Ok(());
602        }
603        if upper[0] == "CONTINGENCY" {
604            let name = self.case.as_ref().map_or("", |open| open.name.as_str());
605            return Err(bad(format!(
606                "line {}: CONTINGENCY starts before case '{name}' reached END",
607                line.number
608            )));
609        }
610        if opens_block(upper) {
611            self.open_block(line);
612            return Ok(());
613        }
614        let action = match parse_action(upper, words) {
615            Some(action) if action_writable(&action) => action,
616            recognized => {
617                if recognized.is_some() {
618                    self.parsed.unwritable(line.number, line.trimmed());
619                } else {
620                    self.parsed.unrecognized(line.number, line.trimmed());
621                }
622                ContingencyAction::Unrecognized {
623                    text: line.trimmed().to_owned(),
624                }
625            }
626        };
627        if let Some(open) = self.case.as_mut() {
628            open.actions.push(action);
629        }
630        Ok(())
631    }
632
633    fn read_file_line(&mut self, line: &LexedLine<'_>, upper: &[String], words: &[&str]) {
634        match upper[0].as_str() {
635            "CONTINGENCY" => self.open_case(line),
636            "END" if upper.len() == 1 => self.ended = true,
637            "SKIP" if upper.len() == 1 => self.skip_opened = Some(line.number),
638            _ if opens_block(upper) => self.open_block(line),
639            _ => match parse_automatic(upper, words) {
640                Some(spec) if writable(&spec.subsystem) => self.parsed.set.automatic.push(spec),
641                recognized => {
642                    if recognized.is_some() {
643                        self.parsed.unwritable(line.number, line.trimmed());
644                    } else {
645                        self.parsed.unrecognized(line.number, line.trimmed());
646                    }
647                    self.keep_statement(line, false);
648                }
649            },
650        }
651    }
652
653    /// Open a case named by the first token after the `CONTINGENCY` keyword.
654    ///
655    /// A further token is reported and dropped: a line that opened no case
656    /// would leave that case's `END` to terminate the file and every statement
657    /// after it to read as text. A name no written line states keeps the whole
658    /// line as text instead, because a case the writer cannot name back does
659    /// not hold the writer's fixed point.
660    fn open_case(&mut self, line: &LexedLine<'_>) {
661        let name = case_name_of(line);
662        if !writable(&name) {
663            self.parsed.unwritable(line.number, line.trimmed());
664            self.keep_statement(line, false);
665            return;
666        }
667        if line.tokens.len() > 2 {
668            let extra = line.words()[2..].join(" ");
669            self.parsed.note(
670                &codes::READ_CON_SOURCE_MALFORMED,
671                format!(
672                    "line {}: CONTINGENCY states more than a case name, and the tokens after it are not kept: {extra}",
673                    line.number
674                ),
675            );
676        }
677        self.case = Some(OpenCase {
678            name,
679            opened: line.number,
680            actions: Vec::new(),
681        });
682    }
683
684    fn open_block(&mut self, line: &LexedLine<'_>) {
685        self.parsed.dispatch_block(line.number, line.trimmed());
686        self.block = Some(OpenBlock {
687            opened: line.number,
688            lines: vec![line.trimmed().to_owned()],
689        });
690    }
691}
692
693/// Whether the line is a bare `END`, which closes a case, a `SKIP`, or a
694/// dispatch block.
695fn is_end(line: &LexedLine<'_>) -> bool {
696    line.kind == LineKind::Statement
697        && line.tokens.len() == 1
698        && line.tokens[0].text.eq_ignore_ascii_case("END")
699}
700
701/// Whether the line opens a nested block that runs to the next `END`. A line
702/// ending in `DISPATCH` opens one, and so does a `DEFAULT DISPATCH` line with
703/// a direction or level after it (`DEFAULT DISPATCH DOWN`,
704/// `DEFAULT DISPATCH FIRSTLEVEL`).
705fn opens_block(upper: &[String]) -> bool {
706    if upper.last().is_some_and(|word| word == "DISPATCH") {
707        return true;
708    }
709    upper.first().is_some_and(|word| word == "DEFAULT")
710        && upper.get(1).is_some_and(|word| word == "DISPATCH")
711}
712
713/// The case name on a `CONTINGENCY` line: the token after the keyword,
714/// trimmed. The tokenizer's quote rule keeps a name holding an apostrophe
715/// whole, so `CONTINGENCY 'L_000022O'~1'` names `L_000022O'~1`, and a trailing
716/// comment stays out of the name. A line stating no name names nothing, and a
717/// line stating more than one token names the first.
718fn case_name_of(line: &LexedLine<'_>) -> String {
719    line.tokens
720        .get(1)
721        .map_or_else(String::new, |token| token.text.trim().to_owned())
722}
723
724// ---------------------------------------------------------------------------
725// Statement grammar
726// ---------------------------------------------------------------------------
727
728fn parse_bus(token: &str) -> Option<BusId> {
729    token.parse::<usize>().ok().map(BusId)
730}
731
732/// Read `BUS i` or a bare `i` at `at`, advancing past it.
733fn take_bus(upper: &[String], at: &mut usize) -> Option<BusId> {
734    if upper.get(*at).is_some_and(|word| word == "BUS") {
735        *at += 1;
736    }
737    let bus = parse_bus(upper.get(*at)?)?;
738    *at += 1;
739    Some(bus)
740}
741
742fn take_keyword(upper: &[String], at: &mut usize, keyword: &str) -> Option<()> {
743    if upper.get(*at)? != keyword {
744        return None;
745    }
746    *at += 1;
747    Some(())
748}
749
750/// Read an optional `CIRCUIT c` tail. An absent tail means circuit `1`; a
751/// trailing token that is not a circuit tail rejects the line.
752fn take_circuit(upper: &[String], words: &[&str], at: &mut usize) -> Option<String> {
753    if *at == upper.len() {
754        return Some("1".to_owned());
755    }
756    if !matches!(
757        upper[*at].as_str(),
758        "CIRCUIT" | "CKT" | "CIRCUITS" | "CIRCUT"
759    ) {
760        return None;
761    }
762    let value = words.get(*at + 1)?.trim();
763    *at += 2;
764    if *at != upper.len() {
765        return None;
766    }
767    Some(if value.is_empty() {
768        "1".to_owned()
769    } else {
770        value.to_owned()
771    })
772}
773
774/// Read an element id, which may be quoted and may carry padding.
775fn take_id(words: &[&str], at: &mut usize) -> Option<String> {
776    let id = words.get(*at)?.trim();
777    *at += 1;
778    Some(id.to_owned())
779}
780
781fn parse_action(upper: &[String], words: &[&str]) -> Option<ContingencyAction> {
782    let verb = upper.first()?.as_str();
783    let noun = upper.get(1).map_or("", String::as_str);
784    match (verb, noun) {
785        ("OPEN" | "TRIP" | "DISCONNECT", "LINE" | "BRANCH") => parse_open_branch(upper, words),
786        ("OPEN" | "TRIP" | "DISCONNECT", "THREEWINDING") => parse_three_winding(upper, words),
787        ("DISCONNECT", "BUS") => {
788            let mut at = 1;
789            let bus = take_bus(upper, &mut at)?;
790            (at == upper.len()).then_some(ContingencyAction::DisconnectBus { bus })
791        }
792        ("REMOVE" | "TRIP", "MACHINE" | "UNIT") => {
793            let (bus, id) = parse_id_from_bus(upper, words)?;
794            Some(ContingencyAction::RemoveMachine { bus, id })
795        }
796        ("ADD", "MACHINE" | "UNIT") => {
797            let mut at = 2;
798            let id = take_id(words, &mut at)?;
799            take_keyword(upper, &mut at, "TO")?;
800            let bus = take_bus(upper, &mut at)?;
801            (at == upper.len()).then_some(ContingencyAction::AddMachine { bus, id })
802        }
803        ("REMOVE" | "TRIP", "SHUNT") => {
804            let (bus, id) = parse_optional_id_from_bus(upper, words)?;
805            Some(ContingencyAction::RemoveShunt { bus, id })
806        }
807        ("REMOVE" | "TRIP", "LOAD") => {
808            let (bus, id) = parse_optional_id_from_bus(upper, words)?;
809            Some(ContingencyAction::RemoveLoad { bus, id })
810        }
811        ("REMOVE" | "TRIP", "SWSHUNT") => {
812            let mut at = 2;
813            take_keyword(upper, &mut at, "FROM")?;
814            let bus = take_bus(upper, &mut at)?;
815            (at == upper.len()).then_some(ContingencyAction::RemoveSwitchedShunt { bus })
816        }
817        ("INCREASE" | "RAISE" | "DECREASE" | "SET", _) => parse_change(upper),
818        _ => None,
819    }
820}
821
822/// `OPEN LINE FROM BUS i TO BUS j [CIRCUIT c]`, or the three winding spelling
823/// that states a third bus in place of the circuit tail.
824fn parse_open_branch(upper: &[String], words: &[&str]) -> Option<ContingencyAction> {
825    let mut at = 2;
826    take_keyword(upper, &mut at, "FROM")?;
827    let from = take_bus(upper, &mut at)?;
828    take_keyword(upper, &mut at, "TO")?;
829    let to = take_bus(upper, &mut at)?;
830    if upper.get(at).is_some_and(|word| word == "TO") {
831        at += 1;
832        let third = take_bus(upper, &mut at)?;
833        let circuit = take_circuit(upper, words, &mut at)?;
834        return Some(ContingencyAction::OpenThreeWinding {
835            buses: [from, to, third],
836            circuit,
837        });
838    }
839    let circuit = take_circuit(upper, words, &mut at)?;
840    Some(ContingencyAction::OpenBranch { from, to, circuit })
841}
842
843/// `OPEN THREEWINDING AT BUS a TO BUS b TO BUS c [CIRCUIT c]`.
844fn parse_three_winding(upper: &[String], words: &[&str]) -> Option<ContingencyAction> {
845    let mut at = 2;
846    take_keyword(upper, &mut at, "AT")?;
847    let first = take_bus(upper, &mut at)?;
848    take_keyword(upper, &mut at, "TO")?;
849    let second = take_bus(upper, &mut at)?;
850    take_keyword(upper, &mut at, "TO")?;
851    let third = take_bus(upper, &mut at)?;
852    let circuit = take_circuit(upper, words, &mut at)?;
853    Some(ContingencyAction::OpenThreeWinding {
854        buses: [first, second, third],
855        circuit,
856    })
857}
858
859/// `REMOVE MACHINE id FROM BUS i`.
860fn parse_id_from_bus(upper: &[String], words: &[&str]) -> Option<(BusId, String)> {
861    let mut at = 2;
862    let id = take_id(words, &mut at)?;
863    take_keyword(upper, &mut at, "FROM")?;
864    let bus = take_bus(upper, &mut at)?;
865    (at == upper.len()).then_some((bus, id))
866}
867
868/// `REMOVE SHUNT [id] FROM BUS i`.
869fn parse_optional_id_from_bus(upper: &[String], words: &[&str]) -> Option<(BusId, Option<String>)> {
870    let mut at = 2;
871    let id = if upper.get(at).is_some_and(|word| word == "FROM") {
872        None
873    } else {
874        Some(take_id(words, &mut at)?)
875    };
876    take_keyword(upper, &mut at, "FROM")?;
877    let bus = take_bus(upper, &mut at)?;
878    (at == upper.len()).then_some((bus, id))
879}
880
881/// `INCREASE BUS i LOAD BY x PERCENT` and its synonyms, and the `SET ... TO`
882/// spelling.
883fn parse_change(upper: &[String]) -> Option<ContingencyAction> {
884    let op = match upper[0].as_str() {
885        "INCREASE" | "RAISE" => ChangeOp::Increase,
886        "DECREASE" => ChangeOp::Decrease,
887        "SET" => ChangeOp::Set,
888        _ => return None,
889    };
890    let mut at = 1;
891    let bus = take_bus(upper, &mut at)?;
892    let load = match upper.get(at)?.as_str() {
893        "LOAD" => true,
894        "GENERATION" => false,
895        _ => return None,
896    };
897    at += 1;
898    take_keyword(
899        upper,
900        &mut at,
901        if op == ChangeOp::Set { "TO" } else { "BY" },
902    )?;
903    let stated = upper.get(at)?.as_str();
904    at += 1;
905    let (amount, unit) = if let Some(head) = stated.strip_suffix('%') {
906        (head.parse::<f64>().ok()?, ChangeUnit::Percent)
907    } else {
908        let amount = stated.parse::<f64>().ok()?;
909        let unit = match upper.get(at)?.as_str() {
910            "MW" => ChangeUnit::Mw,
911            "PERCENT" | "%" => ChangeUnit::Percent,
912            _ => return None,
913        };
914        at += 1;
915        (amount, unit)
916    };
917    if at != upper.len() || !amount.is_finite() {
918        return None;
919    }
920    let change = Change { op, amount, unit };
921    Some(if load {
922        ContingencyAction::ChangeLoad { bus, change }
923    } else {
924        ContingencyAction::ChangeGeneration { bus, change }
925    })
926}
927
928/// `SINGLE BRANCH IN SUBSYSTEM name [3WLOWVOLTAGE]`.
929fn parse_automatic(upper: &[String], words: &[&str]) -> Option<AutomaticSpec> {
930    let order = match upper.first()?.as_str() {
931        "SINGLE" => AutomaticOrder::Single,
932        "DOUBLE" => AutomaticOrder::Double,
933        _ => return None,
934    };
935    let target = match upper.get(1)?.as_str() {
936        "BRANCH" | "LINE" => AutomaticTarget::Branch,
937        "UNIT" | "MACHINE" => AutomaticTarget::Unit,
938        "TIE" => AutomaticTarget::Tie,
939        _ => return None,
940    };
941    let mut at = 2;
942    if !matches!(upper.get(at)?.as_str(), "IN" | "FROM") {
943        return None;
944    }
945    at += 1;
946    take_keyword(upper, &mut at, "SUBSYSTEM")?;
947    let subsystem = words.get(at)?.trim().to_owned();
948    at += 1;
949    let low_voltage_3w = upper.get(at).is_some_and(|word| word == "3WLOWVOLTAGE");
950    if low_voltage_3w {
951        at += 1;
952    }
953    (at == upper.len()).then_some(AutomaticSpec {
954        order,
955        target,
956        subsystem,
957        low_voltage_3w,
958    })
959}
960
961/// `i TO j [CIRCUIT c]` or `FROM BUS i TO BUS j [CIRCUIT c]` inside a `SKIP`
962/// block.
963fn parse_skip_rule(upper: &[String], words: &[&str]) -> Option<SkipRule> {
964    let mut at = 0;
965    if upper.first().is_some_and(|word| word == "FROM") {
966        at = 1;
967    }
968    let from = take_bus(upper, &mut at)?;
969    take_keyword(upper, &mut at, "TO")?;
970    let to = take_bus(upper, &mut at)?;
971    let circuit = take_circuit(upper, words, &mut at)?;
972    Some(SkipRule { from, to, circuit })
973}
974
975// ---------------------------------------------------------------------------
976// Writing
977// ---------------------------------------------------------------------------
978
979/// The quote character a written line closes `value` with: `"` when the value
980/// holds an apostrophe, `'` otherwise. A value holding both has none, because
981/// a quoted token ends at the first quote of its own character that whitespace
982/// or the end of the line follows.
983fn delimiter(value: &str) -> Option<char> {
984    match (value.contains('\''), value.contains('"')) {
985        (true, true) => None,
986        (true, false) => Some('"'),
987        (false, _) => Some('\''),
988    }
989}
990
991/// Whether a written line states `value` as one token that reads back
992/// unchanged. The reader keeps a statement naming a value without one as
993/// text, so a set read from a file names only values that have one.
994fn writable(value: &str) -> bool {
995    delimiter(value).is_some()
996}
997
998/// A name as written: quoted, as PSS/E quotes a case name and a subsystem
999/// name, with the delimiter the value does not hold. A value holding both
1000/// quote characters reaches the writer only from a set built in memory, and is
1001/// stated single quoted.
1002fn quoted(value: &str) -> String {
1003    let quote = delimiter(value).unwrap_or('\'');
1004    format!("{quote}{value}{quote}")
1005}
1006
1007/// A field as written. A value that is empty, holds whitespace, opens with
1008/// `/`, or holds a quote character is quoted, so the line states it as one
1009/// token: an unquoted token opening with `/` would end the statement and leave
1010/// the rest of the line a comment. Every other value is written bare, as
1011/// PSS/E writes an id and a circuit.
1012pub(crate) fn field(value: &str) -> String {
1013    if value.is_empty()
1014        || value.contains(char::is_whitespace)
1015        || value.starts_with('/')
1016        || value.contains(['\'', '"'])
1017    {
1018        quoted(value)
1019    } else {
1020        value.to_owned()
1021    }
1022}
1023
1024/// Whether every value the action states has a written form.
1025fn action_writable(action: &ContingencyAction) -> bool {
1026    match action {
1027        ContingencyAction::OpenBranch { circuit, .. }
1028        | ContingencyAction::OpenThreeWinding { circuit, .. } => writable(circuit),
1029        ContingencyAction::RemoveMachine { id, .. } | ContingencyAction::AddMachine { id, .. } => {
1030            writable(id)
1031        }
1032        ContingencyAction::RemoveShunt { id, .. } | ContingencyAction::RemoveLoad { id, .. } => {
1033            id.as_deref().is_none_or(writable)
1034        }
1035        _ => true,
1036    }
1037}
1038
1039fn write_action(action: &ContingencyAction) -> String {
1040    match action {
1041        ContingencyAction::OpenBranch { from, to, circuit } => format!(
1042            "OPEN LINE FROM BUS {:>6} TO BUS {:>6} CIRCUIT {}",
1043            from.0,
1044            to.0,
1045            field(circuit)
1046        ),
1047        ContingencyAction::OpenThreeWinding { buses, circuit } => format!(
1048            "OPEN THREEWINDING AT BUS {:>6} TO BUS {:>6} TO BUS {:>6} CIRCUIT {}",
1049            buses[0].0,
1050            buses[1].0,
1051            buses[2].0,
1052            field(circuit)
1053        ),
1054        ContingencyAction::RemoveMachine { bus, id } => {
1055            format!("REMOVE MACHINE {} FROM BUS {:>6}", field(id), bus.0)
1056        }
1057        ContingencyAction::AddMachine { bus, id } => {
1058            format!("ADD MACHINE {} TO BUS {:>6}", field(id), bus.0)
1059        }
1060        ContingencyAction::RemoveShunt { bus, id } => match id {
1061            Some(id) => format!("REMOVE SHUNT {} FROM BUS {:>6}", field(id), bus.0),
1062            None => format!("REMOVE SHUNT FROM BUS {:>6}", bus.0),
1063        },
1064        ContingencyAction::RemoveSwitchedShunt { bus } => {
1065            format!("REMOVE SWSHUNT FROM BUS {:>6}", bus.0)
1066        }
1067        ContingencyAction::RemoveLoad { bus, id } => match id {
1068            Some(id) => format!("REMOVE LOAD {} FROM BUS {:>6}", field(id), bus.0),
1069            None => format!("REMOVE LOAD FROM BUS {:>6}", bus.0),
1070        },
1071        ContingencyAction::DisconnectBus { bus } => format!("DISCONNECT BUS {:>6}", bus.0),
1072        ContingencyAction::ChangeLoad { bus, change } => write_change(*bus, "LOAD", change),
1073        ContingencyAction::ChangeGeneration { bus, change } => {
1074            write_change(*bus, "GENERATION", change)
1075        }
1076        ContingencyAction::Unrecognized { text } => text.clone(),
1077    }
1078}
1079
1080fn write_change(bus: BusId, target: &str, change: &Change) -> String {
1081    let unit = match change.unit {
1082        ChangeUnit::Mw => "MW",
1083        ChangeUnit::Percent => "PERCENT",
1084    };
1085    let amount = change.amount;
1086    let bus = bus.0;
1087    match change.op {
1088        ChangeOp::Increase => format!("INCREASE BUS {bus} {target} BY {amount} {unit}"),
1089        ChangeOp::Decrease => format!("DECREASE BUS {bus} {target} BY {amount} {unit}"),
1090        ChangeOp::Set => format!("SET BUS {bus} {target} TO {amount} {unit}"),
1091    }
1092}
1093
1094fn write_automatic(spec: &AutomaticSpec) -> String {
1095    let order = match spec.order {
1096        AutomaticOrder::Single => "SINGLE",
1097        AutomaticOrder::Double => "DOUBLE",
1098    };
1099    let (target, preposition) = match spec.target {
1100        AutomaticTarget::Branch => ("BRANCH", "IN"),
1101        AutomaticTarget::Unit => ("UNIT", "IN"),
1102        AutomaticTarget::Tie => ("TIE", "FROM"),
1103    };
1104    let tail = if spec.low_voltage_3w {
1105        " 3WLOWVOLTAGE"
1106    } else {
1107        ""
1108    };
1109    format!(
1110        "{order} {target} {preposition} SUBSYSTEM {}{tail}",
1111        quoted(&spec.subsystem)
1112    )
1113}