1mod expand;
38mod lexer;
39pub mod mon;
40mod resolve;
41pub mod sub;
42
43use std::cmp::Ordering;
44
45pub use expand::Expanded;
46use lexer::{LexedLine, LineKind, lex};
47pub use mon::{
48 BranchRef, InterfaceMember, MonitorScope, MonitorStatement, MonitoredParsed,
49 MonitoredResolution, MonitoredSet, ResolvedInterface, ResolvedVoltageScope, UnresolvedMonitor,
50 UnresolvedMonitorReason,
51};
52pub use resolve::{
53 ContingencyResolution, PsseEquipmentIndex, ResolvedCase, ResolvedComponent, UnresolvedAction,
54 UnresolvedReason,
55};
56pub use sub::{
57 JoinName, SelectorGroup, Subsystem, SubsystemParsed, SubsystemSelector, SubsystemSet,
58};
59
60use crate::diagnostics::{Diagnostic, DiagnosticInfo, codes};
61use crate::network::BusId;
62use crate::{Error, Result};
63
64const FMT: &str = "psse contingency";
65
66const MAX_READER_NOTES: usize = 16;
69
70fn note_within_budget(
75 diagnostics: &mut Vec<Diagnostic>,
76 info: &'static DiagnosticInfo,
77 truncated: &'static DiagnosticInfo,
78 message: String,
79) {
80 match diagnostics.len().cmp(&MAX_READER_NOTES) {
81 Ordering::Less => diagnostics.push(Diagnostic::of(info, message)),
82 Ordering::Equal => {
83 diagnostics.push(Diagnostic::of(truncated, "further reader notes suppressed"));
84 }
85 Ordering::Greater => {}
86 }
87}
88
89#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
91#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
92pub struct ContingencySet {
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub header: Vec<String>,
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
99 pub cases: Vec<ContingencyCase>,
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub automatic: Vec<AutomaticSpec>,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub skips: Vec<SkipRule>,
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 pub retained: Vec<RetainedStatement>,
109}
110
111#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114pub struct ContingencyCase {
115 pub name: String,
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
117 pub actions: Vec<ContingencyAction>,
118}
119
120#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123#[serde(tag = "kind", rename_all = "snake_case")]
124#[non_exhaustive]
125pub enum ContingencyAction {
126 OpenBranch {
129 from: BusId,
130 to: BusId,
131 circuit: String,
132 },
133 OpenThreeWinding { buses: [BusId; 3], circuit: String },
135 RemoveMachine { bus: BusId, id: String },
137 AddMachine { bus: BusId, id: String },
139 RemoveShunt {
142 bus: BusId,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 id: Option<String>,
145 },
146 RemoveSwitchedShunt { bus: BusId },
148 RemoveLoad {
150 bus: BusId,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 id: Option<String>,
153 },
154 DisconnectBus { bus: BusId },
156 ChangeLoad { bus: BusId, change: Change },
158 ChangeGeneration { bus: BusId, change: Change },
160 Unrecognized { text: String },
163}
164
165impl ContingencyAction {
166 #[must_use]
170 pub fn to_con_statement(&self) -> String {
171 write_action(self)
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
177#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
178pub struct Change {
179 pub op: ChangeOp,
181 pub amount: f64,
185 pub unit: ChangeUnit,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
191#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
192#[serde(rename_all = "snake_case")]
193#[non_exhaustive]
194pub enum ChangeOp {
195 Increase,
196 Decrease,
197 Set,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
202#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
203#[serde(rename_all = "snake_case")]
204#[non_exhaustive]
205pub enum ChangeUnit {
206 Mw,
207 Percent,
208}
209
210#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
212#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
213pub struct AutomaticSpec {
214 pub order: AutomaticOrder,
215 pub target: AutomaticTarget,
216 pub subsystem: String,
218 pub low_voltage_3w: bool,
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
225#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
226#[serde(rename_all = "snake_case")]
227#[non_exhaustive]
228pub enum AutomaticOrder {
229 Single,
230 Double,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
235#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
236#[serde(rename_all = "snake_case")]
237#[non_exhaustive]
238pub enum AutomaticTarget {
239 Branch,
240 Unit,
241 Tie,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
248pub struct SkipRule {
249 pub from: BusId,
250 pub to: BusId,
251 pub circuit: String,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
256#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
257pub struct RetainedStatement {
258 #[cfg_attr(feature = "schema", schemars(range(min = 1)))]
261 pub line: usize,
262 pub text: String,
264 #[serde(default)]
268 pub after_end: bool,
269}
270
271#[derive(Debug, Clone)]
274#[non_exhaustive]
275pub struct ContingencyParsed {
276 pub set: ContingencySet,
277 pub diagnostics: Vec<Diagnostic>,
279}
280
281impl ContingencyParsed {
282 fn note(&mut self, info: &'static DiagnosticInfo, message: String) {
283 note_within_budget(
284 &mut self.diagnostics,
285 info,
286 &codes::READ_CON_NOTES_TRUNCATED,
287 message,
288 );
289 }
290
291 fn unrecognized(&mut self, number: usize, text: &str) {
292 self.note(
293 &codes::READ_CON_STATEMENT_UNRECOGNIZED,
294 format!("line {number}: statement kept as text: {text}"),
295 );
296 }
297
298 fn unwritable(&mut self, number: usize, text: &str) {
301 self.note(
302 &codes::READ_CON_SOURCE_MALFORMED,
303 format!(
304 "line {number}: a value holding both quote characters has no written form, and the statement was kept as text: {text}"
305 ),
306 );
307 }
308
309 fn dispatch_block(&mut self, number: usize, text: &str) {
311 self.note(
312 &codes::READ_CON_STATEMENT_UNRECOGNIZED,
313 format!("line {number}: dispatch block kept as text: {text}"),
314 );
315 }
316}
317
318fn bad(message: String) -> Error {
319 Error::FormatRead {
320 format: FMT,
321 message,
322 }
323}
324
325struct OpenCase {
327 name: String,
328 opened: usize,
329 actions: Vec<ContingencyAction>,
330}
331
332struct OpenBlock {
334 opened: usize,
335 lines: Vec<String>,
336}
337
338impl ContingencySet {
339 pub fn parse(text: &str) -> Result<ContingencyParsed> {
355 let mut reader = Reader::new();
356 for line in lex(text) {
357 reader.read_line(&line)?;
358 }
359 reader.finish()
360 }
361
362 #[must_use]
372 pub fn to_con(&self) -> String {
373 use std::fmt::Write as _;
374
375 let mut out = String::new();
376 for line in &self.header {
377 out.push_str(line);
378 out.push('\n');
379 }
380 for spec in &self.automatic {
381 out.push_str(&write_automatic(spec));
382 out.push('\n');
383 }
384 if !self.skips.is_empty() {
385 out.push_str("SKIP\n");
386 for rule in &self.skips {
387 let circuit = field(&rule.circuit);
388 let _ = writeln!(
389 out,
390 "{:>6} TO {:>6} CIRCUIT {circuit}",
391 rule.from.0, rule.to.0
392 );
393 }
394 out.push_str("END\n");
395 }
396 for case in &self.cases {
397 let _ = writeln!(out, "CONTINGENCY {}", quoted(&case.name));
398 for action in &case.actions {
399 out.push_str(&write_action(action));
400 out.push('\n');
401 }
402 out.push_str("END\n");
403 }
404 for statement in self.retained.iter().filter(|kept| !kept.after_end) {
405 out.push_str(&statement.text);
406 out.push('\n');
407 }
408 out.push_str("END\n");
409 for statement in self.retained.iter().filter(|kept| kept.after_end) {
410 out.push_str(&statement.text);
411 out.push('\n');
412 }
413 out
414 }
415}
416
417struct Reader {
419 parsed: ContingencyParsed,
420 case: Option<OpenCase>,
421 block: Option<OpenBlock>,
422 skip_opened: Option<usize>,
423 seen_statement: bool,
426 ended: bool,
428 noted_text_after_end: bool,
429}
430
431impl Reader {
432 fn new() -> Self {
433 Reader {
434 parsed: ContingencyParsed {
435 set: ContingencySet::default(),
436 diagnostics: Vec::new(),
437 },
438 case: None,
439 block: None,
440 skip_opened: None,
441 seen_statement: false,
442 ended: false,
443 noted_text_after_end: false,
444 }
445 }
446
447 fn read_line(&mut self, line: &LexedLine<'_>) -> Result<()> {
448 if self.block.is_some() {
449 self.read_block_line(line);
450 return Ok(());
451 }
452 if self.ended {
453 self.keep_after_end(line);
454 return Ok(());
455 }
456 if !self.take_header(line) {
457 return Ok(());
458 }
459 let upper = line.keywords();
460 let words = line.words();
461 if self.skip_opened.is_some() {
462 self.read_skip_line(line, &upper, &words);
463 return Ok(());
464 }
465 if self.case.is_some() {
466 return self.read_case_line(line, &upper, &words);
467 }
468 self.read_file_line(line, &upper, &words);
469 Ok(())
470 }
471
472 fn finish(self) -> Result<ContingencyParsed> {
474 if let Some(open) = self.block {
475 return Err(bad(format!(
476 "line {}: a dispatch block has no END",
477 open.opened
478 )));
479 }
480 if let Some(open) = self.case {
481 return Err(bad(format!(
482 "line {}: CONTINGENCY '{}' has no END",
483 open.opened, open.name
484 )));
485 }
486 if let Some(opened) = self.skip_opened {
487 return Err(bad(format!("line {opened}: SKIP has no END")));
488 }
489 Ok(self.parsed)
490 }
491
492 fn take_header(&mut self, line: &LexedLine<'_>) -> bool {
495 if self.seen_statement {
496 return line.kind == LineKind::Statement;
497 }
498 match line.kind {
499 LineKind::Blank => false,
500 LineKind::Comment => {
501 self.parsed.set.header.push(line.text.to_owned());
502 false
503 }
504 LineKind::Statement => {
505 self.seen_statement = true;
506 true
507 }
508 }
509 }
510
511 fn read_block_line(&mut self, line: &LexedLine<'_>) {
515 let closed = is_end(line);
516 let text = line.trimmed().to_owned();
517 if let Some(open) = self.block.as_mut() {
518 open.lines.push(text);
519 }
520 if !closed {
521 return;
522 }
523 let Some(open) = self.block.take() else {
524 return;
525 };
526 let text = open.lines.join("\n");
527 match self.case.as_mut() {
528 Some(case) => case.actions.push(ContingencyAction::Unrecognized { text }),
529 None => self.parsed.set.retained.push(RetainedStatement {
530 line: open.opened,
531 text,
532 after_end: false,
533 }),
534 }
535 }
536
537 fn keep_after_end(&mut self, line: &LexedLine<'_>) {
541 if line.kind != LineKind::Statement {
542 return;
543 }
544 if !self.noted_text_after_end {
545 self.noted_text_after_end = true;
546 self.parsed.note(
547 &codes::READ_CON_TEXT_AFTER_END,
548 format!("line {}: text follows the file END", line.number),
549 );
550 }
551 self.keep_statement(line, true);
552 }
553
554 fn keep_statement(&mut self, line: &LexedLine<'_>, after_end: bool) {
558 self.parsed.set.retained.push(RetainedStatement {
559 line: line.number,
560 text: line.trimmed().to_owned(),
561 after_end,
562 });
563 }
564
565 fn read_skip_line(&mut self, line: &LexedLine<'_>, upper: &[String], words: &[&str]) {
566 if is_end(line) {
567 self.skip_opened = None;
568 return;
569 }
570 match parse_skip_rule(upper, words) {
571 Some(rule) if writable(&rule.circuit) => {
572 self.parsed.set.skips.push(rule);
573 return;
574 }
575 Some(_) => self.parsed.unwritable(line.number, line.trimmed()),
576 None => self.parsed.note(
577 &codes::READ_CON_SOURCE_MALFORMED,
578 format!(
579 "line {}: a SKIP line states no branch and was kept as text: {}",
580 line.number,
581 line.trimmed()
582 ),
583 ),
584 }
585 self.keep_statement(line, false);
586 }
587
588 fn read_case_line(
589 &mut self,
590 line: &LexedLine<'_>,
591 upper: &[String],
592 words: &[&str],
593 ) -> Result<()> {
594 if is_end(line) {
595 if let Some(open) = self.case.take() {
596 self.parsed.set.cases.push(ContingencyCase {
597 name: open.name,
598 actions: open.actions,
599 });
600 }
601 return Ok(());
602 }
603 if upper[0] == "CONTINGENCY" {
604 let name = self.case.as_ref().map_or("", |open| open.name.as_str());
605 return Err(bad(format!(
606 "line {}: CONTINGENCY starts before case '{name}' reached END",
607 line.number
608 )));
609 }
610 if opens_block(upper) {
611 self.open_block(line);
612 return Ok(());
613 }
614 let action = match parse_action(upper, words) {
615 Some(action) if action_writable(&action) => action,
616 recognized => {
617 if recognized.is_some() {
618 self.parsed.unwritable(line.number, line.trimmed());
619 } else {
620 self.parsed.unrecognized(line.number, line.trimmed());
621 }
622 ContingencyAction::Unrecognized {
623 text: line.trimmed().to_owned(),
624 }
625 }
626 };
627 if let Some(open) = self.case.as_mut() {
628 open.actions.push(action);
629 }
630 Ok(())
631 }
632
633 fn read_file_line(&mut self, line: &LexedLine<'_>, upper: &[String], words: &[&str]) {
634 match upper[0].as_str() {
635 "CONTINGENCY" => self.open_case(line),
636 "END" if upper.len() == 1 => self.ended = true,
637 "SKIP" if upper.len() == 1 => self.skip_opened = Some(line.number),
638 _ if opens_block(upper) => self.open_block(line),
639 _ => match parse_automatic(upper, words) {
640 Some(spec) if writable(&spec.subsystem) => self.parsed.set.automatic.push(spec),
641 recognized => {
642 if recognized.is_some() {
643 self.parsed.unwritable(line.number, line.trimmed());
644 } else {
645 self.parsed.unrecognized(line.number, line.trimmed());
646 }
647 self.keep_statement(line, false);
648 }
649 },
650 }
651 }
652
653 fn open_case(&mut self, line: &LexedLine<'_>) {
661 let name = case_name_of(line);
662 if !writable(&name) {
663 self.parsed.unwritable(line.number, line.trimmed());
664 self.keep_statement(line, false);
665 return;
666 }
667 if line.tokens.len() > 2 {
668 let extra = line.words()[2..].join(" ");
669 self.parsed.note(
670 &codes::READ_CON_SOURCE_MALFORMED,
671 format!(
672 "line {}: CONTINGENCY states more than a case name, and the tokens after it are not kept: {extra}",
673 line.number
674 ),
675 );
676 }
677 self.case = Some(OpenCase {
678 name,
679 opened: line.number,
680 actions: Vec::new(),
681 });
682 }
683
684 fn open_block(&mut self, line: &LexedLine<'_>) {
685 self.parsed.dispatch_block(line.number, line.trimmed());
686 self.block = Some(OpenBlock {
687 opened: line.number,
688 lines: vec![line.trimmed().to_owned()],
689 });
690 }
691}
692
693fn is_end(line: &LexedLine<'_>) -> bool {
696 line.kind == LineKind::Statement
697 && line.tokens.len() == 1
698 && line.tokens[0].text.eq_ignore_ascii_case("END")
699}
700
701fn opens_block(upper: &[String]) -> bool {
706 if upper.last().is_some_and(|word| word == "DISPATCH") {
707 return true;
708 }
709 upper.first().is_some_and(|word| word == "DEFAULT")
710 && upper.get(1).is_some_and(|word| word == "DISPATCH")
711}
712
713fn case_name_of(line: &LexedLine<'_>) -> String {
719 line.tokens
720 .get(1)
721 .map_or_else(String::new, |token| token.text.trim().to_owned())
722}
723
724fn parse_bus(token: &str) -> Option<BusId> {
729 token.parse::<usize>().ok().map(BusId)
730}
731
732fn take_bus(upper: &[String], at: &mut usize) -> Option<BusId> {
734 if upper.get(*at).is_some_and(|word| word == "BUS") {
735 *at += 1;
736 }
737 let bus = parse_bus(upper.get(*at)?)?;
738 *at += 1;
739 Some(bus)
740}
741
742fn take_keyword(upper: &[String], at: &mut usize, keyword: &str) -> Option<()> {
743 if upper.get(*at)? != keyword {
744 return None;
745 }
746 *at += 1;
747 Some(())
748}
749
750fn take_circuit(upper: &[String], words: &[&str], at: &mut usize) -> Option<String> {
753 if *at == upper.len() {
754 return Some("1".to_owned());
755 }
756 if !matches!(
757 upper[*at].as_str(),
758 "CIRCUIT" | "CKT" | "CIRCUITS" | "CIRCUT"
759 ) {
760 return None;
761 }
762 let value = words.get(*at + 1)?.trim();
763 *at += 2;
764 if *at != upper.len() {
765 return None;
766 }
767 Some(if value.is_empty() {
768 "1".to_owned()
769 } else {
770 value.to_owned()
771 })
772}
773
774fn take_id(words: &[&str], at: &mut usize) -> Option<String> {
776 let id = words.get(*at)?.trim();
777 *at += 1;
778 Some(id.to_owned())
779}
780
781fn parse_action(upper: &[String], words: &[&str]) -> Option<ContingencyAction> {
782 let verb = upper.first()?.as_str();
783 let noun = upper.get(1).map_or("", String::as_str);
784 match (verb, noun) {
785 ("OPEN" | "TRIP" | "DISCONNECT", "LINE" | "BRANCH") => parse_open_branch(upper, words),
786 ("OPEN" | "TRIP" | "DISCONNECT", "THREEWINDING") => parse_three_winding(upper, words),
787 ("DISCONNECT", "BUS") => {
788 let mut at = 1;
789 let bus = take_bus(upper, &mut at)?;
790 (at == upper.len()).then_some(ContingencyAction::DisconnectBus { bus })
791 }
792 ("REMOVE" | "TRIP", "MACHINE" | "UNIT") => {
793 let (bus, id) = parse_id_from_bus(upper, words)?;
794 Some(ContingencyAction::RemoveMachine { bus, id })
795 }
796 ("ADD", "MACHINE" | "UNIT") => {
797 let mut at = 2;
798 let id = take_id(words, &mut at)?;
799 take_keyword(upper, &mut at, "TO")?;
800 let bus = take_bus(upper, &mut at)?;
801 (at == upper.len()).then_some(ContingencyAction::AddMachine { bus, id })
802 }
803 ("REMOVE" | "TRIP", "SHUNT") => {
804 let (bus, id) = parse_optional_id_from_bus(upper, words)?;
805 Some(ContingencyAction::RemoveShunt { bus, id })
806 }
807 ("REMOVE" | "TRIP", "LOAD") => {
808 let (bus, id) = parse_optional_id_from_bus(upper, words)?;
809 Some(ContingencyAction::RemoveLoad { bus, id })
810 }
811 ("REMOVE" | "TRIP", "SWSHUNT") => {
812 let mut at = 2;
813 take_keyword(upper, &mut at, "FROM")?;
814 let bus = take_bus(upper, &mut at)?;
815 (at == upper.len()).then_some(ContingencyAction::RemoveSwitchedShunt { bus })
816 }
817 ("INCREASE" | "RAISE" | "DECREASE" | "SET", _) => parse_change(upper),
818 _ => None,
819 }
820}
821
822fn parse_open_branch(upper: &[String], words: &[&str]) -> Option<ContingencyAction> {
825 let mut at = 2;
826 take_keyword(upper, &mut at, "FROM")?;
827 let from = take_bus(upper, &mut at)?;
828 take_keyword(upper, &mut at, "TO")?;
829 let to = take_bus(upper, &mut at)?;
830 if upper.get(at).is_some_and(|word| word == "TO") {
831 at += 1;
832 let third = take_bus(upper, &mut at)?;
833 let circuit = take_circuit(upper, words, &mut at)?;
834 return Some(ContingencyAction::OpenThreeWinding {
835 buses: [from, to, third],
836 circuit,
837 });
838 }
839 let circuit = take_circuit(upper, words, &mut at)?;
840 Some(ContingencyAction::OpenBranch { from, to, circuit })
841}
842
843fn parse_three_winding(upper: &[String], words: &[&str]) -> Option<ContingencyAction> {
845 let mut at = 2;
846 take_keyword(upper, &mut at, "AT")?;
847 let first = take_bus(upper, &mut at)?;
848 take_keyword(upper, &mut at, "TO")?;
849 let second = take_bus(upper, &mut at)?;
850 take_keyword(upper, &mut at, "TO")?;
851 let third = take_bus(upper, &mut at)?;
852 let circuit = take_circuit(upper, words, &mut at)?;
853 Some(ContingencyAction::OpenThreeWinding {
854 buses: [first, second, third],
855 circuit,
856 })
857}
858
859fn parse_id_from_bus(upper: &[String], words: &[&str]) -> Option<(BusId, String)> {
861 let mut at = 2;
862 let id = take_id(words, &mut at)?;
863 take_keyword(upper, &mut at, "FROM")?;
864 let bus = take_bus(upper, &mut at)?;
865 (at == upper.len()).then_some((bus, id))
866}
867
868fn parse_optional_id_from_bus(upper: &[String], words: &[&str]) -> Option<(BusId, Option<String>)> {
870 let mut at = 2;
871 let id = if upper.get(at).is_some_and(|word| word == "FROM") {
872 None
873 } else {
874 Some(take_id(words, &mut at)?)
875 };
876 take_keyword(upper, &mut at, "FROM")?;
877 let bus = take_bus(upper, &mut at)?;
878 (at == upper.len()).then_some((bus, id))
879}
880
881fn parse_change(upper: &[String]) -> Option<ContingencyAction> {
884 let op = match upper[0].as_str() {
885 "INCREASE" | "RAISE" => ChangeOp::Increase,
886 "DECREASE" => ChangeOp::Decrease,
887 "SET" => ChangeOp::Set,
888 _ => return None,
889 };
890 let mut at = 1;
891 let bus = take_bus(upper, &mut at)?;
892 let load = match upper.get(at)?.as_str() {
893 "LOAD" => true,
894 "GENERATION" => false,
895 _ => return None,
896 };
897 at += 1;
898 take_keyword(
899 upper,
900 &mut at,
901 if op == ChangeOp::Set { "TO" } else { "BY" },
902 )?;
903 let stated = upper.get(at)?.as_str();
904 at += 1;
905 let (amount, unit) = if let Some(head) = stated.strip_suffix('%') {
906 (head.parse::<f64>().ok()?, ChangeUnit::Percent)
907 } else {
908 let amount = stated.parse::<f64>().ok()?;
909 let unit = match upper.get(at)?.as_str() {
910 "MW" => ChangeUnit::Mw,
911 "PERCENT" | "%" => ChangeUnit::Percent,
912 _ => return None,
913 };
914 at += 1;
915 (amount, unit)
916 };
917 if at != upper.len() || !amount.is_finite() {
918 return None;
919 }
920 let change = Change { op, amount, unit };
921 Some(if load {
922 ContingencyAction::ChangeLoad { bus, change }
923 } else {
924 ContingencyAction::ChangeGeneration { bus, change }
925 })
926}
927
928fn parse_automatic(upper: &[String], words: &[&str]) -> Option<AutomaticSpec> {
930 let order = match upper.first()?.as_str() {
931 "SINGLE" => AutomaticOrder::Single,
932 "DOUBLE" => AutomaticOrder::Double,
933 _ => return None,
934 };
935 let target = match upper.get(1)?.as_str() {
936 "BRANCH" | "LINE" => AutomaticTarget::Branch,
937 "UNIT" | "MACHINE" => AutomaticTarget::Unit,
938 "TIE" => AutomaticTarget::Tie,
939 _ => return None,
940 };
941 let mut at = 2;
942 if !matches!(upper.get(at)?.as_str(), "IN" | "FROM") {
943 return None;
944 }
945 at += 1;
946 take_keyword(upper, &mut at, "SUBSYSTEM")?;
947 let subsystem = words.get(at)?.trim().to_owned();
948 at += 1;
949 let low_voltage_3w = upper.get(at).is_some_and(|word| word == "3WLOWVOLTAGE");
950 if low_voltage_3w {
951 at += 1;
952 }
953 (at == upper.len()).then_some(AutomaticSpec {
954 order,
955 target,
956 subsystem,
957 low_voltage_3w,
958 })
959}
960
961fn parse_skip_rule(upper: &[String], words: &[&str]) -> Option<SkipRule> {
964 let mut at = 0;
965 if upper.first().is_some_and(|word| word == "FROM") {
966 at = 1;
967 }
968 let from = take_bus(upper, &mut at)?;
969 take_keyword(upper, &mut at, "TO")?;
970 let to = take_bus(upper, &mut at)?;
971 let circuit = take_circuit(upper, words, &mut at)?;
972 Some(SkipRule { from, to, circuit })
973}
974
975fn delimiter(value: &str) -> Option<char> {
984 match (value.contains('\''), value.contains('"')) {
985 (true, true) => None,
986 (true, false) => Some('"'),
987 (false, _) => Some('\''),
988 }
989}
990
991fn writable(value: &str) -> bool {
995 delimiter(value).is_some()
996}
997
998fn quoted(value: &str) -> String {
1003 let quote = delimiter(value).unwrap_or('\'');
1004 format!("{quote}{value}{quote}")
1005}
1006
1007pub(crate) fn field(value: &str) -> String {
1013 if value.is_empty()
1014 || value.contains(char::is_whitespace)
1015 || value.starts_with('/')
1016 || value.contains(['\'', '"'])
1017 {
1018 quoted(value)
1019 } else {
1020 value.to_owned()
1021 }
1022}
1023
1024fn action_writable(action: &ContingencyAction) -> bool {
1026 match action {
1027 ContingencyAction::OpenBranch { circuit, .. }
1028 | ContingencyAction::OpenThreeWinding { circuit, .. } => writable(circuit),
1029 ContingencyAction::RemoveMachine { id, .. } | ContingencyAction::AddMachine { id, .. } => {
1030 writable(id)
1031 }
1032 ContingencyAction::RemoveShunt { id, .. } | ContingencyAction::RemoveLoad { id, .. } => {
1033 id.as_deref().is_none_or(writable)
1034 }
1035 _ => true,
1036 }
1037}
1038
1039fn write_action(action: &ContingencyAction) -> String {
1040 match action {
1041 ContingencyAction::OpenBranch { from, to, circuit } => format!(
1042 "OPEN LINE FROM BUS {:>6} TO BUS {:>6} CIRCUIT {}",
1043 from.0,
1044 to.0,
1045 field(circuit)
1046 ),
1047 ContingencyAction::OpenThreeWinding { buses, circuit } => format!(
1048 "OPEN THREEWINDING AT BUS {:>6} TO BUS {:>6} TO BUS {:>6} CIRCUIT {}",
1049 buses[0].0,
1050 buses[1].0,
1051 buses[2].0,
1052 field(circuit)
1053 ),
1054 ContingencyAction::RemoveMachine { bus, id } => {
1055 format!("REMOVE MACHINE {} FROM BUS {:>6}", field(id), bus.0)
1056 }
1057 ContingencyAction::AddMachine { bus, id } => {
1058 format!("ADD MACHINE {} TO BUS {:>6}", field(id), bus.0)
1059 }
1060 ContingencyAction::RemoveShunt { bus, id } => match id {
1061 Some(id) => format!("REMOVE SHUNT {} FROM BUS {:>6}", field(id), bus.0),
1062 None => format!("REMOVE SHUNT FROM BUS {:>6}", bus.0),
1063 },
1064 ContingencyAction::RemoveSwitchedShunt { bus } => {
1065 format!("REMOVE SWSHUNT FROM BUS {:>6}", bus.0)
1066 }
1067 ContingencyAction::RemoveLoad { bus, id } => match id {
1068 Some(id) => format!("REMOVE LOAD {} FROM BUS {:>6}", field(id), bus.0),
1069 None => format!("REMOVE LOAD FROM BUS {:>6}", bus.0),
1070 },
1071 ContingencyAction::DisconnectBus { bus } => format!("DISCONNECT BUS {:>6}", bus.0),
1072 ContingencyAction::ChangeLoad { bus, change } => write_change(*bus, "LOAD", change),
1073 ContingencyAction::ChangeGeneration { bus, change } => {
1074 write_change(*bus, "GENERATION", change)
1075 }
1076 ContingencyAction::Unrecognized { text } => text.clone(),
1077 }
1078}
1079
1080fn write_change(bus: BusId, target: &str, change: &Change) -> String {
1081 let unit = match change.unit {
1082 ChangeUnit::Mw => "MW",
1083 ChangeUnit::Percent => "PERCENT",
1084 };
1085 let amount = change.amount;
1086 let bus = bus.0;
1087 match change.op {
1088 ChangeOp::Increase => format!("INCREASE BUS {bus} {target} BY {amount} {unit}"),
1089 ChangeOp::Decrease => format!("DECREASE BUS {bus} {target} BY {amount} {unit}"),
1090 ChangeOp::Set => format!("SET BUS {bus} {target} TO {amount} {unit}"),
1091 }
1092}
1093
1094fn write_automatic(spec: &AutomaticSpec) -> String {
1095 let order = match spec.order {
1096 AutomaticOrder::Single => "SINGLE",
1097 AutomaticOrder::Double => "DOUBLE",
1098 };
1099 let (target, preposition) = match spec.target {
1100 AutomaticTarget::Branch => ("BRANCH", "IN"),
1101 AutomaticTarget::Unit => ("UNIT", "IN"),
1102 AutomaticTarget::Tie => ("TIE", "FROM"),
1103 };
1104 let tail = if spec.low_voltage_3w {
1105 " 3WLOWVOLTAGE"
1106 } else {
1107 ""
1108 };
1109 format!(
1110 "{order} {target} {preposition} SUBSYSTEM {}{tail}",
1111 quoted(&spec.subsystem)
1112 )
1113}