mod expand;
mod lexer;
pub mod mon;
mod resolve;
pub mod sub;
mod tara;
use std::cmp::Ordering;
pub use expand::Expanded;
use lexer::{LexedLine, LineKind, lex};
pub use mon::{
BranchRef, InterfaceMember, MonitorScope, MonitorStatement, MonitoredParsed,
MonitoredResolution, MonitoredSet, ResolvedInterface, ResolvedVoltageScope, UnresolvedMonitor,
UnresolvedMonitorReason,
};
pub use resolve::{
ContingencyResolution, PsseEquipmentIndex, ResolvedCase, ResolvedComponent,
SharedPsseEquipmentIndex, UnresolvedAction, UnresolvedReason,
};
pub use sub::{
JoinName, SelectorGroup, Subsystem, SubsystemParsed, SubsystemSelector, SubsystemSet,
};
pub use tara::{
DefaultDispatch, DispatchBlock, DispatchEntry, DispatchLevel, DispatchedAction, ScaledQuantity,
SubsystemDispatchRule,
};
use crate::diagnostics::{Diagnostic, DiagnosticInfo, codes};
use crate::network::BusId;
use crate::{Error, Result};
const FMT: &str = "psse contingency";
const MAX_READER_NOTES: usize = 16;
fn note_within_budget(
diagnostics: &mut Vec<Diagnostic>,
info: &'static DiagnosticInfo,
truncated: &'static DiagnosticInfo,
message: String,
) {
match diagnostics.len().cmp(&MAX_READER_NOTES) {
Ordering::Less => diagnostics.push(Diagnostic::of(info, message)),
Ordering::Equal => {
diagnostics.push(Diagnostic::of(truncated, "further reader notes suppressed"));
}
Ordering::Greater => {}
}
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ContingencySet {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub header: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cases: Vec<ContingencyCase>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub automatic: Vec<AutomaticSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skips: Vec<SkipRule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub retained: Vec<RetainedStatement>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ContingencyCase {
pub name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<ContingencyAction>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ContingencyAction {
OpenBranch {
from: BusId,
to: BusId,
circuit: String,
},
OpenThreeWinding { buses: [BusId; 3], circuit: String },
RemoveMachine { bus: BusId, id: String },
AddMachine { bus: BusId, id: String },
RemoveShunt {
bus: BusId,
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
RemoveSwitchedShunt { bus: BusId },
RemoveLoad {
bus: BusId,
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
DisconnectBus { bus: BusId },
ChangeLoad { bus: BusId, change: Change },
ChangeGeneration { bus: BusId, change: Change },
Unrecognized { text: String },
}
impl ContingencyAction {
#[must_use]
pub fn to_con_statement(&self) -> String {
write_action(self)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Change {
pub op: ChangeOp,
pub amount: f64,
pub unit: ChangeUnit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ChangeOp {
Increase,
Decrease,
Set,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ChangeUnit {
Mw,
Percent,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct AutomaticSpec {
pub order: AutomaticOrder,
pub target: AutomaticTarget,
pub subsystem: String,
pub low_voltage_3w: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AutomaticOrder {
Single,
Double,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AutomaticTarget {
Branch,
Unit,
Tie,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SkipRule {
pub from: BusId,
pub to: BusId,
pub circuit: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct RetainedStatement {
#[cfg_attr(feature = "schema", schemars(range(min = 1)))]
pub line: usize,
pub text: String,
#[serde(default)]
pub after_end: bool,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ContingencyParsed {
pub set: ContingencySet,
pub diagnostics: Vec<Diagnostic>,
}
impl ContingencyParsed {
fn note(&mut self, info: &'static DiagnosticInfo, message: String) {
note_within_budget(
&mut self.diagnostics,
info,
&codes::READ_CON_NOTES_TRUNCATED,
message,
);
}
fn unrecognized(&mut self, number: usize, text: &str) {
self.note(
&codes::READ_CON_STATEMENT_UNRECOGNIZED,
format!("line {number}: statement kept as text: {text}"),
);
}
fn unwritable(&mut self, number: usize, text: &str) {
self.note(
&codes::READ_CON_SOURCE_MALFORMED,
format!(
"line {number}: a value holding both quote characters has no written form, and the statement was kept as text: {text}"
),
);
}
fn dispatch_block(&mut self, number: usize, text: &str) {
self.note(
&codes::READ_CON_STATEMENT_UNRECOGNIZED,
format!("line {number}: dispatch block kept as text: {text}"),
);
}
}
fn bad(message: String) -> Error {
Error::FormatRead {
format: FMT,
message,
}
}
struct OpenCase {
name: String,
opened: usize,
actions: Vec<ContingencyAction>,
}
struct OpenBlock {
opened: usize,
lines: Vec<String>,
}
impl ContingencySet {
pub fn parse(text: &str) -> Result<ContingencyParsed> {
let mut reader = Reader::new();
for line in lex(text) {
reader.read_line(&line)?;
}
reader.finish()
}
#[must_use]
pub fn to_con(&self) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for line in &self.header {
out.push_str(line);
out.push('\n');
}
for spec in &self.automatic {
out.push_str(&write_automatic(spec));
out.push('\n');
}
if !self.skips.is_empty() {
out.push_str("SKIP\n");
for rule in &self.skips {
let circuit = field(&rule.circuit);
let _ = writeln!(
out,
"{:>6} TO {:>6} CIRCUIT {circuit}",
rule.from.0, rule.to.0
);
}
out.push_str("END\n");
}
for case in &self.cases {
let _ = writeln!(out, "CONTINGENCY {}", quoted(&case.name));
for action in &case.actions {
out.push_str(&write_action(action));
out.push('\n');
}
out.push_str("END\n");
}
for statement in self.retained.iter().filter(|kept| !kept.after_end) {
out.push_str(&statement.text);
out.push('\n');
}
out.push_str("END\n");
for statement in self.retained.iter().filter(|kept| kept.after_end) {
out.push_str(&statement.text);
out.push('\n');
}
out
}
}
struct Reader {
parsed: ContingencyParsed,
case: Option<OpenCase>,
block: Option<OpenBlock>,
skip_opened: Option<usize>,
seen_statement: bool,
ended: bool,
noted_text_after_end: bool,
}
impl Reader {
fn new() -> Self {
Reader {
parsed: ContingencyParsed {
set: ContingencySet::default(),
diagnostics: Vec::new(),
},
case: None,
block: None,
skip_opened: None,
seen_statement: false,
ended: false,
noted_text_after_end: false,
}
}
fn read_line(&mut self, line: &LexedLine<'_>) -> Result<()> {
if self.block.is_some() {
self.read_block_line(line);
return Ok(());
}
if self.ended {
self.keep_after_end(line);
return Ok(());
}
if !self.take_header(line) {
return Ok(());
}
let upper = line.keywords();
let words = line.words();
if self.skip_opened.is_some() {
self.read_skip_line(line, &upper, &words);
return Ok(());
}
if self.case.is_some() {
return self.read_case_line(line, &upper, &words);
}
self.read_file_line(line, &upper, &words);
Ok(())
}
fn finish(self) -> Result<ContingencyParsed> {
if let Some(open) = self.block {
return Err(bad(format!(
"line {}: a dispatch block has no END",
open.opened
)));
}
if let Some(open) = self.case {
return Err(bad(format!(
"line {}: CONTINGENCY '{}' has no END",
open.opened, open.name
)));
}
if let Some(opened) = self.skip_opened {
return Err(bad(format!("line {opened}: SKIP has no END")));
}
Ok(self.parsed)
}
fn take_header(&mut self, line: &LexedLine<'_>) -> bool {
if self.seen_statement {
return line.kind == LineKind::Statement;
}
match line.kind {
LineKind::Blank => false,
LineKind::Comment => {
self.parsed.set.header.push(line.text.to_owned());
false
}
LineKind::Statement => {
self.seen_statement = true;
true
}
}
}
fn read_block_line(&mut self, line: &LexedLine<'_>) {
let closed = is_end(line);
let text = line.trimmed().to_owned();
if let Some(open) = self.block.as_mut() {
open.lines.push(text);
}
if !closed {
return;
}
let Some(open) = self.block.take() else {
return;
};
let text = open.lines.join("\n");
match self.case.as_mut() {
Some(case) => case.actions.push(ContingencyAction::Unrecognized { text }),
None => self.parsed.set.retained.push(RetainedStatement {
line: open.opened,
text,
after_end: false,
}),
}
}
fn keep_after_end(&mut self, line: &LexedLine<'_>) {
if line.kind != LineKind::Statement {
return;
}
if !self.noted_text_after_end {
self.noted_text_after_end = true;
self.parsed.note(
&codes::READ_CON_TEXT_AFTER_END,
format!("line {}: text follows the file END", line.number),
);
}
self.keep_statement(line, true);
}
fn keep_statement(&mut self, line: &LexedLine<'_>, after_end: bool) {
self.parsed.set.retained.push(RetainedStatement {
line: line.number,
text: line.trimmed().to_owned(),
after_end,
});
}
fn read_skip_line(&mut self, line: &LexedLine<'_>, upper: &[String], words: &[&str]) {
if is_end(line) {
self.skip_opened = None;
return;
}
match parse_skip_rule(upper, words) {
Some(rule) if writable(&rule.circuit) => {
self.parsed.set.skips.push(rule);
return;
}
Some(_) => self.parsed.unwritable(line.number, line.trimmed()),
None => self.parsed.note(
&codes::READ_CON_SOURCE_MALFORMED,
format!(
"line {}: a SKIP line states no branch and was kept as text: {}",
line.number,
line.trimmed()
),
),
}
self.keep_statement(line, false);
}
fn read_case_line(
&mut self,
line: &LexedLine<'_>,
upper: &[String],
words: &[&str],
) -> Result<()> {
if is_end(line) {
if let Some(open) = self.case.take() {
self.parsed.set.cases.push(ContingencyCase {
name: open.name,
actions: open.actions,
});
}
return Ok(());
}
if upper[0] == "CONTINGENCY" {
let name = self.case.as_ref().map_or("", |open| open.name.as_str());
return Err(bad(format!(
"line {}: CONTINGENCY starts before case '{name}' reached END",
line.number
)));
}
if opens_block(upper) {
self.open_block(line);
return Ok(());
}
let action = match parse_action(upper, words) {
Some(action) if action_writable(&action) => action,
recognized => {
if recognized.is_some() {
self.parsed.unwritable(line.number, line.trimmed());
} else {
self.parsed.unrecognized(line.number, line.trimmed());
}
ContingencyAction::Unrecognized {
text: line.trimmed().to_owned(),
}
}
};
if let Some(open) = self.case.as_mut() {
open.actions.push(action);
}
Ok(())
}
fn read_file_line(&mut self, line: &LexedLine<'_>, upper: &[String], words: &[&str]) {
match upper[0].as_str() {
"CONTINGENCY" => self.open_case(line),
"END" if upper.len() == 1 => self.ended = true,
"SKIP" if upper.len() == 1 => self.skip_opened = Some(line.number),
_ if opens_block(upper) => self.open_block(line),
_ => match parse_automatic(upper, words) {
Some(spec) if writable(&spec.subsystem) => self.parsed.set.automatic.push(spec),
recognized => {
if recognized.is_some() {
self.parsed.unwritable(line.number, line.trimmed());
} else {
self.parsed.unrecognized(line.number, line.trimmed());
}
self.keep_statement(line, false);
}
},
}
}
fn open_case(&mut self, line: &LexedLine<'_>) {
let name = case_name_of(line);
if !writable(&name) {
self.parsed.unwritable(line.number, line.trimmed());
self.keep_statement(line, false);
return;
}
if line.tokens.len() > 2 {
let extra = line.words()[2..].join(" ");
self.parsed.note(
&codes::READ_CON_SOURCE_MALFORMED,
format!(
"line {}: CONTINGENCY states more than a case name, and the tokens after it are not kept: {extra}",
line.number
),
);
}
self.case = Some(OpenCase {
name,
opened: line.number,
actions: Vec::new(),
});
}
fn open_block(&mut self, line: &LexedLine<'_>) {
self.parsed.dispatch_block(line.number, line.trimmed());
self.block = Some(OpenBlock {
opened: line.number,
lines: vec![line.trimmed().to_owned()],
});
}
}
fn is_end(line: &LexedLine<'_>) -> bool {
line.kind == LineKind::Statement
&& line.tokens.len() == 1
&& line.tokens[0].text.eq_ignore_ascii_case("END")
}
fn opens_block(upper: &[String]) -> bool {
if upper.last().is_some_and(|word| word == "DISPATCH") {
return true;
}
upper.first().is_some_and(|word| word == "DEFAULT")
&& upper.get(1).is_some_and(|word| word == "DISPATCH")
}
fn case_name_of(line: &LexedLine<'_>) -> String {
line.tokens
.get(1)
.map_or_else(String::new, |token| token.text.trim().to_owned())
}
fn parse_bus(token: &str) -> Option<BusId> {
token.parse::<usize>().ok().map(BusId)
}
fn take_bus(upper: &[String], at: &mut usize) -> Option<BusId> {
take_bus_with(upper, at, &mut |index| parse_bus(upper.get(index)?))
}
fn take_bus_with(
upper: &[String],
at: &mut usize,
bus_at: &mut impl FnMut(usize) -> Option<BusId>,
) -> Option<BusId> {
let bus = if let Some(bus) = bus_at(*at) {
bus
} else {
take_keyword(upper, at, "BUS")?;
bus_at(*at)?
};
*at += 1;
Some(bus)
}
fn take_keyword(upper: &[String], at: &mut usize, keyword: &str) -> Option<()> {
if upper.get(*at)? != keyword {
return None;
}
*at += 1;
Some(())
}
fn take_circuit(upper: &[String], words: &[&str], at: &mut usize) -> Option<String> {
if *at == upper.len() {
return Some("1".to_owned());
}
if !matches!(
upper[*at].as_str(),
"CIRCUIT" | "CKT" | "CIRCUITS" | "CIRCUT"
) {
return None;
}
let value = words.get(*at + 1)?.trim();
*at += 2;
if *at != upper.len() {
return None;
}
Some(if value.is_empty() {
"1".to_owned()
} else {
value.to_owned()
})
}
fn take_id(words: &[&str], at: &mut usize) -> Option<String> {
let id = words.get(*at)?.trim();
*at += 1;
Some(id.to_owned())
}
fn parse_action(upper: &[String], words: &[&str]) -> Option<ContingencyAction> {
parse_action_with_buses(upper, words, &mut |at| parse_bus(upper.get(at)?))
}
fn parse_action_with_buses(
upper: &[String],
words: &[&str],
bus_at: &mut impl FnMut(usize) -> Option<BusId>,
) -> Option<ContingencyAction> {
let verb = upper.first()?.as_str();
let noun = upper.get(1).map_or("", String::as_str);
match (verb, noun) {
("OPEN" | "TRIP" | "DISCONNECT", "LINE" | "BRANCH") => {
parse_open_branch(upper, words, bus_at)
}
("OPEN" | "TRIP" | "DISCONNECT", "THREEWINDING") => {
parse_three_winding(upper, words, bus_at)
}
("DISCONNECT", "BUS") => {
let mut at = 1;
let bus = take_bus_with(upper, &mut at, bus_at)?;
(at == upper.len()).then_some(ContingencyAction::DisconnectBus { bus })
}
("REMOVE" | "TRIP", "MACHINE" | "UNIT") => {
let (bus, id) = parse_id_from_bus(upper, words, bus_at)?;
Some(ContingencyAction::RemoveMachine { bus, id })
}
("ADD", "MACHINE" | "UNIT") => {
let mut at = 2;
let id = take_id(words, &mut at)?;
take_keyword(upper, &mut at, "TO")?;
let bus = take_bus_with(upper, &mut at, bus_at)?;
(at == upper.len()).then_some(ContingencyAction::AddMachine { bus, id })
}
("REMOVE" | "TRIP", "SHUNT") => {
let (bus, id) = parse_optional_id_from_bus(upper, words, bus_at)?;
Some(ContingencyAction::RemoveShunt { bus, id })
}
("REMOVE" | "TRIP", "LOAD") => {
let (bus, id) = parse_optional_id_from_bus(upper, words, bus_at)?;
Some(ContingencyAction::RemoveLoad { bus, id })
}
("REMOVE" | "TRIP", "SWSHUNT") => {
let mut at = 2;
take_keyword(upper, &mut at, "FROM")?;
let bus = take_bus_with(upper, &mut at, bus_at)?;
(at == upper.len()).then_some(ContingencyAction::RemoveSwitchedShunt { bus })
}
("INCREASE" | "RAISE" | "DECREASE" | "SET", _) => parse_change(upper, bus_at),
_ => None,
}
}
fn parse_open_branch(
upper: &[String],
words: &[&str],
bus_at: &mut impl FnMut(usize) -> Option<BusId>,
) -> Option<ContingencyAction> {
let mut at = 2;
take_keyword(upper, &mut at, "FROM")?;
let from = take_bus_with(upper, &mut at, bus_at)?;
take_keyword(upper, &mut at, "TO")?;
let to = take_bus_with(upper, &mut at, bus_at)?;
if upper.get(at).is_some_and(|word| word == "TO") {
at += 1;
let third = take_bus_with(upper, &mut at, bus_at)?;
let circuit = take_circuit(upper, words, &mut at)?;
return Some(ContingencyAction::OpenThreeWinding {
buses: [from, to, third],
circuit,
});
}
let circuit = take_circuit(upper, words, &mut at)?;
Some(ContingencyAction::OpenBranch { from, to, circuit })
}
fn parse_three_winding(
upper: &[String],
words: &[&str],
bus_at: &mut impl FnMut(usize) -> Option<BusId>,
) -> Option<ContingencyAction> {
let mut at = 2;
take_keyword(upper, &mut at, "AT")?;
let first = take_bus_with(upper, &mut at, bus_at)?;
take_keyword(upper, &mut at, "TO")?;
let second = take_bus_with(upper, &mut at, bus_at)?;
take_keyword(upper, &mut at, "TO")?;
let third = take_bus_with(upper, &mut at, bus_at)?;
let circuit = take_circuit(upper, words, &mut at)?;
Some(ContingencyAction::OpenThreeWinding {
buses: [first, second, third],
circuit,
})
}
fn parse_id_from_bus(
upper: &[String],
words: &[&str],
bus_at: &mut impl FnMut(usize) -> Option<BusId>,
) -> Option<(BusId, String)> {
let mut at = 2;
let id = take_id(words, &mut at)?;
take_keyword(upper, &mut at, "FROM")?;
let bus = take_bus_with(upper, &mut at, bus_at)?;
(at == upper.len()).then_some((bus, id))
}
fn parse_optional_id_from_bus(
upper: &[String],
words: &[&str],
bus_at: &mut impl FnMut(usize) -> Option<BusId>,
) -> Option<(BusId, Option<String>)> {
let mut at = 2;
let id = if upper.get(at).is_some_and(|word| word == "FROM") {
None
} else {
Some(take_id(words, &mut at)?)
};
take_keyword(upper, &mut at, "FROM")?;
let bus = take_bus_with(upper, &mut at, bus_at)?;
(at == upper.len()).then_some((bus, id))
}
fn parse_change(
upper: &[String],
bus_at: &mut impl FnMut(usize) -> Option<BusId>,
) -> Option<ContingencyAction> {
let op = match upper[0].as_str() {
"INCREASE" | "RAISE" => ChangeOp::Increase,
"DECREASE" => ChangeOp::Decrease,
"SET" => ChangeOp::Set,
_ => return None,
};
let mut at = 1;
let bus = take_bus_with(upper, &mut at, bus_at)?;
let load = match upper.get(at)?.as_str() {
"LOAD" => true,
"GENERATION" => false,
_ => return None,
};
at += 1;
take_keyword(
upper,
&mut at,
if op == ChangeOp::Set { "TO" } else { "BY" },
)?;
let stated = upper.get(at)?.as_str();
at += 1;
let (amount, unit) = if let Some(head) = stated.strip_suffix('%') {
(head.parse::<f64>().ok()?, ChangeUnit::Percent)
} else {
let amount = stated.parse::<f64>().ok()?;
let unit = match upper.get(at)?.as_str() {
"MW" => ChangeUnit::Mw,
"PERCENT" | "%" => ChangeUnit::Percent,
_ => return None,
};
at += 1;
(amount, unit)
};
if at != upper.len() || !amount.is_finite() {
return None;
}
let change = Change { op, amount, unit };
Some(if load {
ContingencyAction::ChangeLoad { bus, change }
} else {
ContingencyAction::ChangeGeneration { bus, change }
})
}
fn parse_automatic(upper: &[String], words: &[&str]) -> Option<AutomaticSpec> {
let order = match upper.first()?.as_str() {
"SINGLE" => AutomaticOrder::Single,
"DOUBLE" => AutomaticOrder::Double,
_ => return None,
};
let target = match upper.get(1)?.as_str() {
"BRANCH" | "LINE" => AutomaticTarget::Branch,
"UNIT" | "MACHINE" => AutomaticTarget::Unit,
"TIE" => AutomaticTarget::Tie,
_ => return None,
};
let mut at = 2;
if !matches!(upper.get(at)?.as_str(), "IN" | "FROM") {
return None;
}
at += 1;
take_keyword(upper, &mut at, "SUBSYSTEM")?;
let subsystem = words.get(at)?.trim().to_owned();
at += 1;
let low_voltage_3w = upper.get(at).is_some_and(|word| word == "3WLOWVOLTAGE");
if low_voltage_3w {
at += 1;
}
(at == upper.len()).then_some(AutomaticSpec {
order,
target,
subsystem,
low_voltage_3w,
})
}
fn parse_skip_rule(upper: &[String], words: &[&str]) -> Option<SkipRule> {
let mut at = 0;
if upper.first().is_some_and(|word| word == "FROM") {
at = 1;
}
let from = take_bus(upper, &mut at)?;
take_keyword(upper, &mut at, "TO")?;
let to = take_bus(upper, &mut at)?;
let circuit = take_circuit(upper, words, &mut at)?;
Some(SkipRule { from, to, circuit })
}
fn delimiter(value: &str) -> Option<char> {
match (value.contains('\''), value.contains('"')) {
(true, true) => None,
(true, false) => Some('"'),
(false, _) => Some('\''),
}
}
fn writable(value: &str) -> bool {
delimiter(value).is_some()
}
fn quoted(value: &str) -> String {
let quote = delimiter(value).unwrap_or('\'');
format!("{quote}{value}{quote}")
}
pub(crate) fn field(value: &str) -> String {
if value.is_empty()
|| value.contains(char::is_whitespace)
|| value.starts_with('/')
|| value.contains(['\'', '"'])
{
quoted(value)
} else {
value.to_owned()
}
}
fn action_writable(action: &ContingencyAction) -> bool {
match action {
ContingencyAction::OpenBranch { circuit, .. }
| ContingencyAction::OpenThreeWinding { circuit, .. } => writable(circuit),
ContingencyAction::RemoveMachine { id, .. } | ContingencyAction::AddMachine { id, .. } => {
writable(id)
}
ContingencyAction::RemoveShunt { id, .. } | ContingencyAction::RemoveLoad { id, .. } => {
id.as_deref().is_none_or(writable)
}
_ => true,
}
}
fn write_action(action: &ContingencyAction) -> String {
match action {
ContingencyAction::OpenBranch { from, to, circuit } => format!(
"OPEN LINE FROM BUS {:>6} TO BUS {:>6} CIRCUIT {}",
from.0,
to.0,
field(circuit)
),
ContingencyAction::OpenThreeWinding { buses, circuit } => format!(
"OPEN THREEWINDING AT BUS {:>6} TO BUS {:>6} TO BUS {:>6} CIRCUIT {}",
buses[0].0,
buses[1].0,
buses[2].0,
field(circuit)
),
ContingencyAction::RemoveMachine { bus, id } => {
format!("REMOVE MACHINE {} FROM BUS {:>6}", field(id), bus.0)
}
ContingencyAction::AddMachine { bus, id } => {
format!("ADD MACHINE {} TO BUS {:>6}", field(id), bus.0)
}
ContingencyAction::RemoveShunt { bus, id } => match id {
Some(id) => format!("REMOVE SHUNT {} FROM BUS {:>6}", field(id), bus.0),
None => format!("REMOVE SHUNT FROM BUS {:>6}", bus.0),
},
ContingencyAction::RemoveSwitchedShunt { bus } => {
format!("REMOVE SWSHUNT FROM BUS {:>6}", bus.0)
}
ContingencyAction::RemoveLoad { bus, id } => match id {
Some(id) => format!("REMOVE LOAD {} FROM BUS {:>6}", field(id), bus.0),
None => format!("REMOVE LOAD FROM BUS {:>6}", bus.0),
},
ContingencyAction::DisconnectBus { bus } => format!("DISCONNECT BUS {:>6}", bus.0),
ContingencyAction::ChangeLoad { bus, change } => write_change(*bus, "LOAD", change),
ContingencyAction::ChangeGeneration { bus, change } => {
write_change(*bus, "GENERATION", change)
}
ContingencyAction::Unrecognized { text } => text.clone(),
}
}
fn write_change(bus: BusId, target: &str, change: &Change) -> String {
let unit = match change.unit {
ChangeUnit::Mw => "MW",
ChangeUnit::Percent => "PERCENT",
};
let amount = change.amount;
let bus = bus.0;
match change.op {
ChangeOp::Increase => format!("INCREASE BUS {bus} {target} BY {amount} {unit}"),
ChangeOp::Decrease => format!("DECREASE BUS {bus} {target} BY {amount} {unit}"),
ChangeOp::Set => format!("SET BUS {bus} {target} TO {amount} {unit}"),
}
}
fn write_automatic(spec: &AutomaticSpec) -> String {
let order = match spec.order {
AutomaticOrder::Single => "SINGLE",
AutomaticOrder::Double => "DOUBLE",
};
let (target, preposition) = match spec.target {
AutomaticTarget::Branch => ("BRANCH", "IN"),
AutomaticTarget::Unit => ("UNIT", "IN"),
AutomaticTarget::Tie => ("TIE", "FROM"),
};
let tail = if spec.low_voltage_3w {
" 3WLOWVOLTAGE"
} else {
""
};
format!(
"{order} {target} {preposition} SUBSYSTEM {}{tail}",
quoted(&spec.subsystem)
)
}