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