Skip to main content

powerio_prob/
update.rs

1//! Typed, atomic updates to electrical assignments, network limits, and
2//! calculation instances.
3
4use 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/// Unit carried by an active power replacement.
36#[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/// An absolute active power replacement with an explicit unit.
46#[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/// How an aggregate active demand replacement is divided among the in service
100/// loads connected to one bus.
101#[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    /// Give every participating load the same share of the aggregate demand.
107    Equal,
108    /// Preserve each load's share of the current aggregate active demand.
109    /// Every participating load must have a persistent identity and a
110    /// nonnegative active demand, and their sum must be positive.
111    ProportionalToCurrentActivePower,
112}
113
114/// Unit carried by a reactive power replacement.
115#[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/// An absolute reactive power replacement with an explicit unit.
125#[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/// Unit carried by an apparent power rating replacement.
179#[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/// An absolute apparent power rating with an explicit unit.
189#[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/// An update to an electrical assignment. Every replacement is absolute.
290/// A conductor resolved power update names one terminal; a balanced update
291/// leaves `terminal` as `None`.
292#[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/// An update to a physical network parameter or limit. Every replacement is
344/// absolute. A conductor resolved line rating names one terminal; a balanced
345/// branch rating leaves `terminal` as `None`.
346#[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/// One update to a calculation instance.
359#[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/// A field changed by an update batch.
381#[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/// One exact component field changed by an update batch.
400#[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/// Exact changes made by one atomic update batch.
427#[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    /// True only when a changed branch service flag or switch position changes
441    /// which terminal pairs are electrically connected.
442    #[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/// Implementation trait for [`apply_updates`]. It is public only so the free
491/// function can be generic across the built in targets.
492#[doc(hidden)]
493pub trait UpdateTarget<U> {
494    fn apply_update_batch(&mut self, updates: &[U]) -> Result<UpdateReport, Error>;
495}
496
497/// Validate a complete batch, then apply it atomically to `target`.
498///
499/// If any update is invalid, `target` is unchanged.
500pub 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
507/// A balanced calculation instance whose network can receive typed updates.
508///
509/// This trait lets one PowerIO operation cover the balanced power flow and
510/// optimal power flow instance types without copying their tables.
511pub trait BalancedCalculationInstance: Clone + UpdateTarget<CalculationUpdate> {
512    /// The instance's balanced electrical network.
513    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
530/// Replace the aggregate active demand at one bus using an explicit allocation
531/// rule, then apply the resulting load updates atomically to the module.
532///
533/// Only in service loads participate. The returned report names every load
534/// whose active demand changed. No load is selected by table position.
535///
536/// # Errors
537/// The bus is unknown, the bus has no in service load, a participating load
538/// lacks a persistent identity, or the selected allocation rule has no valid
539/// basis.
540pub 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            // Rounding can leave the remainder a hair below zero.
607            (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)] // exhaustive typed dispatch; splitting it would duplicate validation
1031fn 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)] // one exhaustive map from registered fields to network defaults
1303fn 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)] // exhaustive typed dispatch; splitting it would duplicate validation
1405fn 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)] // exhaustive typed dispatch; splitting it would duplicate validation
1672fn 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)] // exhaustive typed dispatch; splitting it would duplicate validation
1979fn 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}