1use std::collections::BTreeMap;
8use std::sync::Arc;
9
10use powerio_core::Error;
11use powerio_dist::MulticonductorNetwork;
12
13use crate::diagnostics::codes;
14use crate::instance::{LinDist3FlowNode, LinDist3FlowOpfInstance, LinDist3FlowPfInstance};
15use crate::solution::{Producer, Residuals, Termination};
16
17#[derive(Clone, Debug, Default, PartialEq)]
25#[non_exhaustive]
26pub struct LinDist3FlowOpfValues {
27 pub terminal_voltage_magnitude_squared: Vec<f64>,
28 pub line_active_power: Vec<f64>,
29 pub line_reactive_power: Vec<f64>,
30 pub generator_active_power: Vec<f64>,
31 pub generator_reactive_power: Vec<f64>,
32 pub source_active_power: Vec<f64>,
33 pub source_reactive_power: Vec<f64>,
34}
35
36fn shape(what: &str, actual: usize, expected: usize) -> Result<(), Error> {
37 if actual == expected {
38 Ok(())
39 } else {
40 Err(Error::new(
41 &codes::BUILD_SOLUTION_SHAPE_MISMATCH,
42 format!("{what} carries {actual} values; expected {expected}"),
43 ))
44 }
45}
46
47fn duplicate(family: &str, identity: &str) -> Error {
48 Error::new(
49 &codes::BUILD_OPERATING_POINT_IDENTITY_UNKNOWN,
50 format!("duplicate {family} identity `{identity}`"),
51 )
52}
53
54#[derive(Clone, Debug)]
55struct SolutionIndex {
56 nodes: BTreeMap<(String, String), usize>,
57 line_conductors: BTreeMap<(String, usize), usize>,
58 generators: BTreeMap<String, (usize, usize)>,
59 sources: BTreeMap<String, (usize, usize)>,
60}
61
62impl SolutionIndex {
63 fn new(instance: &LinDist3FlowOpfInstance) -> Result<Self, Error> {
64 let mut nodes = BTreeMap::new();
65 for (position, node) in instance.topology().nodes.iter().enumerate() {
66 let key = (node.bus.to_ascii_lowercase(), node.terminal.clone());
67 if nodes.insert(key, position).is_some() {
68 return Err(duplicate(
69 "terminal",
70 &format!("{}/{}", node.bus, node.terminal),
71 ));
72 }
73 }
74
75 let mut line_conductors = BTreeMap::new();
76 for (position, conductor) in instance.topology().conductors.iter().enumerate() {
77 let key = (conductor.line.clone(), conductor.conductor_position);
78 if line_conductors.insert(key, position).is_some() {
79 return Err(duplicate("line conductor", &conductor.line));
80 }
81 }
82
83 let mut generators = BTreeMap::new();
84 let mut offset = 0;
85 for generator in instance.network().generators() {
86 let channels = generator.p_nom.len();
87 if generators
88 .insert(generator.name.clone(), (offset, channels))
89 .is_some()
90 {
91 return Err(duplicate("generator", &generator.name));
92 }
93 offset += channels;
94 }
95
96 let mut sources = BTreeMap::new();
97 let mut offset = 0;
98 for source in instance.network().sources() {
99 let channels = source.terminal_map.len();
100 if sources
101 .insert(source.name.clone(), (offset, channels))
102 .is_some()
103 {
104 return Err(duplicate("voltage source", &source.name));
105 }
106 offset += channels;
107 }
108 Ok(Self {
109 nodes,
110 line_conductors,
111 generators,
112 sources,
113 })
114 }
115}
116
117fn validate_values(
118 instance: &LinDist3FlowOpfInstance,
119 values: &LinDist3FlowOpfValues,
120) -> Result<SolutionIndex, Error> {
121 let network = instance.network();
122 let nodes = instance.topology().nodes.len();
123 let conductors = instance.topology().conductors.len();
124 let generators = network
125 .generators()
126 .iter()
127 .map(|generator| generator.p_nom.len())
128 .sum();
129 let sources = network
130 .sources()
131 .iter()
132 .map(|source| source.terminal_map.len())
133 .sum();
134 shape(
135 "terminal squared voltage",
136 values.terminal_voltage_magnitude_squared.len(),
137 nodes,
138 )?;
139 shape(
140 "line active power",
141 values.line_active_power.len(),
142 conductors,
143 )?;
144 shape(
145 "line reactive power",
146 values.line_reactive_power.len(),
147 conductors,
148 )?;
149 shape(
150 "generator active power",
151 values.generator_active_power.len(),
152 generators,
153 )?;
154 shape(
155 "generator reactive power",
156 values.generator_reactive_power.len(),
157 generators,
158 )?;
159 shape(
160 "source active power",
161 values.source_active_power.len(),
162 sources,
163 )?;
164 shape(
165 "source reactive power",
166 values.source_reactive_power.len(),
167 sources,
168 )?;
169 SolutionIndex::new(instance)
170}
171
172#[derive(Clone, Debug)]
174pub struct LinDist3FlowOpfSolution {
175 instance: Arc<LinDist3FlowOpfInstance>,
176 termination: Termination,
177 residuals: Residuals,
178 producer: Producer,
179 values: LinDist3FlowOpfValues,
180 objective: f64,
181 index: SolutionIndex,
182}
183
184impl LinDist3FlowOpfSolution {
185 pub const FORMULATION: &'static str = "lindist3flow";
186
187 pub fn new(
192 instance: Arc<LinDist3FlowOpfInstance>,
193 termination: Termination,
194 values: LinDist3FlowOpfValues,
195 objective: f64,
196 ) -> Result<Self, Error> {
197 let index = validate_values(&instance, &values)?;
198 Ok(Self {
199 instance,
200 termination,
201 residuals: Residuals::default(),
202 producer: None,
203 values,
204 objective,
205 index,
206 })
207 }
208
209 #[must_use]
210 pub const fn formulation(&self) -> &'static str {
211 Self::FORMULATION
212 }
213
214 #[must_use]
215 pub fn instance(&self) -> &LinDist3FlowOpfInstance {
216 &self.instance
217 }
218
219 #[must_use]
220 pub fn shared_instance(&self) -> Arc<LinDist3FlowOpfInstance> {
221 Arc::clone(&self.instance)
222 }
223
224 #[must_use]
225 pub fn network(&self) -> &MulticonductorNetwork {
226 self.instance.network()
227 }
228
229 #[must_use]
230 pub const fn termination(&self) -> &Termination {
231 &self.termination
232 }
233
234 #[must_use]
235 pub const fn residuals(&self) -> &Residuals {
236 &self.residuals
237 }
238
239 #[must_use]
240 pub fn producer(&self) -> Option<&str> {
241 self.producer.as_deref()
242 }
243
244 #[must_use]
245 pub const fn values(&self) -> &LinDist3FlowOpfValues {
246 &self.values
247 }
248
249 #[must_use]
250 pub const fn objective(&self) -> f64 {
251 self.objective
252 }
253
254 #[must_use]
255 pub fn with_producer(mut self, producer: impl Into<String>) -> Self {
256 self.producer = Some(producer.into());
257 self
258 }
259
260 #[must_use]
261 pub const fn with_residuals(mut self, residuals: Residuals) -> Self {
262 self.residuals = residuals;
263 self
264 }
265
266 pub fn node_order(&self) -> impl ExactSizeIterator<Item = &LinDist3FlowNode> {
267 self.instance.topology().nodes.iter()
268 }
269
270 #[must_use]
271 pub fn terminal_voltage_magnitude_squared(&self, bus: &str, terminal: &str) -> Option<f64> {
272 let position = self
273 .index
274 .nodes
275 .get(&(bus.to_ascii_lowercase(), terminal.to_owned()))?;
276 Some(self.values.terminal_voltage_magnitude_squared[*position])
277 }
278
279 #[must_use]
280 pub fn terminal_voltage_magnitude(&self, bus: &str, terminal: &str) -> Option<f64> {
281 let squared = self.terminal_voltage_magnitude_squared(bus, terminal)?;
282 (squared >= 0.0).then(|| squared.sqrt())
283 }
284
285 #[must_use]
286 pub fn line_active_power(&self, line: &str, conductor: usize) -> Option<f64> {
287 Some(
288 self.values.line_active_power[*self
289 .index
290 .line_conductors
291 .get(&(line.to_owned(), conductor))?],
292 )
293 }
294
295 #[must_use]
296 pub fn line_reactive_power(&self, line: &str, conductor: usize) -> Option<f64> {
297 Some(
298 self.values.line_reactive_power[*self
299 .index
300 .line_conductors
301 .get(&(line.to_owned(), conductor))?],
302 )
303 }
304
305 fn channel_position(
306 index: &BTreeMap<String, (usize, usize)>,
307 identity: &str,
308 channel: usize,
309 ) -> Option<usize> {
310 let (offset, count) = *index.get(identity)?;
311 (channel < count).then_some(offset + channel)
312 }
313
314 #[must_use]
315 pub fn generator_active_power(&self, generator: &str, channel: usize) -> Option<f64> {
316 Some(
317 self.values.generator_active_power
318 [Self::channel_position(&self.index.generators, generator, channel)?],
319 )
320 }
321
322 #[must_use]
323 pub fn generator_reactive_power(&self, generator: &str, channel: usize) -> Option<f64> {
324 Some(
325 self.values.generator_reactive_power
326 [Self::channel_position(&self.index.generators, generator, channel)?],
327 )
328 }
329
330 #[must_use]
331 pub fn source_active_power(&self, source: &str, channel: usize) -> Option<f64> {
332 Some(
333 self.values.source_active_power
334 [Self::channel_position(&self.index.sources, source, channel)?],
335 )
336 }
337
338 #[must_use]
339 pub fn source_reactive_power(&self, source: &str, channel: usize) -> Option<f64> {
340 Some(
341 self.values.source_reactive_power
342 [Self::channel_position(&self.index.sources, source, channel)?],
343 )
344 }
345}
346
347#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
349#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
350#[serde(rename_all = "snake_case")]
351#[non_exhaustive]
352pub enum LinDist3FlowLimitKind {
353 LineApparentPower,
354 LineCurrentFrom,
355 LineCurrentTo,
356}
357
358impl LinDist3FlowLimitKind {
359 #[must_use]
360 pub const fn unit(self) -> &'static str {
361 match self {
362 Self::LineApparentPower => "VA",
363 Self::LineCurrentFrom | Self::LineCurrentTo => "A",
364 }
365 }
366}
367
368#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
370#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
371#[non_exhaustive]
372pub struct LinDist3FlowLimitCheck {
373 pub line: String,
374 pub conductor: usize,
375 pub kind: LinDist3FlowLimitKind,
376 pub value: f64,
377 pub limit: f64,
378 pub loading_ratio: f64,
379 pub overloaded: bool,
380 pub constraint_enforced: bool,
382}
383
384impl LinDist3FlowLimitCheck {
385 #[must_use]
387 pub fn monitored(
388 line: impl Into<String>,
389 conductor: usize,
390 kind: LinDist3FlowLimitKind,
391 value: f64,
392 limit: f64,
393 ) -> Self {
394 let loading_ratio = value / limit;
395 Self {
396 line: line.into(),
397 conductor,
398 kind,
399 value,
400 limit,
401 loading_ratio,
402 overloaded: loading_ratio > 1.0,
403 constraint_enforced: false,
404 }
405 }
406}
407
408fn invalid_limit(reason: impl Into<String>) -> Error {
409 Error::new(&codes::BUILD_SOLUTION_LIMIT_CHECK_INVALID, reason.into())
410}
411
412fn ratings(
413 line: Option<&[f64]>,
414 code: Option<&[f64]>,
415 conductors: usize,
416 label: &str,
417) -> Result<Vec<Option<f64>>, Error> {
418 let Some(values) = line.or(code) else {
419 return Ok(vec![None; conductors]);
420 };
421 if values.len() != conductors {
422 return Err(invalid_limit(format!(
423 "{label} has {} entries, expected {conductors}",
424 values.len()
425 )));
426 }
427 values
428 .iter()
429 .enumerate()
430 .map(|(position, value)| {
431 if value.is_finite() && *value > 0.0 {
432 Ok(Some(*value))
433 } else {
434 Err(invalid_limit(format!(
435 "{label} entry {position} must be finite and positive"
436 )))
437 }
438 })
439 .collect()
440}
441
442#[allow(clippy::too_many_lines)] pub fn evaluate_lindist3flow_pf_limits(
451 instance: &LinDist3FlowPfInstance,
452 values: &LinDist3FlowOpfValues,
453) -> Result<Vec<LinDist3FlowLimitCheck>, Error> {
454 let formulation = instance.formulation();
455 if values.line_active_power.len() != formulation.topology().conductors.len()
456 || values.line_reactive_power.len() != formulation.topology().conductors.len()
457 || values.terminal_voltage_magnitude_squared.len() != formulation.topology().nodes.len()
458 {
459 return Err(invalid_limit(
460 "fixed-dispatch values do not match the topology node/conductor axes",
461 ));
462 }
463 let nodes = formulation
464 .topology()
465 .nodes
466 .iter()
467 .enumerate()
468 .map(|(position, node)| {
469 (
470 (node.bus.to_ascii_lowercase(), node.terminal.clone()),
471 position,
472 )
473 })
474 .collect::<BTreeMap<_, _>>();
475 let mut by_line = BTreeMap::<usize, Vec<(usize, usize)>>::new();
476 for (value_position, conductor) in formulation.topology().conductors.iter().enumerate() {
477 by_line
478 .entry(conductor.source_line_row)
479 .or_default()
480 .push((conductor.conductor_position, value_position));
481 }
482
483 let mut checks = Vec::new();
484 for (line_row, mut positions) in by_line {
485 let line =
486 formulation.network().lines().get(line_row).ok_or_else(|| {
487 invalid_limit(format!("topology names missing line row {line_row}"))
488 })?;
489 positions.sort_by_key(|(conductor, _)| *conductor);
490 let code = formulation
491 .network()
492 .linecode(&line.linecode)
493 .ok_or_else(|| {
494 invalid_limit(format!(
495 "line `{}` names missing linecode `{}`",
496 line.name, line.linecode
497 ))
498 })?;
499 let conductor_count = positions.len();
500 let current_limits = ratings(
501 line.i_max.as_deref(),
502 code.i_max.as_deref(),
503 conductor_count,
504 &format!("line `{}` current limit", line.name),
505 )?;
506 let apparent_limits = ratings(
507 line.s_max.as_deref(),
508 code.s_max.as_deref(),
509 conductor_count,
510 &format!("line `{}` apparent-power limit", line.name),
511 )?;
512 for (conductor, value_position) in positions {
513 let topology = &formulation.topology().conductors[value_position];
514 let p = values.line_active_power[value_position];
515 let q = values.line_reactive_power[value_position];
516 if !p.is_finite() || !q.is_finite() {
517 return Err(invalid_limit(format!(
518 "line `{}` conductor {conductor} has non-finite power",
519 line.name
520 )));
521 }
522 let apparent = p.hypot(q);
523 if let Some(limit) = apparent_limits[conductor] {
524 checks.push(LinDist3FlowLimitCheck::monitored(
525 &line.name,
526 conductor,
527 LinDist3FlowLimitKind::LineApparentPower,
528 apparent,
529 limit,
530 ));
531 }
532 let Some(limit) = current_limits[conductor] else {
533 continue;
534 };
535 let (from, to) = if topology.reversed {
536 (&topology.child, &topology.parent)
537 } else {
538 (&topology.parent, &topology.child)
539 };
540 for (node, kind) in [
541 (from, LinDist3FlowLimitKind::LineCurrentFrom),
542 (to, LinDist3FlowLimitKind::LineCurrentTo),
543 ] {
544 let position = nodes
545 .get(&(node.bus.to_ascii_lowercase(), node.terminal.clone()))
546 .copied()
547 .ok_or_else(|| {
548 invalid_limit(format!(
549 "line `{}` endpoint `{}/{}` is absent from the node axis",
550 line.name, node.bus, node.terminal
551 ))
552 })?;
553 let squared_voltage = values.terminal_voltage_magnitude_squared[position];
554 if !squared_voltage.is_finite() || squared_voltage <= 0.0 {
555 return Err(invalid_limit(format!(
556 "line `{}` endpoint `{}/{}` has nonpositive or non-finite squared voltage",
557 line.name, node.bus, node.terminal
558 )));
559 }
560 checks.push(LinDist3FlowLimitCheck::monitored(
561 &line.name,
562 conductor,
563 kind,
564 apparent / squared_voltage.sqrt(),
565 limit,
566 ));
567 }
568 }
569 }
570 Ok(checks)
571}
572
573#[derive(Clone, Debug)]
575pub struct LinDist3FlowPfSolution {
576 instance: Arc<LinDist3FlowPfInstance>,
577 termination: Termination,
578 residuals: Residuals,
579 producer: Producer,
580 values: LinDist3FlowOpfValues,
581 limit_checks: Vec<LinDist3FlowLimitCheck>,
582 index: SolutionIndex,
583}
584
585impl LinDist3FlowPfSolution {
586 pub const FORMULATION: &'static str = "lindist3flow_fixed_dispatch";
587
588 pub fn new(
596 instance: Arc<LinDist3FlowPfInstance>,
597 termination: Termination,
598 values: LinDist3FlowOpfValues,
599 ) -> Result<Self, Error> {
600 let index = validate_values(instance.formulation(), &values)?;
601 let limit_checks = if matches!(termination, Termination::Converged) {
602 evaluate_lindist3flow_pf_limits(&instance, &values)?
603 } else {
604 Vec::new()
605 };
606 Ok(Self {
607 instance,
608 termination,
609 residuals: Residuals::default(),
610 producer: None,
611 values,
612 limit_checks,
613 index,
614 })
615 }
616
617 #[must_use]
618 pub const fn formulation(&self) -> &'static str {
619 Self::FORMULATION
620 }
621
622 #[must_use]
623 pub fn instance(&self) -> &LinDist3FlowPfInstance {
624 &self.instance
625 }
626
627 #[must_use]
628 pub fn shared_instance(&self) -> Arc<LinDist3FlowPfInstance> {
629 Arc::clone(&self.instance)
630 }
631
632 #[must_use]
633 pub fn network(&self) -> &MulticonductorNetwork {
634 self.instance.network()
635 }
636
637 #[must_use]
638 pub const fn termination(&self) -> &Termination {
639 &self.termination
640 }
641
642 #[must_use]
643 pub const fn residuals(&self) -> &Residuals {
644 &self.residuals
645 }
646
647 #[must_use]
648 pub fn producer(&self) -> Option<&str> {
649 self.producer.as_deref()
650 }
651
652 #[must_use]
653 pub const fn values(&self) -> &LinDist3FlowOpfValues {
654 &self.values
655 }
656
657 #[must_use]
658 pub fn limit_checks(&self) -> &[LinDist3FlowLimitCheck] {
659 &self.limit_checks
660 }
661
662 #[must_use]
663 pub fn with_producer(mut self, producer: impl Into<String>) -> Self {
664 self.producer = Some(producer.into());
665 self
666 }
667
668 #[must_use]
669 pub const fn with_residuals(mut self, residuals: Residuals) -> Self {
670 self.residuals = residuals;
671 self
672 }
673
674 #[must_use]
675 pub fn terminal_voltage_magnitude_squared(&self, bus: &str, terminal: &str) -> Option<f64> {
676 let position = self
677 .index
678 .nodes
679 .get(&(bus.to_ascii_lowercase(), terminal.to_owned()))?;
680 Some(self.values.terminal_voltage_magnitude_squared[*position])
681 }
682
683 #[must_use]
684 pub fn line_active_power(&self, line: &str, conductor: usize) -> Option<f64> {
685 let position = self
686 .index
687 .line_conductors
688 .get(&(line.to_owned(), conductor))?;
689 Some(self.values.line_active_power[*position])
690 }
691
692 #[must_use]
693 pub fn line_reactive_power(&self, line: &str, conductor: usize) -> Option<f64> {
694 let position = self
695 .index
696 .line_conductors
697 .get(&(line.to_owned(), conductor))?;
698 Some(self.values.line_reactive_power[*position])
699 }
700}
701
702#[cfg(test)]
703mod tests {
704 use crate::{LinDist3FlowBuildOptions, LinDist3FlowOpfInstance};
705 use powerio_dist::{DistBus, DistLine, DistLineCode, MulticonductorNetwork, VoltageSource};
706
707 use super::*;
708
709 fn instance() -> Arc<LinDist3FlowOpfInstance> {
710 let terminal = vec!["1".to_owned()];
711 let mut network = MulticonductorNetwork::named("solution");
712 network
713 .buses_mut()
714 .push(DistBus::new("source", terminal.clone()));
715 network
716 .buses_mut()
717 .push(DistBus::new("load", terminal.clone()));
718 network
719 .line_codes_mut()
720 .push(DistLineCode::new("one", vec![vec![0.1]], vec![vec![0.2]]));
721 network.lines_mut().push(DistLine::new(
722 "line",
723 "source",
724 "load",
725 terminal.clone(),
726 terminal.clone(),
727 "one",
728 1.0,
729 ));
730 network.sources_mut().push(VoltageSource::new(
731 "grid",
732 "source",
733 terminal,
734 vec![230.0],
735 vec![0.0],
736 ));
737 Arc::new(
738 LinDist3FlowOpfInstance::from_network(network, LinDist3FlowBuildOptions::default())
739 .unwrap(),
740 )
741 }
742
743 fn values() -> LinDist3FlowOpfValues {
744 LinDist3FlowOpfValues {
745 terminal_voltage_magnitude_squared: vec![230.0f64.powi(2), 228.0f64.powi(2)],
746 line_active_power: vec![1_000.0],
747 line_reactive_power: vec![200.0],
748 generator_active_power: Vec::new(),
749 generator_reactive_power: Vec::new(),
750 source_active_power: vec![1_000.0],
751 source_reactive_power: vec![200.0],
752 }
753 }
754
755 #[test]
756 fn result_is_keyed_by_formulation_identities() {
757 let instance = instance();
758 let solution = LinDist3FlowOpfSolution::new(
759 Arc::clone(&instance),
760 Termination::Converged,
761 values(),
762 0.3,
763 )
764 .unwrap()
765 .with_producer("test-solver");
766 assert_eq!(solution.formulation(), "lindist3flow");
767 assert_eq!(solution.producer(), Some("test-solver"));
768 assert!((solution.terminal_voltage_magnitude("load", "1").unwrap() - 228.0).abs() < 1e-12);
769 assert!((solution.line_active_power("line", 0).unwrap() - 1_000.0).abs() < 1e-12);
770 assert!((solution.source_reactive_power("grid", 0).unwrap() - 200.0).abs() < 1e-12);
771 assert!(std::ptr::eq(solution.instance(), instance.as_ref()));
772 }
773
774 #[test]
775 fn every_physical_axis_is_shape_checked() {
776 let instance = instance();
777 let mut wrong = values();
778 wrong.line_active_power.push(0.0);
779 assert!(
780 LinDist3FlowOpfSolution::new(instance, Termination::Converged, wrong, 0.0).is_err()
781 );
782 }
783}