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