1use 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
38const KV_TOLERANCE: f64 = 1e-6;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43#[non_exhaustive]
44pub enum DispatchLevel {
45 Default,
47 Up,
49 Down,
51 FirstLevel,
53}
54
55#[derive(Debug, Clone, PartialEq)]
57#[non_exhaustive]
58pub enum DispatchEntry {
59 Subsystem { name: String, share: Option<f64> },
62 Bus { bus: BusId, share: Option<f64> },
64 ParticipatingMachines,
66}
67
68#[derive(Debug, Clone, Default, PartialEq)]
70#[non_exhaustive]
71pub struct DispatchBlock {
72 pub entries: Vec<DispatchEntry>,
73 pub unread: Vec<String>,
75}
76
77#[derive(Debug, Clone, PartialEq)]
79#[non_exhaustive]
80pub struct DefaultDispatch {
81 pub level: DispatchLevel,
82 pub block: DispatchBlock,
83 pub line: usize,
85}
86
87#[derive(Debug, Clone, PartialEq)]
89#[non_exhaustive]
90pub struct DispatchedAction {
91 pub action: ContingencyAction,
93 pub block: DispatchBlock,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum ScaledQuantity {
100 Generation,
102 Load,
104 Import,
106 Export,
108}
109
110#[derive(Debug, Clone, PartialEq)]
112#[non_exhaustive]
113pub enum SubsystemDispatchRule {
114 Participate { include_offline: bool },
116 Scale {
120 quantity: ScaledQuantity,
121 pmax_greater_mw: Option<f64>,
122 include_offline: bool,
123 include_nonconforming: bool,
124 },
125 Baseload { code: i64 },
127 TurbineType { code: i64 },
129 Except { words: Vec<String> },
131}
132
133impl ContingencySet {
134 #[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 #[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 #[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
214fn 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
307fn 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
362fn normalize_name(name: &str) -> String {
368 name.split_whitespace()
369 .map(str::to_ascii_uppercase)
370 .collect::<Vec<_>>()
371 .join(" ")
372}
373
374#[derive(Debug, Clone, Default)]
376pub(super) struct NameIndex {
377 buses: HashMap<String, Vec<(String, f64, BusId)>>,
379 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 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 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
462fn 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
472pub(super) enum TextAction {
475 Action(ContingencyAction),
478 Branch(usize),
480}
481
482pub(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 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 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 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}