Skip to main content

powerio_tx/contingency/
tara.rs

1//! The TARA statements a `.con` or `.sub` file states beyond the PSS/E
2//! grammar, read at analysis time.
3//!
4//! The readers keep these statements as text: a statement naming a bus by
5//! name stays an [`ContingencyAction::Unrecognized`] action, a dispatch block
6//! stays one text action or one retained statement, and a subsystem's dispatch
7//! lines stay retained on the subsystem. The PowerIO IR layouts of the three
8//! sets are fixed within 0.11, so the typed forms here are views over that
9//! text rather than fields of the sets:
10//!
11//! - **Names.** A quoted bus operand names a bus by its name and base kV
12//!   (`'NAME 345'`), the form TARA reads after `BUSNAMES`.
13//!   The action grammar's optional `BUS` keyword remains optional, and a
14//!   numeric operand keeps its bus number even when quoted.
15//!   [`ContingencySet::resolve`] binds such a statement by name, and a
16//!   statement naming one quoted branch, the form read after `BRANCHNAMES`,
17//!   by the branch's name.
18//! - **Dispatch.** [`ContingencySet::calc_default_dispatch`] reads the
19//!   `DEFAULT DISPATCH [UP | DOWN | FIRSTLEVEL]` blocks, and
20//!   [`ContingencyAction::calc_dispatched_action`] a case action that closes
21//!   with `DISPATCH` and the block of lines under it.
22//! - **Subsystem rules.** [`Subsystem::calc_dispatch_rules`] reads `PARTICIPATE`,
23//!   `SCALE`, `BASELOAD`, `TURBINETYPE`, and `EXCEPT` lines.
24//!
25//! A name matches exactly first, then without regard to letter case and runs
26//! of whitespace; a name that matches several elements at either stage is
27//! ambiguous and binds none.
28
29use std::collections::HashMap;
30
31use crate::network::{BalancedNetwork, BusId};
32
33use super::lexer::{LexedLine, LineKind, lex};
34use super::resolve::UnresolvedReason;
35use super::sub::Subsystem;
36use super::{ContingencyAction, ContingencySet, parse_action};
37
38/// Base kV values this far apart name the same voltage level.
39const KV_TOLERANCE: f64 = 1e-6;
40
41/// Which `DEFAULT DISPATCH` block a set states.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43#[non_exhaustive]
44pub enum DispatchLevel {
45    /// `DEFAULT DISPATCH` with no qualifier.
46    Default,
47    /// `DEFAULT DISPATCH UP`.
48    Up,
49    /// `DEFAULT DISPATCH DOWN`.
50    Down,
51    /// `DEFAULT DISPATCH FIRSTLEVEL`.
52    FirstLevel,
53}
54
55/// One line of a dispatch block.
56#[derive(Debug, Clone, PartialEq)]
57#[non_exhaustive]
58pub enum DispatchEntry {
59    /// `SUBSYSTEM name [x]`: a subsystem, with the number the line states
60    /// after it, when it states one.
61    Subsystem { name: String, share: Option<f64> },
62    /// `BUS n [x]`: one bus, with the number the line states after it.
63    Bus { bus: BusId, share: Option<f64> },
64    /// `PARTICIPATING MACHINES`.
65    ParticipatingMachines,
66}
67
68/// The lines of one dispatch block, between its opening line and its `END`.
69#[derive(Debug, Clone, Default, PartialEq)]
70#[non_exhaustive]
71pub struct DispatchBlock {
72    pub entries: Vec<DispatchEntry>,
73    /// Lines outside the entry grammar, trimmed, in block order.
74    pub unread: Vec<String>,
75}
76
77/// A `DEFAULT DISPATCH` block of a contingency set.
78#[derive(Debug, Clone, PartialEq)]
79#[non_exhaustive]
80pub struct DefaultDispatch {
81    pub level: DispatchLevel,
82    pub block: DispatchBlock,
83    /// The line the block opened on.
84    pub line: usize,
85}
86
87/// A case action that closes with `DISPATCH`, and the block it states.
88#[derive(Debug, Clone, PartialEq)]
89#[non_exhaustive]
90pub struct DispatchedAction {
91    /// The action its opening line states ahead of `DISPATCH`.
92    pub action: ContingencyAction,
93    pub block: DispatchBlock,
94}
95
96/// What a `SCALE ALL` line scales.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum ScaledQuantity {
100    /// `SCALE ALL GENERATION`.
101    Generation,
102    /// `SCALE ALL LOAD`.
103    Load,
104    /// `SCALE ALL FOR IMPORT`.
105    Import,
106    /// `SCALE ALL FOR EXPORT`.
107    Export,
108}
109
110/// One TARA dispatch line inside a subsystem.
111#[derive(Debug, Clone, PartialEq)]
112#[non_exhaustive]
113pub enum SubsystemDispatchRule {
114    /// `PARTICIPATE [INCLUDE OFFLINE]`.
115    Participate { include_offline: bool },
116    /// `SCALE ALL GENERATION | LOAD | FOR IMPORT | FOR EXPORT`, with
117    /// `WITH PMAX GREATER x MW`, `INCLUDE OFFLINE`, and
118    /// `INCLUDE NONCONFORMING` when stated.
119    Scale {
120        quantity: ScaledQuantity,
121        pmax_greater_mw: Option<f64>,
122        include_offline: bool,
123        include_nonconforming: bool,
124    },
125    /// `BASELOAD n`.
126    Baseload { code: i64 },
127    /// `TURBINETYPE n`.
128    TurbineType { code: i64 },
129    /// `EXCEPT` and the words that follow it, as written.
130    Except { words: Vec<String> },
131}
132
133impl ContingencySet {
134    /// The `DEFAULT DISPATCH` blocks the set states at file level, in file
135    /// order. The reader keeps each block as one retained statement holding
136    /// every line; this reads its level and entries.
137    #[must_use]
138    pub fn calc_default_dispatch(&self) -> Vec<DefaultDispatch> {
139        self.retained
140            .iter()
141            .filter(|statement| !statement.after_end)
142            .filter_map(|statement| {
143                let lines = lex(&statement.text);
144                let (head, body) = split_block(&lines)?;
145                let upper: Vec<String> = head.keywords();
146                if upper.len() > 3
147                    || upper[0] != "DEFAULT"
148                    || upper.get(1).map(String::as_str) != Some("DISPATCH")
149                {
150                    return None;
151                }
152                let level = match upper.get(2).map(String::as_str) {
153                    None => DispatchLevel::Default,
154                    Some("UP") => DispatchLevel::Up,
155                    Some("DOWN") => DispatchLevel::Down,
156                    Some("FIRSTLEVEL") => DispatchLevel::FirstLevel,
157                    Some(_) => return None,
158                };
159                Some(DefaultDispatch {
160                    level,
161                    block: read_block(body),
162                    line: statement.line,
163                })
164            })
165            .collect()
166    }
167}
168
169impl ContingencyAction {
170    /// The action and dispatch block of a case action that closes with
171    /// `DISPATCH`, which the reader keeps as one text action holding every
172    /// line of the block. `None` for any other action, and for a block whose
173    /// opening line names a bus by name: that one binds through
174    /// [`ContingencySet::resolve`], which can look the name up.
175    #[must_use]
176    pub fn calc_dispatched_action(&self) -> Option<DispatchedAction> {
177        let Self::Unrecognized { text } = self else {
178            return None;
179        };
180        let lines = lex(text);
181        let (head, body) = split_block(&lines)?;
182        let upper = head.keywords();
183        if upper.last().map(String::as_str) != Some("DISPATCH") {
184            return None;
185        }
186        let words = head.words();
187        let n = upper.len() - 1;
188        let action = parse_action(&upper[..n], &words[..n])?;
189        Some(DispatchedAction {
190            action,
191            block: read_block(body),
192        })
193    }
194}
195
196impl Subsystem {
197    /// The TARA dispatch lines the subsystem states, in the order the
198    /// subsystem keeps them: its own retained lines, then each `JOIN` group's.
199    /// A line outside this grammar is not listed and stays text only.
200    #[must_use]
201    pub fn calc_dispatch_rules(&self) -> Vec<SubsystemDispatchRule> {
202        self.retained
203            .iter()
204            .chain(self.groups.iter().flat_map(|group| group.retained.iter()))
205            .filter_map(|statement| {
206                let lines = lex(&statement.text);
207                let line = lines.iter().find(|l| l.kind == LineKind::Statement)?;
208                parse_subsystem_rule(&line.keywords(), &line.words())
209            })
210            .collect()
211    }
212}
213
214/// The opening statement line of a block and the statement lines under it,
215/// up to and excluding its `END`.
216fn split_block<'a, 'b>(
217    lines: &'b [LexedLine<'a>],
218) -> Option<(&'b LexedLine<'a>, &'b [LexedLine<'a>])> {
219    let statements: Vec<usize> = lines
220        .iter()
221        .enumerate()
222        .filter(|(_, line)| line.kind == LineKind::Statement)
223        .map(|(index, _)| index)
224        .collect();
225    let (&first, rest) = statements.split_first()?;
226    let last = rest.last().copied().filter(|&index| {
227        let tokens = &lines[index].tokens;
228        tokens.len() == 1 && tokens[0].text.eq_ignore_ascii_case("END")
229    });
230    let end = last.unwrap_or(lines.len());
231    Some((&lines[first], &lines[first + 1..end]))
232}
233
234fn read_block(lines: &[LexedLine<'_>]) -> DispatchBlock {
235    let mut block = DispatchBlock::default();
236    for line in lines.iter().filter(|l| l.kind == LineKind::Statement) {
237        let upper = line.keywords();
238        let words = line.words();
239        let share = |at: usize| -> Option<Option<f64>> {
240            match words.get(at) {
241                None => Some(None),
242                Some(word) => word.parse::<f64>().ok().filter(|v| v.is_finite()).map(Some),
243            }
244        };
245        let entry = match upper[0].as_str() {
246            "SUBSYSTEM" | "SYSTEM" if words.len() <= 3 => {
247                words
248                    .get(1)
249                    .zip(share(2))
250                    .map(|(name, share)| DispatchEntry::Subsystem {
251                        name: name.trim().to_owned(),
252                        share,
253                    })
254            }
255            "BUS" if words.len() <= 3 => words
256                .get(1)
257                .and_then(|bus| bus.parse::<usize>().ok())
258                .zip(share(2))
259                .map(|(bus, share)| DispatchEntry::Bus {
260                    bus: BusId(bus),
261                    share,
262                }),
263            "PARTICIPATING" if upper.len() == 2 && upper[1] == "MACHINES" => {
264                Some(DispatchEntry::ParticipatingMachines)
265            }
266            _ => None,
267        };
268        match entry {
269            Some(entry) => block.entries.push(entry),
270            None => block.unread.push(line.trimmed().to_owned()),
271        }
272    }
273    block
274}
275
276fn parse_subsystem_rule(upper: &[String], words: &[&str]) -> Option<SubsystemDispatchRule> {
277    let include = |at: usize, what: &str| {
278        upper.get(at).is_some_and(|w| w == "INCLUDE")
279            && upper.get(at + 1).is_some_and(|w| w == what)
280    };
281    match upper.first()?.as_str() {
282        "PARTICIPATE" => match upper.len() {
283            1 => Some(SubsystemDispatchRule::Participate {
284                include_offline: false,
285            }),
286            3 if include(1, "OFFLINE") => Some(SubsystemDispatchRule::Participate {
287                include_offline: true,
288            }),
289            _ => None,
290        },
291        "BASELOAD" | "TURBINETYPE" if upper.len() == 2 => {
292            let code = upper[1].parse::<i64>().ok()?;
293            Some(if upper[0] == "BASELOAD" {
294                SubsystemDispatchRule::Baseload { code }
295            } else {
296                SubsystemDispatchRule::TurbineType { code }
297            })
298        }
299        "EXCEPT" => Some(SubsystemDispatchRule::Except {
300            words: words[1..].iter().map(|w| (*w).to_owned()).collect(),
301        }),
302        "SCALE" => parse_scale(upper),
303        _ => None,
304    }
305}
306
307/// `SCALE ALL GENERATION [WITH PMAX GREATER x MW]`,
308/// `SCALE ALL LOAD [INCLUDE NONCONFORMING]`, and
309/// `SCALE ALL FOR IMPORT | EXPORT [WITH PMAX GREATER x MW] [INCLUDE OFFLINE]`.
310fn parse_scale(upper: &[String]) -> Option<SubsystemDispatchRule> {
311    if upper.get(1)? != "ALL" {
312        return None;
313    }
314    let (quantity, mut at) = match upper.get(2)?.as_str() {
315        "GENERATION" => (ScaledQuantity::Generation, 3),
316        "LOAD" | "LOAD_ID" => (ScaledQuantity::Load, 3),
317        "FOR" => match upper.get(3)?.as_str() {
318            "IMPORT" => (ScaledQuantity::Import, 4),
319            "EXPORT" => (ScaledQuantity::Export, 4),
320            _ => return None,
321        },
322        _ => return None,
323    };
324    let mut pmax_greater_mw = None;
325    let mut include_offline = false;
326    let mut include_nonconforming = false;
327    while at < upper.len() {
328        match upper[at].as_str() {
329            "WITH"
330                if upper.get(at + 1).is_some_and(|w| w == "PMAX")
331                    && upper.get(at + 2).is_some_and(|w| w == "GREATER")
332                    && upper.get(at + 4).is_some_and(|w| w == "MW") =>
333            {
334                pmax_greater_mw = Some(
335                    upper
336                        .get(at + 3)?
337                        .parse::<f64>()
338                        .ok()
339                        .filter(|value| value.is_finite())?,
340                );
341                at += 5;
342            }
343            "INCLUDE" if upper.get(at + 1).is_some_and(|w| w == "OFFLINE") => {
344                include_offline = true;
345                at += 2;
346            }
347            "INCLUDE" if upper.get(at + 1).is_some_and(|w| w == "NONCONFORMING") => {
348                include_nonconforming = true;
349                at += 2;
350            }
351            _ => return None,
352        }
353    }
354    Some(SubsystemDispatchRule::Scale {
355        quantity,
356        pmax_greater_mw,
357        include_offline,
358        include_nonconforming,
359    })
360}
361
362// ---------------------------------------------------------------------------
363// Names
364// ---------------------------------------------------------------------------
365
366/// A name compared without regard to letter case and runs of whitespace.
367fn normalize_name(name: &str) -> String {
368    name.split_whitespace()
369        .map(str::to_ascii_uppercase)
370        .collect::<Vec<_>>()
371        .join(" ")
372}
373
374/// The buses and branches of a network by name, for the TARA name forms.
375#[derive(Debug, Clone, Default)]
376pub(super) struct NameIndex {
377    /// Normalized bus name to `(name as stated, base kV, bus)`.
378    buses: HashMap<String, Vec<(String, f64, BusId)>>,
379    /// Normalized branch name to `(name as stated, branch row)`.
380    branches: HashMap<String, Vec<(String, usize)>>,
381}
382
383impl NameIndex {
384    pub(super) fn new(net: &BalancedNetwork) -> Self {
385        let mut index = Self::default();
386        for bus in net.buses() {
387            if let Some(name) = bus.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
388                index.buses.entry(normalize_name(name)).or_default().push((
389                    name.to_owned(),
390                    bus.base_kv,
391                    bus.id,
392                ));
393            }
394        }
395        for (row, branch) in net.branches().iter().enumerate() {
396            if let Some(name) = branch
397                .name
398                .as_deref()
399                .map(str::trim)
400                .filter(|n| !n.is_empty())
401            {
402                index
403                    .branches
404                    .entry(normalize_name(name))
405                    .or_default()
406                    .push((name.to_owned(), row));
407            }
408        }
409        index
410    }
411
412    /// The bus a quoted `NAME [kV]` token names. The last whitespace
413    /// separated word is the base kV when it reads as a number.
414    fn bus(&self, token: &str) -> Result<BusId, UnresolvedReason> {
415        let token = token.trim();
416        let (name, kv) = match token.rsplit_once(char::is_whitespace) {
417            Some((name, kv)) => match kv.parse::<f64>() {
418                Ok(kv) if kv.is_finite() => (name.trim(), Some(kv)),
419                _ => (token, None),
420            },
421            None => (token, None),
422        };
423        let candidates: Vec<&(String, f64, BusId)> = self
424            .buses
425            .get(&normalize_name(name))
426            .into_iter()
427            .flatten()
428            .filter(|(_, base, _)| kv.is_none_or(|kv| (base - kv).abs() <= KV_TOLERANCE))
429            .collect();
430        let exact: Vec<BusId> = candidates
431            .iter()
432            .filter(|(stated, ..)| stated == name)
433            .map(|(.., bus)| *bus)
434            .collect();
435        let all: Vec<BusId> = candidates.iter().map(|(.., bus)| *bus).collect();
436        pick(&exact, &all).map_err(|matches| match matches {
437            0 => UnresolvedReason::NoSuchBusName,
438            matches => UnresolvedReason::AmbiguousBusName { matches },
439        })
440    }
441
442    /// The branch rows a quoted branch name names.
443    fn branch(&self, name: &str) -> Result<usize, UnresolvedReason> {
444        let name = name.trim();
445        let candidates = self
446            .branches
447            .get(&normalize_name(name))
448            .map_or(&[][..], Vec::as_slice);
449        let exact: Vec<usize> = candidates
450            .iter()
451            .filter(|(stated, _)| stated == name)
452            .map(|(_, row)| *row)
453            .collect();
454        let all: Vec<usize> = candidates.iter().map(|(_, row)| *row).collect();
455        pick(&exact, &all).map_err(|matches| match matches {
456            0 => UnresolvedReason::NoSuchBranchName,
457            matches => UnresolvedReason::AmbiguousBranchName { matches },
458        })
459    }
460}
461
462/// The one element a name names: the exact match when there is one, else
463/// the one match without regard to case and whitespace. On failure, how many
464/// elements matched at the stage that decided: zero, or more than one.
465fn pick<T: Copy>(exact: &[T], all: &[T]) -> Result<T, usize> {
466    match (exact, all) {
467        ([one], _) | ([], [one]) => Ok(*one),
468        ([], many) | (many, _) => Err(many.len()),
469    }
470}
471
472/// What a statement kept as text names, when it is a TARA form this module
473/// reads.
474pub(super) enum TextAction {
475    /// A statement in the PSS/E grammar once its named buses are numbers,
476    /// or the action ahead of a `DISPATCH` block.
477    Action(ContingencyAction),
478    /// `OPEN | TRIP | DISCONNECT [BRANCH | LINE] 'name'`: one branch by name.
479    Branch(usize),
480}
481
482/// Read a statement kept as text: a dispatch block's opening action, a
483/// statement naming buses by name, or one branch named by name. `None` when
484/// the text is none of these, so it stays unrecognized.
485pub(super) fn read_text_action(
486    names: &NameIndex,
487    text: &str,
488) -> Option<Result<TextAction, UnresolvedReason>> {
489    let lines = lex(text);
490    let (head, _) = split_block(&lines)?;
491    let mut upper = head.keywords();
492    let mut words: Vec<String> = head.words().iter().map(|w| (*w).to_owned()).collect();
493    if upper.last().is_some_and(|w| w == "DISPATCH") && upper.len() > 1 {
494        upper.pop();
495        words.pop();
496    }
497    // A branch named by name.
498    let quoted: Vec<usize> = head
499        .tokens
500        .iter()
501        .enumerate()
502        .filter(|(_, token)| token.quoted)
503        .map(|(at, _)| at)
504        .collect();
505    if matches!(upper[0].as_str(), "OPEN" | "TRIP" | "DISCONNECT")
506        && quoted.len() == 1
507        && quoted[0] == upper.len() - 1
508        && (upper.len() == 2
509            || (upper.len() == 3 && matches!(upper[1].as_str(), "BRANCH" | "LINE")))
510    {
511        return Some(names.branch(&words[quoted[0]]).map(TextAction::Branch));
512    }
513    // Only the shared grammar decides which positions are bus operands.
514    // Try the numeric grammar first so quoted numbers cannot bind a decoy
515    // bus with that name, including when a DISPATCH block was unwrapped.
516    let mut named = false;
517    let mut unresolved = None;
518    let views: Vec<&str> = words.iter().map(String::as_str).collect();
519    let action = super::parse_action_with_buses(&upper, &views, &mut |at| {
520        let token = head.tokens.get(at)?;
521        if let Some(bus) = super::parse_bus(token.text) {
522            return Some(bus);
523        }
524        if !token.quoted {
525            return None;
526        }
527        named = true;
528        Some(names.bus(token.text).unwrap_or_else(|reason| {
529            unresolved.get_or_insert(reason);
530            // Keep checking syntax; this placeholder never reaches binding.
531            BusId(0)
532        }))
533    })?;
534    if let Some(reason) = unresolved {
535        return Some(Err(reason));
536    }
537    let dispatched = head.keywords().last().is_some_and(|w| w == "DISPATCH");
538    (named || dispatched).then_some(Ok(TextAction::Action(action)))
539}