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