1mod 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
71const MAX_READER_NOTES: usize = 16;
74
75fn 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#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97pub struct ContingencySet {
98 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub header: Vec<String>,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub cases: Vec<ContingencyCase>,
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
107 pub automatic: Vec<AutomaticSpec>,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub skips: Vec<SkipRule>,
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub retained: Vec<RetainedStatement>,
114}
115
116#[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#[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 OpenBranch {
134 from: BusId,
135 to: BusId,
136 circuit: String,
137 },
138 OpenThreeWinding { buses: [BusId; 3], circuit: String },
140 RemoveMachine { bus: BusId, id: String },
142 AddMachine { bus: BusId, id: String },
144 RemoveShunt {
147 bus: BusId,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 id: Option<String>,
150 },
151 RemoveSwitchedShunt { bus: BusId },
153 RemoveLoad {
155 bus: BusId,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 id: Option<String>,
158 },
159 DisconnectBus { bus: BusId },
161 ChangeLoad { bus: BusId, change: Change },
163 ChangeGeneration { bus: BusId, change: Change },
165 Unrecognized { text: String },
168}
169
170impl ContingencyAction {
171 #[must_use]
175 pub fn to_con_statement(&self) -> String {
176 write_action(self)
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
182#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
183pub struct Change {
184 pub op: ChangeOp,
186 pub amount: f64,
190 pub unit: ChangeUnit,
192}
193
194#[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#[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#[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 pub subsystem: String,
223 pub low_voltage_3w: bool,
226}
227
228#[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#[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 Tie,
248}
249
250#[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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
261#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
262pub struct RetainedStatement {
263 #[cfg_attr(feature = "schema", schemars(range(min = 1)))]
266 pub line: usize,
267 pub text: String,
269 #[serde(default)]
273 pub after_end: bool,
274}
275
276#[derive(Debug, Clone)]
279#[non_exhaustive]
280pub struct ContingencyParsed {
281 pub set: ContingencySet,
282 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 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 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
330struct OpenCase {
332 name: String,
333 opened: usize,
334 actions: Vec<ContingencyAction>,
335}
336
337struct OpenBlock {
339 opened: usize,
340 lines: Vec<String>,
341}
342
343impl ContingencySet {
344 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 #[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
422struct Reader {
424 parsed: ContingencyParsed,
425 case: Option<OpenCase>,
426 block: Option<OpenBlock>,
427 skip_opened: Option<usize>,
428 seen_statement: bool,
431 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 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 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 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 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 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 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
698fn 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
706fn 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
718fn 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
729fn parse_bus(token: &str) -> Option<BusId> {
734 token.parse::<usize>().ok().map(BusId)
735}
736
737fn take_bus(upper: &[String], at: &mut usize) -> Option<BusId> {
739 take_bus_with(upper, at, &mut |index| parse_bus(upper.get(index)?))
740}
741
742fn 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
768fn 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
792fn 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
803fn 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
855fn 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
880fn 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
900fn 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
913fn 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
930fn 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
980fn 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
1013fn 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
1027fn 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
1043fn writable(value: &str) -> bool {
1047 delimiter(value).is_some()
1048}
1049
1050fn quoted(value: &str) -> String {
1055 let quote = delimiter(value).unwrap_or('\'');
1056 format!("{quote}{value}{quote}")
1057}
1058
1059pub(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
1076fn 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}