Skip to main content

powerio_tx/
normalize.rs

1//! The universal normalization shared by the PowerModels reader/writer and
2//! [`BalancedNetwork::to_normalized`].
3//!
4//! Two things live here so there is one implementation of each:
5//!
6//! - **Per-unit scaling factors and the gen-cost rescale** ([`cost_to_pu`] /
7//!   [`cost_from_pu`], [`DEG_TO_RAD`] / [`RAD_TO_DEG`], [`GEN_PU_KEYS`]). The
8//!   PowerModels writer scales raw model values into its per-unit JSON; the
9//!   reader inverts it; [`BalancedNetwork::to_normalized`] scales the same way into a new
10//!   `BalancedNetwork`. The cost rescale is the one piece subtle enough that a second copy
11//!   would drift, so it has a single home.
12//! - **[`BalancedNetwork::to_normalized`]**: a derived, computation-ready form, per unit,
13//!   radians, out of service filtered, source ID preserving, bus types canonicalized.
14
15use std::collections::{HashMap, HashSet};
16
17use crate::islands::{
18    IslandPartition, IslandReferencePolicy, bus_list, kept_reference, largest_generator_bus,
19};
20use crate::merge::{BusMerge, BusMergeRule};
21use crate::network::{
22    BalancedNetwork, BalancedNetworkTables, Branch, Bus, BusId, BusType, GEN_EXTRA_KEYS, GenCost,
23    Generator, Hvdc, Load, LoadVoltageModel, Shunt, SourceFormat, StaticVarCompensator, Storage,
24    Switch, Transformer3W, TransformerControl, TransformerControlMode,
25};
26use crate::{Error, Result};
27
28/// Degrees → radians. The per-unit convention stores angles in radians; the raw
29/// model keeps MATPOWER degrees.
30pub(crate) const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
31
32/// Radians → degrees, the inverse of [`DEG_TO_RAD`], used when reading a per-unit
33/// source back into the neutral degree model.
34pub(crate) const RAD_TO_DEG: f64 = 180.0 / std::f64::consts::PI;
35
36fn norm_transformer_control(
37    control: &mut TransformerControl,
38    base_mva: f64,
39    map: &HashMap<BusId, BusId>,
40) {
41    control.controlled_bus = control
42        .controlled_bus
43        .and_then(|controlled_bus| remap(map, controlled_bus));
44    // Siemens defines RMA/RMI as phase shift angles only for |COD| 3 and 5.
45    // The values are unused for |COD| 0 and 4, so Fixed and DcLineQuantity
46    // retain their source values instead of assigning units they do not have.
47    if matches!(
48        control.mode,
49        TransformerControlMode::ActiveFlow | TransformerControlMode::AsymmetricActiveFlow
50    ) {
51        control.tap_min *= DEG_TO_RAD;
52        control.tap_max *= DEG_TO_RAD;
53    }
54    // Siemens defines VMA/VMI as Mvar for |COD| 2 and MW for |COD| 3 and 5.
55    // They are unused for |COD| 0 and 4.
56    if matches!(
57        control.mode,
58        TransformerControlMode::ReactiveFlow
59            | TransformerControlMode::ActiveFlow
60            | TransformerControlMode::AsymmetricActiveFlow
61    ) {
62        control.band_min /= base_mva;
63        control.band_max /= base_mva;
64    }
65    if let Some(angle) = &mut control.winding_connection_angle {
66        *angle *= DEG_TO_RAD;
67    }
68}
69
70/// The gen capability columns that are per-unitized (the ramp rates). The PQ-curve
71/// points (`pc1`/`pc2`/`qc*`) and `apf` stay raw, exactly as PowerModels'
72/// `make_per_unit!` leaves them, so a column is scaled in one place and can't drift
73/// between the reader, the writer, and [`BalancedNetwork::to_normalized`].
74pub(crate) const GEN_PU_KEYS: [&str; 4] = ["ramp_agc", "ramp_10", "ramp_30", "ramp_q"];
75
76/// Default branch angle difference bound used by PowerModels parse time repair.
77#[allow(clippy::approx_constant)]
78pub const POWER_MODELS_ANGLE_BOUND_PAD: f64 = 1.0472;
79
80/// What normalization does with a closed switch that joins two buses.
81#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
82#[non_exhaustive]
83pub enum ClosedSwitchPolicy {
84    /// Merge the buses closed switches join before the pass, with
85    /// [`BusMergeRule::closed_switches`], and report the merge under
86    /// `CANONICALIZE.MERGE`. The matrix builders and solver preparations
87    /// stamp branches only, so a normalized network carries no closed switch
88    /// for them to leave apart. A closed switch on an isolated or undeclared
89    /// bus is left to the filters that drop that bus. Returns
90    /// [`Error::ClosedSwitch`] if a requested merge is refused, for example
91    /// when it would join two windings of one three winding transformer.
92    #[default]
93    Merge,
94    /// Refuse a network with such a switch with [`Error::ClosedSwitch`]
95    /// (`BUILD.SWITCH.CLOSED`).
96    Refuse,
97    /// Keep closed switches as data, as normalization did before it merged
98    /// them, for a consumer that models switches itself.
99    Keep,
100}
101
102/// Options for [`BalancedNetwork::to_normalized_with_options`].
103#[derive(Clone, Copy, Debug, PartialEq)]
104pub struct NormalizeOptions {
105    /// Clamp branch angle difference bounds to the interval PowerModels relaxations
106    /// accept. Disabled by default so [`BalancedNetwork::to_normalized`] stays unchanged.
107    pub clamp_angle_bounds: bool,
108    /// Replacement magnitude, in radians, for clamped angle bounds.
109    pub angle_bound_pad: f64,
110}
111
112impl Default for NormalizeOptions {
113    fn default() -> Self {
114        Self {
115            clamp_angle_bounds: false,
116            angle_bound_pad: POWER_MODELS_ANGLE_BOUND_PAD,
117        }
118    }
119}
120
121/// Options for [`BalancedNetwork::to_normalized_with`]: the
122/// [`NormalizeOptions`] repairs and the normalization choices added after
123/// them. Build one from its default or from [`NormalizeOptions`] and set the
124/// rest with the `with_*` methods.
125#[derive(Clone, Copy, Debug, Default, PartialEq)]
126#[non_exhaustive]
127pub struct NormalizeSettings {
128    /// The repairs [`BalancedNetwork::to_normalized_with_options`] applies.
129    pub options: NormalizeOptions,
130    /// What happens to closed switches; by default they merge.
131    pub closed_switches: ClosedSwitchPolicy,
132    /// How the reference buses of a network with several islands are chosen.
133    /// [`IslandReferencePolicy::Stated`], the default, keeps the behavior of
134    /// [`BalancedNetwork::to_normalized`];
135    /// [`IslandReferencePolicy::PerIsland`] gives every island one reference
136    /// and leaves out the islands no in-service generator supplies.
137    pub island_references: IslandReferencePolicy,
138}
139
140impl NormalizeSettings {
141    /// `options` with the default choice for everything else.
142    #[must_use]
143    pub const fn new(options: NormalizeOptions) -> Self {
144        Self {
145            options,
146            closed_switches: ClosedSwitchPolicy::Merge,
147            island_references: IslandReferencePolicy::Stated,
148        }
149    }
150
151    #[must_use]
152    pub const fn with_options(mut self, options: NormalizeOptions) -> Self {
153        self.options = options;
154        self
155    }
156
157    #[must_use]
158    pub const fn with_closed_switches(mut self, policy: ClosedSwitchPolicy) -> Self {
159        self.closed_switches = policy;
160        self
161    }
162
163    #[must_use]
164    pub const fn with_island_references(mut self, policy: IslandReferencePolicy) -> Self {
165        self.island_references = policy;
166        self
167    }
168}
169
170impl From<NormalizeOptions> for NormalizeSettings {
171    fn from(options: NormalizeOptions) -> Self {
172        Self::new(options)
173    }
174}
175
176/// Output of [`BalancedNetwork::to_normalized_with_options`].
177#[derive(Clone, Debug)]
178pub struct NormalizedNetwork {
179    pub network: BalancedNetwork,
180    /// The pass's findings as structured records.
181    pub diagnostics: Vec<crate::diagnostics::Diagnostic>,
182    /// The same findings as `CODE: message` lines.
183    pub warnings: Vec<String>,
184}
185
186/// What [`BalancedNetwork::to_normalized_with`] changed in the network before
187/// the pass itself, beside the [`NormalizedNetwork`].
188#[derive(Clone, Debug, Default)]
189#[non_exhaustive]
190pub struct NormalizeReport {
191    /// The closed switch merge under [`ClosedSwitchPolicy::Merge`]: its bus
192    /// map, removed switches, and row maps from the source. `None` when no
193    /// closed switch joined two buses the pass keeps, or under another
194    /// policy.
195    pub bus_merge: Option<BusMerge>,
196}
197
198/// Row provenance for one normalize pass: for each dense position in the
199/// [`IndexedNetwork`](crate::IndexedNetwork) view of the normalized network,
200/// the row of the same element family in the source network. `None` marks an
201/// element the pipeline synthesized, which has no source row.
202///
203/// Every field is positional over that view, so `buses[dense_index]` resolves
204/// a matrix row back to its source element. Each length equals the matching
205/// element table of the view: `buses` equals `view.n()`, `branches` equals
206/// `view.branches().len()`.
207///
208/// The star lowering that the view applies to a 3-winding transformer appends
209/// one bus, its star branches, and a magnetizing shunt. Those entries are
210/// `None`. The lowering also consumes the transformer itself, so the view
211/// holds none; `transformers_3w` therefore stays positional over the
212/// normalized network's own list.
213///
214/// The map is valid only for the [`NormalizedNetwork`] returned beside it. A
215/// later mutation of that network ([`BalancedNetwork::merge_bus`],
216/// [`BalancedNetwork::reduce_zero_impedance`], [`BalancedNetwork::reduce_passthrough_buses`],
217/// [`BalancedNetwork::subset`], or a hand edit) invalidates every entry; run the pass
218/// again instead of patching the map.
219#[derive(Clone, Debug)]
220#[non_exhaustive]
221pub struct NormalizeSourceRows {
222    pub buses: Vec<Option<usize>>,
223    pub loads: Vec<Option<usize>>,
224    pub shunts: Vec<Option<usize>>,
225    pub static_var_compensators: Vec<Option<usize>>,
226    pub branches: Vec<Option<usize>>,
227    pub switches: Vec<Option<usize>>,
228    pub generators: Vec<Option<usize>>,
229    pub storage: Vec<Option<usize>>,
230    pub hvdc: Vec<Option<usize>>,
231    pub transformers_3w: Vec<Option<usize>>,
232}
233
234impl NormalizeSourceRows {
235    /// Rows over a merged network, carried back to the network `merge`
236    /// read. Buses resolve by id; a removed branch or switch has no merged
237    /// row, and a shunt the merge added for a removed branch's charging has
238    /// no source row. The other families keep their rows.
239    fn through_merge(mut self, merge: &BusMerge) -> Self {
240        let source_bus: HashMap<BusId, usize> = merge
241            .source
242            .buses()
243            .iter()
244            .enumerate()
245            .map(|(row, bus)| (bus.id, row))
246            .collect();
247        let merged_buses = merge.network.buses();
248        for row in self.buses.iter_mut().flatten() {
249            *row = source_bus[&merged_buses[*row].id];
250        }
251        let inverse = |rows: &[Option<usize>]| {
252            let mut inverse = vec![0; rows.iter().flatten().count()];
253            for (source, merged) in rows.iter().enumerate() {
254                if let Some(merged) = merged {
255                    inverse[*merged] = source;
256                }
257            }
258            inverse
259        };
260        let branches = inverse(&merge.branch_rows);
261        for row in self.branches.iter_mut().flatten() {
262            *row = branches[*row];
263        }
264        let switches = inverse(&merge.switch_rows);
265        for row in self.switches.iter_mut().flatten() {
266            *row = switches[*row];
267        }
268        let source_shunts = merge.source.shunts().len();
269        for row in &mut self.shunts {
270            if row.is_some_and(|row| row >= source_shunts) {
271                *row = None;
272            }
273        }
274        self
275    }
276
277    /// Grow the families the star lowering appends to so each length matches the
278    /// lowered form of `net`. The appended entries have no source row. The
279    /// lengths come from [`BalancedNetwork::lowered_lengths`], which counts them off the
280    /// transformer records, so padding never builds the lowering itself.
281    pub(crate) fn pad_to_lowered(&mut self, net: &BalancedNetwork) {
282        let lengths = net.lowered_lengths();
283        self.buses.resize(lengths.buses, None);
284        self.branches.resize(lengths.branches, None);
285        self.shunts.resize(lengths.shunts, None);
286    }
287}
288
289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
290enum CostModel {
291    Piecewise,
292    Polynomial,
293    Unknown,
294}
295
296impl From<u8> for CostModel {
297    fn from(value: u8) -> Self {
298        match value {
299            1 => CostModel::Piecewise,
300            2 => CostModel::Polynomial,
301            _ => CostModel::Unknown,
302        }
303    }
304}
305
306/// Gen cost coefficients rescaled into the per-unit basis, trimmed to the length
307/// the model implies (a polynomial keeps `ncost` coeffs; a piecewise curve keeps
308/// `2·ncost` `(mw, cost)` values). MATPOWER pads every gencost row to the matrix
309/// width with trailing zeros; the padding would make a polynomial read as a
310/// higher-degree curve and mis-scale, so it is dropped here.
311///
312/// Polynomial (model 2): coeff `i` is the term `p^(k-1-i)`, so per unit scales it
313/// by `base^(k-1-i)`. Piecewise (model 1): the MW breakpoints (even positions) are
314/// divided by `base`; the cost ordinates (odd positions) stay. Any other model has
315/// unknown coefficient semantics, so it passes through untouched — the exact
316/// inverse of [`cost_from_pu`]'s own passthrough.
317pub(crate) fn cost_to_pu(cost: &GenCost, base: f64) -> Vec<f64> {
318    let mut coeffs = cost.coeffs.clone();
319    scale_coeffs_to_pu(&mut coeffs, cost.ncost, cost.model, base);
320    coeffs
321}
322
323/// [`cost_to_pu`] over a vector the caller already owns, so a rescale in place
324/// keeps its allocation.
325pub(crate) fn scale_coeffs_to_pu(coeffs: &mut Vec<f64>, ncost: usize, model: u8, base: f64) {
326    match CostModel::from(model) {
327        CostModel::Polynomial => {
328            coeffs.truncate(ncost.min(coeffs.len()));
329            let k = coeffs.len();
330            // The exponent k-1-i is in [0, k-1]; a polynomial never has i32::MAX-many
331            // terms, so the conversion can't fail (loud, not silent, if it ever did).
332            for (i, c) in coeffs.iter_mut().enumerate() {
333                *c *= base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32"));
334            }
335        }
336        CostModel::Piecewise => {
337            // saturating_mul: `ncost` comes from input (JSON deserializes it
338            // unchecked), so an oversized count must clamp to the coefficient
339            // length instead of overflowing.
340            coeffs.truncate(ncost.saturating_mul(2).min(coeffs.len()));
341            for c in coeffs.iter_mut().step_by(2) {
342                *c /= base;
343            }
344        }
345        CostModel::Unknown => {}
346    }
347}
348
349/// Undo [`cost_to_pu`] for the neutral MW basis: a polynomial (model 2) divides
350/// coeff `i` by `base^(k-1-i)`, a piecewise curve (model 1) multiplies its MW
351/// breakpoints (even positions) by `base`. The exact inverse of [`cost_to_pu`] on
352/// the trimmed coefficient vector — JSON-sourced coefficients arrive already
353/// trimmed, so this does no trimming; other models pass through unchanged.
354pub(crate) fn cost_from_pu(coeffs: &[f64], model: u8, base: f64) -> Vec<f64> {
355    let k = coeffs.len();
356    match CostModel::from(model) {
357        CostModel::Polynomial => coeffs
358            .iter()
359            .enumerate()
360            .map(|(i, &c)| c / base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32")))
361            .collect(),
362        CostModel::Piecewise => coeffs
363            .iter()
364            .enumerate()
365            .map(|(i, &c)| if i % 2 == 0 { c * base } else { c })
366            .collect(),
367        CostModel::Unknown => coeffs.to_vec(),
368    }
369}
370
371/// Map a source bus id to its surviving normalized id, or `None` if the bus was dropped.
372fn remap(map: &HashMap<BusId, BusId>, id: BusId) -> Option<BusId> {
373    map.get(&id).copied()
374}
375
376fn norm_loads(
377    loads: &[Load],
378    base: f64,
379    map: &HashMap<BusId, BusId>,
380) -> (Vec<Load>, Vec<Option<usize>>) {
381    loads
382        .iter()
383        .enumerate()
384        .filter(|(_, l)| l.in_service)
385        .filter_map(|(row, l)| {
386            Some((
387                Load {
388                    bus: remap(map, l.bus)?,
389                    p: l.p / base,
390                    q: l.q / base,
391                    voltage_model: l
392                        .voltage_model
393                        .as_ref()
394                        .map(|m| norm_load_voltage_model(m, base)),
395                    ..l.clone()
396                },
397                Some(row),
398            ))
399        })
400        .unzip()
401}
402
403fn norm_load_voltage_model(model: &LoadVoltageModel, base: f64) -> LoadVoltageModel {
404    match model {
405        LoadVoltageModel::ConstantPower => LoadVoltageModel::ConstantPower,
406        LoadVoltageModel::Zip {
407            p_constant_power,
408            q_constant_power,
409            p_constant_current,
410            q_constant_current,
411            p_constant_impedance,
412            q_constant_impedance,
413            v_nom,
414            load_type,
415            scaling,
416        } => LoadVoltageModel::Zip {
417            p_constant_power: p_constant_power / base,
418            q_constant_power: q_constant_power / base,
419            p_constant_current: p_constant_current / base,
420            q_constant_current: q_constant_current / base,
421            p_constant_impedance: p_constant_impedance / base,
422            q_constant_impedance: q_constant_impedance / base,
423            v_nom: *v_nom,
424            load_type: *load_type,
425            scaling: *scaling,
426        },
427        LoadVoltageModel::Exponential {
428            p,
429            q,
430            v_nom,
431            gamma_p,
432            gamma_q,
433        } => LoadVoltageModel::Exponential {
434            p: p / base,
435            q: q / base,
436            v_nom: *v_nom,
437            gamma_p: *gamma_p,
438            gamma_q: *gamma_q,
439        },
440    }
441}
442
443fn norm_shunts(
444    shunts: &[Shunt],
445    base: f64,
446    map: &HashMap<BusId, BusId>,
447) -> (Vec<Shunt>, Vec<Option<usize>>) {
448    shunts
449        .iter()
450        .enumerate()
451        .filter(|(_, s)| s.in_service)
452        .filter_map(|(row, s)| {
453            let mut shunt = s.clone();
454            shunt.bus = remap(map, s.bus)?;
455            shunt.g = s.g / base;
456            shunt.b = s.b / base;
457            // Remap the switched-shunt control bus and drop it if its target was
458            // filtered out, so the normalized network has no dangling reference.
459            if let Some(c) = &mut shunt.control {
460                c.control_bus = c.control_bus.and_then(|b| remap(map, b));
461                for block in &mut c.blocks {
462                    block.g /= base;
463                    block.b /= base;
464                }
465            }
466            Some((shunt, Some(row)))
467        })
468        .unzip()
469}
470
471fn norm_static_var_compensators(
472    compensators: &[StaticVarCompensator],
473    base: f64,
474    map: &HashMap<BusId, BusId>,
475) -> (Vec<StaticVarCompensator>, Vec<Option<usize>>) {
476    compensators
477        .iter()
478        .enumerate()
479        .filter(|(_, svc)| svc.in_service)
480        .filter_map(|(row, svc)| {
481            let mut normalized = svc.clone();
482            normalized.bus = remap(map, svc.bus)?;
483            normalized.p = svc.p / base;
484            normalized.q = svc.q / base;
485            Some((normalized, Some(row)))
486        })
487        .unzip()
488}
489
490fn norm_branches(
491    branches: &[Branch],
492    base: f64,
493    map: &HashMap<BusId, BusId>,
494) -> (Vec<Branch>, Vec<Option<usize>>) {
495    branches
496        .iter()
497        .enumerate()
498        .filter(|(_, br)| br.in_service)
499        .filter_map(|(row, br)| {
500            let mut branch = br.clone();
501            branch.from = remap(map, br.from)?;
502            branch.to = remap(map, br.to)?;
503            branch.rate_a = br.rate_a / base;
504            branch.rate_b = br.rate_b / base;
505            branch.rate_c = br.rate_c / base;
506            for set in &mut branch.rating_sets {
507                set.rate_mva /= base;
508            }
509            branch.tap = br.calc_effective_tap();
510            branch.shift = br.shift * DEG_TO_RAD;
511            branch.angmin = br.angmin * DEG_TO_RAD;
512            branch.angmax = br.angmax * DEG_TO_RAD;
513            if let Some(s) = &mut branch.solution {
514                s.pf /= base;
515                s.qf /= base;
516                s.pt /= base;
517                s.qt /= base;
518            }
519            // Remap the regulated-bus reference through the id map and drop it
520            // if its target was filtered out (out of service / isolated), so the
521            // normalized network has no dangling control reference.
522            if let Some(c) = &mut branch.control {
523                norm_transformer_control(c, base, map);
524            }
525            Some((branch, Some(row)))
526        })
527        .unzip()
528}
529
530fn validate_normalize_options(options: &NormalizeOptions) -> Result<()> {
531    if options.clamp_angle_bounds
532        && (!options.angle_bound_pad.is_finite()
533            || options.angle_bound_pad <= 0.0
534            || options.angle_bound_pad >= std::f64::consts::FRAC_PI_2)
535    {
536        return Err(Error::InvalidNormalizeOption {
537            field: "angle_bound_pad",
538            value: options.angle_bound_pad,
539        });
540    }
541    Ok(())
542}
543
544#[allow(clippy::float_cmp)] // exact replacement accounting, not numerical equivalence
545fn clamp_angle_bounds(
546    branches: &mut [Branch],
547    pad: f64,
548    warnings: &mut crate::diagnostics::Diagnostics,
549) {
550    for (idx, br) in branches.iter_mut().enumerate() {
551        let old_min = br.angmin;
552        let old_max = br.angmax;
553        let mut changes = Vec::new();
554
555        let (corrected_min, corrected_max) =
556            correct_angle_difference_bounds_with_pad(old_min, old_max, pad, 0.0);
557
558        if old_min != corrected_min {
559            br.angmin = corrected_min;
560            changes.push(format!("angmin {old_min} -> {}", br.angmin));
561        }
562        if old_max != corrected_max {
563            br.angmax = corrected_max;
564            changes.push(format!("angmax {old_max} -> {}", br.angmax));
565        }
566
567        if !changes.is_empty() {
568            warnings.push(
569                &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_BOUNDS_CLAMPED,
570                format!(
571                    "branch {idx} angle difference bounds clamped: {}",
572                    changes.join(", ")
573                ),
574            );
575        }
576    }
577}
578
579/// Correct one branch angle difference interval using the same ±60 degree
580/// rule as PowerModels' `correct_voltage_angle_differences!`.
581///
582/// A lower bound at or below −90 degrees becomes −60 degrees, an upper bound
583/// at or above 90 degrees becomes 60 degrees, and the MATPOWER 0/0 spelling
584/// becomes ±60 degrees. If correcting one side would invert the interval, the
585/// result is also ±60 degrees. Inputs and outputs are radians.
586///
587/// This is the pad centered on zero, which suits an unshifted branch only;
588/// [`AngleDifferenceBounds::ShiftCenteredPad`] centers it on the branch's
589/// phase shift.
590#[must_use]
591pub fn correct_angle_difference_bounds(angle_min: f64, angle_max: f64) -> (f64, f64) {
592    correct_angle_difference_bounds_with_pad(
593        angle_min,
594        angle_max,
595        POWER_MODELS_ANGLE_BOUND_PAD,
596        0.0,
597    )
598}
599
600/// One branch angle difference interval as the source states it, for a
601/// consumer that applies no pad. Inputs and outputs are radians.
602///
603/// The interval constrains `theta_from - theta_to`, as MATPOWER and
604/// PowerModels define it. The MATPOWER `0`/`0` spelling states no
605/// constraint. A lower side at or below −360 degrees and an upper side at or
606/// above 360 degrees also denote absent limits. Absent sides become negative
607/// or positive infinity, respectively: unwrapped DC angle differences can
608/// exceed a full turn. Every other stated bound passes through unchanged.
609#[must_use]
610pub fn stated_angle_difference_bounds(angle_min: f64, angle_max: f64) -> (f64, f64) {
611    let full = std::f64::consts::TAU;
612    if angle_min == 0.0 && angle_max == 0.0 {
613        return (f64::NEG_INFINITY, f64::INFINITY);
614    }
615    let angle_min = if angle_min <= -full {
616        f64::NEG_INFINITY
617    } else {
618        angle_min
619    };
620    let angle_max = if angle_max >= full {
621        f64::INFINITY
622    } else {
623        angle_max
624    };
625    (angle_min, angle_max)
626}
627
628/// Which angle difference bounds an OPF or power flow preparation carries.
629///
630/// Every bound constrains `theta_from - theta_to`, the MATPOWER and
631/// PowerModels convention, which is not net of a branch's phase shift.
632///
633/// The assembly options select one with `with_angle_difference_bounds`. Left
634/// unselected, their `correct_angle_difference_bounds` flag decides as it
635/// always has: `true`, the default, is [`Self::PowerModelsPad`] and `false`
636/// is [`Self::Stated`].
637#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
638#[serde(rename_all = "snake_case")]
639#[non_exhaustive]
640pub enum AngleDifferenceBounds {
641    /// PowerModels' ±60 degree pad exactly as its
642    /// `correct_voltage_angle_differences!` applies it
643    /// ([`correct_angle_difference_bounds`]): a side the source leaves
644    /// unconstrained or unusable (at or beyond ±90 degrees, the `0`/`0`
645    /// spelling, or an inverted window) becomes ±60 degrees around zero, and
646    /// a usable stated side is kept. A branch that shifts phase by more than
647    /// 60 degrees cannot meet this window; select
648    /// [`Self::ShiftCenteredPad`] or [`Self::Stated`] for such a case.
649    PowerModelsPad,
650    /// The same pad centered on the branch's phase shift, so a phase
651    /// shifting branch keeps its operating point inside the window. An
652    /// unshifted branch gets exactly [`Self::PowerModelsPad`].
653    ShiftCenteredPad,
654    /// The bounds the source states ([`stated_angle_difference_bounds`]):
655    /// an absent side becomes negative or positive infinity.
656    Stated,
657    /// No angle difference bound: every interval is `(−∞, +∞)`.
658    None,
659}
660
661impl AngleDifferenceBounds {
662    /// The selection the legacy `correct_angle_difference_bounds` flag
663    /// stands for: [`Self::PowerModelsPad`] when `true`, [`Self::Stated`]
664    /// when `false`.
665    #[must_use]
666    pub const fn from_correct_flag(correct: bool) -> Self {
667        if correct {
668            Self::PowerModelsPad
669        } else {
670            Self::Stated
671        }
672    }
673
674    /// Whether this selection pads unconstrained sides, which is what the
675    /// preparations' `correct_angle_difference_bounds` flag reports.
676    #[must_use]
677    pub const fn is_padded(self) -> bool {
678        matches!(self, Self::PowerModelsPad | Self::ShiftCenteredPad)
679    }
680
681    /// The interval for one branch, in radians, from its stated `angmin`
682    /// and `angmax` and the phase shift the preparation models.
683    #[must_use]
684    pub fn calc_bounds(self, angle_min: f64, angle_max: f64, shift: f64) -> (f64, f64) {
685        match self {
686            Self::PowerModelsPad => correct_angle_difference_bounds(angle_min, angle_max),
687            Self::ShiftCenteredPad => correct_angle_difference_bounds_with_pad(
688                angle_min,
689                angle_max,
690                POWER_MODELS_ANGLE_BOUND_PAD,
691                shift,
692            ),
693            Self::Stated => stated_angle_difference_bounds(angle_min, angle_max),
694            Self::None => (f64::NEG_INFINITY, f64::INFINITY),
695        }
696    }
697}
698
699/// The PowerModels rule with the pad centered on `center`, the branch's
700/// phase shift in radians: PowerModels itself centers it on zero.
701#[allow(clippy::float_cmp)] // the `0`/`0` spelling is an exact sentinel
702fn correct_angle_difference_bounds_with_pad(
703    angle_min: f64,
704    angle_max: f64,
705    pad: f64,
706    center: f64,
707) -> (f64, f64) {
708    let (mut low, mut high) = (angle_min, angle_max);
709    if low <= -std::f64::consts::FRAC_PI_2 {
710        low = center - pad;
711    }
712    if high >= std::f64::consts::FRAC_PI_2 {
713        high = center + pad;
714    }
715    if angle_min == 0.0 && angle_max == 0.0 || low > high {
716        return (center - pad, center + pad);
717    }
718    (low, high)
719}
720
721fn norm_gens(
722    gens: &[Generator],
723    base: f64,
724    map: &HashMap<BusId, BusId>,
725) -> (Vec<Generator>, Vec<Option<usize>>) {
726    gens.iter()
727        .enumerate()
728        .filter(|(_, g)| g.in_service)
729        .filter_map(|(row, g)| {
730            let mut generator = g.clone();
731            generator.bus = remap(map, g.bus)?;
732            generator.pg = g.pg / base;
733            generator.qg = g.qg / base;
734            generator.pmax = g.pmax / base;
735            generator.pmin = g.pmin / base;
736            generator.qmax = g.qmax / base;
737            generator.qmin = g.qmin / base;
738            if let Some(c) = &mut generator.cost {
739                scale_coeffs_to_pu(&mut c.coeffs, c.ncost, c.model, base);
740            }
741            // `GenCaps` is indexed by `GEN_EXTRA_KEYS`, so the two zip exactly.
742            for (cap, key) in generator.caps.iter_mut().zip(GEN_EXTRA_KEYS) {
743                if GEN_PU_KEYS.contains(&key)
744                    && let Some(v) = cap
745                {
746                    *v /= base;
747                }
748            }
749            // Remap the regulated bus through the same id map; drop it if its
750            // target was filtered out so the normalized form stays consistent.
751            generator.regulated_bus = g.regulated_bus.and_then(|b| remap(map, b));
752            Some((generator, Some(row)))
753        })
754        .unzip()
755}
756
757fn norm_switches(
758    switches: &[Switch],
759    base: f64,
760    map: &HashMap<BusId, BusId>,
761) -> (Vec<Switch>, Vec<Option<usize>>) {
762    switches
763        .iter()
764        .enumerate()
765        .filter_map(|(row, s)| {
766            let switch = Switch {
767                from: remap(map, s.from)?,
768                to: remap(map, s.to)?,
769                thermal_rating: s.thermal_rating.map(|v| v / base),
770                pf: s.pf.map(|v| v / base),
771                qf: s.qf.map(|v| v / base),
772                pt: s.pt.map(|v| v / base),
773                qt: s.qt.map(|v| v / base),
774                ..s.clone()
775            };
776            Some((switch, Some(row)))
777        })
778        .unzip()
779}
780
781fn norm_storage(
782    storage: &[Storage],
783    base: f64,
784    map: &HashMap<BusId, BusId>,
785) -> (Vec<Storage>, Vec<Option<usize>>) {
786    storage
787        .iter()
788        .enumerate()
789        .filter(|(_, s)| s.in_service)
790        .filter_map(|(row, s)| {
791            // ps/qs stay raw (PowerModels' make_per_unit! leaves the dispatch
792            // setpoint alone); the energy, ratings, limits, and losses scale.
793            let unit = Storage {
794                bus: remap(map, s.bus)?,
795                energy: s.energy / base,
796                energy_rating: s.energy_rating / base,
797                charge_rating: s.charge_rating / base,
798                discharge_rating: s.discharge_rating / base,
799                thermal_rating: s.thermal_rating / base,
800                qmin: s.qmin / base,
801                qmax: s.qmax / base,
802                p_loss: s.p_loss / base,
803                q_loss: s.q_loss / base,
804                ..s.clone()
805            };
806            Some((unit, Some(row)))
807        })
808        .unzip()
809}
810
811fn norm_hvdc(
812    hvdc: &[Hvdc],
813    base: f64,
814    map: &HashMap<BusId, BusId>,
815) -> (Vec<Hvdc>, Vec<Option<usize>>) {
816    hvdc.iter()
817        .enumerate()
818        .filter(|(_, d)| d.in_service)
819        .filter_map(|(row, d)| {
820            // No sign flip: the writer's Pt/Qf/Qt negation is a PowerModels output
821            // convention, not part of per-unit normalization. The aggregate
822            // pmin/pmax stay raw, matching make_per_unit!.
823            let mut link = d.clone();
824            link.from = remap(map, d.from)?;
825            link.to = remap(map, d.to)?;
826            link.pf = d.pf / base;
827            link.pt = d.pt / base;
828            link.qf = d.qf / base;
829            link.qt = d.qt / base;
830            link.qminf = d.qminf / base;
831            link.qmaxf = d.qmaxf / base;
832            link.qmint = d.qmint / base;
833            link.qmaxt = d.qmaxt / base;
834            link.loss0 = d.loss0 / base;
835            if let Some(c) = &mut link.cost {
836                scale_coeffs_to_pu(&mut c.coeffs, c.ncost, c.model, base);
837            }
838            Some((link, Some(row)))
839        })
840        .unzip()
841}
842
843fn norm_transformers_3w(
844    xfmrs: &[Transformer3W],
845    base: f64,
846    map: &HashMap<BusId, BusId>,
847    warnings: &mut crate::diagnostics::Diagnostics,
848) -> (Vec<Transformer3W>, Vec<Option<usize>>) {
849    let mut taken_out = Vec::new();
850    let normalized = xfmrs
851        .iter()
852        .enumerate()
853        .filter(|(_, t)| t.in_service)
854        .filter_map(|(row, t)| {
855            // A winding whose bus this pass drops goes out of service, as PSS/E
856            // takes out a winding on a disconnected bus; the other two stay
857            // coupled through the star point. Its bus field then names a
858            // kept winding's bus, so the record stays reference consistent,
859            // and the source row gives the original. A transformer left with
860            // fewer than two windings couples nothing and is dropped. Phase
861            // shifts and the star angle go to radians; winding ratings go per
862            // unit; the pairwise impedances are already per unit on the system
863            // base.
864            let mut live = t.winding_in_service();
865            for (state, winding) in live.iter_mut().zip(&t.windings) {
866                *state &= remap(map, winding.bus).is_some();
867            }
868            let kept_bus = t
869                .windings
870                .iter()
871                .zip(live)
872                .find(|(_, on)| *on)
873                .map(|(winding, _)| winding.bus)?;
874            if live.iter().filter(|&&on| on).count() < 2 {
875                return None;
876            }
877            let mut windings = t.windings.clone();
878            for w in &mut windings {
879                w.bus = remap(map, w.bus).unwrap_or(kept_bus);
880                if let Some(control) = &mut w.control {
881                    norm_transformer_control(control, base, map);
882                }
883                w.shift *= DEG_TO_RAD;
884                w.rate_a /= base;
885                w.rate_b /= base;
886                w.rate_c /= base;
887            }
888            let mut transformer = Transformer3W {
889                windings,
890                star_va: t.star_va * DEG_TO_RAD,
891                ..t.clone()
892            };
893            for winding in 0..3 {
894                let mut sets = t.winding_rating_sets(winding);
895                for set in &mut sets {
896                    set.rate_mva /= base;
897                }
898                transformer.set_winding_rating_sets(winding, sets);
899            }
900            if live != t.winding_in_service() {
901                transformer.set_winding_in_service(live);
902                taken_out.push(row);
903            }
904            Some((transformer, Some(row)))
905        })
906        .unzip();
907    if !taken_out.is_empty() {
908        let names = taken_out
909            .iter()
910            .take(5)
911            .map(|&row| {
912                xfmrs[row]
913                    .uid
914                    .clone()
915                    .unwrap_or_else(|| format!("transformers_3w:{row}"))
916            })
917            .collect::<Vec<_>>()
918            .join(", ");
919        warnings.push(
920            &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_WINDING_TAKEN_OUT,
921            format!(
922                "{} three winding transformer(s) have a winding on a bus normalization drops; \
923                 that winding was taken out of service and the other two kept ({names})",
924                taken_out.len()
925            ),
926        );
927    }
928    normalized
929}
930
931/// No reference survived the bus type pass: anchor the slack at the largest
932/// pmax in-service generator's bus and record the designation on the coded
933/// channel, or refuse when there is no generator to anchor it.
934fn designate_reference(
935    buses: &mut [Bus],
936    generators: &[Generator],
937    warnings: &mut crate::diagnostics::Diagnostics,
938) -> Result<()> {
939    let slack = generators
940        .iter()
941        .max_by(|a, b| {
942            // A NaN pmax must never win the slack: map it below every real
943            // bound so the choice stays deterministic (an unbounded +Inf
944            // pmax still wins, as the largest capacity).
945            let key = |p: f64| if p.is_nan() { f64::NEG_INFINITY } else { p };
946            key(a.pmax).total_cmp(&key(b.pmax))
947        })
948        .map(|g| g.bus)
949        .ok_or(Error::NoReferenceBus)?;
950    if let Some(b) = buses.iter_mut().find(|b| b.id == slack) {
951        b.kind = BusType::Ref;
952        warnings.push(
953            &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_REFERENCE_DESIGNATED,
954            format!(
955                "the case states no reference bus that survives normalization; bus {slack} \
956                 hosts the largest pmax in-service generator and was designated the slack"
957            ),
958        );
959    }
960    Ok(())
961}
962
963/// The buses of the islands no in-service generator supplies, each island
964/// reported as left out.
965fn unsupplied_island_buses(
966    network: &BalancedNetwork,
967    partition: &IslandPartition,
968    warnings: &mut crate::diagnostics::Diagnostics,
969) -> HashSet<BusId> {
970    let mut unsupplied = HashSet::new();
971    for island in partition
972        .islands
973        .iter()
974        .filter(|island| !island.is_supplied())
975    {
976        warnings.record(
977            network.unsupplied_island_finding(island, "was left out of the normalized network"),
978        );
979        unsupplied.extend(island.buses.iter().copied());
980    }
981    unsupplied
982}
983
984/// Canonical bus types: a bus hosting an in-service generator keeps `Ref` if
985/// the file marked it `Ref`, else becomes `Pv`; a gen-less bus is `Pq`. A
986/// `Pq` bus that becomes `Pv` is reported, since its generator now holds the
987/// bus voltage.
988fn canonicalize_bus_types(
989    buses: &mut [Bus],
990    generators: &[Generator],
991    warnings: &mut crate::diagnostics::Diagnostics,
992) {
993    let gen_buses: HashSet<BusId> = generators.iter().map(|g| g.bus).collect();
994    let mut retyped_pq = Vec::new();
995    for b in buses.iter_mut() {
996        if b.kind == BusType::Pq && gen_buses.contains(&b.id) {
997            retyped_pq.push(b.id);
998        }
999        b.kind = match (gen_buses.contains(&b.id), b.kind) {
1000            (true, BusType::Ref) => BusType::Ref,
1001            (true, _) => BusType::Pv,
1002            (false, _) => BusType::Pq,
1003        };
1004    }
1005    if !retyped_pq.is_empty() {
1006        warnings.push(
1007            &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_GENERATOR_BUS_RETYPED,
1008            format!(
1009                "{} PQ bus(es) host an in-service generator and were retyped PV, so the \
1010                 generator holds the bus voltage ({})",
1011                retyped_pq.len(),
1012                bus_list(&retyped_pq)
1013            ),
1014        );
1015    }
1016}
1017
1018/// The reference buses of the normalized tables: one per island of
1019/// `partition` under [`IslandReferencePolicy::PerIsland`], and the largest
1020/// generator's bus when no reference survives at all.
1021fn assign_normalized_references(
1022    buses: &mut [Bus],
1023    generators: &[Generator],
1024    partition: Option<&IslandPartition>,
1025    warnings: &mut crate::diagnostics::Diagnostics,
1026) -> Result<()> {
1027    if let Some(partition) = partition {
1028        assign_normalized_island_references(buses, generators, partition, warnings);
1029    }
1030    if !buses.iter().any(|b| b.kind == BusType::Ref) {
1031        designate_reference(buses, generators, warnings)?;
1032    }
1033    Ok(())
1034}
1035
1036/// [`IslandReferencePolicy::PerIsland`] over the normalized tables: each
1037/// supplied island of `partition` keeps exactly one reference bus. The bus
1038/// types are already canonical, so a reference hosts a generator and a demoted
1039/// one becomes PV. This picks the same buses as
1040/// [`BalancedNetwork::assign_island_references`] on the raw network.
1041fn assign_normalized_island_references(
1042    buses: &mut [Bus],
1043    generators: &[Generator],
1044    partition: &IslandPartition,
1045    warnings: &mut crate::diagnostics::Diagnostics,
1046) {
1047    let mut island_generators: HashMap<usize, Vec<usize>> = HashMap::new();
1048    for (row, generator) in generators.iter().enumerate() {
1049        if let Some(island) = partition.island_of(generator.bus) {
1050            island_generators.entry(island).or_default().push(row);
1051        }
1052    }
1053    let mut island_references: HashMap<usize, Vec<BusId>> = HashMap::new();
1054    for bus in buses.iter().filter(|bus| bus.kind == BusType::Ref) {
1055        if let Some(island) = partition.island_of(bus.id) {
1056            island_references.entry(island).or_default().push(bus.id);
1057        }
1058    }
1059    let mut designated = HashSet::new();
1060    let mut demoted = HashSet::new();
1061    for (position, island) in partition.islands.iter().enumerate() {
1062        let Some(rows) = island_generators.get(&position) else {
1063            continue;
1064        };
1065        let mut references = island_references.remove(&position).unwrap_or_default();
1066        references.sort_unstable();
1067        match references.as_slice() {
1068            [_] => {}
1069            [] => {
1070                if let Some(bus) = largest_generator_bus(generators, rows) {
1071                    warnings.push(
1072                        &crate::diagnostics::codes::CANONICALIZE_ISLAND_REFERENCE_DESIGNATED,
1073                        format!(
1074                            "the island of {} bus(es) ({}) has no reference bus that survives \
1075                             normalization; bus {bus} hosts its largest pmax in-service \
1076                             generator and was designated the reference",
1077                            island.buses.len(),
1078                            bus_list(&island.buses)
1079                        ),
1080                    );
1081                    designated.insert(bus);
1082                }
1083            }
1084            many => {
1085                let kept = kept_reference(many, generators, rows).unwrap_or(many[0]);
1086                let others: Vec<BusId> = many.iter().copied().filter(|&b| b != kept).collect();
1087                warnings.push(
1088                    &crate::diagnostics::codes::CANONICALIZE_ISLAND_REFERENCE_DEMOTED,
1089                    format!(
1090                        "the island of {} bus(es) keeps {} reference buses; bus {kept} hosts the \
1091                         most generation and stays the reference, and bus(es) {} became PV",
1092                        island.buses.len(),
1093                        many.len(),
1094                        bus_list(&others)
1095                    ),
1096                );
1097                demoted.extend(others);
1098            }
1099        }
1100    }
1101    for bus in buses.iter_mut() {
1102        if designated.contains(&bus.id) {
1103            bus.kind = BusType::Ref;
1104        } else if demoted.contains(&bus.id) {
1105            bus.kind = BusType::Pv;
1106        }
1107    }
1108}
1109
1110impl BalancedNetwork {
1111    /// A normalized, computation-ready copy of this network. The raw `BalancedNetwork` is
1112    /// kept lossless (MATPOWER units, 1-based sparse ids, out-of-service elements
1113    /// retained); `to_normalized` derives the form a solver or ML pipeline wants:
1114    ///
1115    /// - **Per unit** (÷`base_mva`): gen `pg/qg/pmax/pmin/qmax/qmin` and the ramp
1116    ///   caps (`GEN_PU_KEYS`); load `p/q`; shunt `g/b`; branch `rate_a/b/c`;
1117    ///   storage energy/ratings/limits/losses; HVDC `pf/pt/qf/qt`, reactive limits,
1118    ///   `loss0`; gen-cost coefficients (`cost_to_pu`). Storage `ps/qs` and HVDC
1119    ///   aggregate `pmin/pmax` stay raw, matching the PowerModels per-unit
1120    ///   convention. Voltages, impedances, tap, and `loss1` are already
1121    ///   dimensionless.
1122    /// - **Radians**: bus `va`; branch `shift/angmin/angmax`.
1123    /// - **Tap**: `0 → 1.0` (an explicit `1` is kept).
1124    /// - **Filtered**: drop buses typed isolated (`BusType::Isolated`) and every
1125    ///   out-of-service element, then drop any element left referencing a dropped
1126    ///   bus. A bus orphaned by the out-of-service filter (no in-service branch,
1127    ///   but not typed isolated) is kept — its load is real — and surfaces as its
1128    ///   own island, which the grounding check reports if it has no reference.
1129    /// - **Closed switches**: the buses a closed switch joins merge into one
1130    ///   ([`ClosedSwitchPolicy::Merge`], reported under `CANONICALIZE.MERGE`),
1131    ///   whose survivor is the reference bus, else a generator bus, else a
1132    ///   regulated bus, smallest id first. The matrix builders model no
1133    ///   switches, so this keeps their buses together;
1134    ///   [`BalancedNetwork::to_normalized_with`] keeps or refuses them instead.
1135    /// - **IDs**: kept buses retain their source bus ids, and every surviving
1136    ///   endpoint stays in the same id space. Consumers that need dense rows should
1137    ///   use [`IndexedNetwork`](crate::IndexedNetwork), which derives `[0, n)`
1138    ///   indices without destroying source ids.
1139    /// - **Bus types**: a bus hosting a surviving generator keeps `REF` if the file
1140    ///   marked it `REF`, otherwise becomes `PV`; a generator-less bus is `PQ` (so a
1141    ///   generator-less `REF` is demoted). A `PQ` bus retyped `PV` is reported as
1142    ///   `CANONICALIZE.NORMALIZE.GENERATOR_BUS_RETYPED`. The file's `REF` buses are
1143    ///   kept, several included, and the consumer picks the slack. Only when no
1144    ///   reference bus survives is the largest-`pmax` in-service generator's bus
1145    ///   promoted to `REF`;
1146    ///   [`NormalizeSettings::island_references`] applies that rule per island
1147    ///   instead.
1148    ///
1149    /// This is a derived product, not a source for write-back: `source` is dropped
1150    /// and `source_format` is [`SourceFormat::Normalized`], so writing it serializes
1151    /// the per-unit/radian model instead of echoing the raw bytes, and a consumer
1152    /// can tell it apart from a raw in-memory network.
1153    ///
1154    /// Scope is the universal canonicalization only. It does not synthesize a
1155    /// missing `rate_a` or restrict the gen-cost model — those are solver
1156    /// preparation choices a consumer applies on top. Use
1157    /// [`BalancedNetwork::to_normalized_with_options`] for the opt in PowerModels angle
1158    /// bound repair. The cost *rescale* is
1159    /// universal and lives here; the model *restriction* does not.
1160    ///
1161    /// # Errors
1162    /// [`Error::InvalidBaseMva`] if `base_mva` is not a positive, finite number
1163    /// (every per-unit divisor), so a malformed base can't silently poison the
1164    /// whole network with `NaN`/`Inf` or sign-flipped values.
1165    /// [`Error::NoReferenceBus`] if no reference bus can be established — no `REF`
1166    /// survives and there is no in-service generator to anchor one.
1167    /// [`Error::ClosedSwitch`] if a requested switch merge cannot complete,
1168    /// such as a connection between windings of one three winding transformer.
1169    pub fn to_normalized(&self) -> Result<BalancedNetwork> {
1170        Ok(self
1171            .to_normalized_with_options(&NormalizeOptions::default())?
1172            .network)
1173    }
1174
1175    /// Like [`BalancedNetwork::to_normalized`], with opt in solver preparation repairs
1176    /// that report fidelity warnings.
1177    pub fn to_normalized_with_options(
1178        &self,
1179        options: &NormalizeOptions,
1180    ) -> Result<NormalizedNetwork> {
1181        Ok(self.normalize_inner(&NormalizeSettings::new(*options))?.0)
1182    }
1183
1184    /// Like [`BalancedNetwork::to_normalized_with_options`], under every
1185    /// choice [`NormalizeSettings`] states, also returning what the pass
1186    /// changed before normalizing ([`NormalizeReport`]), such as the closed
1187    /// switch merge.
1188    ///
1189    /// # Errors
1190    /// As [`BalancedNetwork::to_normalized`], and [`Error::ClosedSwitch`]
1191    /// under [`ClosedSwitchPolicy::Refuse`] or an incomplete
1192    /// [`ClosedSwitchPolicy::Merge`].
1193    pub fn to_normalized_with(
1194        &self,
1195        settings: &NormalizeSettings,
1196    ) -> Result<(NormalizedNetwork, NormalizeReport)> {
1197        let (normalized, _, report) = self.normalize_inner(settings)?;
1198        Ok((normalized, report))
1199    }
1200
1201    /// Like [`BalancedNetwork::to_normalized_with_options`], also returning the
1202    /// [`NormalizeSourceRows`] row provenance.
1203    ///
1204    /// The rows are positional over the
1205    /// [`IndexedNetwork`](crate::IndexedNetwork) view of the returned network,
1206    /// which is the index space a matrix row or a solver table row lives in.
1207    /// That view star-lowers a 3-winding transformer, so it holds more buses,
1208    /// branches, and shunts than the returned [`NormalizedNetwork`] does;
1209    /// indexing the returned network by a row position is out of bounds on any
1210    /// case that carries one. Resolve a row through the view:
1211    ///
1212    /// ```
1213    /// # use powerio_tx::{IndexedNetwork, BalancedNetwork, NormalizeOptions};
1214    /// # fn f(raw: &BalancedNetwork) -> powerio_tx::Result<()> {
1215    /// let (normalized, rows) = raw.to_normalized_with_source_rows(&NormalizeOptions::default())?;
1216    /// let view = IndexedNetwork::new(&normalized.network);
1217    /// for (dense, source) in rows.buses.iter().enumerate() {
1218    ///     let bus = &view.network().buses()[dense];
1219    ///     // `source` is `None` for the synthetic star bus the view appended.
1220    ///     let _ = (bus, source);
1221    /// }
1222    /// # Ok(())
1223    /// # }
1224    /// ```
1225    #[doc(hidden)]
1226    pub fn to_normalized_with_source_rows(
1227        &self,
1228        options: &NormalizeOptions,
1229    ) -> Result<(NormalizedNetwork, NormalizeSourceRows)> {
1230        let (normalized, mut rows, _) = self.normalize_inner(&NormalizeSettings::new(*options))?;
1231        rows.pad_to_lowered(&normalized.network);
1232        Ok((normalized, rows))
1233    }
1234
1235    /// The pass itself, after what `settings` asks of closed switches. The
1236    /// rows it gives cover the normalized network before the star lowering,
1237    /// so only [`Self::to_normalized_with_source_rows`] pays for the lowered
1238    /// lengths.
1239    fn normalize_inner(
1240        &self,
1241        settings: &NormalizeSettings,
1242    ) -> Result<(NormalizedNetwork, NormalizeSourceRows, NormalizeReport)> {
1243        let options = &settings.options;
1244        validate_normalize_options(options)?;
1245        let (kept, left) = self.closed_switch_rows();
1246        if kept.is_empty() || settings.closed_switches == ClosedSwitchPolicy::Keep {
1247            let (normalized, rows) = self.normalize_pass(settings)?;
1248            return Ok((normalized, rows, NormalizeReport::default()));
1249        }
1250        if settings.closed_switches == ClosedSwitchPolicy::Refuse {
1251            let switch = &self.switches()[kept[0]];
1252            return Err(Error::ClosedSwitch {
1253                row: kept[0],
1254                from: switch.from,
1255                to: switch.to,
1256                closed: kept.len(),
1257            });
1258        }
1259        let merge = self.merge_normalization_switches(&left)?;
1260        // A winding-pair refusal leaves the selected switch in the result.
1261        // Report the source row, even when earlier switches were removed or
1262        // their endpoints were remapped by a successful partial merge.
1263        let mut remaining = kept
1264            .into_iter()
1265            .filter(|&row| merge.switch_rows[row].is_some());
1266        if let Some(row) = remaining.next() {
1267            let switch = &self.switches()[row];
1268            return Err(Error::ClosedSwitch {
1269                row,
1270                from: switch.from,
1271                to: switch.to,
1272                closed: 1 + remaining.count(),
1273            });
1274        }
1275        let (mut normalized, rows) = merge.network.normalize_pass(settings)?;
1276        let rows = rows.through_merge(&merge);
1277        let mut diagnostics = merge.diagnostics.clone();
1278        diagnostics.append(&mut normalized.diagnostics);
1279        normalized.warnings = crate::diagnostics::render_diagnostics(&diagnostics);
1280        normalized.diagnostics = diagnostics;
1281        Ok((
1282            normalized,
1283            rows,
1284            NormalizeReport {
1285                bus_merge: Some(merge),
1286            },
1287        ))
1288    }
1289
1290    /// Merge only switches whose endpoints survive bus filtering, then
1291    /// compose the merge's switch provenance back to the original table.
1292    fn merge_normalization_switches(&self, left: &[usize]) -> Result<BusMerge> {
1293        if left.is_empty() {
1294            return self.merge_buses(&BusMergeRule::closed_switches());
1295        }
1296        let mut eligible = self.clone();
1297        let excluded: HashSet<_> = left.iter().copied().collect();
1298        let mut source_rows = Vec::new();
1299        let mut row = 0;
1300        eligible.switches_mut().retain_mut(|switch| {
1301            let keep = !excluded.contains(&row);
1302            if keep {
1303                source_rows.push(row);
1304                // Keep anonymous source identities in merge diagnostics even
1305                // though filtering changes this temporary table's row numbers.
1306                if switch.uid.is_none() {
1307                    switch.uid = Some(format!("switches:{row}"));
1308                }
1309            }
1310            row += 1;
1311            keep
1312        });
1313        // Opening an ineligible switch is insufficient: an undeclared bus
1314        // still leaves a dangling reference for merge_buses to validate.
1315        let mut merge = eligible.merge_buses(&BusMergeRule::closed_switches())?;
1316        let mut switch_rows = vec![None; self.switches().len()];
1317        for (&source, &merged) in source_rows.iter().zip(&merge.switch_rows) {
1318            switch_rows[source] = merged;
1319            if let Some(row) = merged {
1320                merge.network.switches_mut()[row]
1321                    .uid
1322                    .clone_from(&self.switches()[source].uid);
1323            }
1324        }
1325        for removed in &mut merge.removed_switches {
1326            removed.row = source_rows[removed.row];
1327        }
1328        merge.switch_rows = switch_rows;
1329        merge.source = self.clone();
1330        Ok(merge)
1331    }
1332
1333    /// Closed switches joining distinct buses the pass keeps, and all
1334    /// switches on isolated or undeclared buses, which the pass drops.
1335    fn closed_switch_rows(&self) -> (Vec<usize>, Vec<usize>) {
1336        let kept_bus: HashMap<BusId, bool> = self
1337            .buses()
1338            .iter()
1339            .map(|bus| (bus.id, bus.kind != BusType::Isolated))
1340            .collect();
1341        let kept = |bus: BusId| kept_bus.get(&bus).copied().unwrap_or(false);
1342        self.switches().iter().enumerate().fold(
1343            (Vec::new(), Vec::new()),
1344            |(mut eligible, mut left), (row, switch)| {
1345                if !kept(switch.from) || !kept(switch.to) {
1346                    left.push(row);
1347                } else if switch.closed && switch.from != switch.to {
1348                    eligible.push(row);
1349                }
1350                (eligible, left)
1351            },
1352        )
1353    }
1354
1355    /// One normalization pass over this network as it stands.
1356    fn normalize_pass(
1357        &self,
1358        settings: &NormalizeSettings,
1359    ) -> Result<(NormalizedNetwork, NormalizeSourceRows)> {
1360        let options = &settings.options;
1361        validate_normalize_options(options)?;
1362        self.check_base_mva()?;
1363        let base = self.base_mva();
1364        let mut warnings = crate::diagnostics::Diagnostics::new();
1365
1366        // Per island references: an island no in-service generator supplies
1367        // cannot be solved, so its buses stay out like isolated ones and the
1368        // element filters below drop what sits on them. The partition follows
1369        // what this pass keeps: a three winding transformer keeps its live
1370        // windings when another winding sits on an isolated bus.
1371        let partition = (settings.island_references == IslandReferencePolicy::PerIsland)
1372            .then(|| self.calc_islands());
1373        let unsupplied = partition
1374            .as_ref()
1375            .map(|partition| unsupplied_island_buses(self, partition, &mut warnings))
1376            .unwrap_or_default();
1377
1378        // Kept buses keep their original `kind` for now (the reference scan below
1379        // reads it) and their source ids. Isolated buses are dropped.
1380        let mut id_map: HashMap<BusId, BusId> = HashMap::with_capacity(self.buses().len());
1381        let mut buses: Vec<Bus> = Vec::with_capacity(self.buses().len());
1382        // The pass keeps only elements that came from a source row, so each row
1383        // here is `Some`; the `None` entries appear later, when
1384        // `pad_to_lowered` extends the map over what the star lowering appends.
1385        let mut bus_rows: Vec<Option<usize>> = Vec::with_capacity(self.buses().len());
1386        for (row, b) in self.buses().iter().enumerate() {
1387            if b.kind == BusType::Isolated || unsupplied.contains(&b.id) {
1388                continue;
1389            }
1390            id_map.insert(b.id, b.id);
1391            buses.push(Bus {
1392                va: b.va * DEG_TO_RAD,
1393                ..b.clone()
1394            });
1395            bus_rows.push(Some(row));
1396        }
1397        let (loads, load_rows) = norm_loads(self.loads(), base, &id_map);
1398        let (shunts, shunt_rows) = norm_shunts(self.shunts(), base, &id_map);
1399        let (static_var_compensators, static_var_compensator_rows) =
1400            norm_static_var_compensators(self.static_var_compensators(), base, &id_map);
1401        let (mut branches, branch_rows) = norm_branches(self.branches(), base, &id_map);
1402        if options.clamp_angle_bounds {
1403            clamp_angle_bounds(&mut branches, options.angle_bound_pad, &mut warnings);
1404        }
1405        let (switches, switch_rows) = norm_switches(self.switches(), base, &id_map);
1406        let (generators, generator_rows) = norm_gens(self.generators(), base, &id_map);
1407        let (storage, storage_rows) = norm_storage(self.storage(), base, &id_map);
1408        let (hvdc, hvdc_rows) = norm_hvdc(self.hvdc(), base, &id_map);
1409        let (transformers_3w, transformer_3w_rows) =
1410            norm_transformers_3w(self.transformers_3w(), base, &id_map, &mut warnings);
1411        let source_rows = NormalizeSourceRows {
1412            buses: bus_rows,
1413            loads: load_rows,
1414            shunts: shunt_rows,
1415            static_var_compensators: static_var_compensator_rows,
1416            branches: branch_rows,
1417            switches: switch_rows,
1418            generators: generator_rows,
1419            storage: storage_rows,
1420            hvdc: hvdc_rows,
1421            transformers_3w: transformer_3w_rows,
1422        };
1423
1424        // Bus types. Multiple file `Ref` buses are kept as-is unless the per
1425        // island policy keeps one per island, and only when no `Ref` survives
1426        // is the largest-pmax generator's bus promoted.
1427        canonicalize_bus_types(&mut buses, &generators, &mut warnings);
1428        assign_normalized_references(&mut buses, &generators, partition.as_ref(), &mut warnings)?;
1429        // The other silent semantic decision this gateway announces: a
1430        // solver-ready copy whose cost objective is identically zero.
1431        if !generators.is_empty() && generators.iter().all(|g| g.cost.is_none()) {
1432            warnings.push(
1433                &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_GEN_COST_ABSENT,
1434                format!(
1435                    "the case has {} in-service generator(s) and no cost data; any cost \
1436                     objective built from it is identically zero",
1437                    generators.len()
1438                ),
1439            );
1440        }
1441
1442        let mut net = BalancedNetwork::from_tables(BalancedNetworkTables {
1443            name: self.name().clone(),
1444            base_mva: base,
1445            base_frequency: self.base_frequency(),
1446            geo: self.geo().clone(),
1447            case_metadata: self.case_metadata().clone(),
1448            detailed_connectivity: self.detailed_connectivity().clone(),
1449            generated_uids: self.generated_uids().clone(),
1450            buses: buses.into(),
1451            loads: loads.into(),
1452            shunts: shunts.into(),
1453            static_var_compensators: static_var_compensators.into(),
1454            branches: branches.into(),
1455            switches: switches.into(),
1456            generators: generators.into(),
1457            storage: storage.into(),
1458            hvdc: hvdc.into(),
1459            transformers_3w: transformers_3w.into(),
1460            // Areas (interchange schedule, per-area swing) are interchange metadata,
1461            // not part of the per unit electrical view, so they are not carried.
1462            areas: Vec::new().into(),
1463            solver: None,
1464            source_format: SourceFormat::Normalized,
1465        });
1466        // Project source topology after all electrical filters have run, including
1467        // unsupplied islands, isolated buses, and out-of-service equipment.
1468        crate::operations::filter_connectivity(self, &mut net);
1469        // The filter drops every reference to a dropped bus by
1470        // construction, so the result is reference-consistent. Assert it in
1471        // debug builds to catch a future regression in the filtering logic.
1472        debug_assert!(
1473            net.validate().is_ok(),
1474            "to_normalized produced a dangling reference"
1475        );
1476        Ok((
1477            NormalizedNetwork {
1478                network: net,
1479                warnings: warnings.lines(),
1480                diagnostics: warnings.into_records(),
1481            },
1482            source_rows,
1483        ))
1484    }
1485}
1486
1487#[cfg(test)]
1488mod tests {
1489    use super::*;
1490
1491    #[test]
1492    fn normalization_merges_closed_switches_unless_told_otherwise() {
1493        use crate::network::Switch;
1494        let mut buses: Vec<Bus> = (1..=4)
1495            .map(|id| Bus::new(BusId(id), BusType::Pq, 230.0))
1496            .collect();
1497        buses[0].kind = BusType::Ref;
1498        buses[3].kind = BusType::Isolated;
1499        let mut net = BalancedNetwork::in_memory(
1500            "switched",
1501            100.0,
1502            buses,
1503            vec![Branch::new(BusId(1), BusId(2), 0.0, 0.1)],
1504        );
1505        // A closed switch 2-3, an open one 1-3, and a closed one onto the
1506        // isolated bus 4, which the pass drops with that bus.
1507        net.switches_mut().extend([
1508            Switch::new(BusId(2), BusId(3), true),
1509            Switch::new(BusId(1), BusId(3), false),
1510            Switch::new(BusId(2), BusId(4), true),
1511        ]);
1512        net.generators_mut().push(Generator::new(BusId(1)));
1513        net.loads_mut().push(Load::new(BusId(3), 10.0, 0.0));
1514
1515        // The default merges, reports the merge, and maps rows to the source.
1516        let (merged, rows) = net
1517            .to_normalized_with_source_rows(&NormalizeOptions::default())
1518            .unwrap();
1519        assert_eq!(merged.network.buses().len(), 2);
1520        assert_eq!(merged.network.loads()[0].bus, BusId(2));
1521        crate::IndexedNetwork::new(&merged.network)
1522            .check_closed_switches()
1523            .unwrap();
1524        assert!(
1525            merged
1526                .diagnostics
1527                .iter()
1528                .any(|d| d.code() == "CANONICALIZE.MERGE.CLOSED_SWITCH")
1529        );
1530        // The open switch survives at merged row 0 and maps back to source row 1.
1531        assert_eq!(rows.switches, [Some(1)]);
1532        assert_eq!(rows.buses, [Some(0), Some(1)]);
1533        assert_eq!(rows.branches, [Some(0)]);
1534        let plain = net.to_normalized().unwrap();
1535        assert_eq!(plain.buses(), merged.network.buses());
1536        assert_eq!(plain.switches(), merged.network.switches());
1537
1538        let (_, report) = net
1539            .to_normalized_with(&NormalizeSettings::default())
1540            .unwrap();
1541        let merge = report.bus_merge.unwrap();
1542        assert_eq!(merge.survivor(BusId(3)), BusId(2));
1543        assert_eq!(merge.survivor(BusId(4)), BusId(4));
1544
1545        // Keep leaves the switches as data, as normalization used to.
1546        let (kept, report) = net
1547            .to_normalized_with(
1548                &NormalizeSettings::default().with_closed_switches(ClosedSwitchPolicy::Keep),
1549            )
1550            .unwrap();
1551        assert!(report.bus_merge.is_none());
1552        assert_eq!(kept.network.buses().len(), 3);
1553        let error = crate::IndexedNetwork::new(&kept.network)
1554            .check_closed_switches()
1555            .unwrap_err();
1556        assert!(error.to_string().starts_with("1 closed switch(es)"));
1557
1558        // Refuse states the switch the pass would keep.
1559        let error = net
1560            .to_normalized_with(
1561                &NormalizeSettings::default().with_closed_switches(ClosedSwitchPolicy::Refuse),
1562            )
1563            .unwrap_err();
1564        assert!(matches!(
1565            error,
1566            Error::ClosedSwitch {
1567                row: 0,
1568                closed: 1,
1569                ..
1570            }
1571        ));
1572        let message = error.to_string();
1573        for call in [
1574            "BusMergeRule::closed_switches()",
1575            "closed_switches=\"merge\"",
1576            "--merge-buses switches",
1577        ] {
1578            assert!(message.contains(call), "{message}");
1579        }
1580
1581        // A network with no closed switch normalizes as it always has.
1582        net.switches_mut()[0].closed = false;
1583        net.switches_mut()[2].closed = false;
1584        let (plain, report) = net
1585            .to_normalized_with(&NormalizeSettings::default())
1586            .unwrap();
1587        assert!(report.bus_merge.is_none());
1588        assert_eq!(plain.network.buses().len(), 3);
1589    }
1590    use crate::network::GeneratorEnergySource;
1591
1592    fn approx(a: f64, b: f64) -> bool {
1593        (a - b).abs() < 1e-9
1594    }
1595
1596    fn angle_bound_fixture() -> BalancedNetwork {
1597        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1598            .join("../tests/data/angle_bounds_clamp.m");
1599        crate::parse_file(path, None).unwrap().network
1600    }
1601
1602    #[test]
1603    fn transformer_control_normalization_follows_psse_field_units_for_every_mode() {
1604        // Siemens PSS/E 35.4.1 defines RMA/RMI as ratios for |COD| 1/2,
1605        // degrees for |COD| 3/5, and unused for |COD| 0/4. VMA/VMI are p.u.
1606        // voltage for |COD| 1, Mvar for |COD| 2, MW for |COD| 3/5, and unused
1607        // for |COD| 0/4. Preserve unused values rather than inventing units.
1608        let map = HashMap::from([(BusId(7), BusId(7))]);
1609        let cases = [
1610            (TransformerControlMode::Fixed, false, false),
1611            (TransformerControlMode::Voltage, false, false),
1612            (TransformerControlMode::ReactiveFlow, false, true),
1613            (TransformerControlMode::ActiveFlow, true, true),
1614            (TransformerControlMode::DcLineQuantity, false, false),
1615            (TransformerControlMode::AsymmetricActiveFlow, true, true),
1616        ];
1617
1618        for (mode, angle_limits, power_band) in cases {
1619            let mut control = TransformerControl::new(mode);
1620            control.controlled_bus = Some(BusId(7));
1621            control.tap_min = -10.0;
1622            control.tap_max = 20.0;
1623            control.band_min = -50.0;
1624            control.band_max = 75.0;
1625            control.winding_connection_angle =
1626                (mode == TransformerControlMode::AsymmetricActiveFlow).then_some(30.0);
1627
1628            norm_transformer_control(&mut control, 100.0, &map);
1629
1630            let tap_scale = if angle_limits { DEG_TO_RAD } else { 1.0 };
1631            let band_scale = if power_band { 0.01 } else { 1.0 };
1632            assert!(approx(control.tap_min, -10.0 * tap_scale), "{mode:?}");
1633            assert!(approx(control.tap_max, 20.0 * tap_scale), "{mode:?}");
1634            assert!(approx(control.band_min, -50.0 * band_scale), "{mode:?}");
1635            assert!(approx(control.band_max, 75.0 * band_scale), "{mode:?}");
1636            assert_eq!(control.controlled_bus, Some(BusId(7)), "{mode:?}");
1637            if mode == TransformerControlMode::AsymmetricActiveFlow {
1638                assert!(approx(
1639                    control.winding_connection_angle.unwrap(),
1640                    30.0 * DEG_TO_RAD
1641                ));
1642            } else {
1643                assert_eq!(control.winding_connection_angle, None, "{mode:?}");
1644            }
1645        }
1646    }
1647
1648    #[test]
1649    fn angle_bound_clamp_is_opt_in_and_matches_powermodels_rules() {
1650        let net = angle_bound_fixture();
1651
1652        let plain = net.to_normalized().unwrap();
1653        assert!(approx(plain.branches()[0].angmin, -std::f64::consts::TAU));
1654        assert!(approx(plain.branches()[0].angmax, std::f64::consts::TAU));
1655        assert!(approx(plain.branches()[1].angmin, 0.0));
1656        assert!(approx(plain.branches()[1].angmax, 0.0));
1657        assert!(approx(plain.branches()[3].angmin, -120.0 * DEG_TO_RAD));
1658        assert!(approx(plain.branches()[3].angmax, -100.0 * DEG_TO_RAD));
1659        assert!(approx(plain.branches()[4].angmin, 100.0 * DEG_TO_RAD));
1660        assert!(approx(plain.branches()[4].angmax, 120.0 * DEG_TO_RAD));
1661
1662        let out = net
1663            .to_normalized_with_options(&NormalizeOptions {
1664                clamp_angle_bounds: true,
1665                ..NormalizeOptions::default()
1666            })
1667            .unwrap();
1668        // The fixture also carries no gencost, so the costless-case warning
1669        // rides beside the clamp lines; hold the clamp set on its own code.
1670        let clamps: Vec<&String> = out
1671            .warnings
1672            .iter()
1673            .filter(|w| w.contains("BOUNDS_CLAMPED"))
1674            .collect();
1675        assert_eq!(clamps.len(), 4, "{:?}", out.warnings);
1676        assert!(clamps[0].contains("branch 0"));
1677        assert!(clamps[1].contains("branch 1"));
1678        assert!(clamps[2].contains("branch 3"));
1679        assert!(clamps[3].contains("branch 4"));
1680
1681        let branches = &out.network.branches();
1682        assert!(approx(branches[0].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
1683        assert!(approx(branches[0].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
1684        assert!(approx(branches[1].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
1685        assert!(approx(branches[1].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
1686        assert!(approx(branches[2].angmin, -30.0 * DEG_TO_RAD));
1687        assert!(approx(branches[2].angmax, 30.0 * DEG_TO_RAD));
1688        assert!(approx(branches[3].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
1689        assert!(approx(branches[3].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
1690        assert!(approx(branches[4].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
1691        assert!(approx(branches[4].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
1692        assert!(branches.iter().all(|br| br.angmin <= br.angmax));
1693    }
1694
1695    #[test]
1696    fn angle_bound_clamp_rejects_invalid_pad() {
1697        let net = angle_bound_fixture();
1698        let err = net
1699            .to_normalized_with_options(&NormalizeOptions {
1700                clamp_angle_bounds: true,
1701                angle_bound_pad: std::f64::consts::FRAC_PI_2,
1702            })
1703            .unwrap_err();
1704        assert!(matches!(
1705            err,
1706            Error::InvalidNormalizeOption {
1707                field: "angle_bound_pad",
1708                ..
1709            }
1710        ));
1711    }
1712
1713    #[test]
1714    fn to_normalized_drops_a_control_bus_whose_target_was_filtered_out() {
1715        use crate::network::{Extras, ShuntBlock, SwitchedShuntControl, SwitchedShuntMode};
1716
1717        let mkbus = |id: usize, kind: BusType| Bus {
1718            id: BusId(id),
1719            kind,
1720            vm: 1.0,
1721            va: 0.0,
1722            base_kv: 230.0,
1723            vmax: 1.1,
1724            vmin: 0.9,
1725            evhi: None,
1726            evlo: None,
1727            area: 1,
1728            zone: 1,
1729            name: None,
1730            uid: None,
1731            location: None,
1732            extras: Extras::new(),
1733        };
1734        let branch = Branch {
1735            name: None,
1736            from: BusId(1),
1737            to: BusId(2),
1738            r: 0.0,
1739            x: 0.1,
1740            b: 0.0,
1741            charging: None,
1742            rate_a: 0.0,
1743            rate_b: 0.0,
1744            rate_c: 0.0,
1745            rating_sets: Vec::new(),
1746            current_ratings: None,
1747            tap: 0.0,
1748            shift: 0.0,
1749            in_service: true,
1750            angmin: -360.0,
1751            angmax: 360.0,
1752            control: None,
1753            solution: None,
1754            uid: None,
1755            route: None,
1756            extras: Extras::new(),
1757        };
1758        // Bus 3 is isolated, so to_normalized drops it.
1759        let mut net = BalancedNetwork::in_memory(
1760            "n",
1761            100.0,
1762            vec![
1763                mkbus(1, BusType::Ref),
1764                mkbus(2, BusType::Pq),
1765                mkbus(3, BusType::Isolated),
1766            ],
1767            vec![branch],
1768        );
1769        net.generators_mut().push(Generator {
1770            bus: BusId(1),
1771            energy_source: GeneratorEnergySource::default(),
1772            pg: 10.0,
1773            qg: 0.0,
1774            pmax: 100.0,
1775            pmin: 0.0,
1776            qmax: 50.0,
1777            qmin: -50.0,
1778            vg: 1.0,
1779            mbase: 100.0,
1780            in_service: true,
1781            cost: None,
1782            caps: Default::default(),
1783            voltage_regulation_on: true,
1784            regulating_terminal: None,
1785            regulated_bus: None,
1786            active_power_control: None,
1787            uid: None,
1788        });
1789        // A switched shunt on bus 2 whose control bus is the (dropped) isolated bus 3.
1790        net.shunts_mut().push(Shunt {
1791            bus: BusId(2),
1792            g: 0.0,
1793            b: 10.0,
1794            in_service: true,
1795            section_count: None,
1796            control: Some(SwitchedShuntControl {
1797                mode: SwitchedShuntMode::Discrete,
1798                vhigh: 1.05,
1799                vlow: 0.95,
1800                control_bus: Some(BusId(3)),
1801                regulating_terminal: None,
1802                rmpct: 100.0,
1803                blocks: vec![ShuntBlock::with_admittance(2, 4.0, 20.0)],
1804            }),
1805            uid: None,
1806            extras: Extras::new(),
1807        });
1808
1809        let norm = net.to_normalized().unwrap();
1810        norm.validate().unwrap();
1811        let c = norm.shunts()[0].control.as_ref().expect("control retained");
1812        assert_eq!(
1813            c.control_bus, None,
1814            "a control bus pointing at a filtered-out isolated bus is dropped, not left dangling"
1815        );
1816        assert!(approx(c.blocks[0].g, 0.04));
1817        assert!(approx(c.blocks[0].b, 0.2));
1818    }
1819
1820    #[test]
1821    fn normalized_slack_tiebreak_ignores_nan_pmax() {
1822        use crate::network::Extras;
1823
1824        let mkbus = |id: usize| Bus {
1825            id: BusId(id),
1826            kind: BusType::Pq,
1827            vm: 1.0,
1828            va: 0.0,
1829            base_kv: 230.0,
1830            vmax: 1.1,
1831            vmin: 0.9,
1832            evhi: None,
1833            evlo: None,
1834            area: 1,
1835            zone: 1,
1836            name: None,
1837            uid: None,
1838            location: None,
1839            extras: Extras::new(),
1840        };
1841        let mkgen = |bus: usize, pmax: f64| Generator {
1842            bus: BusId(bus),
1843            energy_source: GeneratorEnergySource::default(),
1844            pg: 0.0,
1845            qg: 0.0,
1846            pmax,
1847            pmin: 0.0,
1848            qmax: 0.0,
1849            qmin: 0.0,
1850            vg: 1.0,
1851            mbase: 100.0,
1852            in_service: true,
1853            cost: None,
1854            caps: Default::default(),
1855            voltage_regulation_on: true,
1856            regulating_terminal: None,
1857            regulated_bus: None,
1858            active_power_control: None,
1859            uid: None,
1860        };
1861        let mut net = BalancedNetwork::in_memory("n", 100.0, vec![mkbus(1), mkbus(2)], Vec::new());
1862        *net.generators_mut() = vec![mkgen(1, f64::NAN), mkgen(2, 10.0)];
1863        let norm = net.to_normalized().unwrap();
1864
1865        assert_eq!(
1866            norm.buses().iter().find(|b| b.id == BusId(1)).unwrap().kind,
1867            BusType::Pv
1868        );
1869        assert_eq!(
1870            norm.buses().iter().find(|b| b.id == BusId(2)).unwrap().kind,
1871            BusType::Ref
1872        );
1873    }
1874
1875    #[test]
1876    fn cost_to_pu_polynomial_scales_and_trims() {
1877        // Model 2: the coeff of p^j scales by base^j; MATPOWER's trailing-zero
1878        // padding (beyond ncost) is dropped.
1879        let cost = GenCost {
1880            model: 2,
1881            startup: 0.0,
1882            shutdown: 0.0,
1883            ncost: 2,
1884            coeffs: vec![24.035, -403.5, 0.0, 0.0, 0.0, 0.0],
1885        };
1886        let out = cost_to_pu(&cost, 100.0);
1887        assert_eq!(out.len(), 2, "padding dropped");
1888        assert!(approx(out[0], 2403.5)); // 24.035 · 100^1
1889        assert!(approx(out[1], -403.5)); // -403.5 · 100^0
1890    }
1891
1892    #[test]
1893    fn cost_to_pu_piecewise_scales_mw_only_and_trims() {
1894        // Model 1: MW breakpoints (even positions) ÷ base; cost ordinates (odd) raw.
1895        let cost = GenCost {
1896            model: 1,
1897            startup: 0.0,
1898            shutdown: 0.0,
1899            ncost: 4,
1900            coeffs: vec![
1901                0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0, 0.0, 0.0,
1902            ],
1903        };
1904        let out = cost_to_pu(&cost, 100.0);
1905        assert_eq!(out.len(), 8, "trimmed to 2·ncost, padding dropped");
1906        assert!(
1907            approx(out[0], 0.0)
1908                && approx(out[2], 1.0)
1909                && approx(out[4], 2.0)
1910                && approx(out[6], 2.5)
1911        );
1912        assert!(
1913            approx(out[1], 0.0)
1914                && approx(out[3], 2500.0)
1915                && approx(out[5], 5500.0)
1916                && approx(out[7], 7250.0)
1917        );
1918    }
1919
1920    #[test]
1921    fn cost_rescale_round_trips() {
1922        // c2 p² + c1 p + c0 with base 100: per unit then back is the identity.
1923        let cost = GenCost {
1924            model: 2,
1925            startup: 0.0,
1926            shutdown: 0.0,
1927            ncost: 3,
1928            coeffs: vec![0.11, 5.0, 150.0],
1929        };
1930        let pu = cost_to_pu(&cost, 100.0);
1931        // p^2 coeff scales by 100^2, p^1 by 100, constant unchanged.
1932        assert!((pu[0] - 0.11 * 100.0 * 100.0).abs() < 1e-9);
1933        assert!((pu[1] - 5.0 * 100.0).abs() < 1e-9);
1934        assert!((pu[2] - 150.0).abs() < 1e-9);
1935        let back = cost_from_pu(&pu, 2, 100.0);
1936        for (a, b) in back.iter().zip(&cost.coeffs) {
1937            assert!((a - b).abs() < 1e-9);
1938        }
1939    }
1940
1941    #[test]
1942    fn cost_rescale_passes_through_unknown_model() {
1943        // A model outside {1,2} has unknown coefficient semantics, so neither
1944        // direction may touch it; to_pu and from_pu must both be the identity,
1945        // or the round trip silently corrupts a curve we don't understand.
1946        let cost = GenCost {
1947            model: 0,
1948            startup: 0.0,
1949            shutdown: 0.0,
1950            ncost: 2,
1951            coeffs: vec![3.0, 7.0, 9.0],
1952        };
1953        let pu = cost_to_pu(&cost, 100.0);
1954        assert_eq!(pu, cost.coeffs, "to_pu must not scale an unknown model");
1955        let back = cost_from_pu(&pu, cost.model, 100.0);
1956        assert_eq!(back, cost.coeffs, "from_pu must not scale an unknown model");
1957    }
1958
1959    #[test]
1960    fn cost_rescale_round_trips_piecewise() {
1961        // Model 1: cost_from_pu multiplies the MW breakpoints back by base and
1962        // leaves the cost ordinates, the exact inverse of cost_to_pu's even/odd
1963        // split. (cost_to_pu trims, cost_from_pu doesn't, so feed a trimmed row.)
1964        let cost = GenCost {
1965            model: 1,
1966            startup: 0.0,
1967            shutdown: 0.0,
1968            ncost: 4,
1969            coeffs: vec![0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0],
1970        };
1971        let pu = cost_to_pu(&cost, 100.0);
1972        let back = cost_from_pu(&pu, 1, 100.0);
1973        for (a, b) in back.iter().zip(&cost.coeffs) {
1974            assert!((a - b).abs() < 1e-9, "{a} != {b}");
1975        }
1976    }
1977}