1use std::collections::HashSet;
5
6use powerio_core::{ComponentId, Error, HistoryEntry, HistoryId, HistoryKind, PioModule, Producer};
7use powerio_dist::MulticonductorNetwork;
8use powerio_tx::{BalancedNetwork, BusId};
9use serde::{Deserialize, Serialize};
10
11use crate::diagnostics::codes;
12use crate::instance::{
13 AcOpfInstance, AcPfInstance, DcOpfInstance, DcPfInstance, McAcOpfInstance, McAcPfInstance,
14};
15use crate::operating::balanced::{
16 BRANCH_IN_SERVICE, BRANCH_PHASE_SHIFT, BRANCH_TAP_RATIO, GENERATOR_ACTIVE_POWER,
17 GENERATOR_IN_SERVICE, GENERATOR_REACTIVE_POWER, GENERATOR_VOLTAGE_SETPOINT, LOAD_ACTIVE_POWER,
18 LOAD_REACTIVE_POWER, SWITCH_CLOSED,
19};
20use crate::operating::multiconductor::{
21 GENERATOR_ACTIVE_POWER as MC_GENERATOR_ACTIVE_POWER,
22 GENERATOR_REACTIVE_POWER as MC_GENERATOR_REACTIVE_POWER,
23 LOAD_ACTIVE_POWER as MC_LOAD_ACTIVE_POWER, LOAD_REACTIVE_POWER as MC_LOAD_REACTIVE_POWER,
24 SWITCH_CLOSED as MC_SWITCH_CLOSED,
25};
26use crate::operating::{OperatingPoint, QuantityLayout, row_identity};
27
28const LOAD: &str = "load";
29const GENERATOR: &str = "generator";
30const BRANCH: &str = "branch";
31const TRANSFORMER: &str = "transformer";
32const SWITCH: &str = "switch";
33const LINE: &str = "line";
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
37#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38#[serde(rename_all = "snake_case")]
39#[non_exhaustive]
40pub enum ActivePowerUnit {
41 Watts,
42 Megawatts,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
47#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
48pub struct ActivePower {
49 value: f64,
50 unit: ActivePowerUnit,
51}
52
53impl ActivePower {
54 #[must_use]
55 pub const fn from_watts(value: f64) -> Self {
56 Self {
57 value,
58 unit: ActivePowerUnit::Watts,
59 }
60 }
61
62 #[must_use]
63 pub const fn from_megawatts(value: f64) -> Self {
64 Self {
65 value,
66 unit: ActivePowerUnit::Megawatts,
67 }
68 }
69
70 #[must_use]
71 pub const fn value(self) -> f64 {
72 self.value
73 }
74
75 #[must_use]
76 pub const fn unit(self) -> ActivePowerUnit {
77 self.unit
78 }
79
80 fn watts_value(self) -> Result<f64, Error> {
81 let factor = if self.unit == ActivePowerUnit::Megawatts {
82 1_000_000.0
83 } else {
84 1.0
85 };
86 convert_power(self.value, factor, "active power")
87 }
88
89 fn megawatts_value(self) -> Result<f64, Error> {
90 let factor = if self.unit == ActivePowerUnit::Watts {
91 1.0 / 1_000_000.0
92 } else {
93 1.0
94 };
95 convert_power(self.value, factor, "active power")
96 }
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
103#[serde(rename_all = "snake_case")]
104#[non_exhaustive]
105pub enum LoadAllocation {
106 Equal,
108 ProportionalToCurrentActivePower,
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
117#[serde(rename_all = "snake_case")]
118#[non_exhaustive]
119pub enum ReactivePowerUnit {
120 Vars,
121 Megavars,
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub struct ReactivePower {
128 value: f64,
129 unit: ReactivePowerUnit,
130}
131
132impl ReactivePower {
133 #[must_use]
134 pub const fn from_vars(value: f64) -> Self {
135 Self {
136 value,
137 unit: ReactivePowerUnit::Vars,
138 }
139 }
140
141 #[must_use]
142 pub const fn from_megavars(value: f64) -> Self {
143 Self {
144 value,
145 unit: ReactivePowerUnit::Megavars,
146 }
147 }
148
149 #[must_use]
150 pub const fn value(self) -> f64 {
151 self.value
152 }
153
154 #[must_use]
155 pub const fn unit(self) -> ReactivePowerUnit {
156 self.unit
157 }
158
159 fn vars_value(self) -> Result<f64, Error> {
160 let factor = if self.unit == ReactivePowerUnit::Megavars {
161 1_000_000.0
162 } else {
163 1.0
164 };
165 convert_power(self.value, factor, "reactive power")
166 }
167
168 fn megavars_value(self) -> Result<f64, Error> {
169 let factor = if self.unit == ReactivePowerUnit::Vars {
170 1.0 / 1_000_000.0
171 } else {
172 1.0
173 };
174 convert_power(self.value, factor, "reactive power")
175 }
176}
177
178#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
180#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
181#[serde(rename_all = "snake_case")]
182#[non_exhaustive]
183pub enum ApparentPowerUnit {
184 VoltAmperes,
185 MegavoltAmperes,
186}
187
188#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
190#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
191pub struct ApparentPower {
192 value: f64,
193 unit: ApparentPowerUnit,
194}
195
196impl ApparentPower {
197 #[must_use]
198 pub const fn from_volt_amperes(value: f64) -> Self {
199 Self {
200 value,
201 unit: ApparentPowerUnit::VoltAmperes,
202 }
203 }
204
205 #[must_use]
206 pub const fn from_megavolt_amperes(value: f64) -> Self {
207 Self {
208 value,
209 unit: ApparentPowerUnit::MegavoltAmperes,
210 }
211 }
212
213 #[must_use]
214 pub const fn value(self) -> f64 {
215 self.value
216 }
217
218 #[must_use]
219 pub const fn unit(self) -> ApparentPowerUnit {
220 self.unit
221 }
222
223 fn volt_amperes_value(self) -> Result<f64, Error> {
224 let factor = if self.unit == ApparentPowerUnit::MegavoltAmperes {
225 1_000_000.0
226 } else {
227 1.0
228 };
229 let value = convert_power(self.value, factor, "apparent power rating")?;
230 require_nonnegative(value, "apparent power rating")
231 }
232
233 fn megavolt_amperes_value(self) -> Result<f64, Error> {
234 let factor = if self.unit == ApparentPowerUnit::VoltAmperes {
235 1.0 / 1_000_000.0
236 } else {
237 1.0
238 };
239 let value = convert_power(self.value, factor, "apparent power rating")?;
240 require_nonnegative(value, "apparent power rating")
241 }
242}
243
244fn convert_power(value: f64, factor: f64, field: &str) -> Result<f64, Error> {
245 require_finite(value, field)?;
246 let converted = value * factor;
247 require_finite(converted, field)
248}
249
250fn require_finite(value: f64, field: &str) -> Result<f64, Error> {
251 if value.is_finite() {
252 Ok(value)
253 } else {
254 Err(Error::new(
255 &codes::VALIDATE_UPDATE_VALUE_INVALID,
256 format!("{field} must be finite"),
257 ))
258 }
259}
260
261fn require_positive(value: f64, field: &str) -> Result<f64, Error> {
262 let value = require_finite(value, field)?;
263 if value > 0.0 {
264 Ok(value)
265 } else {
266 Err(Error::new(
267 &codes::VALIDATE_UPDATE_VALUE_INVALID,
268 format!("{field} must be greater than zero"),
269 ))
270 }
271}
272
273fn require_nonnegative(value: f64, field: &str) -> Result<f64, Error> {
274 let value = require_finite(value, field)?;
275 if value >= 0.0 {
276 Ok(value)
277 } else {
278 Err(Error::new(
279 &codes::VALIDATE_UPDATE_VALUE_INVALID,
280 format!("{field} must be nonnegative"),
281 ))
282 }
283}
284
285fn number_changed(previous: f64, replacement: f64) -> bool {
286 previous.to_bits() != replacement.to_bits()
287}
288
289#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
293#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
294#[serde(rename_all = "snake_case", tag = "field")]
295#[non_exhaustive]
296pub enum OperatingPointUpdate {
297 LoadActivePower {
298 load: ComponentId,
299 terminal: Option<String>,
300 p: ActivePower,
301 },
302 LoadReactivePower {
303 load: ComponentId,
304 terminal: Option<String>,
305 q: ReactivePower,
306 },
307 GeneratorActivePower {
308 generator: ComponentId,
309 terminal: Option<String>,
310 p: ActivePower,
311 },
312 GeneratorReactivePower {
313 generator: ComponentId,
314 terminal: Option<String>,
315 q: ReactivePower,
316 },
317 GeneratorVoltageMagnitude {
318 generator: ComponentId,
319 vm_pu: f64,
320 },
321 GeneratorInService {
322 generator: ComponentId,
323 in_service: bool,
324 },
325 BranchInService {
326 branch: ComponentId,
327 in_service: bool,
328 },
329 TransformerTapRatio {
330 transformer: ComponentId,
331 tap_ratio: f64,
332 },
333 TransformerPhaseShift {
334 transformer: ComponentId,
335 shift_degrees: f64,
336 },
337 SwitchClosed {
338 switch: ComponentId,
339 closed: bool,
340 },
341}
342
343#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
347#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
348#[serde(rename_all = "snake_case", tag = "field")]
349#[non_exhaustive]
350pub enum NetworkUpdate {
351 BranchThermalRating {
352 branch: ComponentId,
353 terminal: Option<String>,
354 rating: ApparentPower,
355 },
356}
357
358#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
360#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
361#[serde(rename_all = "snake_case", tag = "data_role", content = "update")]
362#[non_exhaustive]
363pub enum CalculationUpdate {
364 OperatingPoint(OperatingPointUpdate),
365 Network(NetworkUpdate),
366}
367
368impl From<OperatingPointUpdate> for CalculationUpdate {
369 fn from(update: OperatingPointUpdate) -> Self {
370 Self::OperatingPoint(update)
371 }
372}
373
374impl From<NetworkUpdate> for CalculationUpdate {
375 fn from(update: NetworkUpdate) -> Self {
376 Self::Network(update)
377 }
378}
379
380#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
382#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
383#[serde(rename_all = "snake_case")]
384#[non_exhaustive]
385pub enum UpdatedField {
386 LoadActivePower,
387 LoadReactivePower,
388 GeneratorActivePower,
389 GeneratorReactivePower,
390 GeneratorVoltageMagnitude,
391 GeneratorInService,
392 BranchThermalRating,
393 BranchInService,
394 TransformerTapRatio,
395 TransformerPhaseShift,
396 SwitchClosed,
397}
398
399#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
401#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
402pub struct UpdateChange {
403 component_id: ComponentId,
404 field: UpdatedField,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
406 terminal: Option<String>,
407}
408
409impl UpdateChange {
410 #[must_use]
411 pub const fn component_id(&self) -> &ComponentId {
412 &self.component_id
413 }
414
415 #[must_use]
416 pub const fn field(&self) -> UpdatedField {
417 self.field
418 }
419
420 #[must_use]
421 pub fn terminal(&self) -> Option<&str> {
422 self.terminal.as_deref()
423 }
424}
425
426#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
428#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
429pub struct UpdateReport {
430 changes: Vec<UpdateChange>,
431 connectivity_changed: bool,
432}
433
434impl UpdateReport {
435 #[must_use]
436 pub fn changes(&self) -> &[UpdateChange] {
437 &self.changes
438 }
439
440 #[must_use]
443 pub const fn connectivity_changed(&self) -> bool {
444 self.connectivity_changed
445 }
446
447 #[must_use]
448 pub fn is_empty(&self) -> bool {
449 self.changes.is_empty()
450 }
451}
452
453#[derive(Default)]
454struct ReportBuilder {
455 seen: HashSet<UpdateChange>,
456 report: UpdateReport,
457}
458
459impl ReportBuilder {
460 fn record(
461 &mut self,
462 component_id: ComponentId,
463 field: UpdatedField,
464 terminal: Option<&str>,
465 changed: bool,
466 connectivity: bool,
467 ) -> Result<(), Error> {
468 let change = UpdateChange {
469 component_id,
470 field,
471 terminal: terminal.map(str::to_owned),
472 };
473 if !self.seen.insert(change.clone()) {
474 return Err(Error::new(
475 &codes::VALIDATE_UPDATE_DUPLICATE_FIELD,
476 format!(
477 "the batch assigns {} {:?} more than once",
478 change.component_id, change.field
479 ),
480 ));
481 }
482 if changed {
483 self.report.connectivity_changed |= connectivity;
484 self.report.changes.push(change);
485 }
486 Ok(())
487 }
488}
489
490#[doc(hidden)]
493pub trait UpdateTarget<U> {
494 fn apply_update_batch(&mut self, updates: &[U]) -> Result<UpdateReport, Error>;
495}
496
497pub fn apply_updates<T, U>(target: &mut T, updates: &[U]) -> Result<UpdateReport, Error>
501where
502 T: UpdateTarget<U>,
503{
504 target.apply_update_batch(updates)
505}
506
507pub trait BalancedCalculationInstance: Clone + UpdateTarget<CalculationUpdate> {
512 fn network(&self) -> &BalancedNetwork;
514}
515
516macro_rules! impl_balanced_calculation_instance {
517 ($($instance:ty),+ $(,)?) => {
518 $(
519 impl BalancedCalculationInstance for $instance {
520 fn network(&self) -> &BalancedNetwork {
521 self.network()
522 }
523 }
524 )+
525 };
526}
527
528impl_balanced_calculation_instance!(DcPfInstance, AcPfInstance, DcOpfInstance, AcOpfInstance);
529
530pub fn apply_bus_load_active_power<T>(
541 module: &mut PioModule<T>,
542 bus: BusId,
543 total: ActivePower,
544 allocation: LoadAllocation,
545) -> Result<UpdateReport, Error>
546where
547 T: BalancedCalculationInstance,
548{
549 let total_mw = require_nonnegative(total.megawatts_value()?, "bus active demand")?;
550 let network = module.value().network();
551 if !network.buses().iter().any(|candidate| candidate.id == bus) {
552 return Err(Error::new(
553 &codes::VALIDATE_UPDATE_COMPONENT_UNKNOWN,
554 format!("the calculation contains no bus `{bus}`"),
555 ));
556 }
557
558 let loads: Vec<_> = network
559 .loads()
560 .iter()
561 .filter(|load| load.in_service && load.bus == bus)
562 .collect();
563 if loads.is_empty() {
564 return Err(Error::new(
565 &codes::VALIDATE_UPDATE_ALLOCATION_FAILED,
566 format!("bus `{bus}` has no in service load to receive active demand"),
567 ));
568 }
569
570 for load in &loads {
571 if load.uid.is_none() {
572 return Err(Error::new(
573 &codes::VALIDATE_UPDATE_STABLE_ID_REQUIRED,
574 format!("an in service load at bus `{bus}` has no persistent identity"),
575 ));
576 }
577 }
578
579 let (weights, weight_sum) = match allocation {
580 LoadAllocation::Equal => {
581 let weights = vec![1.0; loads.len()];
582 let sum = weights.iter().sum();
583 (weights, sum)
584 }
585 LoadAllocation::ProportionalToCurrentActivePower => {
586 let mut sum = 0.0;
587 for load in &loads {
588 require_nonnegative(load.p, "current load active demand")?;
589 sum += load.p;
590 }
591 let sum = require_positive(sum, "current aggregate active demand")?;
592 (loads.iter().map(|load| load.p).collect(), sum)
593 }
594 };
595
596 let mut assigned_mw = 0.0;
597 let mut updates = Vec::with_capacity(loads.len());
598 for (index, load) in loads.iter().enumerate() {
599 let load_id = load.uid.as_deref().ok_or_else(|| {
600 Error::new(
601 &codes::VALIDATE_UPDATE_STABLE_ID_REQUIRED,
602 format!("an in service load at bus `{bus}` has no persistent identity"),
603 )
604 })?;
605 let replacement_mw = if index + 1 == loads.len() {
606 (total_mw - assigned_mw).max(0.0)
608 } else {
609 let replacement = total_mw * (weights[index] / weight_sum);
610 assigned_mw += replacement;
611 replacement
612 };
613 updates.push(CalculationUpdate::OperatingPoint(
614 OperatingPointUpdate::LoadActivePower {
615 load: ComponentId::new(LOAD, load_id)?,
616 terminal: None,
617 p: ActivePower::from_megawatts(replacement_mw),
618 },
619 ));
620 }
621
622 apply_updates(module, &updates)
623}
624
625impl<T, U> UpdateTarget<U> for PioModule<T>
626where
627 T: Clone + UpdateTarget<U>,
628 U: Serialize,
629{
630 fn apply_update_batch(&mut self, updates: &[U]) -> Result<UpdateReport, Error> {
631 let history_id = unused_history_id(self, "apply-updates");
632 let mut edit = self.stage_edit();
633 let report = edit.value_mut().apply_update_batch(updates)?;
634 if report.is_empty() {
635 return Ok(report);
636 }
637
638 let mut parameters = std::collections::BTreeMap::new();
639 parameters.insert(
640 "updates".to_owned(),
641 serde_json::to_value(updates).map_err(|error| {
642 Error::new(
643 &codes::VALIDATE_UPDATE_VALUE_INVALID,
644 format!("cannot record the applied updates: {error}"),
645 )
646 })?,
647 );
648 parameters.insert(
649 "changes".to_owned(),
650 serde_json::to_value(report.changes()).map_err(|error| {
651 Error::new(
652 &codes::VALIDATE_UPDATE_VALUE_INVALID,
653 format!("cannot record the applied changes: {error}"),
654 )
655 })?,
656 );
657 parameters.insert(
658 "connectivity_changed".to_owned(),
659 serde_json::Value::Bool(report.connectivity_changed()),
660 );
661 let history = HistoryEntry::new(history_id, HistoryKind::Edit, "apply_updates")?
662 .with_parameters(parameters)?;
663 let producer = Producer::new("powerio", env!("CARGO_PKG_VERSION"))?;
664 edit.commit(producer, history)?;
665 Ok(report)
666 }
667}
668
669fn unused_history_id<T>(module: &PioModule<T>, base: &str) -> HistoryId {
670 let taken: HashSet<&str> = module
671 .history()
672 .iter()
673 .map(|entry| entry.id().as_str())
674 .collect();
675 if !taken.contains(base) {
676 return HistoryId::new(base).expect("the static update history ID is valid");
677 }
678 let mut suffix = 2usize;
679 loop {
680 let candidate = format!("{base}-{suffix}");
681 if !taken.contains(candidate.as_str()) {
682 return HistoryId::new(candidate).expect("the numbered update history ID is valid");
683 }
684 suffix += 1;
685 }
686}
687
688fn apply_atomically<T: Clone, U>(
689 target: &mut T,
690 updates: &[U],
691 mut apply_one: impl FnMut(&mut T, &U, &mut ReportBuilder) -> Result<(), Error>,
692) -> Result<UpdateReport, Error> {
693 let mut candidate = target.clone();
694 let mut report = ReportBuilder::default();
695 for update in updates {
696 apply_one(&mut candidate, update, &mut report)?;
697 }
698 *target = candidate;
699 Ok(report.report)
700}
701
702fn apply_atomically_with_connectivity<T: Clone, U>(
703 target: &mut T,
704 updates: &[U],
705 mut apply_one: impl FnMut(&mut T, &U, &mut ReportBuilder) -> Result<(), Error>,
706 connectivity: impl Fn(&T) -> Vec<usize>,
707) -> Result<UpdateReport, Error> {
708 let before = connectivity(target);
709 let mut candidate = target.clone();
710 let mut report = ReportBuilder::default();
711 for update in updates {
712 apply_one(&mut candidate, update, &mut report)?;
713 }
714 report.report.connectivity_changed = before != connectivity(&candidate);
715 *target = candidate;
716 Ok(report.report)
717}
718
719fn connectivity_partition(
720 node_count: usize,
721 edges: impl IntoIterator<Item = (usize, usize)>,
722) -> Vec<usize> {
723 fn root(parent: &mut [usize], mut node: usize) -> usize {
724 while parent[node] != node {
725 parent[node] = parent[parent[node]];
726 node = parent[node];
727 }
728 node
729 }
730
731 let mut parent: Vec<usize> = (0..node_count).collect();
732 for (left, right) in edges {
733 let left = root(&mut parent, left);
734 let right = root(&mut parent, right);
735 if left != right {
736 let (keep, replace) = if left < right {
737 (left, right)
738 } else {
739 (right, left)
740 };
741 parent[replace] = keep;
742 }
743 }
744 (0..node_count)
745 .map(|node| root(&mut parent, node))
746 .collect()
747}
748
749fn balanced_connectivity(
750 network: &BalancedNetwork,
751 branch_in_service: impl Fn(usize) -> bool,
752 switch_closed: impl Fn(usize) -> bool,
753) -> Vec<usize> {
754 let bus_rows: std::collections::BTreeMap<_, _> = network
755 .buses()
756 .iter()
757 .enumerate()
758 .map(|(row, bus)| (bus.id, row))
759 .collect();
760 let mut edges = Vec::new();
761 for (row, branch) in network.branches().iter().enumerate() {
762 if branch_in_service(row)
763 && let (Some(&from), Some(&to)) = (bus_rows.get(&branch.from), bus_rows.get(&branch.to))
764 {
765 edges.push((from, to));
766 }
767 }
768 for (row, switch) in network.switches().iter().enumerate() {
769 if switch_closed(row)
770 && let (Some(&from), Some(&to)) = (bus_rows.get(&switch.from), bus_rows.get(&switch.to))
771 {
772 edges.push((from, to));
773 }
774 }
775 for transformer in network.transformers_3w() {
776 for left in 0..transformer.windings.len() {
777 for right in (left + 1)..transformer.windings.len() {
778 if let (Some(&from), Some(&to)) = (
779 bus_rows.get(&transformer.windings[left].bus),
780 bus_rows.get(&transformer.windings[right].bus),
781 ) {
782 edges.push((from, to));
783 }
784 }
785 }
786 }
787 connectivity_partition(network.buses().len(), edges)
788}
789
790fn balanced_network_connectivity(network: &BalancedNetwork) -> Vec<usize> {
791 balanced_connectivity(
792 network,
793 |row| network.branches()[row].in_service,
794 |row| network.switches()[row].closed,
795 )
796}
797
798fn balanced_point_connectivity(point: &OperatingPoint<BalancedNetwork>) -> Vec<usize> {
799 let network = point.network();
800 balanced_connectivity(
801 network,
802 |row| {
803 let branch = &network.branches()[row];
804 let identity = row_identity(branch.uid.as_deref(), "branches", row);
805 point
806 .branch_in_service(&identity)
807 .unwrap_or(branch.in_service)
808 },
809 |row| {
810 let switch = &network.switches()[row];
811 let identity = row_identity(switch.uid.as_deref(), "switches", row);
812 point.switch_closed(&identity).unwrap_or(switch.closed)
813 },
814 )
815}
816
817fn multiconductor_connectivity(
818 network: &MulticonductorNetwork,
819 switch_closed: impl Fn(usize) -> bool,
820) -> Vec<usize> {
821 let bus_rows: std::collections::BTreeMap<_, _> = network
822 .buses()
823 .iter()
824 .enumerate()
825 .map(|(row, bus)| (bus.id.to_ascii_lowercase(), row))
826 .collect();
827 let row = |bus: &str| bus_rows.get(&bus.to_ascii_lowercase()).copied();
828 let mut edges = Vec::new();
829 for line in network.lines() {
830 if let (Some(from), Some(to)) = (row(&line.bus_from), row(&line.bus_to)) {
831 edges.push((from, to));
832 }
833 }
834 for (index, switch) in network.switches().iter().enumerate() {
835 if switch_closed(index)
836 && let (Some(from), Some(to)) = (row(&switch.bus_from), row(&switch.bus_to))
837 {
838 edges.push((from, to));
839 }
840 }
841 for transformer in network.transformers() {
842 for left in 0..transformer.windings.len() {
843 for right in (left + 1)..transformer.windings.len() {
844 if let (Some(from), Some(to)) = (
845 row(&transformer.windings[left].bus),
846 row(&transformer.windings[right].bus),
847 ) {
848 edges.push((from, to));
849 }
850 }
851 }
852 }
853 connectivity_partition(network.buses().len(), edges)
854}
855
856fn multiconductor_network_connectivity(network: &MulticonductorNetwork) -> Vec<usize> {
857 multiconductor_connectivity(network, |row| !network.switches()[row].open)
858}
859
860fn multiconductor_point_connectivity(point: &OperatingPoint<MulticonductorNetwork>) -> Vec<usize> {
861 let network = point.network();
862 multiconductor_connectivity(network, |row| {
863 let switch = &network.switches()[row];
864 point.switch_closed(&switch.name).unwrap_or(!switch.open)
865 })
866}
867
868fn require_component_type(component: &ComponentId, expected: &str) -> Result<(), Error> {
869 if component.component_type() == expected {
870 Ok(())
871 } else {
872 Err(Error::new(
873 &codes::VALIDATE_UPDATE_COMPONENT_TYPE,
874 format!(
875 "{} has component type `{}`; this field requires `{expected}`",
876 component,
877 component.component_type()
878 ),
879 ))
880 }
881}
882
883fn resolve_index<T>(
884 values: &[T],
885 component: &ComponentId,
886 expected_type: &str,
887 mut local_id: impl FnMut(&T) -> Option<&str>,
888) -> Result<usize, Error> {
889 require_component_type(component, expected_type)?;
890 let mut matches = values.iter().enumerate().filter_map(|(index, value)| {
891 (local_id(value) == Some(component.local_id())).then_some(index)
892 });
893 let Some(index) = matches.next() else {
894 return Err(Error::new(
895 &codes::VALIDATE_UPDATE_COMPONENT_UNKNOWN,
896 format!("the target contains no component `{component}`"),
897 ));
898 };
899 if matches.next().is_some() {
900 return Err(Error::new(
901 &codes::VALIDATE_UPDATE_COMPONENT_AMBIGUOUS,
902 format!("the target contains more than one component `{component}`"),
903 ));
904 }
905 Ok(index)
906}
907
908fn resolve_persisted_index<T>(
909 values: &[T],
910 component: &ComponentId,
911 expected_type: &str,
912 table: &str,
913 mut local_id: impl FnMut(&T) -> Option<&str>,
914) -> Result<usize, Error> {
915 require_component_type(component, expected_type)?;
916 let row_prefix = format!("{table}:");
917 if component
918 .local_id()
919 .strip_prefix(&row_prefix)
920 .is_some_and(|row| !row.is_empty() && row.bytes().all(|byte| byte.is_ascii_digit()))
921 {
922 return Err(Error::new(
923 &codes::VALIDATE_UPDATE_STABLE_ID_REQUIRED,
924 format!(
925 "`{}` is a table row spelling; assign a persistent component identity first",
926 component.local_id()
927 ),
928 ));
929 }
930 let mut found = None;
931 let mut unidentified = false;
932 for (index, value) in values.iter().enumerate() {
933 match local_id(value) {
934 Some(local) if local == component.local_id() => {
935 if found.replace(index).is_some() {
936 return Err(Error::new(
937 &codes::VALIDATE_UPDATE_COMPONENT_AMBIGUOUS,
938 format!("the target contains more than one component `{component}`"),
939 ));
940 }
941 }
942 None => unidentified = true,
943 Some(_) => {}
944 }
945 }
946 match found {
947 Some(index) => Ok(index),
948 None if unidentified => Err(Error::new(
949 &codes::VALIDATE_UPDATE_STABLE_ID_REQUIRED,
950 format!(
951 "the target contains an unidentified {expected_type}; assign persistent identities before applying updates"
952 ),
953 )),
954 None => Err(Error::new(
955 &codes::VALIDATE_UPDATE_COMPONENT_UNKNOWN,
956 format!("the target contains no component `{component}`"),
957 )),
958 }
959}
960
961fn require_no_terminal(terminal: Option<&str>, field: &str) -> Result<(), Error> {
962 if terminal.is_none() {
963 Ok(())
964 } else {
965 Err(Error::new(
966 &codes::VALIDATE_UPDATE_FIELD_UNSUPPORTED,
967 format!("a balanced {field} update does not name a terminal"),
968 ))
969 }
970}
971
972fn unsupported(field: &str, model: &str) -> Error {
973 Error::new(
974 &codes::VALIDATE_UPDATE_FIELD_UNSUPPORTED,
975 format!("{model} does not define {field}"),
976 )
977}
978
979fn resolve_balanced_transformer(
980 network: &BalancedNetwork,
981 component: &ComponentId,
982) -> Result<usize, Error> {
983 require_component_type(component, TRANSFORMER)?;
984 if component
985 .local_id()
986 .strip_prefix("branches:")
987 .is_some_and(|row| !row.is_empty() && row.bytes().all(|byte| byte.is_ascii_digit()))
988 {
989 return Err(Error::new(
990 &codes::VALIDATE_UPDATE_STABLE_ID_REQUIRED,
991 format!(
992 "`{}` is a table row spelling; assign a persistent component identity first",
993 component.local_id()
994 ),
995 ));
996 }
997 let unidentified = network
998 .branches()
999 .iter()
1000 .any(|branch| branch.is_transformer() && branch.uid.is_none());
1001 let mut matches = network
1002 .branches()
1003 .iter()
1004 .enumerate()
1005 .filter_map(|(index, branch)| {
1006 (branch.is_transformer() && branch.uid.as_deref() == Some(component.local_id()))
1007 .then_some(index)
1008 });
1009 let Some(index) = matches.next() else {
1010 if unidentified {
1011 return Err(Error::new(
1012 &codes::VALIDATE_UPDATE_STABLE_ID_REQUIRED,
1013 "the target contains an unidentified transformer; assign persistent identities before applying updates",
1014 ));
1015 }
1016 return Err(Error::new(
1017 &codes::VALIDATE_UPDATE_COMPONENT_UNKNOWN,
1018 format!("the target contains no transformer `{component}`"),
1019 ));
1020 };
1021 if matches.next().is_some() {
1022 return Err(Error::new(
1023 &codes::VALIDATE_UPDATE_COMPONENT_AMBIGUOUS,
1024 format!("the target contains more than one transformer `{component}`"),
1025 ));
1026 }
1027 Ok(index)
1028}
1029
1030#[allow(clippy::too_many_lines)] fn apply_balanced_assignment(
1032 network: &mut BalancedNetwork,
1033 update: &OperatingPointUpdate,
1034 report: &mut ReportBuilder,
1035) -> Result<(), Error> {
1036 match update {
1037 OperatingPointUpdate::LoadActivePower { load, terminal, p } => {
1038 require_no_terminal(terminal.as_deref(), "load active power")?;
1039 let replacement = p.megawatts_value()?;
1040 let index = resolve_persisted_index(network.loads(), load, LOAD, "loads", |row| {
1041 row.uid.as_deref()
1042 })?;
1043 let previous = network.loads()[index].p;
1044 powerio_tx::__internal::edit_load_assignment(
1045 network,
1046 index,
1047 load,
1048 powerio_tx::OmittedFieldName::ActivePower,
1049 |row| row.p = replacement,
1050 );
1051 report.record(
1052 load.clone(),
1053 UpdatedField::LoadActivePower,
1054 None,
1055 number_changed(previous, replacement),
1056 false,
1057 )
1058 }
1059 OperatingPointUpdate::LoadReactivePower { load, terminal, q } => {
1060 require_no_terminal(terminal.as_deref(), "load reactive power")?;
1061 let replacement = q.megavars_value()?;
1062 let index = resolve_persisted_index(network.loads(), load, LOAD, "loads", |row| {
1063 row.uid.as_deref()
1064 })?;
1065 let previous = network.loads()[index].q;
1066 powerio_tx::__internal::edit_load_assignment(
1067 network,
1068 index,
1069 load,
1070 powerio_tx::OmittedFieldName::ReactivePower,
1071 |row| row.q = replacement,
1072 );
1073 report.record(
1074 load.clone(),
1075 UpdatedField::LoadReactivePower,
1076 None,
1077 number_changed(previous, replacement),
1078 false,
1079 )
1080 }
1081 OperatingPointUpdate::GeneratorActivePower {
1082 generator,
1083 terminal,
1084 p,
1085 } => {
1086 require_no_terminal(terminal.as_deref(), "generator active power")?;
1087 let replacement = p.megawatts_value()?;
1088 let index = resolve_persisted_index(
1089 network.generators(),
1090 generator,
1091 GENERATOR,
1092 "generators",
1093 |row| row.uid.as_deref(),
1094 )?;
1095 let previous = network.generators()[index].pg;
1096 powerio_tx::__internal::edit_generator_assignment(
1097 network,
1098 index,
1099 generator,
1100 powerio_tx::OmittedFieldName::ActivePower,
1101 |row| row.pg = replacement,
1102 );
1103 report.record(
1104 generator.clone(),
1105 UpdatedField::GeneratorActivePower,
1106 None,
1107 number_changed(previous, replacement),
1108 false,
1109 )
1110 }
1111 OperatingPointUpdate::GeneratorReactivePower {
1112 generator,
1113 terminal,
1114 q,
1115 } => {
1116 require_no_terminal(terminal.as_deref(), "generator reactive power")?;
1117 let replacement = q.megavars_value()?;
1118 let index = resolve_persisted_index(
1119 network.generators(),
1120 generator,
1121 GENERATOR,
1122 "generators",
1123 |row| row.uid.as_deref(),
1124 )?;
1125 let previous = network.generators()[index].qg;
1126 powerio_tx::__internal::edit_generator_assignment(
1127 network,
1128 index,
1129 generator,
1130 powerio_tx::OmittedFieldName::ReactivePower,
1131 |row| row.qg = replacement,
1132 );
1133 report.record(
1134 generator.clone(),
1135 UpdatedField::GeneratorReactivePower,
1136 None,
1137 number_changed(previous, replacement),
1138 false,
1139 )
1140 }
1141 OperatingPointUpdate::GeneratorVoltageMagnitude { generator, vm_pu } => {
1142 let replacement = require_positive(*vm_pu, "generator voltage magnitude")?;
1143 let index = resolve_persisted_index(
1144 network.generators(),
1145 generator,
1146 GENERATOR,
1147 "generators",
1148 |row| row.uid.as_deref(),
1149 )?;
1150 let previous = network.generators()[index].vg;
1151 powerio_tx::__internal::edit_generator_assignment(
1152 network,
1153 index,
1154 generator,
1155 powerio_tx::OmittedFieldName::VoltageSetpoint,
1156 |row| row.vg = replacement,
1157 );
1158 report.record(
1159 generator.clone(),
1160 UpdatedField::GeneratorVoltageMagnitude,
1161 None,
1162 number_changed(previous, replacement),
1163 false,
1164 )
1165 }
1166 OperatingPointUpdate::GeneratorInService {
1167 generator,
1168 in_service,
1169 } => {
1170 let index = resolve_persisted_index(
1171 network.generators(),
1172 generator,
1173 GENERATOR,
1174 "generators",
1175 |row| row.uid.as_deref(),
1176 )?;
1177 let previous = network.generators()[index].in_service;
1178 network.generators_mut()[index].in_service = *in_service;
1179 report.record(
1180 generator.clone(),
1181 UpdatedField::GeneratorInService,
1182 None,
1183 previous != *in_service,
1184 false,
1185 )
1186 }
1187 OperatingPointUpdate::BranchInService { branch, in_service } => {
1188 let index =
1189 resolve_persisted_index(network.branches(), branch, BRANCH, "branches", |row| {
1190 row.uid.as_deref()
1191 })?;
1192 let previous = network.branches()[index].in_service;
1193 network.branches_mut()[index].in_service = *in_service;
1194 report.record(
1195 branch.clone(),
1196 UpdatedField::BranchInService,
1197 None,
1198 previous != *in_service,
1199 previous != *in_service,
1200 )
1201 }
1202 OperatingPointUpdate::TransformerTapRatio {
1203 transformer,
1204 tap_ratio,
1205 } => {
1206 let replacement = require_positive(*tap_ratio, "transformer tap ratio")?;
1207 let index = resolve_balanced_transformer(network, transformer)?;
1208 let previous = network.branches()[index].calc_effective_tap();
1209 network.branches_mut()[index].tap = replacement;
1210 report.record(
1211 transformer.clone(),
1212 UpdatedField::TransformerTapRatio,
1213 None,
1214 number_changed(previous, replacement),
1215 false,
1216 )
1217 }
1218 OperatingPointUpdate::TransformerPhaseShift {
1219 transformer,
1220 shift_degrees,
1221 } => {
1222 let replacement = require_finite(*shift_degrees, "transformer phase shift")?;
1223 let index = resolve_balanced_transformer(network, transformer)?;
1224 let previous = network.branches()[index].shift;
1225 network.branches_mut()[index].shift = replacement;
1226 report.record(
1227 transformer.clone(),
1228 UpdatedField::TransformerPhaseShift,
1229 None,
1230 number_changed(previous, replacement),
1231 false,
1232 )
1233 }
1234 OperatingPointUpdate::SwitchClosed { switch, closed } => {
1235 let index =
1236 resolve_persisted_index(network.switches(), switch, SWITCH, "switches", |row| {
1237 row.uid.as_deref()
1238 })?;
1239 let previous = network.switches()[index].closed;
1240 network.switches_mut()[index].closed = *closed;
1241 report.record(
1242 switch.clone(),
1243 UpdatedField::SwitchClosed,
1244 None,
1245 previous != *closed,
1246 previous != *closed,
1247 )
1248 }
1249 }
1250}
1251
1252fn apply_balanced_network_update(
1253 network: &mut BalancedNetwork,
1254 update: &NetworkUpdate,
1255 report: &mut ReportBuilder,
1256) -> Result<(), Error> {
1257 match update {
1258 NetworkUpdate::BranchThermalRating {
1259 branch,
1260 terminal,
1261 rating,
1262 } => {
1263 require_no_terminal(terminal.as_deref(), "branch thermal rating")?;
1264 let replacement = rating.megavolt_amperes_value()?;
1265 let index =
1266 resolve_persisted_index(network.branches(), branch, BRANCH, "branches", |row| {
1267 row.uid.as_deref()
1268 })?;
1269 let previous = network.branches()[index].rate_a;
1270 network.branches_mut()[index].rate_a = replacement;
1271 report.record(
1272 branch.clone(),
1273 UpdatedField::BranchThermalRating,
1274 None,
1275 number_changed(previous, replacement),
1276 false,
1277 )
1278 }
1279 }
1280}
1281
1282impl UpdateTarget<OperatingPointUpdate> for BalancedNetwork {
1283 fn apply_update_batch(
1284 &mut self,
1285 updates: &[OperatingPointUpdate],
1286 ) -> Result<UpdateReport, Error> {
1287 apply_atomically_with_connectivity(
1288 self,
1289 updates,
1290 apply_balanced_assignment,
1291 balanced_network_connectivity,
1292 )
1293 }
1294}
1295
1296impl UpdateTarget<NetworkUpdate> for BalancedNetwork {
1297 fn apply_update_batch(&mut self, updates: &[NetworkUpdate]) -> Result<UpdateReport, Error> {
1298 apply_atomically(self, updates, apply_balanced_network_update)
1299 }
1300}
1301
1302#[allow(clippy::too_many_lines)] fn balanced_layout_and_defaults(
1304 network: &BalancedNetwork,
1305 quantity: &'static str,
1306) -> Result<(QuantityLayout, Vec<f64>), Error> {
1307 let (identities, defaults): (Vec<String>, Vec<f64>) = match quantity {
1308 LOAD_ACTIVE_POWER => network
1309 .loads()
1310 .iter()
1311 .enumerate()
1312 .map(|(row, value)| (row_identity(value.uid.as_deref(), "loads", row), value.p))
1313 .unzip(),
1314 LOAD_REACTIVE_POWER => network
1315 .loads()
1316 .iter()
1317 .enumerate()
1318 .map(|(row, value)| (row_identity(value.uid.as_deref(), "loads", row), value.q))
1319 .unzip(),
1320 GENERATOR_ACTIVE_POWER => network
1321 .generators()
1322 .iter()
1323 .enumerate()
1324 .map(|(row, value)| {
1325 (
1326 row_identity(value.uid.as_deref(), "generators", row),
1327 value.pg,
1328 )
1329 })
1330 .unzip(),
1331 GENERATOR_REACTIVE_POWER => network
1332 .generators()
1333 .iter()
1334 .enumerate()
1335 .map(|(row, value)| {
1336 (
1337 row_identity(value.uid.as_deref(), "generators", row),
1338 value.qg,
1339 )
1340 })
1341 .unzip(),
1342 GENERATOR_VOLTAGE_SETPOINT => network
1343 .generators()
1344 .iter()
1345 .enumerate()
1346 .map(|(row, value)| {
1347 (
1348 row_identity(value.uid.as_deref(), "generators", row),
1349 value.vg,
1350 )
1351 })
1352 .unzip(),
1353 GENERATOR_IN_SERVICE => network
1354 .generators()
1355 .iter()
1356 .enumerate()
1357 .map(|(row, value)| {
1358 (
1359 row_identity(value.uid.as_deref(), "generators", row),
1360 f64::from(u8::from(value.in_service)),
1361 )
1362 })
1363 .unzip(),
1364 BRANCH_IN_SERVICE | BRANCH_TAP_RATIO | BRANCH_PHASE_SHIFT => network
1365 .branches()
1366 .iter()
1367 .enumerate()
1368 .map(|(row, value)| {
1369 let default = match quantity {
1370 BRANCH_IN_SERVICE => f64::from(u8::from(value.in_service)),
1371 BRANCH_TAP_RATIO => value.calc_effective_tap(),
1372 BRANCH_PHASE_SHIFT => value.shift,
1373 _ => unreachable!(),
1374 };
1375 (row_identity(value.uid.as_deref(), "branches", row), default)
1376 })
1377 .unzip(),
1378 SWITCH_CLOSED => network
1379 .switches()
1380 .iter()
1381 .enumerate()
1382 .map(|(row, value)| {
1383 (
1384 row_identity(value.uid.as_deref(), "switches", row),
1385 f64::from(u8::from(value.closed)),
1386 )
1387 })
1388 .unzip(),
1389 _ => unreachable!("the update names a registered balanced quantity"),
1390 };
1391 Ok((QuantityLayout::from_order(quantity, identities)?, defaults))
1392}
1393
1394fn replace_balanced_point_value(
1395 point: &mut OperatingPoint<BalancedNetwork>,
1396 quantity: &'static str,
1397 identity: &str,
1398 replacement: f64,
1399) -> Result<bool, Error> {
1400 let (layout, defaults) = balanced_layout_and_defaults(point.network(), quantity)?;
1401 point.replace_value(quantity, layout, &defaults, identity, replacement)
1402}
1403
1404#[allow(clippy::too_many_lines)] fn apply_balanced_point_update(
1406 point: &mut OperatingPoint<BalancedNetwork>,
1407 update: &OperatingPointUpdate,
1408 report: &mut ReportBuilder,
1409) -> Result<(), Error> {
1410 let (component, field, quantity, replacement, connectivity) = match update {
1411 OperatingPointUpdate::LoadActivePower { load, terminal, p } => {
1412 require_no_terminal(terminal.as_deref(), "load active power")?;
1413 resolve_persisted_index(point.network().loads(), load, LOAD, "loads", |row| {
1414 row.uid.as_deref()
1415 })?;
1416 (
1417 load,
1418 UpdatedField::LoadActivePower,
1419 LOAD_ACTIVE_POWER,
1420 p.megawatts_value()?,
1421 false,
1422 )
1423 }
1424 OperatingPointUpdate::LoadReactivePower { load, terminal, q } => {
1425 require_no_terminal(terminal.as_deref(), "load reactive power")?;
1426 resolve_persisted_index(point.network().loads(), load, LOAD, "loads", |row| {
1427 row.uid.as_deref()
1428 })?;
1429 (
1430 load,
1431 UpdatedField::LoadReactivePower,
1432 LOAD_REACTIVE_POWER,
1433 q.megavars_value()?,
1434 false,
1435 )
1436 }
1437 OperatingPointUpdate::GeneratorActivePower {
1438 generator,
1439 terminal,
1440 p,
1441 } => {
1442 require_no_terminal(terminal.as_deref(), "generator active power")?;
1443 resolve_persisted_index(
1444 point.network().generators(),
1445 generator,
1446 GENERATOR,
1447 "generators",
1448 |row| row.uid.as_deref(),
1449 )?;
1450 (
1451 generator,
1452 UpdatedField::GeneratorActivePower,
1453 GENERATOR_ACTIVE_POWER,
1454 p.megawatts_value()?,
1455 false,
1456 )
1457 }
1458 OperatingPointUpdate::GeneratorReactivePower {
1459 generator,
1460 terminal,
1461 q,
1462 } => {
1463 require_no_terminal(terminal.as_deref(), "generator reactive power")?;
1464 resolve_persisted_index(
1465 point.network().generators(),
1466 generator,
1467 GENERATOR,
1468 "generators",
1469 |row| row.uid.as_deref(),
1470 )?;
1471 (
1472 generator,
1473 UpdatedField::GeneratorReactivePower,
1474 GENERATOR_REACTIVE_POWER,
1475 q.megavars_value()?,
1476 false,
1477 )
1478 }
1479 OperatingPointUpdate::GeneratorVoltageMagnitude { generator, vm_pu } => {
1480 resolve_persisted_index(
1481 point.network().generators(),
1482 generator,
1483 GENERATOR,
1484 "generators",
1485 |row| row.uid.as_deref(),
1486 )?;
1487 (
1488 generator,
1489 UpdatedField::GeneratorVoltageMagnitude,
1490 GENERATOR_VOLTAGE_SETPOINT,
1491 require_positive(*vm_pu, "generator voltage magnitude")?,
1492 false,
1493 )
1494 }
1495 OperatingPointUpdate::GeneratorInService {
1496 generator,
1497 in_service,
1498 } => {
1499 resolve_persisted_index(
1500 point.network().generators(),
1501 generator,
1502 GENERATOR,
1503 "generators",
1504 |row| row.uid.as_deref(),
1505 )?;
1506 (
1507 generator,
1508 UpdatedField::GeneratorInService,
1509 GENERATOR_IN_SERVICE,
1510 f64::from(u8::from(*in_service)),
1511 false,
1512 )
1513 }
1514 OperatingPointUpdate::BranchInService { branch, in_service } => {
1515 resolve_persisted_index(
1516 point.network().branches(),
1517 branch,
1518 BRANCH,
1519 "branches",
1520 |row| row.uid.as_deref(),
1521 )?;
1522 (
1523 branch,
1524 UpdatedField::BranchInService,
1525 BRANCH_IN_SERVICE,
1526 f64::from(u8::from(*in_service)),
1527 true,
1528 )
1529 }
1530 OperatingPointUpdate::TransformerTapRatio {
1531 transformer,
1532 tap_ratio,
1533 } => {
1534 resolve_balanced_transformer(point.network(), transformer)?;
1535 (
1536 transformer,
1537 UpdatedField::TransformerTapRatio,
1538 BRANCH_TAP_RATIO,
1539 require_positive(*tap_ratio, "transformer tap ratio")?,
1540 false,
1541 )
1542 }
1543 OperatingPointUpdate::TransformerPhaseShift {
1544 transformer,
1545 shift_degrees,
1546 } => {
1547 resolve_balanced_transformer(point.network(), transformer)?;
1548 (
1549 transformer,
1550 UpdatedField::TransformerPhaseShift,
1551 BRANCH_PHASE_SHIFT,
1552 require_finite(*shift_degrees, "transformer phase shift")?,
1553 false,
1554 )
1555 }
1556 OperatingPointUpdate::SwitchClosed { switch, closed } => {
1557 resolve_persisted_index(
1558 point.network().switches(),
1559 switch,
1560 SWITCH,
1561 "switches",
1562 |row| row.uid.as_deref(),
1563 )?;
1564 (
1565 switch,
1566 UpdatedField::SwitchClosed,
1567 SWITCH_CLOSED,
1568 f64::from(u8::from(*closed)),
1569 true,
1570 )
1571 }
1572 };
1573 let changed = replace_balanced_point_value(point, quantity, component.local_id(), replacement)?;
1574 report.record(
1575 component.clone(),
1576 field,
1577 None,
1578 changed,
1579 changed && connectivity,
1580 )
1581}
1582
1583impl UpdateTarget<OperatingPointUpdate> for OperatingPoint<BalancedNetwork> {
1584 fn apply_update_batch(
1585 &mut self,
1586 updates: &[OperatingPointUpdate],
1587 ) -> Result<UpdateReport, Error> {
1588 apply_atomically_with_connectivity(
1589 self,
1590 updates,
1591 apply_balanced_point_update,
1592 balanced_point_connectivity,
1593 )
1594 }
1595}
1596
1597fn require_terminal<'a>(terminal: Option<&'a str>, field: &str) -> Result<&'a str, Error> {
1598 terminal.ok_or_else(|| {
1599 Error::new(
1600 &codes::VALIDATE_UPDATE_TERMINAL_UNKNOWN,
1601 format!("a conductor resolved {field} update must name a terminal"),
1602 )
1603 })
1604}
1605
1606fn resolve_terminal(
1607 terminals: &[String],
1608 terminal: &str,
1609 component: &ComponentId,
1610) -> Result<usize, Error> {
1611 let mut matches = terminals
1612 .iter()
1613 .enumerate()
1614 .filter_map(|(index, name)| (name == terminal).then_some(index));
1615 let Some(index) = matches.next() else {
1616 return Err(Error::new(
1617 &codes::VALIDATE_UPDATE_TERMINAL_UNKNOWN,
1618 format!("{component} has no terminal `{terminal}`"),
1619 ));
1620 };
1621 if matches.next().is_some() {
1622 return Err(Error::new(
1623 &codes::VALIDATE_UPDATE_TERMINAL_UNKNOWN,
1624 format!("{component} repeats terminal `{terminal}`"),
1625 ));
1626 }
1627 Ok(index)
1628}
1629
1630fn resolve_power_terminal(
1631 terminals: &[String],
1632 value_count: usize,
1633 terminal: &str,
1634 component: &ComponentId,
1635 field: &str,
1636) -> Result<usize, Error> {
1637 if value_count > terminals.len() {
1638 return Err(Error::new(
1639 &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
1640 format!(
1641 "{component} has {value_count} {field} values for {} declared terminals",
1642 terminals.len()
1643 ),
1644 ));
1645 }
1646 resolve_terminal(&terminals[..value_count], terminal, component)
1647}
1648
1649fn replace_terminal_value(
1650 values: &mut [f64],
1651 terminal_count: usize,
1652 terminal: usize,
1653 replacement: f64,
1654 component: &ComponentId,
1655 field: &str,
1656) -> Result<bool, Error> {
1657 if values.len() != terminal_count {
1658 return Err(Error::new(
1659 &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
1660 format!(
1661 "{component} has {} {field} values for {terminal_count} terminals",
1662 values.len()
1663 ),
1664 ));
1665 }
1666 let previous = values[terminal];
1667 values[terminal] = replacement;
1668 Ok(number_changed(previous, replacement))
1669}
1670
1671#[allow(clippy::too_many_lines)] fn apply_multiconductor_assignment(
1673 network: &mut MulticonductorNetwork,
1674 update: &OperatingPointUpdate,
1675 report: &mut ReportBuilder,
1676) -> Result<(), Error> {
1677 match update {
1678 OperatingPointUpdate::LoadActivePower { load, terminal, p } => {
1679 let terminal = require_terminal(terminal.as_deref(), "load active power")?;
1680 let replacement = p.watts_value()?;
1681 let index = resolve_index(network.loads(), load, LOAD, |row| Some(row.name.as_str()))?;
1682 let terminal_count = network.loads()[index].p_nom.len();
1683 let terminal_index = resolve_power_terminal(
1684 &network.loads()[index].terminal_map,
1685 terminal_count,
1686 terminal,
1687 load,
1688 "active power",
1689 )?;
1690 let changed = replace_terminal_value(
1691 &mut network.loads_mut()[index].p_nom,
1692 terminal_count,
1693 terminal_index,
1694 replacement,
1695 load,
1696 "active power",
1697 )?;
1698 report.record(
1699 load.clone(),
1700 UpdatedField::LoadActivePower,
1701 Some(terminal),
1702 changed,
1703 false,
1704 )
1705 }
1706 OperatingPointUpdate::LoadReactivePower { load, terminal, q } => {
1707 let terminal = require_terminal(terminal.as_deref(), "load reactive power")?;
1708 let replacement = q.vars_value()?;
1709 let index = resolve_index(network.loads(), load, LOAD, |row| Some(row.name.as_str()))?;
1710 let terminal_count = network.loads()[index].q_nom.len();
1711 let terminal_index = resolve_power_terminal(
1712 &network.loads()[index].terminal_map,
1713 terminal_count,
1714 terminal,
1715 load,
1716 "reactive power",
1717 )?;
1718 let changed = replace_terminal_value(
1719 &mut network.loads_mut()[index].q_nom,
1720 terminal_count,
1721 terminal_index,
1722 replacement,
1723 load,
1724 "reactive power",
1725 )?;
1726 report.record(
1727 load.clone(),
1728 UpdatedField::LoadReactivePower,
1729 Some(terminal),
1730 changed,
1731 false,
1732 )
1733 }
1734 OperatingPointUpdate::GeneratorActivePower {
1735 generator,
1736 terminal,
1737 p,
1738 } => {
1739 let terminal = require_terminal(terminal.as_deref(), "generator active power")?;
1740 let replacement = p.watts_value()?;
1741 let index = resolve_index(network.generators(), generator, GENERATOR, |row| {
1742 Some(row.name.as_str())
1743 })?;
1744 let terminal_count = network.generators()[index].p_nom.len();
1745 let terminal_index = resolve_power_terminal(
1746 &network.generators()[index].terminal_map,
1747 terminal_count,
1748 terminal,
1749 generator,
1750 "active power",
1751 )?;
1752 let changed = replace_terminal_value(
1753 &mut network.generators_mut()[index].p_nom,
1754 terminal_count,
1755 terminal_index,
1756 replacement,
1757 generator,
1758 "active power",
1759 )?;
1760 report.record(
1761 generator.clone(),
1762 UpdatedField::GeneratorActivePower,
1763 Some(terminal),
1764 changed,
1765 false,
1766 )
1767 }
1768 OperatingPointUpdate::GeneratorReactivePower {
1769 generator,
1770 terminal,
1771 q,
1772 } => {
1773 let terminal = require_terminal(terminal.as_deref(), "generator reactive power")?;
1774 let replacement = q.vars_value()?;
1775 let index = resolve_index(network.generators(), generator, GENERATOR, |row| {
1776 Some(row.name.as_str())
1777 })?;
1778 let terminal_count = network.generators()[index].q_nom.len();
1779 let terminal_index = resolve_power_terminal(
1780 &network.generators()[index].terminal_map,
1781 terminal_count,
1782 terminal,
1783 generator,
1784 "reactive power",
1785 )?;
1786 let changed = replace_terminal_value(
1787 &mut network.generators_mut()[index].q_nom,
1788 terminal_count,
1789 terminal_index,
1790 replacement,
1791 generator,
1792 "reactive power",
1793 )?;
1794 report.record(
1795 generator.clone(),
1796 UpdatedField::GeneratorReactivePower,
1797 Some(terminal),
1798 changed,
1799 false,
1800 )
1801 }
1802 OperatingPointUpdate::SwitchClosed { switch, closed } => {
1803 let index = resolve_index(network.switches(), switch, SWITCH, |row| {
1804 Some(row.name.as_str())
1805 })?;
1806 let previous = !network.switches()[index].open;
1807 network.switches_mut()[index].open = !*closed;
1808 report.record(
1809 switch.clone(),
1810 UpdatedField::SwitchClosed,
1811 None,
1812 previous != *closed,
1813 previous != *closed,
1814 )
1815 }
1816 OperatingPointUpdate::GeneratorVoltageMagnitude { .. } => Err(unsupported(
1817 "a generator voltage setpoint",
1818 "MulticonductorNetwork",
1819 )),
1820 OperatingPointUpdate::GeneratorInService { .. } => Err(unsupported(
1821 "generator service status",
1822 "MulticonductorNetwork",
1823 )),
1824 OperatingPointUpdate::BranchInService { .. } => Err(unsupported(
1825 "branch service status",
1826 "MulticonductorNetwork",
1827 )),
1828 OperatingPointUpdate::TransformerTapRatio { .. } => Err(unsupported(
1829 "one transformer tap ratio without a winding number",
1830 "MulticonductorNetwork",
1831 )),
1832 OperatingPointUpdate::TransformerPhaseShift { .. } => Err(unsupported(
1833 "a transformer phase shift",
1834 "MulticonductorNetwork",
1835 )),
1836 }
1837}
1838
1839fn apply_multiconductor_network_update(
1840 network: &mut MulticonductorNetwork,
1841 update: &NetworkUpdate,
1842 report: &mut ReportBuilder,
1843) -> Result<(), Error> {
1844 match update {
1845 NetworkUpdate::BranchThermalRating {
1846 branch,
1847 terminal,
1848 rating,
1849 } => {
1850 require_component_type(branch, LINE)?;
1851 let terminal = require_terminal(terminal.as_deref(), "line thermal rating")?;
1852 let replacement = rating.volt_amperes_value()?;
1853 let index =
1854 resolve_index(network.lines(), branch, LINE, |row| Some(row.name.as_str()))?;
1855 let terminal_index =
1856 resolve_terminal(&network.lines()[index].terminal_map_from, terminal, branch)?;
1857 let terminal_count = network.lines()[index].terminal_map_from.len();
1858 if network.lines()[index]
1859 .s_max
1860 .as_ref()
1861 .is_some_and(|values| values.len() != terminal_count)
1862 {
1863 return Err(Error::new(
1864 &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
1865 format!(
1866 "{branch} has a thermal rating count that differs from its terminal count"
1867 ),
1868 ));
1869 }
1870 let values = network.lines_mut()[index]
1871 .s_max
1872 .get_or_insert_with(|| vec![f64::INFINITY; terminal_count]);
1873 let previous = values[terminal_index];
1874 values[terminal_index] = replacement;
1875 report.record(
1876 branch.clone(),
1877 UpdatedField::BranchThermalRating,
1878 Some(terminal),
1879 number_changed(previous, replacement),
1880 false,
1881 )
1882 }
1883 }
1884}
1885
1886impl UpdateTarget<OperatingPointUpdate> for MulticonductorNetwork {
1887 fn apply_update_batch(
1888 &mut self,
1889 updates: &[OperatingPointUpdate],
1890 ) -> Result<UpdateReport, Error> {
1891 apply_atomically_with_connectivity(
1892 self,
1893 updates,
1894 apply_multiconductor_assignment,
1895 multiconductor_network_connectivity,
1896 )
1897 }
1898}
1899
1900impl UpdateTarget<NetworkUpdate> for MulticonductorNetwork {
1901 fn apply_update_batch(&mut self, updates: &[NetworkUpdate]) -> Result<UpdateReport, Error> {
1902 apply_atomically(self, updates, apply_multiconductor_network_update)
1903 }
1904}
1905
1906fn multiconductor_layout_and_defaults(
1907 network: &MulticonductorNetwork,
1908 quantity: &'static str,
1909) -> Result<(QuantityLayout, Vec<f64>), Error> {
1910 let (identities, defaults): (Vec<String>, Vec<f64>) = match quantity {
1911 MC_LOAD_ACTIVE_POWER | MC_LOAD_REACTIVE_POWER => network
1912 .loads()
1913 .iter()
1914 .flat_map(|load| {
1915 load.terminal_map
1916 .iter()
1917 .enumerate()
1918 .map(move |(index, terminal)| {
1919 let values = if quantity == MC_LOAD_ACTIVE_POWER {
1920 &load.p_nom
1921 } else {
1922 &load.q_nom
1923 };
1924 (
1925 format!("{}/{terminal}", load.name),
1926 values.get(index).copied().unwrap_or(0.0),
1927 )
1928 })
1929 })
1930 .unzip(),
1931 MC_GENERATOR_ACTIVE_POWER | MC_GENERATOR_REACTIVE_POWER => network
1932 .generators()
1933 .iter()
1934 .flat_map(|generator| {
1935 generator
1936 .terminal_map
1937 .iter()
1938 .enumerate()
1939 .map(move |(index, terminal)| {
1940 let values = if quantity == MC_GENERATOR_ACTIVE_POWER {
1941 &generator.p_nom
1942 } else {
1943 &generator.q_nom
1944 };
1945 (
1946 format!("{}/{terminal}", generator.name),
1947 values.get(index).copied().unwrap_or(0.0),
1948 )
1949 })
1950 })
1951 .unzip(),
1952 MC_SWITCH_CLOSED => network
1953 .switches()
1954 .iter()
1955 .map(|switch| (switch.name.clone(), f64::from(u8::from(!switch.open))))
1956 .unzip(),
1957 _ => unreachable!("the update names a registered multiconductor quantity"),
1958 };
1959 if defaults.iter().any(|value| !value.is_finite()) {
1960 return Err(Error::new(
1961 &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
1962 format!("{quantity} defaults do not align with the component terminals"),
1963 ));
1964 }
1965 Ok((QuantityLayout::from_order(quantity, identities)?, defaults))
1966}
1967
1968fn replace_multiconductor_point_value(
1969 point: &mut OperatingPoint<MulticonductorNetwork>,
1970 quantity: &'static str,
1971 identity: &str,
1972 replacement: f64,
1973) -> Result<bool, Error> {
1974 let (layout, defaults) = multiconductor_layout_and_defaults(point.network(), quantity)?;
1975 point.replace_value(quantity, layout, &defaults, identity, replacement)
1976}
1977
1978#[allow(clippy::too_many_lines)] fn apply_multiconductor_point_update(
1980 point: &mut OperatingPoint<MulticonductorNetwork>,
1981 update: &OperatingPointUpdate,
1982 report: &mut ReportBuilder,
1983) -> Result<(), Error> {
1984 let (component, terminal, field, quantity, replacement, connectivity) = match update {
1985 OperatingPointUpdate::LoadActivePower { load, terminal, p } => {
1986 let terminal = require_terminal(terminal.as_deref(), "load active power")?;
1987 let index = resolve_index(point.network().loads(), load, LOAD, |row| {
1988 Some(row.name.as_str())
1989 })?;
1990 resolve_power_terminal(
1991 &point.network().loads()[index].terminal_map,
1992 point.network().loads()[index].p_nom.len(),
1993 terminal,
1994 load,
1995 "active power",
1996 )?;
1997 (
1998 load,
1999 Some(terminal),
2000 UpdatedField::LoadActivePower,
2001 MC_LOAD_ACTIVE_POWER,
2002 p.watts_value()?,
2003 false,
2004 )
2005 }
2006 OperatingPointUpdate::LoadReactivePower { load, terminal, q } => {
2007 let terminal = require_terminal(terminal.as_deref(), "load reactive power")?;
2008 let index = resolve_index(point.network().loads(), load, LOAD, |row| {
2009 Some(row.name.as_str())
2010 })?;
2011 resolve_power_terminal(
2012 &point.network().loads()[index].terminal_map,
2013 point.network().loads()[index].q_nom.len(),
2014 terminal,
2015 load,
2016 "reactive power",
2017 )?;
2018 (
2019 load,
2020 Some(terminal),
2021 UpdatedField::LoadReactivePower,
2022 MC_LOAD_REACTIVE_POWER,
2023 q.vars_value()?,
2024 false,
2025 )
2026 }
2027 OperatingPointUpdate::GeneratorActivePower {
2028 generator,
2029 terminal,
2030 p,
2031 } => {
2032 let terminal = require_terminal(terminal.as_deref(), "generator active power")?;
2033 let index = resolve_index(point.network().generators(), generator, GENERATOR, |row| {
2034 Some(row.name.as_str())
2035 })?;
2036 resolve_power_terminal(
2037 &point.network().generators()[index].terminal_map,
2038 point.network().generators()[index].p_nom.len(),
2039 terminal,
2040 generator,
2041 "active power",
2042 )?;
2043 (
2044 generator,
2045 Some(terminal),
2046 UpdatedField::GeneratorActivePower,
2047 MC_GENERATOR_ACTIVE_POWER,
2048 p.watts_value()?,
2049 false,
2050 )
2051 }
2052 OperatingPointUpdate::GeneratorReactivePower {
2053 generator,
2054 terminal,
2055 q,
2056 } => {
2057 let terminal = require_terminal(terminal.as_deref(), "generator reactive power")?;
2058 let index = resolve_index(point.network().generators(), generator, GENERATOR, |row| {
2059 Some(row.name.as_str())
2060 })?;
2061 resolve_power_terminal(
2062 &point.network().generators()[index].terminal_map,
2063 point.network().generators()[index].q_nom.len(),
2064 terminal,
2065 generator,
2066 "reactive power",
2067 )?;
2068 (
2069 generator,
2070 Some(terminal),
2071 UpdatedField::GeneratorReactivePower,
2072 MC_GENERATOR_REACTIVE_POWER,
2073 q.vars_value()?,
2074 false,
2075 )
2076 }
2077 OperatingPointUpdate::SwitchClosed { switch, closed } => {
2078 resolve_index(point.network().switches(), switch, SWITCH, |row| {
2079 Some(row.name.as_str())
2080 })?;
2081 (
2082 switch,
2083 None,
2084 UpdatedField::SwitchClosed,
2085 MC_SWITCH_CLOSED,
2086 f64::from(u8::from(*closed)),
2087 true,
2088 )
2089 }
2090 OperatingPointUpdate::GeneratorVoltageMagnitude { .. } => {
2091 return Err(unsupported(
2092 "a generator voltage setpoint",
2093 "OperatingPoint<MulticonductorNetwork>",
2094 ));
2095 }
2096 OperatingPointUpdate::GeneratorInService { .. } => {
2097 return Err(unsupported(
2098 "generator service status",
2099 "OperatingPoint<MulticonductorNetwork>",
2100 ));
2101 }
2102 OperatingPointUpdate::BranchInService { .. } => {
2103 return Err(unsupported(
2104 "branch service status",
2105 "OperatingPoint<MulticonductorNetwork>",
2106 ));
2107 }
2108 OperatingPointUpdate::TransformerTapRatio { .. } => {
2109 return Err(unsupported(
2110 "one transformer tap ratio without a winding number",
2111 "OperatingPoint<MulticonductorNetwork>",
2112 ));
2113 }
2114 OperatingPointUpdate::TransformerPhaseShift { .. } => {
2115 return Err(unsupported(
2116 "a transformer phase shift",
2117 "OperatingPoint<MulticonductorNetwork>",
2118 ));
2119 }
2120 };
2121 let identity = match terminal {
2122 Some(terminal) => format!("{}/{terminal}", component.local_id()),
2123 None => component.local_id().to_owned(),
2124 };
2125 let changed = replace_multiconductor_point_value(point, quantity, &identity, replacement)?;
2126 report.record(
2127 component.clone(),
2128 field,
2129 terminal,
2130 changed,
2131 changed && connectivity,
2132 )
2133}
2134
2135impl UpdateTarget<OperatingPointUpdate> for OperatingPoint<MulticonductorNetwork> {
2136 fn apply_update_batch(
2137 &mut self,
2138 updates: &[OperatingPointUpdate],
2139 ) -> Result<UpdateReport, Error> {
2140 apply_atomically_with_connectivity(
2141 self,
2142 updates,
2143 apply_multiconductor_point_update,
2144 multiconductor_point_connectivity,
2145 )
2146 }
2147}
2148
2149macro_rules! impl_balanced_calculation_updates {
2150 ($($instance:ty),+ $(,)?) => {
2151 $(
2152 impl UpdateTarget<CalculationUpdate> for $instance {
2153 fn apply_update_batch(
2154 &mut self,
2155 updates: &[CalculationUpdate],
2156 ) -> Result<UpdateReport, Error> {
2157 if updates.is_empty() {
2158 return Ok(UpdateReport::default());
2159 }
2160 let candidate = self.clone();
2161 let before_connectivity = balanced_network_connectivity(candidate.network());
2162 let mut network = candidate.network().clone();
2163 let mut report = ReportBuilder::default();
2164 for update in updates {
2165 match update {
2166 CalculationUpdate::OperatingPoint(update) => {
2167 apply_balanced_assignment(&mut network, update, &mut report)?;
2168 }
2169 CalculationUpdate::Network(update) => {
2170 apply_balanced_network_update(&mut network, update, &mut report)?;
2171 }
2172 }
2173 }
2174 report.report.connectivity_changed =
2175 before_connectivity != balanced_network_connectivity(&network);
2176 let candidate = candidate.with_network(network)?;
2177 *self = candidate;
2178 Ok(report.report)
2179 }
2180 }
2181 )+
2182 };
2183}
2184
2185impl_balanced_calculation_updates!(DcPfInstance, AcPfInstance, DcOpfInstance, AcOpfInstance);
2186
2187macro_rules! impl_multiconductor_calculation_updates {
2188 ($($instance:ty),+ $(,)?) => {
2189 $(
2190 impl UpdateTarget<CalculationUpdate> for $instance {
2191 fn apply_update_batch(
2192 &mut self,
2193 updates: &[CalculationUpdate],
2194 ) -> Result<UpdateReport, Error> {
2195 if updates.is_empty() {
2196 return Ok(UpdateReport::default());
2197 }
2198 let candidate = self.clone();
2199 let before_connectivity =
2200 multiconductor_network_connectivity(candidate.network());
2201 let mut network = candidate.network().clone();
2202 let mut report = ReportBuilder::default();
2203 for update in updates {
2204 match update {
2205 CalculationUpdate::OperatingPoint(update) => {
2206 apply_multiconductor_assignment(&mut network, update, &mut report)?;
2207 }
2208 CalculationUpdate::Network(update) => {
2209 apply_multiconductor_network_update(&mut network, update, &mut report)?;
2210 }
2211 }
2212 }
2213 report.report.connectivity_changed =
2214 before_connectivity != multiconductor_network_connectivity(&network);
2215 let candidate = candidate.with_network(network)?;
2216 *self = candidate;
2217 Ok(report.report)
2218 }
2219 }
2220 )+
2221 };
2222}
2223
2224impl_multiconductor_calculation_updates!(McAcPfInstance, McAcOpfInstance);
2225
2226#[cfg(test)]
2227mod tests {
2228 use powerio_core::{FormatId, Source};
2229 use powerio_dist::{DistBus, DistSwitch};
2230 use powerio_tx::{
2231 Branch, Bus, BusId, BusType, DetailedConnectivity, Load, OmittedField, OmittedFieldName,
2232 Switch,
2233 };
2234
2235 use super::*;
2236 use crate::BalancedOperatingPointBuilder;
2237
2238 fn component(component_type: &str, local_id: &str) -> ComponentId {
2239 ComponentId::new(component_type, local_id).unwrap()
2240 }
2241
2242 fn assert_number_eq(actual: f64, expected: f64) {
2243 assert_eq!(actual.to_bits(), expected.to_bits());
2244 }
2245
2246 fn balanced_network() -> BalancedNetwork {
2247 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case9.m");
2248 let mut network = powerio_tx::parse(Source::open(path).unwrap())
2249 .unwrap()
2250 .into_value();
2251 for (index, load) in network.loads_mut().iter_mut().enumerate() {
2252 load.uid = Some(format!("load-{index}"));
2253 }
2254 for (index, generator) in network.generators_mut().iter_mut().enumerate() {
2255 generator.uid = Some(format!("generator-{index}"));
2256 }
2257 for (index, branch) in network.branches_mut().iter_mut().enumerate() {
2258 branch.uid = Some(format!("branch-{index}"));
2259 }
2260 network.branches_mut()[0].tap = 1.0;
2261 network.branches_mut()[0].uid = Some("transformer-0".to_owned());
2262 let mut switch = Switch::new(network.buses()[0].id, network.buses()[1].id, true);
2263 switch.uid = Some("switch-0".to_owned());
2264 network.switches_mut().push(switch);
2265 network
2266 }
2267
2268 fn multiconductor_network() -> MulticonductorNetwork {
2269 let dss = "New Circuit.c basekv=12.47 pu=1 phases=3 bus1=a\n\
2270 New Line.l1 bus1=a.1.2.3 bus2=b.1.2.3 phases=3 r1=0.1 x1=0.2 length=1 units=km\n\
2271 New Load.ld bus1=b.1.2.3 phases=3 conn=wye kv=7.2 kw=30 kvar=9\n\
2272 New Generator.g bus1=b.1.2.3 phases=3 conn=wye kv=7.2 kw=12 kvar=3\n";
2273 let source = Source::from_memory("<memory>", dss.as_bytes().to_vec())
2274 .unwrap()
2275 .with_format(FormatId::new("dss").unwrap());
2276 let mut network = powerio_dist::parse(source).unwrap().into_value();
2277 network.switches_mut().push(DistSwitch::new(
2278 "sw",
2279 "a",
2280 "b",
2281 vec!["1".to_owned()],
2282 vec!["1".to_owned()],
2283 false,
2284 ));
2285 network
2286 }
2287
2288 #[test]
2289 fn invalid_batch_leaves_the_network_unchanged() {
2290 let mut network = balanced_network();
2291 let previous = network.loads()[0].p;
2292 let error = apply_updates(
2293 &mut network,
2294 &[
2295 OperatingPointUpdate::LoadActivePower {
2296 load: component(LOAD, "load-0"),
2297 terminal: None,
2298 p: ActivePower::from_megawatts(previous + 10.0),
2299 },
2300 OperatingPointUpdate::GeneratorActivePower {
2301 generator: component(GENERATOR, "missing"),
2302 terminal: None,
2303 p: ActivePower::from_megawatts(1.0),
2304 },
2305 ],
2306 )
2307 .unwrap_err();
2308 assert_eq!(
2309 error.info().unwrap().code,
2310 "VALIDATE.UPDATE.COMPONENT_UNKNOWN"
2311 );
2312 assert_number_eq(network.loads()[0].p, previous);
2313 }
2314
2315 #[test]
2316 fn balanced_update_removes_only_the_exact_source_omissions() {
2317 let mut network = balanced_network();
2318 let load = component(LOAD, "load-0");
2319 let mut second_load = network.loads()[0].clone();
2320 second_load.uid = Some("load-1".to_owned());
2321 network.loads_mut().push(second_load);
2322 let second_load = component(LOAD, "load-1");
2323 let generator = component(GENERATOR, "generator-0");
2324 let mut detailed = DetailedConnectivity::default();
2325 detailed.omitted_fields = vec![
2326 OmittedField::new(load.clone(), OmittedFieldName::ActivePower),
2327 OmittedField::new(load.clone(), OmittedFieldName::ReactivePower),
2328 OmittedField::new(second_load.clone(), OmittedFieldName::ActivePower),
2329 OmittedField::new(generator.clone(), OmittedFieldName::ActivePower),
2330 OmittedField::new(generator.clone(), OmittedFieldName::ReactivePower),
2331 OmittedField::new(generator.clone(), OmittedFieldName::VoltageSetpoint),
2332 ];
2333 *network.detailed_connectivity_mut() = Some(std::sync::Arc::new(detailed));
2334
2335 apply_updates(
2336 &mut network,
2337 &[
2338 OperatingPointUpdate::LoadActivePower {
2339 load: load.clone(),
2340 terminal: None,
2341 p: ActivePower::from_megawatts(12.0),
2342 },
2343 OperatingPointUpdate::GeneratorReactivePower {
2344 generator: generator.clone(),
2345 terminal: None,
2346 q: ReactivePower::from_megavars(3.0),
2347 },
2348 ],
2349 )
2350 .unwrap();
2351
2352 let omitted = &network
2353 .detailed_connectivity()
2354 .as_deref()
2355 .unwrap()
2356 .omitted_fields;
2357 assert!(!omitted.contains(&OmittedField::new(
2358 load.clone(),
2359 OmittedFieldName::ActivePower,
2360 )));
2361 assert!(!omitted.contains(&OmittedField::new(
2362 generator.clone(),
2363 OmittedFieldName::ReactivePower,
2364 )));
2365 assert!(omitted.contains(&OmittedField::new(load, OmittedFieldName::ReactivePower,)));
2366 assert!(omitted.contains(&OmittedField::new(
2367 second_load,
2368 OmittedFieldName::ActivePower,
2369 )));
2370 assert!(omitted.contains(&OmittedField::new(
2371 generator.clone(),
2372 OmittedFieldName::ActivePower,
2373 )));
2374 assert!(omitted.contains(&OmittedField::new(
2375 generator,
2376 OmittedFieldName::VoltageSetpoint,
2377 )));
2378 assert_number_eq(network.loads()[0].p, 12.0);
2379 assert_number_eq(network.generators()[0].qg, 3.0);
2380 }
2381
2382 #[test]
2383 fn module_update_records_exact_changes_and_invalidates_retained_bytes() {
2384 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case9.m");
2385 let mut module = powerio_tx::parse(Source::open(path).unwrap()).unwrap();
2386 let load_id = module.value().loads()[0].uid.clone().unwrap();
2387 let previous = module.value().loads()[0].p;
2388 assert!(module.source().is_some());
2389
2390 let update = OperatingPointUpdate::LoadActivePower {
2391 load: component(LOAD, &load_id),
2392 terminal: None,
2393 p: ActivePower::from_megawatts(previous + 1.0),
2394 };
2395 let report = apply_updates(&mut module, std::slice::from_ref(&update)).unwrap();
2396 assert_eq!(report.changes().len(), 1);
2397 assert_number_eq(module.value().loads()[0].p, previous + 1.0);
2398 assert!(module.source().is_none());
2399 assert_eq!(module.producer().name(), "powerio");
2400 assert_eq!(module.history().len(), 1);
2401 assert_eq!(module.history()[0].kind(), HistoryKind::Edit);
2402 assert_eq!(module.history()[0].name(), "apply_updates");
2403 assert_eq!(
2404 module.history()[0].parameters()["updates"],
2405 serde_json::to_value([&update]).unwrap()
2406 );
2407 assert_eq!(
2408 module.history()[0].parameters()["changes"],
2409 serde_json::to_value(report.changes()).unwrap()
2410 );
2411
2412 apply_updates(
2413 &mut module,
2414 &[OperatingPointUpdate::LoadActivePower {
2415 load: component(LOAD, &load_id),
2416 terminal: None,
2417 p: ActivePower::from_megawatts(previous + 2.0),
2418 }],
2419 )
2420 .unwrap();
2421 assert_eq!(module.history()[1].id().as_str(), "apply-updates-2");
2422 }
2423
2424 #[test]
2425 fn no_op_module_update_keeps_retained_bytes_and_history() {
2426 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case9.m");
2427 let mut module = powerio_tx::parse(Source::open(path).unwrap()).unwrap();
2428 let load_id = module.value().loads()[0].uid.clone().unwrap();
2429 let current = module.value().loads()[0].p;
2430 let report = apply_updates(
2431 &mut module,
2432 &[OperatingPointUpdate::LoadActivePower {
2433 load: component(LOAD, &load_id),
2434 terminal: None,
2435 p: ActivePower::from_megawatts(current),
2436 }],
2437 )
2438 .unwrap();
2439 assert!(report.is_empty());
2440 assert!(module.source().is_some());
2441 assert!(module.history().is_empty());
2442 }
2443
2444 #[test]
2445 fn load_updates_require_a_load_identity() {
2446 let mut network = balanced_network();
2447 let previous = network.loads()[0].p;
2448 let bus_id = network.loads()[0].bus.0.to_string();
2449 let error = apply_updates(
2450 &mut network,
2451 &[OperatingPointUpdate::LoadActivePower {
2452 load: component("bus", &bus_id),
2453 terminal: None,
2454 p: ActivePower::from_megawatts(99.0),
2455 }],
2456 )
2457 .unwrap_err();
2458 assert_eq!(error.info().unwrap().code, "VALIDATE.UPDATE.COMPONENT_TYPE");
2459 assert_number_eq(network.loads()[0].p, previous);
2460 }
2461
2462 #[test]
2463 fn bus_load_update_uses_the_explicit_allocation_rule() {
2464 let mut network = balanced_network();
2465 let bus = network.loads()[0].bus;
2466 network.loads_mut()[0].uid = Some("load-a".to_owned());
2467 network.loads_mut()[0].p = 30.0;
2468
2469 let mut second = Load::new(bus, 10.0, 2.0);
2470 second.uid = Some("load-b".to_owned());
2471 network.loads_mut().push(second);
2472
2473 let mut out_of_service = Load::new(bus, 70.0, 14.0);
2474 out_of_service.uid = Some("load-off".to_owned());
2475 out_of_service.in_service = false;
2476 network.loads_mut().push(out_of_service);
2477
2478 let instance = DcOpfInstance::from_network(network).unwrap();
2479 let mut module = PioModule::new(instance);
2480 let report = apply_bus_load_active_power(
2481 &mut module,
2482 bus,
2483 ActivePower::from_megawatts(100.0),
2484 LoadAllocation::ProportionalToCurrentActivePower,
2485 )
2486 .unwrap();
2487
2488 let network = module.value().network();
2489 let active_power = |uid: &str| {
2490 network
2491 .loads()
2492 .iter()
2493 .find(|load| load.uid.as_deref() == Some(uid))
2494 .unwrap()
2495 .p
2496 };
2497 assert_number_eq(active_power("load-a"), 75.0);
2498 assert_number_eq(active_power("load-b"), 25.0);
2499 assert_number_eq(active_power("load-off"), 70.0);
2500 assert_eq!(report.changes().len(), 2);
2501 assert!(
2502 report
2503 .changes()
2504 .iter()
2505 .all(|change| change.field() == UpdatedField::LoadActivePower)
2506 );
2507 assert_eq!(module.history().len(), 1);
2508 assert_eq!(module.history()[0].name(), "apply_updates");
2509 }
2510
2511 #[test]
2512 fn bus_load_update_refuses_an_undefined_proportional_basis() {
2513 let mut network = balanced_network();
2514 let bus = network.loads()[0].bus;
2515 network.loads_mut()[0].p = 0.0;
2516 let instance = DcOpfInstance::from_network(network).unwrap();
2517 let mut module = PioModule::new(instance);
2518 let error = apply_bus_load_active_power(
2519 &mut module,
2520 bus,
2521 ActivePower::from_megawatts(20.0),
2522 LoadAllocation::ProportionalToCurrentActivePower,
2523 )
2524 .unwrap_err();
2525 assert_eq!(error.info().unwrap().code, "VALIDATE.UPDATE.VALUE_INVALID");
2526 assert_number_eq(module.value().network().loads()[0].p, 0.0);
2527 assert!(module.history().is_empty());
2528 }
2529
2530 #[test]
2531 fn equal_bus_load_allocation_restores_zero_demand() {
2532 let mut network = balanced_network();
2533 let bus = network.loads()[0].bus;
2534 network.loads_mut()[0].uid = Some("load-a".to_owned());
2535 network.loads_mut()[0].p = 0.0;
2536
2537 let mut second = Load::new(bus, 0.0, 0.0);
2538 second.uid = Some("load-b".to_owned());
2539 network.loads_mut().push(second);
2540
2541 let instance = DcOpfInstance::from_network(network).unwrap();
2542 let mut module = PioModule::new(instance);
2543 let report = apply_bus_load_active_power(
2544 &mut module,
2545 bus,
2546 ActivePower::from_megawatts(20.0),
2547 LoadAllocation::Equal,
2548 )
2549 .unwrap();
2550
2551 let active_power = module
2552 .value()
2553 .network()
2554 .loads()
2555 .iter()
2556 .filter(|load| load.in_service && load.bus == bus)
2557 .map(|load| load.p)
2558 .collect::<Vec<_>>();
2559 assert_eq!(active_power, vec![10.0, 10.0]);
2560 assert_eq!(report.changes().len(), 2);
2561 }
2562
2563 #[test]
2564 fn row_spellings_and_unidentified_records_are_not_update_targets() {
2565 let mut network = balanced_network();
2566 let previous = network.loads()[0].p;
2567 let error = apply_updates(
2568 &mut network,
2569 &[OperatingPointUpdate::LoadActivePower {
2570 load: component(LOAD, "loads:0"),
2571 terminal: None,
2572 p: ActivePower::from_megawatts(previous + 1.0),
2573 }],
2574 )
2575 .unwrap_err();
2576 assert_eq!(
2577 error.info().unwrap().code,
2578 "VALIDATE.UPDATE.STABLE_ID_REQUIRED"
2579 );
2580 assert_number_eq(network.loads()[0].p, previous);
2581
2582 network.loads_mut()[0].uid = None;
2583 let error = apply_updates(
2584 &mut network,
2585 &[OperatingPointUpdate::LoadActivePower {
2586 load: component(LOAD, "load-0"),
2587 terminal: None,
2588 p: ActivePower::from_megawatts(previous + 1.0),
2589 }],
2590 )
2591 .unwrap_err();
2592 assert_eq!(
2593 error.info().unwrap().code,
2594 "VALIDATE.UPDATE.STABLE_ID_REQUIRED"
2595 );
2596 assert_number_eq(network.loads()[0].p, previous);
2597 }
2598
2599 #[test]
2600 fn balanced_initial_update_fields_apply_as_absolute_replacements() {
2601 let mut network = balanced_network();
2602 let updates = [
2603 OperatingPointUpdate::LoadReactivePower {
2604 load: component(LOAD, "load-0"),
2605 terminal: None,
2606 q: ReactivePower::from_vars(4_000_000.0),
2607 },
2608 OperatingPointUpdate::GeneratorActivePower {
2609 generator: component(GENERATOR, "generator-0"),
2610 terminal: None,
2611 p: ActivePower::from_megawatts(71.0),
2612 },
2613 OperatingPointUpdate::GeneratorReactivePower {
2614 generator: component(GENERATOR, "generator-0"),
2615 terminal: None,
2616 q: ReactivePower::from_megavars(8.0),
2617 },
2618 OperatingPointUpdate::GeneratorVoltageMagnitude {
2619 generator: component(GENERATOR, "generator-0"),
2620 vm_pu: 1.02,
2621 },
2622 OperatingPointUpdate::TransformerTapRatio {
2623 transformer: component(TRANSFORMER, "transformer-0"),
2624 tap_ratio: 1.05,
2625 },
2626 OperatingPointUpdate::TransformerPhaseShift {
2627 transformer: component(TRANSFORMER, "transformer-0"),
2628 shift_degrees: 3.0,
2629 },
2630 OperatingPointUpdate::SwitchClosed {
2631 switch: component(SWITCH, "switch-0"),
2632 closed: false,
2633 },
2634 ];
2635 let report = apply_updates(&mut network, &updates).unwrap();
2636 assert_number_eq(network.loads()[0].q, 4.0);
2637 assert_number_eq(network.generators()[0].pg, 71.0);
2638 assert_number_eq(network.generators()[0].qg, 8.0);
2639 assert_number_eq(network.generators()[0].vg, 1.02);
2640 assert_number_eq(network.branches()[0].tap, 1.05);
2641 assert_number_eq(network.branches()[0].shift, 3.0);
2642 assert!(!network.switches()[0].closed);
2643 assert_eq!(report.changes().len(), updates.len());
2644 assert!(!report.connectivity_changed());
2645 }
2646
2647 #[test]
2648 fn report_names_exact_fields_and_only_connectivity_edits() {
2649 let mut network = balanced_network();
2650 let generator = component(GENERATOR, "generator-0");
2651 let report = apply_updates(
2652 &mut network,
2653 &[OperatingPointUpdate::GeneratorInService {
2654 generator: generator.clone(),
2655 in_service: false,
2656 }],
2657 )
2658 .unwrap();
2659 assert_eq!(report.changes().len(), 1);
2660 assert_eq!(report.changes()[0].component_id(), &generator);
2661 assert_eq!(
2662 report.changes()[0].field(),
2663 UpdatedField::GeneratorInService
2664 );
2665 assert!(!report.connectivity_changed());
2666
2667 let branch = component(BRANCH, "branch-1");
2668 let report = apply_updates(
2669 &mut network,
2670 &[OperatingPointUpdate::BranchInService {
2671 branch: branch.clone(),
2672 in_service: false,
2673 }],
2674 )
2675 .unwrap();
2676 assert_eq!(report.changes()[0].component_id(), &branch);
2677 assert!(!report.connectivity_changed());
2678
2679 let report = apply_updates(
2680 &mut network,
2681 &[OperatingPointUpdate::BranchInService {
2682 branch,
2683 in_service: false,
2684 }],
2685 )
2686 .unwrap();
2687 assert!(report.is_empty());
2688 assert!(!report.connectivity_changed());
2689 }
2690
2691 #[test]
2692 fn connectivity_report_compares_the_complete_energized_graph() {
2693 let buses = vec![
2694 Bus::new(BusId(1), BusType::Ref, 230.0),
2695 Bus::new(BusId(2), BusType::Pq, 230.0),
2696 ];
2697 let branches = vec![
2698 Branch::new(BusId(1), BusId(2), 0.0, 0.1),
2699 Branch::new(BusId(1), BusId(2), 0.0, 0.2),
2700 ];
2701 let mut network = BalancedNetwork::in_memory("parallel", 100.0, buses, branches);
2702 let first = network.branches()[0].uid.clone().unwrap();
2703 let second = network.branches()[1].uid.clone().unwrap();
2704
2705 let report = apply_updates(
2706 &mut network,
2707 &[OperatingPointUpdate::BranchInService {
2708 branch: component(BRANCH, &first),
2709 in_service: false,
2710 }],
2711 )
2712 .unwrap();
2713 assert!(!report.connectivity_changed());
2714
2715 let report = apply_updates(
2716 &mut network,
2717 &[OperatingPointUpdate::BranchInService {
2718 branch: component(BRANCH, &second),
2719 in_service: false,
2720 }],
2721 )
2722 .unwrap();
2723 assert!(report.connectivity_changed());
2724 }
2725
2726 #[test]
2727 fn network_updates_are_copy_on_write_and_convert_units() {
2728 let mut network = balanced_network();
2729 let untouched = network.clone();
2730 let report = apply_updates(
2731 &mut network,
2732 &[NetworkUpdate::BranchThermalRating {
2733 branch: component(BRANCH, "branch-1"),
2734 terminal: None,
2735 rating: ApparentPower::from_volt_amperes(125_000_000.0),
2736 }],
2737 )
2738 .unwrap();
2739 assert_number_eq(network.branches()[1].rate_a, 125.0);
2740 assert_ne!(
2741 untouched.branches()[1].rate_a.to_bits(),
2742 125.0_f64.to_bits()
2743 );
2744 assert_eq!(
2745 report.changes()[0].field(),
2746 UpdatedField::BranchThermalRating
2747 );
2748 }
2749
2750 #[test]
2751 fn point_update_adds_one_typed_override_without_changing_its_network() {
2752 let network = balanced_network();
2753 let original = network.loads()[0].p;
2754 let mut point = BalancedOperatingPointBuilder::for_point(network.clone())
2755 .build_point()
2756 .unwrap();
2757 let untouched = point.clone();
2758 let report = apply_updates(
2759 &mut point,
2760 &[OperatingPointUpdate::LoadActivePower {
2761 load: component(LOAD, "load-0"),
2762 terminal: None,
2763 p: ActivePower::from_megawatts(original + 7.0),
2764 }],
2765 )
2766 .unwrap();
2767 assert_eq!(point.load_active_power("load-0"), Some(original + 7.0));
2768 assert_eq!(untouched.load_active_power("load-0"), None);
2769 assert_number_eq(point.network().loads()[0].p, original);
2770 assert_eq!(report.changes()[0].field(), UpdatedField::LoadActivePower);
2771 }
2772
2773 #[test]
2774 fn duplicate_assignments_are_rejected_atomically() {
2775 let mut network = balanced_network();
2776 let previous = network.loads()[0].p;
2777 let load = component(LOAD, "load-0");
2778 let error = apply_updates(
2779 &mut network,
2780 &[
2781 OperatingPointUpdate::LoadActivePower {
2782 load: load.clone(),
2783 terminal: None,
2784 p: ActivePower::from_megawatts(previous + 1.0),
2785 },
2786 OperatingPointUpdate::LoadActivePower {
2787 load,
2788 terminal: None,
2789 p: ActivePower::from_megawatts(previous + 2.0),
2790 },
2791 ],
2792 )
2793 .unwrap_err();
2794 assert_eq!(
2795 error.info().unwrap().code,
2796 "VALIDATE.UPDATE.DUPLICATE_FIELD"
2797 );
2798 assert_number_eq(network.loads()[0].p, previous);
2799 }
2800
2801 #[test]
2802 fn calculation_updates_preserve_the_instance_type_and_replace_its_inputs() {
2803 let network = balanced_network();
2804 let mut instance = DcPfInstance::from_network(network).unwrap();
2805 let previous = instance.network().loads()[0].p;
2806 let report = apply_updates(
2807 &mut instance,
2808 &[CalculationUpdate::OperatingPoint(
2809 OperatingPointUpdate::LoadActivePower {
2810 load: component(LOAD, "load-0"),
2811 terminal: None,
2812 p: ActivePower::from_megawatts(previous + 5.0),
2813 },
2814 )],
2815 )
2816 .unwrap();
2817 assert_number_eq(instance.network().loads()[0].p, previous + 5.0);
2818 assert_eq!(report.changes()[0].field(), UpdatedField::LoadActivePower);
2819 }
2820
2821 #[test]
2822 fn conductor_resolved_updates_name_the_exact_terminal() {
2823 let mut network = multiconductor_network();
2824 let load = component(LOAD, "ld");
2825 let terminal = network.loads()[0].terminal_map[0].clone();
2826 let original = network.loads()[0].p_nom[0];
2827 let report = apply_updates(
2828 &mut network,
2829 &[OperatingPointUpdate::LoadActivePower {
2830 load: load.clone(),
2831 terminal: Some(terminal.clone()),
2832 p: ActivePower::from_watts(original + 1_000.0),
2833 }],
2834 )
2835 .unwrap();
2836 assert_number_eq(network.loads()[0].p_nom[0], original + 1_000.0);
2837 assert_eq!(report.changes()[0].component_id(), &load);
2838 assert_eq!(report.changes()[0].terminal(), Some(terminal.as_str()));
2839
2840 let report = apply_updates(
2841 &mut network,
2842 &[OperatingPointUpdate::SwitchClosed {
2843 switch: component(SWITCH, "sw"),
2844 closed: false,
2845 }],
2846 )
2847 .unwrap();
2848 assert!(!report.connectivity_changed());
2849
2850 network
2851 .buses_mut()
2852 .push(DistBus::new("c", vec!["1".to_owned()]));
2853 network.switches_mut().push(DistSwitch::new(
2854 "bridge",
2855 "b",
2856 "c",
2857 vec!["1".to_owned()],
2858 vec!["1".to_owned()],
2859 false,
2860 ));
2861 let report = apply_updates(
2862 &mut network,
2863 &[OperatingPointUpdate::SwitchClosed {
2864 switch: component(SWITCH, "bridge"),
2865 closed: false,
2866 }],
2867 )
2868 .unwrap();
2869 assert!(report.connectivity_changed());
2870 }
2871}