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 let mut windings = transformer.windings.iter().filter_map(|w| row(&w.bus));
847 if let Some(first) = windings.next() {
848 edges.extend(windings.map(|other| (first, other)));
849 }
850 }
851 connectivity_partition(network.buses().len(), edges)
852}
853
854pub(crate) fn 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 #[test]
2239 fn winding_connectivity_keeps_all_resolved_buses_in_one_partition() {
2240 use powerio_dist::{DistTransformer, DistWinding, DistWindingConn};
2241 let mut network = MulticonductorNetwork::new();
2242 let count = 1024;
2243 network
2244 .buses_mut()
2245 .extend((0..=count).map(|i| DistBus::new(i.to_string(), vec!["1".into()])));
2246 let winding = |bus: String| {
2247 DistWinding::new(bus, vec!["1".into()], DistWindingConn::Wye, 230.0, 1000.0)
2248 };
2249 let mut windings = vec![winding("unresolved".into())];
2250 windings.extend((0..count).rev().map(|i| winding(i.to_string())));
2251 network.transformers_mut().push(DistTransformer::new(
2252 "many-windings",
2253 windings,
2254 Vec::new(),
2255 1,
2256 ));
2257 let partition = multiconductor_network_connectivity(&network);
2260 assert!(partition[..count].iter().all(|root| *root == partition[0]));
2261 assert_ne!(partition[count], partition[0]);
2262 }
2263
2264 fn component(component_type: &str, local_id: &str) -> ComponentId {
2265 ComponentId::new(component_type, local_id).unwrap()
2266 }
2267
2268 fn assert_number_eq(actual: f64, expected: f64) {
2269 assert_eq!(actual.to_bits(), expected.to_bits());
2270 }
2271
2272 fn balanced_network() -> BalancedNetwork {
2273 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case9.m");
2274 let mut network = powerio_tx::parse(Source::open(path).unwrap())
2275 .unwrap()
2276 .into_value();
2277 for (index, load) in network.loads_mut().iter_mut().enumerate() {
2278 load.uid = Some(format!("load-{index}"));
2279 }
2280 for (index, generator) in network.generators_mut().iter_mut().enumerate() {
2281 generator.uid = Some(format!("generator-{index}"));
2282 }
2283 for (index, branch) in network.branches_mut().iter_mut().enumerate() {
2284 branch.uid = Some(format!("branch-{index}"));
2285 }
2286 network.branches_mut()[0].tap = 1.0;
2287 network.branches_mut()[0].uid = Some("transformer-0".to_owned());
2288 let mut switch = Switch::new(network.buses()[0].id, network.buses()[1].id, true);
2289 switch.uid = Some("switch-0".to_owned());
2290 network.switches_mut().push(switch);
2291 network
2292 }
2293
2294 fn multiconductor_network() -> MulticonductorNetwork {
2295 let dss = "New Circuit.c basekv=12.47 pu=1 phases=3 bus1=a\n\
2296 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\
2297 New Load.ld bus1=b.1.2.3 phases=3 conn=wye kv=7.2 kw=30 kvar=9\n\
2298 New Generator.g bus1=b.1.2.3 phases=3 conn=wye kv=7.2 kw=12 kvar=3\n";
2299 let source = Source::from_memory("<memory>", dss.as_bytes().to_vec())
2300 .unwrap()
2301 .with_format(FormatId::new("dss").unwrap());
2302 let mut network = powerio_dist::parse(source).unwrap().into_value();
2303 network.switches_mut().push(DistSwitch::new(
2304 "sw",
2305 "a",
2306 "b",
2307 vec!["1".to_owned()],
2308 vec!["1".to_owned()],
2309 false,
2310 ));
2311 network
2312 }
2313
2314 #[test]
2315 fn invalid_batch_leaves_the_network_unchanged() {
2316 let mut network = balanced_network();
2317 let previous = network.loads()[0].p;
2318 let error = apply_updates(
2319 &mut network,
2320 &[
2321 OperatingPointUpdate::LoadActivePower {
2322 load: component(LOAD, "load-0"),
2323 terminal: None,
2324 p: ActivePower::from_megawatts(previous + 10.0),
2325 },
2326 OperatingPointUpdate::GeneratorActivePower {
2327 generator: component(GENERATOR, "missing"),
2328 terminal: None,
2329 p: ActivePower::from_megawatts(1.0),
2330 },
2331 ],
2332 )
2333 .unwrap_err();
2334 assert_eq!(
2335 error.info().unwrap().code,
2336 "VALIDATE.UPDATE.COMPONENT_UNKNOWN"
2337 );
2338 assert_number_eq(network.loads()[0].p, previous);
2339 }
2340
2341 #[test]
2342 fn balanced_update_removes_only_the_exact_source_omissions() {
2343 let mut network = balanced_network();
2344 let load = component(LOAD, "load-0");
2345 let mut second_load = network.loads()[0].clone();
2346 second_load.uid = Some("load-1".to_owned());
2347 network.loads_mut().push(second_load);
2348 let second_load = component(LOAD, "load-1");
2349 let generator = component(GENERATOR, "generator-0");
2350 let mut detailed = DetailedConnectivity::default();
2351 detailed.omitted_fields = vec![
2352 OmittedField::new(load.clone(), OmittedFieldName::ActivePower),
2353 OmittedField::new(load.clone(), OmittedFieldName::ReactivePower),
2354 OmittedField::new(second_load.clone(), OmittedFieldName::ActivePower),
2355 OmittedField::new(generator.clone(), OmittedFieldName::ActivePower),
2356 OmittedField::new(generator.clone(), OmittedFieldName::ReactivePower),
2357 OmittedField::new(generator.clone(), OmittedFieldName::VoltageSetpoint),
2358 ];
2359 *network.detailed_connectivity_mut() = Some(std::sync::Arc::new(detailed));
2360
2361 apply_updates(
2362 &mut network,
2363 &[
2364 OperatingPointUpdate::LoadActivePower {
2365 load: load.clone(),
2366 terminal: None,
2367 p: ActivePower::from_megawatts(12.0),
2368 },
2369 OperatingPointUpdate::GeneratorReactivePower {
2370 generator: generator.clone(),
2371 terminal: None,
2372 q: ReactivePower::from_megavars(3.0),
2373 },
2374 ],
2375 )
2376 .unwrap();
2377
2378 let omitted = &network
2379 .detailed_connectivity()
2380 .as_deref()
2381 .unwrap()
2382 .omitted_fields;
2383 assert!(!omitted.contains(&OmittedField::new(
2384 load.clone(),
2385 OmittedFieldName::ActivePower,
2386 )));
2387 assert!(!omitted.contains(&OmittedField::new(
2388 generator.clone(),
2389 OmittedFieldName::ReactivePower,
2390 )));
2391 assert!(omitted.contains(&OmittedField::new(load, OmittedFieldName::ReactivePower,)));
2392 assert!(omitted.contains(&OmittedField::new(
2393 second_load,
2394 OmittedFieldName::ActivePower,
2395 )));
2396 assert!(omitted.contains(&OmittedField::new(
2397 generator.clone(),
2398 OmittedFieldName::ActivePower,
2399 )));
2400 assert!(omitted.contains(&OmittedField::new(
2401 generator,
2402 OmittedFieldName::VoltageSetpoint,
2403 )));
2404 assert_number_eq(network.loads()[0].p, 12.0);
2405 assert_number_eq(network.generators()[0].qg, 3.0);
2406 }
2407
2408 #[test]
2409 fn module_update_records_exact_changes_and_invalidates_retained_bytes() {
2410 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case9.m");
2411 let mut module = powerio_tx::parse(Source::open(path).unwrap()).unwrap();
2412 let load_id = module.value().loads()[0].uid.clone().unwrap();
2413 let previous = module.value().loads()[0].p;
2414 assert!(module.source().is_some());
2415
2416 let update = OperatingPointUpdate::LoadActivePower {
2417 load: component(LOAD, &load_id),
2418 terminal: None,
2419 p: ActivePower::from_megawatts(previous + 1.0),
2420 };
2421 let report = apply_updates(&mut module, std::slice::from_ref(&update)).unwrap();
2422 assert_eq!(report.changes().len(), 1);
2423 assert_number_eq(module.value().loads()[0].p, previous + 1.0);
2424 assert!(module.source().is_none());
2425 assert_eq!(module.producer().name(), "powerio");
2426 assert_eq!(module.history().len(), 1);
2427 assert_eq!(module.history()[0].kind(), HistoryKind::Edit);
2428 assert_eq!(module.history()[0].name(), "apply_updates");
2429 assert_eq!(
2430 module.history()[0].parameters()["updates"],
2431 serde_json::to_value([&update]).unwrap()
2432 );
2433 assert_eq!(
2434 module.history()[0].parameters()["changes"],
2435 serde_json::to_value(report.changes()).unwrap()
2436 );
2437
2438 apply_updates(
2439 &mut module,
2440 &[OperatingPointUpdate::LoadActivePower {
2441 load: component(LOAD, &load_id),
2442 terminal: None,
2443 p: ActivePower::from_megawatts(previous + 2.0),
2444 }],
2445 )
2446 .unwrap();
2447 assert_eq!(module.history()[1].id().as_str(), "apply-updates-2");
2448 }
2449
2450 #[test]
2451 fn no_op_module_update_keeps_retained_bytes_and_history() {
2452 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case9.m");
2453 let mut module = powerio_tx::parse(Source::open(path).unwrap()).unwrap();
2454 let load_id = module.value().loads()[0].uid.clone().unwrap();
2455 let current = module.value().loads()[0].p;
2456 let report = apply_updates(
2457 &mut module,
2458 &[OperatingPointUpdate::LoadActivePower {
2459 load: component(LOAD, &load_id),
2460 terminal: None,
2461 p: ActivePower::from_megawatts(current),
2462 }],
2463 )
2464 .unwrap();
2465 assert!(report.is_empty());
2466 assert!(module.source().is_some());
2467 assert_eq!(module.history(), []);
2468 }
2469
2470 #[test]
2471 fn load_updates_require_a_load_identity() {
2472 let mut network = balanced_network();
2473 let previous = network.loads()[0].p;
2474 let bus_id = network.loads()[0].bus.0.to_string();
2475 let error = apply_updates(
2476 &mut network,
2477 &[OperatingPointUpdate::LoadActivePower {
2478 load: component("bus", &bus_id),
2479 terminal: None,
2480 p: ActivePower::from_megawatts(99.0),
2481 }],
2482 )
2483 .unwrap_err();
2484 assert_eq!(error.info().unwrap().code, "VALIDATE.UPDATE.COMPONENT_TYPE");
2485 assert_number_eq(network.loads()[0].p, previous);
2486 }
2487
2488 #[test]
2489 fn bus_load_update_uses_the_explicit_allocation_rule() {
2490 let mut network = balanced_network();
2491 let bus = network.loads()[0].bus;
2492 network.loads_mut()[0].uid = Some("load-a".to_owned());
2493 network.loads_mut()[0].p = 30.0;
2494
2495 let mut second = Load::new(bus, 10.0, 2.0);
2496 second.uid = Some("load-b".to_owned());
2497 network.loads_mut().push(second);
2498
2499 let mut out_of_service = Load::new(bus, 70.0, 14.0);
2500 out_of_service.uid = Some("load-off".to_owned());
2501 out_of_service.in_service = false;
2502 network.loads_mut().push(out_of_service);
2503
2504 let instance = DcOpfInstance::from_network(network).unwrap();
2505 let mut module = PioModule::new(instance);
2506 let report = apply_bus_load_active_power(
2507 &mut module,
2508 bus,
2509 ActivePower::from_megawatts(100.0),
2510 LoadAllocation::ProportionalToCurrentActivePower,
2511 )
2512 .unwrap();
2513
2514 let network = module.value().network();
2515 let active_power = |uid: &str| {
2516 network
2517 .loads()
2518 .iter()
2519 .find(|load| load.uid.as_deref() == Some(uid))
2520 .unwrap()
2521 .p
2522 };
2523 assert_number_eq(active_power("load-a"), 75.0);
2524 assert_number_eq(active_power("load-b"), 25.0);
2525 assert_number_eq(active_power("load-off"), 70.0);
2526 assert_eq!(report.changes().len(), 2);
2527 assert!(
2528 report
2529 .changes()
2530 .iter()
2531 .all(|change| change.field() == UpdatedField::LoadActivePower)
2532 );
2533 assert_eq!(module.history().len(), 1);
2534 assert_eq!(module.history()[0].name(), "apply_updates");
2535 }
2536
2537 #[test]
2538 fn bus_load_update_refuses_an_undefined_proportional_basis() {
2539 let mut network = balanced_network();
2540 let bus = network.loads()[0].bus;
2541 network.loads_mut()[0].p = 0.0;
2542 let instance = DcOpfInstance::from_network(network).unwrap();
2543 let mut module = PioModule::new(instance);
2544 let error = apply_bus_load_active_power(
2545 &mut module,
2546 bus,
2547 ActivePower::from_megawatts(20.0),
2548 LoadAllocation::ProportionalToCurrentActivePower,
2549 )
2550 .unwrap_err();
2551 assert_eq!(error.info().unwrap().code, "VALIDATE.UPDATE.VALUE_INVALID");
2552 assert_number_eq(module.value().network().loads()[0].p, 0.0);
2553 assert_eq!(module.history(), []);
2554 }
2555
2556 #[test]
2557 fn equal_bus_load_allocation_restores_zero_demand() {
2558 let mut network = balanced_network();
2559 let bus = network.loads()[0].bus;
2560 network.loads_mut()[0].uid = Some("load-a".to_owned());
2561 network.loads_mut()[0].p = 0.0;
2562
2563 let mut second = Load::new(bus, 0.0, 0.0);
2564 second.uid = Some("load-b".to_owned());
2565 network.loads_mut().push(second);
2566
2567 let instance = DcOpfInstance::from_network(network).unwrap();
2568 let mut module = PioModule::new(instance);
2569 let report = apply_bus_load_active_power(
2570 &mut module,
2571 bus,
2572 ActivePower::from_megawatts(20.0),
2573 LoadAllocation::Equal,
2574 )
2575 .unwrap();
2576
2577 let active_power = module
2578 .value()
2579 .network()
2580 .loads()
2581 .iter()
2582 .filter(|load| load.in_service && load.bus == bus)
2583 .map(|load| load.p)
2584 .collect::<Vec<_>>();
2585 assert_eq!(active_power, vec![10.0, 10.0]);
2586 assert_eq!(report.changes().len(), 2);
2587 }
2588
2589 #[test]
2590 fn row_spellings_and_unidentified_records_are_not_update_targets() {
2591 let mut network = balanced_network();
2592 let previous = network.loads()[0].p;
2593 let error = apply_updates(
2594 &mut network,
2595 &[OperatingPointUpdate::LoadActivePower {
2596 load: component(LOAD, "loads:0"),
2597 terminal: None,
2598 p: ActivePower::from_megawatts(previous + 1.0),
2599 }],
2600 )
2601 .unwrap_err();
2602 assert_eq!(
2603 error.info().unwrap().code,
2604 "VALIDATE.UPDATE.STABLE_ID_REQUIRED"
2605 );
2606 assert_number_eq(network.loads()[0].p, previous);
2607
2608 network.loads_mut()[0].uid = None;
2609 let error = apply_updates(
2610 &mut network,
2611 &[OperatingPointUpdate::LoadActivePower {
2612 load: component(LOAD, "load-0"),
2613 terminal: None,
2614 p: ActivePower::from_megawatts(previous + 1.0),
2615 }],
2616 )
2617 .unwrap_err();
2618 assert_eq!(
2619 error.info().unwrap().code,
2620 "VALIDATE.UPDATE.STABLE_ID_REQUIRED"
2621 );
2622 assert_number_eq(network.loads()[0].p, previous);
2623 }
2624
2625 #[test]
2626 fn balanced_initial_update_fields_apply_as_absolute_replacements() {
2627 let mut network = balanced_network();
2628 let updates = [
2629 OperatingPointUpdate::LoadReactivePower {
2630 load: component(LOAD, "load-0"),
2631 terminal: None,
2632 q: ReactivePower::from_vars(4_000_000.0),
2633 },
2634 OperatingPointUpdate::GeneratorActivePower {
2635 generator: component(GENERATOR, "generator-0"),
2636 terminal: None,
2637 p: ActivePower::from_megawatts(71.0),
2638 },
2639 OperatingPointUpdate::GeneratorReactivePower {
2640 generator: component(GENERATOR, "generator-0"),
2641 terminal: None,
2642 q: ReactivePower::from_megavars(8.0),
2643 },
2644 OperatingPointUpdate::GeneratorVoltageMagnitude {
2645 generator: component(GENERATOR, "generator-0"),
2646 vm_pu: 1.02,
2647 },
2648 OperatingPointUpdate::TransformerTapRatio {
2649 transformer: component(TRANSFORMER, "transformer-0"),
2650 tap_ratio: 1.05,
2651 },
2652 OperatingPointUpdate::TransformerPhaseShift {
2653 transformer: component(TRANSFORMER, "transformer-0"),
2654 shift_degrees: 3.0,
2655 },
2656 OperatingPointUpdate::SwitchClosed {
2657 switch: component(SWITCH, "switch-0"),
2658 closed: false,
2659 },
2660 ];
2661 let report = apply_updates(&mut network, &updates).unwrap();
2662 assert_number_eq(network.loads()[0].q, 4.0);
2663 assert_number_eq(network.generators()[0].pg, 71.0);
2664 assert_number_eq(network.generators()[0].qg, 8.0);
2665 assert_number_eq(network.generators()[0].vg, 1.02);
2666 assert_number_eq(network.branches()[0].tap, 1.05);
2667 assert_number_eq(network.branches()[0].shift, 3.0);
2668 assert!(!network.switches()[0].closed);
2669 assert_eq!(report.changes().len(), updates.len());
2670 assert!(!report.connectivity_changed());
2671 }
2672
2673 #[test]
2674 fn report_names_exact_fields_and_only_connectivity_edits() {
2675 let mut network = balanced_network();
2676 let generator = component(GENERATOR, "generator-0");
2677 let report = apply_updates(
2678 &mut network,
2679 &[OperatingPointUpdate::GeneratorInService {
2680 generator: generator.clone(),
2681 in_service: false,
2682 }],
2683 )
2684 .unwrap();
2685 assert_eq!(report.changes().len(), 1);
2686 assert_eq!(report.changes()[0].component_id(), &generator);
2687 assert_eq!(
2688 report.changes()[0].field(),
2689 UpdatedField::GeneratorInService
2690 );
2691 assert!(!report.connectivity_changed());
2692
2693 let branch = component(BRANCH, "branch-1");
2694 let report = apply_updates(
2695 &mut network,
2696 &[OperatingPointUpdate::BranchInService {
2697 branch: branch.clone(),
2698 in_service: false,
2699 }],
2700 )
2701 .unwrap();
2702 assert_eq!(report.changes()[0].component_id(), &branch);
2703 assert!(!report.connectivity_changed());
2704
2705 let report = apply_updates(
2706 &mut network,
2707 &[OperatingPointUpdate::BranchInService {
2708 branch,
2709 in_service: false,
2710 }],
2711 )
2712 .unwrap();
2713 assert!(report.is_empty());
2714 assert!(!report.connectivity_changed());
2715 }
2716
2717 #[test]
2718 fn connectivity_report_compares_the_complete_energized_graph() {
2719 let buses = vec![
2720 Bus::new(BusId(1), BusType::Ref, 230.0),
2721 Bus::new(BusId(2), BusType::Pq, 230.0),
2722 ];
2723 let branches = vec![
2724 Branch::new(BusId(1), BusId(2), 0.0, 0.1),
2725 Branch::new(BusId(1), BusId(2), 0.0, 0.2),
2726 ];
2727 let mut network = BalancedNetwork::in_memory("parallel", 100.0, buses, branches);
2728 let first = network.branches()[0].uid.clone().unwrap();
2729 let second = network.branches()[1].uid.clone().unwrap();
2730
2731 let report = apply_updates(
2732 &mut network,
2733 &[OperatingPointUpdate::BranchInService {
2734 branch: component(BRANCH, &first),
2735 in_service: false,
2736 }],
2737 )
2738 .unwrap();
2739 assert!(!report.connectivity_changed());
2740
2741 let report = apply_updates(
2742 &mut network,
2743 &[OperatingPointUpdate::BranchInService {
2744 branch: component(BRANCH, &second),
2745 in_service: false,
2746 }],
2747 )
2748 .unwrap();
2749 assert!(report.connectivity_changed());
2750 }
2751
2752 #[test]
2753 fn network_updates_are_copy_on_write_and_convert_units() {
2754 let mut network = balanced_network();
2755 let untouched = network.clone();
2756 let report = apply_updates(
2757 &mut network,
2758 &[NetworkUpdate::BranchThermalRating {
2759 branch: component(BRANCH, "branch-1"),
2760 terminal: None,
2761 rating: ApparentPower::from_volt_amperes(125_000_000.0),
2762 }],
2763 )
2764 .unwrap();
2765 assert_number_eq(network.branches()[1].rate_a, 125.0);
2766 assert_ne!(
2767 untouched.branches()[1].rate_a.to_bits(),
2768 125.0_f64.to_bits()
2769 );
2770 assert_eq!(
2771 report.changes()[0].field(),
2772 UpdatedField::BranchThermalRating
2773 );
2774 }
2775
2776 #[test]
2777 fn point_update_adds_one_typed_override_without_changing_its_network() {
2778 let network = balanced_network();
2779 let original = network.loads()[0].p;
2780 let mut point = BalancedOperatingPointBuilder::for_point(network.clone())
2781 .build_point()
2782 .unwrap();
2783 let untouched = point.clone();
2784 let report = apply_updates(
2785 &mut point,
2786 &[OperatingPointUpdate::LoadActivePower {
2787 load: component(LOAD, "load-0"),
2788 terminal: None,
2789 p: ActivePower::from_megawatts(original + 7.0),
2790 }],
2791 )
2792 .unwrap();
2793 assert_eq!(point.load_active_power("load-0"), Some(original + 7.0));
2794 assert_eq!(untouched.load_active_power("load-0"), None);
2795 assert_number_eq(point.network().loads()[0].p, original);
2796 assert_eq!(report.changes()[0].field(), UpdatedField::LoadActivePower);
2797 }
2798
2799 #[test]
2800 fn duplicate_assignments_are_rejected_atomically() {
2801 let mut network = balanced_network();
2802 let previous = network.loads()[0].p;
2803 let load = component(LOAD, "load-0");
2804 let error = apply_updates(
2805 &mut network,
2806 &[
2807 OperatingPointUpdate::LoadActivePower {
2808 load: load.clone(),
2809 terminal: None,
2810 p: ActivePower::from_megawatts(previous + 1.0),
2811 },
2812 OperatingPointUpdate::LoadActivePower {
2813 load,
2814 terminal: None,
2815 p: ActivePower::from_megawatts(previous + 2.0),
2816 },
2817 ],
2818 )
2819 .unwrap_err();
2820 assert_eq!(
2821 error.info().unwrap().code,
2822 "VALIDATE.UPDATE.DUPLICATE_FIELD"
2823 );
2824 assert_number_eq(network.loads()[0].p, previous);
2825 }
2826
2827 #[test]
2828 fn calculation_updates_preserve_the_instance_type_and_replace_its_inputs() {
2829 let network = balanced_network();
2830 let mut instance = DcPfInstance::from_network(network).unwrap();
2831 let previous = instance.network().loads()[0].p;
2832 let report = apply_updates(
2833 &mut instance,
2834 &[CalculationUpdate::OperatingPoint(
2835 OperatingPointUpdate::LoadActivePower {
2836 load: component(LOAD, "load-0"),
2837 terminal: None,
2838 p: ActivePower::from_megawatts(previous + 5.0),
2839 },
2840 )],
2841 )
2842 .unwrap();
2843 assert_number_eq(instance.network().loads()[0].p, previous + 5.0);
2844 assert_eq!(report.changes()[0].field(), UpdatedField::LoadActivePower);
2845 }
2846
2847 #[test]
2848 fn conductor_resolved_updates_name_the_exact_terminal() {
2849 let mut network = multiconductor_network();
2850 let load = component(LOAD, "ld");
2851 let terminal = network.loads()[0].terminal_map[0].clone();
2852 let original = network.loads()[0].p_nom[0];
2853 let report = apply_updates(
2854 &mut network,
2855 &[OperatingPointUpdate::LoadActivePower {
2856 load: load.clone(),
2857 terminal: Some(terminal.clone()),
2858 p: ActivePower::from_watts(original + 1_000.0),
2859 }],
2860 )
2861 .unwrap();
2862 assert_number_eq(network.loads()[0].p_nom[0], original + 1_000.0);
2863 assert_eq!(report.changes()[0].component_id(), &load);
2864 assert_eq!(report.changes()[0].terminal(), Some(terminal.as_str()));
2865
2866 let report = apply_updates(
2867 &mut network,
2868 &[OperatingPointUpdate::SwitchClosed {
2869 switch: component(SWITCH, "sw"),
2870 closed: false,
2871 }],
2872 )
2873 .unwrap();
2874 assert!(!report.connectivity_changed());
2875
2876 network
2877 .buses_mut()
2878 .push(DistBus::new("c", vec!["1".to_owned()]));
2879 network.switches_mut().push(DistSwitch::new(
2880 "bridge",
2881 "b",
2882 "c",
2883 vec!["1".to_owned()],
2884 vec!["1".to_owned()],
2885 false,
2886 ));
2887 let report = apply_updates(
2888 &mut network,
2889 &[OperatingPointUpdate::SwitchClosed {
2890 switch: component(SWITCH, "bridge"),
2891 closed: false,
2892 }],
2893 )
2894 .unwrap();
2895 assert!(report.connectivity_changed());
2896 }
2897}