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