Skip to main content

powerio/
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::network::{
18    BalancedNetwork, Branch, Bus, BusId, BusType, GEN_EXTRA_KEYS, GenCost, Generator, Hvdc, Load,
19    LoadVoltageModel, Shunt, SourceFormat, Storage, Switch, Transformer3W,
20};
21use crate::{Error, Result};
22
23/// Degrees → radians. The per-unit convention stores angles in radians; the raw
24/// model keeps MATPOWER degrees.
25pub(crate) const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
26
27/// Radians → degrees, the inverse of [`DEG_TO_RAD`], used when reading a per-unit
28/// source back into the neutral degree model.
29pub(crate) const RAD_TO_DEG: f64 = 180.0 / std::f64::consts::PI;
30
31/// The gen capability columns that are per-unitized (the ramp rates). The PQ-curve
32/// points (`pc1`/`pc2`/`qc*`) and `apf` stay raw, exactly as PowerModels'
33/// `make_per_unit!` leaves them, so a column is scaled in one place and can't drift
34/// between the reader, the writer, and [`BalancedNetwork::to_normalized`].
35pub(crate) const GEN_PU_KEYS: [&str; 4] = ["ramp_agc", "ramp_10", "ramp_30", "ramp_q"];
36
37/// Default branch angle difference bound used by PowerModels parse time repair.
38#[allow(clippy::approx_constant)]
39pub const POWER_MODELS_ANGLE_BOUND_PAD: f64 = 1.0472;
40
41/// Options for [`BalancedNetwork::to_normalized_with_options`].
42#[derive(Clone, Copy, Debug, PartialEq)]
43pub struct NormalizeOptions {
44    /// Clamp branch angle difference bounds to the interval PowerModels relaxations
45    /// accept. Disabled by default so [`BalancedNetwork::to_normalized`] stays unchanged.
46    pub clamp_angle_bounds: bool,
47    /// Replacement magnitude, in radians, for clamped angle bounds.
48    pub angle_bound_pad: f64,
49}
50
51impl Default for NormalizeOptions {
52    fn default() -> Self {
53        Self {
54            clamp_angle_bounds: false,
55            angle_bound_pad: POWER_MODELS_ANGLE_BOUND_PAD,
56        }
57    }
58}
59
60/// Output of [`BalancedNetwork::to_normalized_with_options`].
61#[derive(Clone, Debug)]
62pub struct NormalizedNetwork {
63    pub network: BalancedNetwork,
64    pub warnings: Vec<String>,
65}
66
67/// Row provenance for one normalize pass: for each dense position in the
68/// [`IndexedNetwork`](crate::IndexedNetwork) view of the normalized network,
69/// the row of the same element family in the source network. `None` marks an
70/// element the pipeline synthesized, which has no source row.
71///
72/// Every field is positional over that view, so `buses[dense_index]` resolves
73/// a matrix row back to its source element. Each length equals the matching
74/// element table of the view: `buses` equals `view.n()`, `branches` equals
75/// `view.branches().len()`.
76///
77/// The star lowering that the view applies to a 3-winding transformer appends
78/// one bus, its star branches, and a magnetizing shunt. Those entries are
79/// `None`. The lowering also consumes the transformer itself, so the view
80/// holds none; `transformers_3w` therefore stays positional over the
81/// normalized network's own list.
82///
83/// The map is valid only for the [`NormalizedNetwork`] returned beside it. A
84/// later mutation of that network ([`BalancedNetwork::merge_bus`],
85/// [`BalancedNetwork::reduce_zero_impedance`], [`BalancedNetwork::reduce_passthrough_buses`],
86/// [`BalancedNetwork::subset`], or a hand edit) invalidates every entry; run the pass
87/// again instead of patching the map.
88#[derive(Clone, Debug)]
89#[non_exhaustive]
90pub struct NormalizeSourceRows {
91    pub buses: Vec<Option<usize>>,
92    pub loads: Vec<Option<usize>>,
93    pub shunts: Vec<Option<usize>>,
94    pub branches: Vec<Option<usize>>,
95    pub switches: Vec<Option<usize>>,
96    pub generators: Vec<Option<usize>>,
97    pub storage: Vec<Option<usize>>,
98    pub hvdc: Vec<Option<usize>>,
99    pub transformers_3w: Vec<Option<usize>>,
100}
101
102impl NormalizeSourceRows {
103    /// The map for a network that is already normalized: it is its own source,
104    /// so every element maps to its own row. Positional over `net` itself —
105    /// [`Self::pad_to_lowered`] extends it to the star-lowered view.
106    pub(crate) fn identity(net: &BalancedNetwork) -> Self {
107        let ident = |n: usize| (0..n).map(Some).collect();
108        Self {
109            buses: ident(net.buses.len()),
110            loads: ident(net.loads.len()),
111            shunts: ident(net.shunts.len()),
112            branches: ident(net.branches.len()),
113            switches: ident(net.switches.len()),
114            generators: ident(net.generators.len()),
115            storage: ident(net.storage.len()),
116            hvdc: ident(net.hvdc.len()),
117            transformers_3w: ident(net.transformers_3w.len()),
118        }
119    }
120
121    /// Grow the families the star lowering appends to so each length matches the
122    /// lowered form of `net`. The appended entries have no source row. The
123    /// lengths come from [`BalancedNetwork::lowered_lengths`], which counts them off the
124    /// transformer records, so padding never builds the lowering itself.
125    pub(crate) fn pad_to_lowered(&mut self, net: &BalancedNetwork) {
126        let lengths = net.lowered_lengths();
127        self.buses.resize(lengths.buses, None);
128        self.branches.resize(lengths.branches, None);
129        self.shunts.resize(lengths.shunts, None);
130    }
131}
132
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134enum CostModel {
135    Piecewise,
136    Polynomial,
137    Unknown,
138}
139
140impl From<u8> for CostModel {
141    fn from(value: u8) -> Self {
142        match value {
143            1 => CostModel::Piecewise,
144            2 => CostModel::Polynomial,
145            _ => CostModel::Unknown,
146        }
147    }
148}
149
150/// Gen cost coefficients rescaled into the per-unit basis, trimmed to the length
151/// the model implies (a polynomial keeps `ncost` coeffs; a piecewise curve keeps
152/// `2·ncost` `(mw, cost)` values). MATPOWER pads every gencost row to the matrix
153/// width with trailing zeros; the padding would make a polynomial read as a
154/// higher-degree curve and mis-scale, so it is dropped here.
155///
156/// Polynomial (model 2): coeff `i` is the term `p^(k-1-i)`, so per unit scales it
157/// by `base^(k-1-i)`. Piecewise (model 1): the MW breakpoints (even positions) are
158/// divided by `base`; the dollar costs (odd positions) stay. Any other model has
159/// unknown coefficient semantics, so it passes through untouched — the exact
160/// inverse of [`cost_from_pu`]'s own passthrough.
161pub(crate) fn cost_to_pu(cost: &GenCost, base: f64) -> Vec<f64> {
162    let mut coeffs = cost.coeffs.clone();
163    scale_coeffs_to_pu(&mut coeffs, cost.ncost, cost.model, base);
164    coeffs
165}
166
167/// [`cost_to_pu`] over a vector the caller already owns, so a rescale in place
168/// keeps its allocation.
169pub(crate) fn scale_coeffs_to_pu(coeffs: &mut Vec<f64>, ncost: usize, model: u8, base: f64) {
170    match CostModel::from(model) {
171        CostModel::Polynomial => {
172            coeffs.truncate(ncost.min(coeffs.len()));
173            let k = coeffs.len();
174            // The exponent k-1-i is in [0, k-1]; a polynomial never has i32::MAX-many
175            // terms, so the conversion can't fail (loud, not silent, if it ever did).
176            for (i, c) in coeffs.iter_mut().enumerate() {
177                *c *= base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32"));
178            }
179        }
180        CostModel::Piecewise => {
181            // saturating_mul: `ncost` comes from input (JSON deserializes it
182            // unchecked), so an oversized count must clamp to the coefficient
183            // length instead of overflowing.
184            coeffs.truncate(ncost.saturating_mul(2).min(coeffs.len()));
185            for c in coeffs.iter_mut().step_by(2) {
186                *c /= base;
187            }
188        }
189        CostModel::Unknown => {}
190    }
191}
192
193/// Undo [`cost_to_pu`] for the neutral MW basis: a polynomial (model 2) divides
194/// coeff `i` by `base^(k-1-i)`, a piecewise curve (model 1) multiplies its MW
195/// breakpoints (even positions) by `base`. The exact inverse of [`cost_to_pu`] on
196/// the trimmed coefficient vector — JSON-sourced coefficients arrive already
197/// trimmed, so this does no trimming; other models pass through unchanged.
198pub(crate) fn cost_from_pu(coeffs: &[f64], model: u8, base: f64) -> Vec<f64> {
199    let k = coeffs.len();
200    match CostModel::from(model) {
201        CostModel::Polynomial => coeffs
202            .iter()
203            .enumerate()
204            .map(|(i, &c)| c / base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32")))
205            .collect(),
206        CostModel::Piecewise => coeffs
207            .iter()
208            .enumerate()
209            .map(|(i, &c)| if i % 2 == 0 { c * base } else { c })
210            .collect(),
211        CostModel::Unknown => coeffs.to_vec(),
212    }
213}
214
215/// Map a source bus id to its surviving normalized id, or `None` if the bus was dropped.
216fn remap(map: &HashMap<BusId, BusId>, id: BusId) -> Option<BusId> {
217    map.get(&id).copied()
218}
219
220fn norm_loads(
221    loads: &[Load],
222    base: f64,
223    map: &HashMap<BusId, BusId>,
224) -> (Vec<Load>, Vec<Option<usize>>) {
225    loads
226        .iter()
227        .enumerate()
228        .filter(|(_, l)| l.in_service)
229        .filter_map(|(row, l)| {
230            Some((
231                Load {
232                    bus: remap(map, l.bus)?,
233                    p: l.p / base,
234                    q: l.q / base,
235                    voltage_model: l
236                        .voltage_model
237                        .as_ref()
238                        .map(|m| norm_load_voltage_model(m, base)),
239                    ..l.clone()
240                },
241                Some(row),
242            ))
243        })
244        .unzip()
245}
246
247fn norm_load_voltage_model(model: &LoadVoltageModel, base: f64) -> LoadVoltageModel {
248    match model {
249        LoadVoltageModel::ConstantPower => LoadVoltageModel::ConstantPower,
250        LoadVoltageModel::Zip {
251            p_constant_power,
252            q_constant_power,
253            p_constant_current,
254            q_constant_current,
255            p_constant_impedance,
256            q_constant_impedance,
257            v_nom,
258            load_type,
259            scaling,
260        } => LoadVoltageModel::Zip {
261            p_constant_power: p_constant_power / base,
262            q_constant_power: q_constant_power / base,
263            p_constant_current: p_constant_current / base,
264            q_constant_current: q_constant_current / base,
265            p_constant_impedance: p_constant_impedance / base,
266            q_constant_impedance: q_constant_impedance / base,
267            v_nom: *v_nom,
268            load_type: *load_type,
269            scaling: *scaling,
270        },
271        LoadVoltageModel::Exponential {
272            p,
273            q,
274            v_nom,
275            gamma_p,
276            gamma_q,
277        } => LoadVoltageModel::Exponential {
278            p: p / base,
279            q: q / base,
280            v_nom: *v_nom,
281            gamma_p: *gamma_p,
282            gamma_q: *gamma_q,
283        },
284    }
285}
286
287fn norm_shunts(
288    shunts: &[Shunt],
289    base: f64,
290    map: &HashMap<BusId, BusId>,
291) -> (Vec<Shunt>, Vec<Option<usize>>) {
292    shunts
293        .iter()
294        .enumerate()
295        .filter(|(_, s)| s.in_service)
296        .filter_map(|(row, s)| {
297            let mut shunt = s.clone();
298            shunt.bus = remap(map, s.bus)?;
299            shunt.g = s.g / base;
300            shunt.b = s.b / base;
301            // Remap the switched-shunt control bus and drop it if its target was
302            // filtered out, so the normalized network has no dangling reference.
303            if let Some(c) = &mut shunt.control {
304                c.control_bus = c.control_bus.and_then(|b| remap(map, b));
305            }
306            Some((shunt, Some(row)))
307        })
308        .unzip()
309}
310
311fn norm_branches(
312    branches: &[Branch],
313    base: f64,
314    map: &HashMap<BusId, BusId>,
315) -> (Vec<Branch>, Vec<Option<usize>>) {
316    branches
317        .iter()
318        .enumerate()
319        .filter(|(_, br)| br.in_service)
320        .filter_map(|(row, br)| {
321            let mut branch = br.clone();
322            branch.from = remap(map, br.from)?;
323            branch.to = remap(map, br.to)?;
324            branch.rate_a = br.rate_a / base;
325            branch.rate_b = br.rate_b / base;
326            branch.rate_c = br.rate_c / base;
327            for set in &mut branch.rating_sets {
328                set.rate_mva /= base;
329            }
330            branch.tap = br.effective_tap();
331            branch.shift = br.shift * DEG_TO_RAD;
332            branch.angmin = br.angmin * DEG_TO_RAD;
333            branch.angmax = br.angmax * DEG_TO_RAD;
334            if let Some(s) = &mut branch.solution {
335                s.pf /= base;
336                s.qf /= base;
337                s.pt /= base;
338                s.qt /= base;
339            }
340            // Remap the regulated-bus reference through the id map and drop it
341            // if its target was filtered out (out of service / isolated), so the
342            // normalized network has no dangling control reference.
343            if let Some(c) = &mut branch.control {
344                c.controlled_bus = c.controlled_bus.and_then(|b| remap(map, b));
345            }
346            Some((branch, Some(row)))
347        })
348        .unzip()
349}
350
351fn validate_normalize_options(options: &NormalizeOptions) -> Result<()> {
352    if options.clamp_angle_bounds
353        && (!options.angle_bound_pad.is_finite()
354            || options.angle_bound_pad <= 0.0
355            || options.angle_bound_pad >= std::f64::consts::FRAC_PI_2)
356    {
357        return Err(Error::InvalidNormalizeOption {
358            field: "angle_bound_pad",
359            value: options.angle_bound_pad,
360        });
361    }
362    Ok(())
363}
364
365fn clamp_angle_bounds(branches: &mut [Branch], pad: f64, warnings: &mut Vec<String>) {
366    for (idx, br) in branches.iter_mut().enumerate() {
367        let old_min = br.angmin;
368        let old_max = br.angmax;
369        let mut changes = Vec::new();
370
371        if old_min <= -std::f64::consts::FRAC_PI_2 {
372            br.angmin = -pad;
373            changes.push(format!("angmin {old_min} -> {}", br.angmin));
374        }
375        if old_max >= std::f64::consts::FRAC_PI_2 {
376            br.angmax = pad;
377            changes.push(format!("angmax {old_max} -> {}", br.angmax));
378        }
379        if old_min == 0.0 && old_max == 0.0 {
380            br.angmin = -pad;
381            br.angmax = pad;
382            changes.push(format!("angmin/angmax 0 -> [{}, {}]", br.angmin, br.angmax));
383        }
384        if !changes.is_empty() && br.angmin > br.angmax {
385            let repaired_min = br.angmin;
386            let repaired_max = br.angmax;
387            br.angmin = -pad;
388            br.angmax = pad;
389            changes.push(format!(
390                "repaired interval {repaired_min}..{repaired_max} widened to [{}, {}]",
391                br.angmin, br.angmax
392            ));
393        }
394
395        if !changes.is_empty() {
396            warnings.push(format!(
397                "branch {idx} angle difference bounds clamped: {}",
398                changes.join(", ")
399            ));
400        }
401    }
402}
403
404fn norm_gens(
405    gens: &[Generator],
406    base: f64,
407    map: &HashMap<BusId, BusId>,
408) -> (Vec<Generator>, Vec<Option<usize>>) {
409    gens.iter()
410        .enumerate()
411        .filter(|(_, g)| g.in_service)
412        .filter_map(|(row, g)| {
413            let mut generator = g.clone();
414            generator.bus = remap(map, g.bus)?;
415            generator.pg = g.pg / base;
416            generator.qg = g.qg / base;
417            generator.pmax = g.pmax / base;
418            generator.pmin = g.pmin / base;
419            generator.qmax = g.qmax / base;
420            generator.qmin = g.qmin / base;
421            if let Some(c) = &mut generator.cost {
422                scale_coeffs_to_pu(&mut c.coeffs, c.ncost, c.model, base);
423            }
424            // `GenCaps` is indexed by `GEN_EXTRA_KEYS`, so the two zip exactly.
425            for (cap, key) in generator.caps.iter_mut().zip(GEN_EXTRA_KEYS) {
426                if GEN_PU_KEYS.contains(&key)
427                    && let Some(v) = cap
428                {
429                    *v /= base;
430                }
431            }
432            // Remap the regulated bus through the same id map; drop it if its
433            // target was filtered out so the normalized form stays consistent.
434            generator.regulated_bus = g.regulated_bus.and_then(|b| remap(map, b));
435            Some((generator, Some(row)))
436        })
437        .unzip()
438}
439
440fn norm_switches(
441    switches: &[Switch],
442    base: f64,
443    map: &HashMap<BusId, BusId>,
444) -> (Vec<Switch>, Vec<Option<usize>>) {
445    switches
446        .iter()
447        .enumerate()
448        .filter_map(|(row, s)| {
449            let switch = Switch {
450                from: remap(map, s.from)?,
451                to: remap(map, s.to)?,
452                thermal_rating: s.thermal_rating.map(|v| v / base),
453                pf: s.pf.map(|v| v / base),
454                qf: s.qf.map(|v| v / base),
455                pt: s.pt.map(|v| v / base),
456                qt: s.qt.map(|v| v / base),
457                ..s.clone()
458            };
459            Some((switch, Some(row)))
460        })
461        .unzip()
462}
463
464fn norm_storage(
465    storage: &[Storage],
466    base: f64,
467    map: &HashMap<BusId, BusId>,
468) -> (Vec<Storage>, Vec<Option<usize>>) {
469    storage
470        .iter()
471        .enumerate()
472        .filter(|(_, s)| s.in_service)
473        .filter_map(|(row, s)| {
474            // ps/qs stay raw (PowerModels' make_per_unit! leaves the dispatch
475            // setpoint alone); the energy, ratings, limits, and losses scale.
476            let unit = Storage {
477                bus: remap(map, s.bus)?,
478                energy: s.energy / base,
479                energy_rating: s.energy_rating / base,
480                charge_rating: s.charge_rating / base,
481                discharge_rating: s.discharge_rating / base,
482                thermal_rating: s.thermal_rating / base,
483                qmin: s.qmin / base,
484                qmax: s.qmax / base,
485                p_loss: s.p_loss / base,
486                q_loss: s.q_loss / base,
487                ..s.clone()
488            };
489            Some((unit, Some(row)))
490        })
491        .unzip()
492}
493
494fn norm_hvdc(
495    hvdc: &[Hvdc],
496    base: f64,
497    map: &HashMap<BusId, BusId>,
498) -> (Vec<Hvdc>, Vec<Option<usize>>) {
499    hvdc.iter()
500        .enumerate()
501        .filter(|(_, d)| d.in_service)
502        .filter_map(|(row, d)| {
503            // No sign flip: the writer's Pt/Qf/Qt negation is a PowerModels output
504            // convention, not part of per-unit normalization. The aggregate
505            // pmin/pmax stay raw, matching make_per_unit!.
506            let mut link = d.clone();
507            link.from = remap(map, d.from)?;
508            link.to = remap(map, d.to)?;
509            link.pf = d.pf / base;
510            link.pt = d.pt / base;
511            link.qf = d.qf / base;
512            link.qt = d.qt / base;
513            link.qminf = d.qminf / base;
514            link.qmaxf = d.qmaxf / base;
515            link.qmint = d.qmint / base;
516            link.qmaxt = d.qmaxt / base;
517            link.loss0 = d.loss0 / base;
518            if let Some(c) = &mut link.cost {
519                scale_coeffs_to_pu(&mut c.coeffs, c.ncost, c.model, base);
520            }
521            Some((link, Some(row)))
522        })
523        .unzip()
524}
525
526fn norm_transformers_3w(
527    xfmrs: &[Transformer3W],
528    base: f64,
529    map: &HashMap<BusId, BusId>,
530) -> (Vec<Transformer3W>, Vec<Option<usize>>) {
531    xfmrs
532        .iter()
533        .enumerate()
534        .filter(|(_, t)| t.in_service)
535        .filter_map(|(row, t)| {
536            // Remap each winding terminal and drop the whole unit if any was filtered
537            // out (a 3-winding transformer can't keep a dangling winding). Phase
538            // shifts and the star angle go to radians; winding ratings go per unit;
539            // the pairwise impedances are already per unit on the system base.
540            let mut windings = t.windings.clone();
541            for w in &mut windings {
542                w.bus = remap(map, w.bus)?;
543                w.shift *= DEG_TO_RAD;
544                w.rate_a /= base;
545                w.rate_b /= base;
546                w.rate_c /= base;
547            }
548            Some((
549                Transformer3W {
550                    windings,
551                    star_va: t.star_va * DEG_TO_RAD,
552                    ..t.clone()
553                },
554                Some(row),
555            ))
556        })
557        .unzip()
558}
559
560impl BalancedNetwork {
561    /// A normalized, computation-ready copy of this network. The raw `BalancedNetwork` is
562    /// kept lossless (MATPOWER units, 1-based sparse ids, out-of-service elements
563    /// retained); `to_normalized` derives the form a solver or ML pipeline wants:
564    ///
565    /// - **Per unit** (÷`base_mva`): gen `pg/qg/pmax/pmin/qmax/qmin` and the ramp
566    ///   caps (`GEN_PU_KEYS`); load `p/q`; shunt `g/b`; branch `rate_a/b/c`;
567    ///   storage energy/ratings/limits/losses; HVDC `pf/pt/qf/qt`, reactive limits,
568    ///   `loss0`; gen-cost coefficients (`cost_to_pu`). Storage `ps/qs` and HVDC
569    ///   aggregate `pmin/pmax` stay raw, matching the PowerModels per-unit
570    ///   convention. Voltages, impedances, tap, and `loss1` are already
571    ///   dimensionless.
572    /// - **Radians**: bus `va`; branch `shift/angmin/angmax`.
573    /// - **Tap**: `0 → 1.0` (an explicit `1` is kept).
574    /// - **Filtered**: drop buses typed isolated (`BusType::Isolated`) and every
575    ///   out-of-service element, then drop any element left referencing a dropped
576    ///   bus. A bus orphaned by the out-of-service filter (no in-service branch,
577    ///   but not typed isolated) is kept — its load is real — and surfaces as its
578    ///   own island, which the grounding check reports if it has no reference.
579    /// - **IDs**: kept buses retain their source bus ids, and every surviving
580    ///   endpoint stays in the same id space. Consumers that need dense rows should
581    ///   use [`IndexedNetwork`](crate::IndexedNetwork), which derives `[0, n)`
582    ///   indices without destroying source ids.
583    /// - **Bus types**: a bus hosting a surviving generator keeps `REF` if the file
584    ///   marked it `REF`, otherwise becomes `PV`; a generator-less bus is `PQ` (so a
585    ///   generator-less `REF` is demoted). The file's `REF` buses are kept, several
586    ///   included, and the consumer picks the slack. Only when no reference bus
587    ///   survives is the largest-`pmax` in-service generator's bus promoted to
588    ///   `REF`.
589    ///
590    /// This is a derived product, not a source for write-back: `source` is dropped
591    /// and `source_format` is [`SourceFormat::Normalized`], so writing it serializes
592    /// the per-unit/radian model instead of echoing the raw bytes, and a consumer
593    /// can tell it apart from a raw in-memory network.
594    ///
595    /// Scope is the universal canonicalization only. It does not synthesize a
596    /// missing `rate_a` or restrict the gen-cost model — those are solver
597    /// preparation choices a consumer applies on top. Use
598    /// [`BalancedNetwork::to_normalized_with_options`] for the opt in PowerModels angle
599    /// bound repair. The cost *rescale* is
600    /// universal and lives here; the model *restriction* does not.
601    ///
602    /// # Errors
603    /// [`Error::InvalidBaseMva`] if `base_mva` is not a positive, finite number
604    /// (every per-unit divisor), so a malformed base can't silently poison the
605    /// whole network with `NaN`/`Inf` or sign-flipped values.
606    /// [`Error::ReferenceBusCount`] if no reference bus can be established — no `REF`
607    /// survives and there is no in-service generator to anchor one.
608    pub fn to_normalized(&self) -> Result<BalancedNetwork> {
609        Ok(self
610            .to_normalized_with_options(&NormalizeOptions::default())?
611            .network)
612    }
613
614    /// Like [`BalancedNetwork::to_normalized`], with opt in solver preparation repairs
615    /// that report fidelity warnings.
616    pub fn to_normalized_with_options(
617        &self,
618        options: &NormalizeOptions,
619    ) -> Result<NormalizedNetwork> {
620        Ok(self.normalize_inner(options)?.0)
621    }
622
623    /// Like [`BalancedNetwork::to_normalized_with_options`], also returning the
624    /// [`NormalizeSourceRows`] row provenance.
625    ///
626    /// The rows are positional over the
627    /// [`IndexedNetwork`](crate::IndexedNetwork) view of the returned network,
628    /// which is the index space a matrix row or a solver table row lives in.
629    /// That view star-lowers a 3-winding transformer, so it holds more buses,
630    /// branches, and shunts than the returned [`NormalizedNetwork`] does;
631    /// indexing the returned network by a row position is out of bounds on any
632    /// case that carries one. Resolve a row through the view:
633    ///
634    /// ```
635    /// # use powerio::{IndexedNetwork, BalancedNetwork, NormalizeOptions};
636    /// # fn f(raw: &BalancedNetwork) -> powerio::Result<()> {
637    /// let (normalized, rows) = raw.to_normalized_with_source_rows(&NormalizeOptions::default())?;
638    /// let view = IndexedNetwork::new(&normalized.network);
639    /// for (dense, source) in rows.buses.iter().enumerate() {
640    ///     let bus = &view.network().buses[dense];
641    ///     // `source` is `None` for the synthetic star bus the view appended.
642    ///     let _ = (bus, source);
643    /// }
644    /// # Ok(())
645    /// # }
646    /// ```
647    pub fn to_normalized_with_source_rows(
648        &self,
649        options: &NormalizeOptions,
650    ) -> Result<(NormalizedNetwork, NormalizeSourceRows)> {
651        let (normalized, mut rows) = self.normalize_inner(options)?;
652        rows.pad_to_lowered(&normalized.network);
653        Ok((normalized, rows))
654    }
655
656    /// The pass itself. The rows it gives cover the normalized network before
657    /// the star lowering, so only [`Self::to_normalized_with_source_rows`] pays
658    /// for the lowered lengths.
659    fn normalize_inner(
660        &self,
661        options: &NormalizeOptions,
662    ) -> Result<(NormalizedNetwork, NormalizeSourceRows)> {
663        validate_normalize_options(options)?;
664        self.check_base_mva()?;
665        let base = self.base_mva;
666
667        // Kept buses keep their original `kind` for now (the reference scan below
668        // reads it) and their source ids. Isolated buses are dropped.
669        let mut id_map: HashMap<BusId, BusId> = HashMap::with_capacity(self.buses.len());
670        let mut buses: Vec<Bus> = Vec::with_capacity(self.buses.len());
671        // The pass keeps only elements that came from a source row, so each row
672        // here is `Some`; the `None` entries appear later, when
673        // `pad_to_lowered` extends the map over what the star lowering appends.
674        let mut bus_rows: Vec<Option<usize>> = Vec::with_capacity(self.buses.len());
675        for (row, b) in self.buses.iter().enumerate() {
676            if b.kind == BusType::Isolated {
677                continue;
678            }
679            id_map.insert(b.id, b.id);
680            buses.push(Bus {
681                va: b.va * DEG_TO_RAD,
682                ..b.clone()
683            });
684            bus_rows.push(Some(row));
685        }
686        let (loads, load_rows) = norm_loads(&self.loads, base, &id_map);
687        let (shunts, shunt_rows) = norm_shunts(&self.shunts, base, &id_map);
688        let (mut branches, branch_rows) = norm_branches(&self.branches, base, &id_map);
689        let mut warnings = Vec::new();
690        if options.clamp_angle_bounds {
691            clamp_angle_bounds(&mut branches, options.angle_bound_pad, &mut warnings);
692        }
693        let (switches, switch_rows) = norm_switches(&self.switches, base, &id_map);
694        let (generators, generator_rows) = norm_gens(&self.generators, base, &id_map);
695        let (storage, storage_rows) = norm_storage(&self.storage, base, &id_map);
696        let (hvdc, hvdc_rows) = norm_hvdc(&self.hvdc, base, &id_map);
697        let (transformers_3w, transformer_3w_rows) =
698            norm_transformers_3w(&self.transformers_3w, base, &id_map);
699        let source_rows = NormalizeSourceRows {
700            buses: bus_rows,
701            loads: load_rows,
702            shunts: shunt_rows,
703            branches: branch_rows,
704            switches: switch_rows,
705            generators: generator_rows,
706            storage: storage_rows,
707            hvdc: hvdc_rows,
708            transformers_3w: transformer_3w_rows,
709        };
710
711        // Bus types: a bus hosting an in-service generator keeps `Ref` if the
712        // file marked it `Ref`, else becomes `Pv`; a gen-less bus is `Pq`.
713        // Multiple file `Ref` buses are kept as-is, and only when no `Ref`
714        // survives is the largest-pmax generator's bus promoted.
715        let gen_buses: HashSet<BusId> = generators.iter().map(|g| g.bus).collect();
716        for b in &mut buses {
717            b.kind = match (gen_buses.contains(&b.id), b.kind) {
718                (true, BusType::Ref) => BusType::Ref,
719                (true, _) => BusType::Pv,
720                (false, _) => BusType::Pq,
721            };
722        }
723        if !buses.iter().any(|b| b.kind == BusType::Ref) {
724            // No reference survived: anchor the slack at the largest-pmax in-service
725            // generator's bus, or error when there is no generator to anchor it.
726            let slack = generators
727                .iter()
728                .max_by(|a, b| {
729                    // A NaN pmax must never win the slack: map it below every real
730                    // bound so the choice stays deterministic (an unbounded +Inf
731                    // pmax still wins, as the largest capacity).
732                    let key = |p: f64| if p.is_nan() { f64::NEG_INFINITY } else { p };
733                    key(a.pmax).total_cmp(&key(b.pmax))
734                })
735                .map(|g| g.bus)
736                .ok_or(Error::ReferenceBusCount { found: 0 })?;
737            if let Some(b) = buses.iter_mut().find(|b| b.id == slack) {
738                b.kind = BusType::Ref;
739            }
740        }
741
742        let net = BalancedNetwork {
743            name: self.name.clone(),
744            base_mva: base,
745            base_frequency: self.base_frequency,
746            geo: self.geo.clone(),
747            buses,
748            loads,
749            shunts,
750            branches,
751            switches,
752            generators,
753            storage,
754            hvdc,
755            transformers_3w,
756            // Areas (interchange schedule, per-area swing) are interchange metadata,
757            // not part of the per unit electrical view, so they are not carried.
758            areas: Vec::new(),
759            solver: None,
760            source_format: SourceFormat::Normalized,
761            source: None,
762        };
763        // The filter drops every reference to a dropped bus by
764        // construction, so the result is reference-consistent. Assert it in
765        // debug builds to catch a future regression in the filtering logic.
766        debug_assert!(
767            net.validate().is_ok(),
768            "to_normalized produced a dangling reference"
769        );
770        Ok((
771            NormalizedNetwork {
772                network: net,
773                warnings,
774            },
775            source_rows,
776        ))
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    fn approx(a: f64, b: f64) -> bool {
785        (a - b).abs() < 1e-9
786    }
787
788    fn angle_bound_fixture() -> BalancedNetwork {
789        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
790            .join("../tests/data/angle_bounds_clamp.m");
791        crate::parse_file(path, None).unwrap().network
792    }
793
794    #[test]
795    fn angle_bound_clamp_is_opt_in_and_matches_powermodels_rules() {
796        let net = angle_bound_fixture();
797
798        let plain = net.to_normalized().unwrap();
799        assert!(approx(plain.branches[0].angmin, -std::f64::consts::TAU));
800        assert!(approx(plain.branches[0].angmax, std::f64::consts::TAU));
801        assert!(approx(plain.branches[1].angmin, 0.0));
802        assert!(approx(plain.branches[1].angmax, 0.0));
803        assert!(approx(plain.branches[3].angmin, -120.0 * DEG_TO_RAD));
804        assert!(approx(plain.branches[3].angmax, -100.0 * DEG_TO_RAD));
805        assert!(approx(plain.branches[4].angmin, 100.0 * DEG_TO_RAD));
806        assert!(approx(plain.branches[4].angmax, 120.0 * DEG_TO_RAD));
807
808        let out = net
809            .to_normalized_with_options(&NormalizeOptions {
810                clamp_angle_bounds: true,
811                ..NormalizeOptions::default()
812            })
813            .unwrap();
814        assert_eq!(out.warnings.len(), 4);
815        assert!(out.warnings[0].contains("branch 0"));
816        assert!(out.warnings[1].contains("branch 1"));
817        assert!(out.warnings[2].contains("branch 3"));
818        assert!(out.warnings[3].contains("branch 4"));
819
820        let branches = &out.network.branches;
821        assert!(approx(branches[0].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
822        assert!(approx(branches[0].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
823        assert!(approx(branches[1].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
824        assert!(approx(branches[1].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
825        assert!(approx(branches[2].angmin, -30.0 * DEG_TO_RAD));
826        assert!(approx(branches[2].angmax, 30.0 * DEG_TO_RAD));
827        assert!(approx(branches[3].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
828        assert!(approx(branches[3].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
829        assert!(approx(branches[4].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
830        assert!(approx(branches[4].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
831        assert!(branches.iter().all(|br| br.angmin <= br.angmax));
832    }
833
834    #[test]
835    fn angle_bound_clamp_rejects_invalid_pad() {
836        let net = angle_bound_fixture();
837        let err = net
838            .to_normalized_with_options(&NormalizeOptions {
839                clamp_angle_bounds: true,
840                angle_bound_pad: std::f64::consts::FRAC_PI_2,
841            })
842            .unwrap_err();
843        assert!(matches!(
844            err,
845            Error::InvalidNormalizeOption {
846                field: "angle_bound_pad",
847                ..
848            }
849        ));
850    }
851
852    #[test]
853    fn to_normalized_drops_a_control_bus_whose_target_was_filtered_out() {
854        use crate::network::{Extras, SwitchedShuntControl, SwitchedShuntMode};
855
856        let mkbus = |id: usize, kind: BusType| Bus {
857            id: BusId(id),
858            kind,
859            vm: 1.0,
860            va: 0.0,
861            base_kv: 230.0,
862            vmax: 1.1,
863            vmin: 0.9,
864            evhi: None,
865            evlo: None,
866            area: 1,
867            zone: 1,
868            name: None,
869            uid: None,
870            location: None,
871            extras: Extras::new(),
872        };
873        let branch = Branch {
874            from: BusId(1),
875            to: BusId(2),
876            r: 0.0,
877            x: 0.1,
878            b: 0.0,
879            charging: None,
880            rate_a: 0.0,
881            rate_b: 0.0,
882            rate_c: 0.0,
883            rating_sets: Vec::new(),
884            current_ratings: None,
885            tap: 0.0,
886            shift: 0.0,
887            in_service: true,
888            angmin: -360.0,
889            angmax: 360.0,
890            control: None,
891            solution: None,
892            uid: None,
893            route: None,
894            extras: Extras::new(),
895        };
896        // Bus 3 is isolated, so to_normalized drops it.
897        let mut net = BalancedNetwork::in_memory(
898            "n",
899            100.0,
900            vec![
901                mkbus(1, BusType::Ref),
902                mkbus(2, BusType::Pq),
903                mkbus(3, BusType::Isolated),
904            ],
905            vec![branch],
906        );
907        net.generators.push(Generator {
908            bus: BusId(1),
909            pg: 10.0,
910            qg: 0.0,
911            pmax: 100.0,
912            pmin: 0.0,
913            qmax: 50.0,
914            qmin: -50.0,
915            vg: 1.0,
916            mbase: 100.0,
917            in_service: true,
918            cost: None,
919            caps: Default::default(),
920            regulated_bus: None,
921            uid: None,
922        });
923        // A switched shunt on bus 2 whose control bus is the (dropped) isolated bus 3.
924        net.shunts.push(Shunt {
925            bus: BusId(2),
926            g: 0.0,
927            b: 10.0,
928            in_service: true,
929            control: Some(SwitchedShuntControl {
930                mode: SwitchedShuntMode::Discrete,
931                vhigh: 1.05,
932                vlow: 0.95,
933                control_bus: Some(BusId(3)),
934                rmpct: 100.0,
935                blocks: Vec::new(),
936            }),
937            uid: None,
938            extras: Extras::new(),
939        });
940
941        let norm = net.to_normalized().unwrap();
942        norm.validate().unwrap();
943        let c = norm.shunts[0].control.as_ref().expect("control retained");
944        assert_eq!(
945            c.control_bus, None,
946            "a control bus pointing at a filtered-out isolated bus is dropped, not left dangling"
947        );
948    }
949
950    #[test]
951    fn normalized_slack_tiebreak_ignores_nan_pmax() {
952        use crate::network::Extras;
953
954        let mkbus = |id: usize| Bus {
955            id: BusId(id),
956            kind: BusType::Pq,
957            vm: 1.0,
958            va: 0.0,
959            base_kv: 230.0,
960            vmax: 1.1,
961            vmin: 0.9,
962            evhi: None,
963            evlo: None,
964            area: 1,
965            zone: 1,
966            name: None,
967            uid: None,
968            location: None,
969            extras: Extras::new(),
970        };
971        let mkgen = |bus: usize, pmax: f64| Generator {
972            bus: BusId(bus),
973            pg: 0.0,
974            qg: 0.0,
975            pmax,
976            pmin: 0.0,
977            qmax: 0.0,
978            qmin: 0.0,
979            vg: 1.0,
980            mbase: 100.0,
981            in_service: true,
982            cost: None,
983            caps: Default::default(),
984            regulated_bus: None,
985            uid: None,
986        };
987        let mut net = BalancedNetwork::in_memory("n", 100.0, vec![mkbus(1), mkbus(2)], Vec::new());
988        net.generators = vec![mkgen(1, f64::NAN), mkgen(2, 10.0)];
989
990        let norm = net.to_normalized().unwrap();
991
992        assert_eq!(
993            norm.buses.iter().find(|b| b.id == BusId(1)).unwrap().kind,
994            BusType::Pv
995        );
996        assert_eq!(
997            norm.buses.iter().find(|b| b.id == BusId(2)).unwrap().kind,
998            BusType::Ref
999        );
1000    }
1001
1002    #[test]
1003    fn cost_to_pu_polynomial_scales_and_trims() {
1004        // Model 2: the coeff of p^j scales by base^j; MATPOWER's trailing-zero
1005        // padding (beyond ncost) is dropped.
1006        let cost = GenCost {
1007            model: 2,
1008            startup: 0.0,
1009            shutdown: 0.0,
1010            ncost: 2,
1011            coeffs: vec![24.035, -403.5, 0.0, 0.0, 0.0, 0.0],
1012        };
1013        let out = cost_to_pu(&cost, 100.0);
1014        assert_eq!(out.len(), 2, "padding dropped");
1015        assert!(approx(out[0], 2403.5)); // 24.035 · 100^1
1016        assert!(approx(out[1], -403.5)); // -403.5 · 100^0
1017    }
1018
1019    #[test]
1020    fn cost_to_pu_piecewise_scales_mw_only_and_trims() {
1021        // Model 1: MW breakpoints (even positions) ÷ base; dollar costs (odd) raw.
1022        let cost = GenCost {
1023            model: 1,
1024            startup: 0.0,
1025            shutdown: 0.0,
1026            ncost: 4,
1027            coeffs: vec![
1028                0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0, 0.0, 0.0,
1029            ],
1030        };
1031        let out = cost_to_pu(&cost, 100.0);
1032        assert_eq!(out.len(), 8, "trimmed to 2·ncost, padding dropped");
1033        assert!(
1034            approx(out[0], 0.0)
1035                && approx(out[2], 1.0)
1036                && approx(out[4], 2.0)
1037                && approx(out[6], 2.5)
1038        );
1039        assert!(
1040            approx(out[1], 0.0)
1041                && approx(out[3], 2500.0)
1042                && approx(out[5], 5500.0)
1043                && approx(out[7], 7250.0)
1044        );
1045    }
1046
1047    #[test]
1048    fn cost_rescale_round_trips() {
1049        // c2 p² + c1 p + c0 with base 100: per unit then back is the identity.
1050        let cost = GenCost {
1051            model: 2,
1052            startup: 0.0,
1053            shutdown: 0.0,
1054            ncost: 3,
1055            coeffs: vec![0.11, 5.0, 150.0],
1056        };
1057        let pu = cost_to_pu(&cost, 100.0);
1058        // p^2 coeff scales by 100^2, p^1 by 100, constant unchanged.
1059        assert!((pu[0] - 0.11 * 100.0 * 100.0).abs() < 1e-9);
1060        assert!((pu[1] - 5.0 * 100.0).abs() < 1e-9);
1061        assert!((pu[2] - 150.0).abs() < 1e-9);
1062        let back = cost_from_pu(&pu, 2, 100.0);
1063        for (a, b) in back.iter().zip(&cost.coeffs) {
1064            assert!((a - b).abs() < 1e-9);
1065        }
1066    }
1067
1068    #[test]
1069    fn cost_rescale_passes_through_unknown_model() {
1070        // A model outside {1,2} has unknown coefficient semantics, so neither
1071        // direction may touch it; to_pu and from_pu must both be the identity,
1072        // or the round trip silently corrupts a curve we don't understand.
1073        let cost = GenCost {
1074            model: 0,
1075            startup: 0.0,
1076            shutdown: 0.0,
1077            ncost: 2,
1078            coeffs: vec![3.0, 7.0, 9.0],
1079        };
1080        let pu = cost_to_pu(&cost, 100.0);
1081        assert_eq!(pu, cost.coeffs, "to_pu must not scale an unknown model");
1082        let back = cost_from_pu(&pu, cost.model, 100.0);
1083        assert_eq!(back, cost.coeffs, "from_pu must not scale an unknown model");
1084    }
1085
1086    #[test]
1087    fn cost_rescale_round_trips_piecewise() {
1088        // Model 1: cost_from_pu multiplies the MW breakpoints back by base and
1089        // leaves the dollar costs, the exact inverse of cost_to_pu's even/odd
1090        // split. (cost_to_pu trims, cost_from_pu doesn't, so feed a trimmed row.)
1091        let cost = GenCost {
1092            model: 1,
1093            startup: 0.0,
1094            shutdown: 0.0,
1095            ncost: 4,
1096            coeffs: vec![0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0],
1097        };
1098        let pu = cost_to_pu(&cost, 100.0);
1099        let back = cost_from_pu(&pu, 1, 100.0);
1100        for (a, b) in back.iter().zip(&cost.coeffs) {
1101            assert!((a - b).abs() < 1e-9, "{a} != {b}");
1102        }
1103    }
1104}