Skip to main content

powerio_prob/instance/
balanced.rs

1//! The balanced calculation instances: `DcPfInstance`, `AcPfInstance`,
2//! `DcOpfInstance`, and `AcOpfInstance`.
3//!
4//! Every instance shares its reusable electrical network as a cheap owning
5//! handle rather than duplicating it into solver preparation arrays: cloning
6//! an instance clones no network table. Fields are private; each instance
7//! exposes a borrowed `network()` accessor and typed calculation data.
8//! Physical limit values stay on the network; an OPF instance selects active
9//! constraints by stable element identity and states its objective as typed
10//! terms.
11//!
12//! A power flow instance contains partial boundary specifications, never a
13//! required complete operating point: the unknown voltages, injections, and
14//! flows are what the calculation solves. A complete
15//! [`OperatingPoint`] can be supplied as an optional solver initial point.
16//! Zero impedance branches are preserved; a projection that cannot represent
17//! them refuses at its own boundary and
18//! [`merge_zero_impedance_buses`](super::merge_zero_impedance_buses) is the
19//! explicit, checked resolution.
20
21use std::collections::{BTreeMap, BTreeSet};
22
23use powerio_core::Error;
24use powerio_tx::{BalancedNetwork, BranchSusceptanceFormula, BusId, BusType};
25use serde::{Deserialize, Serialize};
26
27use crate::OperatingPoint;
28use crate::diagnostics::codes;
29use crate::instance::constraints::ActiveConstraints;
30use crate::instance::objective::{Objective, ObjectiveTerm};
31
32/// One bus of a DC power flow problem: what the calculation is told, never
33/// what it solves.
34#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case", tag = "kind")]
36#[non_exhaustive]
37pub enum DcBusSpecification {
38    /// The net active power injection the bus states, MW (generation minus
39    /// demand over in service elements).
40    NetActivePower { p_mw: f64 },
41    /// A reference bus with its stated voltage angle, degrees.
42    Reference { va_degrees: f64 },
43    /// An isolated bus: no equation.
44    Isolated,
45}
46
47/// One bus of an AC power flow problem, the standard partial specification.
48/// Powers are MW and MVAr, voltage magnitudes per unit, angles degrees, as
49/// the network states them.
50#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
51#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
52#[serde(rename_all = "snake_case", tag = "kind")]
53#[non_exhaustive]
54pub enum AcBusSpecification {
55    /// Prescribed net active and reactive injection.
56    Pq { p: f64, q: f64 },
57    /// Prescribed net active injection and voltage magnitude.
58    Pv { p: f64, vm: f64 },
59    /// Prescribed voltage magnitude and angle.
60    Reference { vm: f64, va: f64 },
61    /// No equation.
62    Isolated,
63}
64
65/// The DC power flow instance: the shared network plus per bus boundary
66/// specifications and the selected branch susceptance formula.
67#[derive(Clone, Debug)]
68pub struct DcPfInstance {
69    network: BalancedNetwork,
70    specifications: Vec<DcBusSpecification>,
71    branch_susceptance_formula: BranchSusceptanceFormula,
72    initial_point: Option<OperatingPoint<BalancedNetwork>>,
73}
74
75impl DcPfInstance {
76    /// Build the instance from the network's stated data: reference buses
77    /// contribute their stated angle, isolated buses no equation, and every
78    /// other bus its net active injection over in service generators and
79    /// loads.
80    ///
81    /// # Errors
82    /// A network with no reference bus.
83    pub fn from_network(mut network: BalancedNetwork) -> Result<Self, Error> {
84        network.assign_missing_component_ids();
85        require_reference(&network)?;
86        let totals = aggregate_bus_elements(&network);
87        let specifications = network
88            .buses()
89            .iter()
90            .map(|bus| match bus.kind {
91                BusType::Ref => DcBusSpecification::Reference { va_degrees: bus.va },
92                BusType::Isolated => DcBusSpecification::Isolated,
93                // Every other declared kind states a net injection.
94                _ => DcBusSpecification::NetActivePower {
95                    p_mw: net_active_power(&totals, bus.id),
96                },
97            })
98            .collect();
99        Ok(Self {
100            network,
101            specifications,
102            branch_susceptance_formula: BranchSusceptanceFormula::default(),
103            initial_point: None,
104        })
105    }
106
107    /// Select the branch susceptance formula, consuming the instance. The
108    /// network handle moves; no table is copied.
109    #[must_use]
110    pub fn with_branch_susceptance_formula(mut self, formula: BranchSusceptanceFormula) -> Self {
111        self.branch_susceptance_formula = formula;
112        self
113    }
114
115    /// Supply an optional solver initial point.
116    #[must_use]
117    pub fn with_initial_point(mut self, point: OperatingPoint<BalancedNetwork>) -> Self {
118        self.initial_point = Some(point);
119        self
120    }
121
122    /// Replace the network and recalculate the fixed bus specifications while
123    /// preserving the branch susceptance formula and a compatible initial
124    /// point.
125    ///
126    /// # Errors
127    /// The replacement has no reference bus or changes an identity layout used
128    /// by the initial point.
129    pub fn with_network(mut self, mut network: BalancedNetwork) -> Result<Self, Error> {
130        network.assign_missing_component_ids();
131        let mut replacement = Self::from_network(network.clone())?
132            .with_branch_susceptance_formula(self.branch_susceptance_formula);
133        if let Some(initial) = self.initial_point.take() {
134            replacement.initial_point = Some(initial.rebind_network(network)?);
135        }
136        Ok(replacement)
137    }
138
139    /// The network this instance calculates on. Borrowed; never a copy.
140    #[must_use]
141    pub fn network(&self) -> &BalancedNetwork {
142        &self.network
143    }
144
145    /// The per bus boundary specifications, in bus table order.
146    #[must_use]
147    pub fn specifications(&self) -> &[DcBusSpecification] {
148        &self.specifications
149    }
150
151    /// The selected branch susceptance formula.
152    #[must_use]
153    pub const fn branch_susceptance_formula(&self) -> BranchSusceptanceFormula {
154        self.branch_susceptance_formula
155    }
156
157    /// The optional solver initial point.
158    #[must_use]
159    pub const fn initial_point(&self) -> Option<&OperatingPoint<BalancedNetwork>> {
160        self.initial_point.as_ref()
161    }
162}
163
164/// The AC power flow instance: the shared network plus one
165/// [`AcBusSpecification`] per bus.
166#[derive(Clone, Debug)]
167pub struct AcPfInstance {
168    network: BalancedNetwork,
169    specifications: Vec<AcBusSpecification>,
170    initial_point: Option<OperatingPoint<BalancedNetwork>>,
171}
172
173impl AcPfInstance {
174    /// Build an AC power flow instance from explicit bus specifications.
175    /// The specification vector follows bus table order and is retained
176    /// exactly; it is not inferred again from bus types, loads, or generator
177    /// schedules.
178    ///
179    /// # Errors
180    /// The specification count differs from the bus count, or no
181    /// specification declares a reference bus.
182    pub fn new(
183        mut network: BalancedNetwork,
184        specifications: Vec<AcBusSpecification>,
185    ) -> Result<Self, Error> {
186        network.assign_missing_component_ids();
187        if specifications.len() != network.buses().len() {
188            return Err(Error::new(
189                &codes::BUILD_INSTANCE_SHAPE_MISMATCH,
190                format!(
191                    "AC power flow specifications carry {} rows; the network has {} buses",
192                    specifications.len(),
193                    network.buses().len()
194                ),
195            ));
196        }
197        if !specifications
198            .iter()
199            .any(|specification| matches!(specification, AcBusSpecification::Reference { .. }))
200        {
201            return Err(Error::new(
202                &codes::BUILD_INSTANCE_NO_REFERENCE_BUS,
203                "the AC power flow specifications state no reference (slack) bus",
204            ));
205        }
206        Ok(Self {
207            network,
208            specifications,
209            initial_point: None,
210        })
211    }
212
213    /// Build the instance from the network's stated data. A PQ bus states
214    /// its net injections; a PV bus its net active injection and the
215    /// regulating generator's voltage setpoint; a reference bus its setpoint
216    /// magnitude and stated angle.
217    ///
218    /// # Errors
219    /// A network with no reference bus, or conflicting active voltage
220    /// controllers: two in service generators at one bus stating different
221    /// voltage setpoints are refused until an explicit edit resolves them.
222    pub fn from_network(mut network: BalancedNetwork) -> Result<Self, Error> {
223        network.assign_missing_component_ids();
224        require_reference(&network)?;
225        let totals = aggregate_bus_elements(&network);
226        let specifications = network
227            .buses()
228            .iter()
229            .map(|bus| {
230                let spec = match bus.kind {
231                    BusType::Isolated => AcBusSpecification::Isolated,
232                    BusType::Pv => AcBusSpecification::Pv {
233                        p: net_active_power(&totals, bus.id),
234                        vm: controlled_magnitude(&totals, bus.id, bus.vm)?,
235                    },
236                    BusType::Ref => AcBusSpecification::Reference {
237                        vm: controlled_magnitude(&totals, bus.id, bus.vm)?,
238                        va: bus.va,
239                    },
240                    // Every other declared kind is the PQ specification.
241                    _ => AcBusSpecification::Pq {
242                        p: net_active_power(&totals, bus.id),
243                        q: net_reactive_power(&totals, bus.id),
244                    },
245                };
246                Ok(spec)
247            })
248            .collect::<Result<Vec<_>, Error>>()?;
249        Self::new(network, specifications)
250    }
251
252    /// Supply an optional solver initial point.
253    #[must_use]
254    pub fn with_initial_point(mut self, point: OperatingPoint<BalancedNetwork>) -> Self {
255        self.initial_point = Some(point);
256        self
257    }
258
259    /// Replace the network and recalculate the fixed bus specifications while
260    /// preserving a compatible initial point.
261    ///
262    /// # Errors
263    /// The replacement has no reference bus, has conflicting voltage
264    /// controllers, or changes an identity layout used by the initial point.
265    pub fn with_network(mut self, mut network: BalancedNetwork) -> Result<Self, Error> {
266        network.assign_missing_component_ids();
267        let mut replacement = Self::from_network(network.clone())?;
268        if let Some(initial) = self.initial_point.take() {
269            replacement.initial_point = Some(initial.rebind_network(network)?);
270        }
271        Ok(replacement)
272    }
273
274    /// The network this instance calculates on. Borrowed; never a copy.
275    #[must_use]
276    pub fn network(&self) -> &BalancedNetwork {
277        &self.network
278    }
279
280    /// The per bus specifications, in bus table order.
281    #[must_use]
282    pub fn specifications(&self) -> &[AcBusSpecification] {
283        &self.specifications
284    }
285
286    /// The optional solver initial point.
287    #[must_use]
288    pub const fn initial_point(&self) -> Option<&OperatingPoint<BalancedNetwork>> {
289        self.initial_point.as_ref()
290    }
291
292    /// The DC power flow instance this AC problem implies: reactive data and
293    /// voltage magnitudes are discarded, and the DC model's flat voltage
294    /// assumption is recorded as a diagnostic.
295    #[must_use]
296    pub fn to_dc_pf(&self) -> (DcPfInstance, Vec<powerio_core::Diagnostic>) {
297        let instance = DcPfInstance {
298            network: self.network.clone(),
299            specifications: self
300                .specifications
301                .iter()
302                .map(|specification| match *specification {
303                    AcBusSpecification::Pq { p, .. } | AcBusSpecification::Pv { p, .. } => {
304                        DcBusSpecification::NetActivePower { p_mw: p }
305                    }
306                    AcBusSpecification::Reference { va, .. } => {
307                        DcBusSpecification::Reference { va_degrees: va }
308                    }
309                    AcBusSpecification::Isolated => DcBusSpecification::Isolated,
310                })
311                .collect(),
312            branch_susceptance_formula: BranchSusceptanceFormula::default(),
313            initial_point: self.initial_point.clone(),
314        };
315        let diagnostics = vec![
316            transform_discarded("reactive power and voltage magnitude specifications"),
317            transform_assumption(
318                "the DC power flow model holds every voltage magnitude at one per unit",
319            ),
320        ];
321        (instance, diagnostics)
322    }
323}
324
325/// The DC optimal power flow instance: the shared network, the typed
326/// objective, the active constraint selections, the selected DC branch
327/// susceptance formula, and the reference conditions the network states.
328#[derive(Clone, Debug)]
329pub struct DcOpfInstance {
330    network: BalancedNetwork,
331    objective: Objective,
332    constraints: ActiveConstraints,
333    branch_susceptance_formula: BranchSusceptanceFormula,
334    initial_point: Option<OperatingPoint<BalancedNetwork>>,
335}
336
337impl DcOpfInstance {
338    /// Build the instance with every stated limit active. The default
339    /// objective is the network's generator cost curves when at least one
340    /// dispatchable generator carries cost data. A network with no applicable
341    /// cost rows becomes an explicit feasibility problem instead of inventing
342    /// zero cost curves. Partially populated costs still select the network
343    /// objective so preparation reports the missing row.
344    ///
345    /// # Errors
346    /// A network with no reference bus or no in service generator attached to
347    /// a non-isolated bus.
348    pub fn from_network(mut network: BalancedNetwork) -> Result<Self, Error> {
349        network.assign_missing_component_ids();
350        require_reference(&network)?;
351        require_dispatchable(&network)?;
352        let objective = default_opf_objective(&network);
353        Ok(Self {
354            network,
355            objective,
356            constraints: ActiveConstraints::default(),
357            branch_susceptance_formula: BranchSusceptanceFormula::default(),
358            initial_point: None,
359        })
360    }
361
362    /// Replace the objective, consuming the instance. The shared network
363    /// moves; no table is copied.
364    #[must_use]
365    pub fn with_objective(mut self, objective: Objective) -> Self {
366        self.objective = objective;
367        self
368    }
369
370    /// Append one objective term, consuming the instance.
371    #[must_use]
372    pub fn with_objective_term(mut self, term: ObjectiveTerm) -> Self {
373        self.objective = std::mem::take(&mut self.objective).with_term(term);
374        self
375    }
376
377    /// Replace the active constraint selections, consuming the instance.
378    #[must_use]
379    pub fn with_constraints(mut self, constraints: ActiveConstraints) -> Self {
380        self.constraints = constraints;
381        self
382    }
383
384    /// Select the branch susceptance formula, consuming the instance.
385    #[must_use]
386    pub fn with_branch_susceptance_formula(mut self, formula: BranchSusceptanceFormula) -> Self {
387        self.branch_susceptance_formula = formula;
388        self
389    }
390
391    /// Supply an optional solver initial point.
392    #[must_use]
393    pub fn with_initial_point(mut self, point: OperatingPoint<BalancedNetwork>) -> Self {
394        self.initial_point = Some(point);
395        self
396    }
397
398    /// Replace the network while preserving this instance's objective,
399    /// constraint selections, branch susceptance formula, and compatible initial point.
400    /// This is the checked path for a parameter edit such as a branch rating
401    /// change; callers do not have to reconstruct the problem and risk
402    /// dropping its semantics.
403    ///
404    /// # Errors
405    /// The replacement has no reference bus or dispatchable generator, or it
406    /// changes an identity layout used by the initial point.
407    pub fn with_network(mut self, mut network: BalancedNetwork) -> Result<Self, Error> {
408        network.assign_missing_component_ids();
409        require_reference(&network)?;
410        require_dispatchable(&network)?;
411        if let Some(initial) = self.initial_point.take() {
412            self.initial_point = Some(initial.rebind_network(network.clone())?);
413        }
414        self.network = network;
415        Ok(self)
416    }
417
418    /// The network this instance calculates on. Borrowed; never a copy.
419    #[must_use]
420    pub fn network(&self) -> &BalancedNetwork {
421        &self.network
422    }
423
424    /// The typed objective.
425    #[must_use]
426    pub const fn objective(&self) -> &Objective {
427        &self.objective
428    }
429
430    /// The active constraint selections.
431    #[must_use]
432    pub const fn constraints(&self) -> &ActiveConstraints {
433        &self.constraints
434    }
435
436    /// The selected branch susceptance formula.
437    #[must_use]
438    pub const fn branch_susceptance_formula(&self) -> BranchSusceptanceFormula {
439        self.branch_susceptance_formula
440    }
441
442    /// The optional solver initial point.
443    #[must_use]
444    pub const fn initial_point(&self) -> Option<&OperatingPoint<BalancedNetwork>> {
445        self.initial_point.as_ref()
446    }
447
448    /// The DC power flow instance for this problem's network at its stated
449    /// injections: the objective and the constraint selections are
450    /// discarded, and the discard is recorded.
451    ///
452    /// # Errors
453    /// As [`DcPfInstance::from_network`].
454    pub fn to_dc_pf(&self) -> Result<(DcPfInstance, Vec<powerio_core::Diagnostic>), Error> {
455        let instance = DcPfInstance::from_network(self.network.clone())?
456            .with_branch_susceptance_formula(self.branch_susceptance_formula);
457        Ok((
458            instance,
459            vec![transform_discarded(
460                "the objective and the active constraint selections",
461            )],
462        ))
463    }
464}
465
466/// The AC optimal power flow instance: the shared network, the typed
467/// objective, and the active generator capability, voltage, thermal, and
468/// angle constraint selections.
469#[derive(Clone, Debug)]
470pub struct AcOpfInstance {
471    network: BalancedNetwork,
472    objective: Objective,
473    constraints: ActiveConstraints,
474    initial_point: Option<OperatingPoint<BalancedNetwork>>,
475}
476
477impl AcOpfInstance {
478    /// Build the instance with every stated limit active. The default
479    /// objective is the network's generator cost curves when at least one
480    /// dispatchable generator carries cost data. A network with no applicable
481    /// cost rows becomes an explicit feasibility problem instead of inventing
482    /// zero cost curves. Partially populated costs still select the network
483    /// objective so preparation reports the missing row.
484    ///
485    /// # Errors
486    /// A network with no reference bus or no in service generator attached to
487    /// a non-isolated bus.
488    pub fn from_network(mut network: BalancedNetwork) -> Result<Self, Error> {
489        network.assign_missing_component_ids();
490        require_reference(&network)?;
491        require_dispatchable(&network)?;
492        let objective = default_opf_objective(&network);
493        Ok(Self {
494            network,
495            objective,
496            constraints: ActiveConstraints::default(),
497            initial_point: None,
498        })
499    }
500
501    /// Replace the objective, consuming the instance. The shared network
502    /// moves; no table is copied.
503    #[must_use]
504    pub fn with_objective(mut self, objective: Objective) -> Self {
505        self.objective = objective;
506        self
507    }
508
509    /// Append one objective term, consuming the instance.
510    #[must_use]
511    pub fn with_objective_term(mut self, term: ObjectiveTerm) -> Self {
512        self.objective = std::mem::take(&mut self.objective).with_term(term);
513        self
514    }
515
516    /// Replace the active constraint selections, consuming the instance.
517    #[must_use]
518    pub fn with_constraints(mut self, constraints: ActiveConstraints) -> Self {
519        self.constraints = constraints;
520        self
521    }
522
523    /// Supply an optional solver initial point.
524    #[must_use]
525    pub fn with_initial_point(mut self, point: OperatingPoint<BalancedNetwork>) -> Self {
526        self.initial_point = Some(point);
527        self
528    }
529
530    /// Replace the network while preserving this instance's objective,
531    /// constraint selections, and compatible initial point.
532    ///
533    /// # Errors
534    /// The replacement has no reference bus or dispatchable generator, or it
535    /// changes an identity layout used by the initial point.
536    pub fn with_network(mut self, mut network: BalancedNetwork) -> Result<Self, Error> {
537        network.assign_missing_component_ids();
538        require_reference(&network)?;
539        require_dispatchable(&network)?;
540        if let Some(initial) = self.initial_point.take() {
541            self.initial_point = Some(initial.rebind_network(network.clone())?);
542        }
543        self.network = network;
544        Ok(self)
545    }
546
547    /// The network this instance calculates on. Borrowed; never a copy.
548    #[must_use]
549    pub fn network(&self) -> &BalancedNetwork {
550        &self.network
551    }
552
553    /// The typed objective.
554    #[must_use]
555    pub const fn objective(&self) -> &Objective {
556        &self.objective
557    }
558
559    /// The active constraint selections.
560    #[must_use]
561    pub const fn constraints(&self) -> &ActiveConstraints {
562        &self.constraints
563    }
564
565    /// The optional solver initial point.
566    #[must_use]
567    pub const fn initial_point(&self) -> Option<&OperatingPoint<BalancedNetwork>> {
568        self.initial_point.as_ref()
569    }
570
571    /// The AC power flow instance for this problem's network at its stated
572    /// injections and setpoints: the objective and the constraint selections
573    /// are discarded, and the discard is recorded.
574    ///
575    /// # Errors
576    /// As [`AcPfInstance::from_network`].
577    pub fn to_ac_pf(&self) -> Result<(AcPfInstance, Vec<powerio_core::Diagnostic>), Error> {
578        let instance = AcPfInstance::from_network(self.network.clone())?;
579        Ok((
580            instance,
581            vec![transform_discarded(
582                "the objective and the active constraint selections",
583            )],
584        ))
585    }
586
587    /// The DC optimal power flow instance this AC problem implies: the
588    /// objective and the generator capability, thermal, and angle selections
589    /// carry over; the voltage bound selection has no DC variable and is
590    /// discarded, and the flat voltage assumption is recorded.
591    #[must_use]
592    pub fn to_dc_opf(&self) -> (DcOpfInstance, Vec<powerio_core::Diagnostic>) {
593        let constraints = ActiveConstraints {
594            generator_capability: self.constraints.generator_capability.clone(),
595            voltage_bounds: crate::instance::ConstraintSelection::None,
596            thermal_limits: self.constraints.thermal_limits.clone(),
597            angle_bounds: self.constraints.angle_bounds.clone(),
598        };
599        let instance = DcOpfInstance {
600            network: self.network.clone(),
601            objective: self.objective.clone(),
602            constraints,
603            branch_susceptance_formula: BranchSusceptanceFormula::default(),
604            initial_point: self.initial_point.clone(),
605        };
606        let diagnostics = vec![
607            transform_discarded("the voltage bound constraint selection"),
608            transform_assumption(
609                "the DC power flow model holds every voltage magnitude at one per unit",
610            ),
611        ];
612        (instance, diagnostics)
613    }
614}
615
616/// Net stated active injection at one bus, MW, over in service elements.
617/// Per bus totals of the in service generators and loads, plus the voltage
618/// setpoint agreement, gathered in one pass so instance construction stays
619/// linear in bus plus generator plus load count.
620#[derive(Default)]
621struct BusAggregate {
622    p_gen: f64,
623    q_gen: f64,
624    p_load: f64,
625    q_load: f64,
626    setpoint: Option<f64>,
627    conflicting: Option<f64>,
628}
629
630fn aggregate_bus_elements(network: &BalancedNetwork) -> BTreeMap<BusId, BusAggregate> {
631    let mut totals: BTreeMap<BusId, BusAggregate> = BTreeMap::new();
632    for generator in network
633        .generators()
634        .iter()
635        .filter(|generator| generator.in_service)
636    {
637        let entry = totals.entry(generator.bus).or_default();
638        entry.p_gen += generator.pg;
639        entry.q_gen += generator.qg;
640        match entry.setpoint {
641            None => entry.setpoint = Some(generator.vg),
642            Some(existing) if existing.to_bits() == generator.vg.to_bits() => {}
643            Some(_) => {
644                if entry.conflicting.is_none() {
645                    entry.conflicting = Some(generator.vg);
646                }
647            }
648        }
649    }
650    for load in network.loads().iter().filter(|load| load.in_service) {
651        let entry = totals.entry(load.bus).or_default();
652        entry.p_load += load.p;
653        entry.q_load += load.q;
654    }
655    totals
656}
657
658fn net_active_power(totals: &BTreeMap<BusId, BusAggregate>, bus: BusId) -> f64 {
659    totals
660        .get(&bus)
661        .map_or(0.0, |entry| entry.p_gen - entry.p_load)
662}
663
664/// Net stated reactive injection at one bus, MVAr, over in service elements.
665fn net_reactive_power(totals: &BTreeMap<BusId, BusAggregate>, bus: BusId) -> f64 {
666    totals
667        .get(&bus)
668        .map_or(0.0, |entry| entry.q_gen - entry.q_load)
669}
670
671/// The controlled voltage magnitude at one bus: the in service generators'
672/// shared setpoint, else the bus's stated magnitude. Two in service
673/// generators stating different setpoints at one bus are conflicting active
674/// voltage controllers and are refused.
675fn controlled_magnitude(
676    totals: &BTreeMap<BusId, BusAggregate>,
677    bus: BusId,
678    stated: f64,
679) -> Result<f64, Error> {
680    let Some(entry) = totals.get(&bus) else {
681        return Ok(stated);
682    };
683    if let (Some(existing), Some(other)) = (entry.setpoint, entry.conflicting) {
684        return Err(Error::new(
685            &codes::BUILD_INSTANCE_VOLTAGE_CONTROL_CONFLICT,
686            format!(
687                "bus {bus} has in service generators stating voltage setpoints {existing} and {other}; resolve the conflict explicitly before constructing the power flow instance"
688            ),
689        ));
690    }
691    Ok(entry.setpoint.unwrap_or(stated))
692}
693
694fn require_reference(network: &BalancedNetwork) -> Result<(), Error> {
695    if network.buses().iter().any(|bus| bus.kind == BusType::Ref) {
696        Ok(())
697    } else {
698        Err(Error::new(
699            &codes::BUILD_INSTANCE_NO_REFERENCE_BUS,
700            "the network states no reference (slack) bus",
701        ))
702    }
703}
704
705fn require_dispatchable(network: &BalancedNetwork) -> Result<(), Error> {
706    let active_buses = active_bus_ids(network);
707    if network
708        .generators()
709        .iter()
710        .any(|generator| generator.in_service && active_buses.contains(&generator.bus))
711    {
712        Ok(())
713    } else {
714        Err(Error::new(
715            &codes::BUILD_INSTANCE_NO_GENERATORS,
716            "the network has no in service generator for the problem to dispatch",
717        ))
718    }
719}
720
721fn default_opf_objective(network: &BalancedNetwork) -> Objective {
722    let active_buses = active_bus_ids(network);
723    if network.generators().iter().any(|generator| {
724        generator.in_service && active_buses.contains(&generator.bus) && generator.cost.is_some()
725    }) {
726        Objective::network_generator_cost()
727    } else {
728        Objective::none()
729    }
730}
731
732fn active_bus_ids(network: &BalancedNetwork) -> BTreeSet<BusId> {
733    network
734        .buses()
735        .iter()
736        .filter(|bus| bus.kind != BusType::Isolated)
737        .map(|bus| bus.id)
738        .collect()
739}
740
741pub(crate) fn transform_discarded(what: &str) -> powerio_core::Diagnostic {
742    powerio_core::Diagnostic::of(
743        &codes::TRANSFORM_INSTANCE_DATA_DISCARDED,
744        format!("{what} of the source instance are not part of the derived calculation"),
745    )
746}
747
748pub(crate) fn transform_assumption(what: &str) -> powerio_core::Diagnostic {
749    powerio_core::Diagnostic::of(&codes::TRANSFORM_INSTANCE_ASSUMPTION, what)
750}