Skip to main content

powerio/
normalize.rs

1//! The universal normalization shared by the PowerModels reader/writer and
2//! [`Network::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; [`Network::to_normalized`] scales the same way into a new
10//!   `Network`. The cost rescale is the one piece subtle enough that a second copy
11//!   would drift, so it has a single home.
12//! - **[`Network::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    Branch, BranchRatingSet, Bus, BusId, BusType, GEN_EXTRA_KEYS, GenCost, Generator, Hvdc, Load,
19    LoadVoltageModel, Network, 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 [`Network::to_normalized`].
35pub(crate) const GEN_PU_KEYS: [&str; 4] = ["ramp_agc", "ramp_10", "ramp_30", "ramp_q"];
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38enum CostModel {
39    Piecewise,
40    Polynomial,
41    Unknown,
42}
43
44impl From<u8> for CostModel {
45    fn from(value: u8) -> Self {
46        match value {
47            1 => CostModel::Piecewise,
48            2 => CostModel::Polynomial,
49            _ => CostModel::Unknown,
50        }
51    }
52}
53
54/// Gen cost coefficients rescaled into the per-unit basis, trimmed to the length
55/// the model implies (a polynomial keeps `ncost` coeffs; a piecewise curve keeps
56/// `2·ncost` `(mw, cost)` values). MATPOWER pads every gencost row to the matrix
57/// width with trailing zeros; the padding would make a polynomial read as a
58/// higher-degree curve and mis-scale, so it is dropped here.
59///
60/// Polynomial (model 2): coeff `i` is the term `p^(k-1-i)`, so per unit scales it
61/// by `base^(k-1-i)`. Piecewise (model 1): the MW breakpoints (even positions) are
62/// divided by `base`; the dollar costs (odd positions) stay. Any other model has
63/// unknown coefficient semantics, so it passes through untouched — the exact
64/// inverse of [`cost_from_pu`]'s own passthrough.
65pub(crate) fn cost_to_pu(cost: &GenCost, base: f64) -> Vec<f64> {
66    match CostModel::from(cost.model) {
67        CostModel::Polynomial => {
68            let coeffs = &cost.coeffs[..cost.ncost.min(cost.coeffs.len())];
69            let k = coeffs.len();
70            // The exponent k-1-i is in [0, k-1]; a polynomial never has i32::MAX-many
71            // terms, so the conversion can't fail (loud, not silent, if it ever did).
72            coeffs
73                .iter()
74                .enumerate()
75                .map(|(i, &c)| {
76                    c * base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32"))
77                })
78                .collect()
79        }
80        CostModel::Piecewise => {
81            let coeffs = &cost.coeffs[..(cost.ncost * 2).min(cost.coeffs.len())];
82            coeffs
83                .iter()
84                .enumerate()
85                .map(|(i, &c)| if i % 2 == 0 { c / base } else { c })
86                .collect()
87        }
88        CostModel::Unknown => cost.coeffs.clone(),
89    }
90}
91
92/// Undo [`cost_to_pu`] for the neutral MW basis: a polynomial (model 2) divides
93/// coeff `i` by `base^(k-1-i)`, a piecewise curve (model 1) multiplies its MW
94/// breakpoints (even positions) by `base`. The exact inverse of [`cost_to_pu`] on
95/// the trimmed coefficient vector — JSON-sourced coefficients arrive already
96/// trimmed, so this does no trimming; other models pass through unchanged.
97pub(crate) fn cost_from_pu(coeffs: &[f64], model: u8, base: f64) -> Vec<f64> {
98    let k = coeffs.len();
99    match CostModel::from(model) {
100        CostModel::Polynomial => coeffs
101            .iter()
102            .enumerate()
103            .map(|(i, &c)| c / base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32")))
104            .collect(),
105        CostModel::Piecewise => coeffs
106            .iter()
107            .enumerate()
108            .map(|(i, &c)| if i % 2 == 0 { c * base } else { c })
109            .collect(),
110        CostModel::Unknown => coeffs.to_vec(),
111    }
112}
113
114/// Map a source bus id to its surviving normalized id, or `None` if the bus was dropped.
115fn remap(map: &HashMap<BusId, BusId>, id: BusId) -> Option<BusId> {
116    map.get(&id).copied()
117}
118
119fn norm_loads(loads: &[Load], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Load> {
120    loads
121        .iter()
122        .filter(|l| l.in_service)
123        .filter_map(|l| {
124            Some(Load {
125                bus: remap(map, l.bus)?,
126                p: l.p / base,
127                q: l.q / base,
128                voltage_model: l
129                    .voltage_model
130                    .as_ref()
131                    .map(|m| norm_load_voltage_model(m, base)),
132                ..l.clone()
133            })
134        })
135        .collect()
136}
137
138fn norm_load_voltage_model(model: &LoadVoltageModel, base: f64) -> LoadVoltageModel {
139    match model {
140        LoadVoltageModel::ConstantPower => LoadVoltageModel::ConstantPower,
141        LoadVoltageModel::Zip {
142            p_constant_power,
143            q_constant_power,
144            p_constant_current,
145            q_constant_current,
146            p_constant_impedance,
147            q_constant_impedance,
148            v_nom,
149            load_type,
150            scaling,
151        } => LoadVoltageModel::Zip {
152            p_constant_power: p_constant_power / base,
153            q_constant_power: q_constant_power / base,
154            p_constant_current: p_constant_current / base,
155            q_constant_current: q_constant_current / base,
156            p_constant_impedance: p_constant_impedance / base,
157            q_constant_impedance: q_constant_impedance / base,
158            v_nom: *v_nom,
159            load_type: *load_type,
160            scaling: *scaling,
161        },
162        LoadVoltageModel::Exponential {
163            p,
164            q,
165            v_nom,
166            gamma_p,
167            gamma_q,
168        } => LoadVoltageModel::Exponential {
169            p: p / base,
170            q: q / base,
171            v_nom: *v_nom,
172            gamma_p: *gamma_p,
173            gamma_q: *gamma_q,
174        },
175    }
176}
177
178fn norm_shunts(shunts: &[Shunt], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Shunt> {
179    shunts
180        .iter()
181        .filter(|s| s.in_service)
182        .filter_map(|s| {
183            Some(Shunt {
184                bus: remap(map, s.bus)?,
185                g: s.g / base,
186                b: s.b / base,
187                // Remap the switched-shunt control bus and drop it if its target was
188                // filtered out, so the normalized network has no dangling reference.
189                control: s.control.clone().map(|mut c| {
190                    c.control_bus = c.control_bus.and_then(|b| remap(map, b));
191                    c
192                }),
193                ..s.clone()
194            })
195        })
196        .collect()
197}
198
199fn norm_branches(branches: &[Branch], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Branch> {
200    branches
201        .iter()
202        .filter(|br| br.in_service)
203        .filter_map(|br| {
204            Some(Branch {
205                from: remap(map, br.from)?,
206                to: remap(map, br.to)?,
207                rate_a: br.rate_a / base,
208                rate_b: br.rate_b / base,
209                rate_c: br.rate_c / base,
210                rating_sets: br
211                    .rating_sets
212                    .iter()
213                    .map(|r| BranchRatingSet {
214                        name: r.name.clone(),
215                        rate_mva: r.rate_mva / base,
216                    })
217                    .collect(),
218                tap: br.effective_tap(),
219                shift: br.shift * DEG_TO_RAD,
220                angmin: br.angmin * DEG_TO_RAD,
221                angmax: br.angmax * DEG_TO_RAD,
222                solution: br.solution.map(|s| crate::network::BranchSolution {
223                    pf: s.pf / base,
224                    qf: s.qf / base,
225                    pt: s.pt / base,
226                    qt: s.qt / base,
227                }),
228                // Remap the regulated-bus reference through the id map and drop it
229                // if its target was filtered out (out of service / isolated), so the
230                // normalized network has no dangling control reference.
231                control: br.control.clone().map(|mut c| {
232                    c.controlled_bus = c.controlled_bus.and_then(|b| remap(map, b));
233                    c
234                }),
235                ..br.clone()
236            })
237        })
238        .collect()
239}
240
241fn norm_gens(gens: &[Generator], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Generator> {
242    gens.iter()
243        .filter(|g| g.in_service)
244        .filter_map(|g| {
245            let bus = remap(map, g.bus)?;
246            let mut caps = g.caps;
247            for (i, key) in GEN_EXTRA_KEYS.iter().enumerate() {
248                if GEN_PU_KEYS.contains(key) {
249                    if let Some(v) = caps[i] {
250                        caps[i] = Some(v / base);
251                    }
252                }
253            }
254            Some(Generator {
255                bus,
256                pg: g.pg / base,
257                qg: g.qg / base,
258                pmax: g.pmax / base,
259                pmin: g.pmin / base,
260                qmax: g.qmax / base,
261                qmin: g.qmin / base,
262                cost: g.cost.as_ref().map(|c| GenCost {
263                    coeffs: cost_to_pu(c, base),
264                    ..c.clone()
265                }),
266                caps,
267                // Remap the regulated bus through the same id map; drop it if its
268                // target was filtered out so the normalized form stays consistent.
269                regulated_bus: g.regulated_bus.and_then(|b| remap(map, b)),
270                ..g.clone()
271            })
272        })
273        .collect()
274}
275
276fn norm_switches(switches: &[Switch], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Switch> {
277    switches
278        .iter()
279        .filter_map(|s| {
280            Some(Switch {
281                from: remap(map, s.from)?,
282                to: remap(map, s.to)?,
283                thermal_rating: s.thermal_rating.map(|v| v / base),
284                pf: s.pf.map(|v| v / base),
285                qf: s.qf.map(|v| v / base),
286                pt: s.pt.map(|v| v / base),
287                qt: s.qt.map(|v| v / base),
288                ..s.clone()
289            })
290        })
291        .collect()
292}
293
294fn norm_storage(storage: &[Storage], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Storage> {
295    storage
296        .iter()
297        .filter(|s| s.in_service)
298        .filter_map(|s| {
299            // ps/qs stay raw (PowerModels' make_per_unit! leaves the dispatch
300            // setpoint alone); the energy, ratings, limits, and losses scale.
301            Some(Storage {
302                bus: remap(map, s.bus)?,
303                energy: s.energy / base,
304                energy_rating: s.energy_rating / base,
305                charge_rating: s.charge_rating / base,
306                discharge_rating: s.discharge_rating / base,
307                thermal_rating: s.thermal_rating / base,
308                qmin: s.qmin / base,
309                qmax: s.qmax / base,
310                p_loss: s.p_loss / base,
311                q_loss: s.q_loss / base,
312                ..s.clone()
313            })
314        })
315        .collect()
316}
317
318fn norm_hvdc(hvdc: &[Hvdc], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Hvdc> {
319    hvdc.iter()
320        .filter(|d| d.in_service)
321        .filter_map(|d| {
322            // No sign flip: the writer's Pt/Qf/Qt negation is a PowerModels output
323            // convention, not part of per-unit normalization. The aggregate
324            // pmin/pmax stay raw, matching make_per_unit!.
325            Some(Hvdc {
326                from: remap(map, d.from)?,
327                to: remap(map, d.to)?,
328                pf: d.pf / base,
329                pt: d.pt / base,
330                qf: d.qf / base,
331                qt: d.qt / base,
332                qminf: d.qminf / base,
333                qmaxf: d.qmaxf / base,
334                qmint: d.qmint / base,
335                qmaxt: d.qmaxt / base,
336                loss0: d.loss0 / base,
337                cost: d.cost.as_ref().map(|c| GenCost {
338                    coeffs: cost_to_pu(c, base),
339                    ..c.clone()
340                }),
341                ..d.clone()
342            })
343        })
344        .collect()
345}
346
347fn norm_transformers_3w(
348    xfmrs: &[Transformer3W],
349    base: f64,
350    map: &HashMap<BusId, BusId>,
351) -> Vec<Transformer3W> {
352    xfmrs
353        .iter()
354        .filter(|t| t.in_service)
355        .filter_map(|t| {
356            // Remap each winding terminal and drop the whole unit if any was filtered
357            // out (a 3-winding transformer can't keep a dangling winding). Phase
358            // shifts and the star angle go to radians; winding ratings go per unit;
359            // the pairwise impedances are already per unit on the system base.
360            let mut windings = t.windings.clone();
361            for w in &mut windings {
362                w.bus = remap(map, w.bus)?;
363                w.shift *= DEG_TO_RAD;
364                w.rate_a /= base;
365                w.rate_b /= base;
366                w.rate_c /= base;
367            }
368            Some(Transformer3W {
369                windings,
370                star_va: t.star_va * DEG_TO_RAD,
371                ..t.clone()
372            })
373        })
374        .collect()
375}
376
377impl Network {
378    /// A normalized, computation-ready copy of this network. The raw `Network` is
379    /// kept lossless (MATPOWER units, 1-based sparse ids, out-of-service elements
380    /// retained); `to_normalized` derives the form a solver or ML pipeline wants:
381    ///
382    /// - **Per unit** (÷`base_mva`): gen `pg/qg/pmax/pmin/qmax/qmin` and the ramp
383    ///   caps (`GEN_PU_KEYS`); load `p/q`; shunt `g/b`; branch `rate_a/b/c`;
384    ///   storage energy/ratings/limits/losses; HVDC `pf/pt/qf/qt`, reactive limits,
385    ///   `loss0`; gen-cost coefficients (`cost_to_pu`). Storage `ps/qs` and HVDC
386    ///   aggregate `pmin/pmax` stay raw, matching the PowerModels per-unit
387    ///   convention. Voltages, impedances, tap, and `loss1` are already
388    ///   dimensionless.
389    /// - **Radians**: bus `va`; branch `shift/angmin/angmax`.
390    /// - **Tap**: `0 → 1.0` (an explicit `1` is kept).
391    /// - **Filtered**: drop buses typed isolated (`BusType::Isolated`) and every
392    ///   out-of-service element, then drop any element left referencing a dropped
393    ///   bus. A bus orphaned by the out-of-service filter (no in-service branch,
394    ///   but not typed isolated) is kept — its load is real — and surfaces as its
395    ///   own island, which the grounding check reports if it has no reference.
396    /// - **IDs**: kept buses retain their source bus ids, and every surviving
397    ///   endpoint stays in the same id space. Consumers that need dense rows should
398    ///   use [`IndexedNetwork`](crate::IndexedNetwork), which derives `[0, n)`
399    ///   indices without destroying source ids.
400    /// - **Bus types**: a bus hosting a surviving generator keeps `REF` if the file
401    ///   marked it `REF`, otherwise becomes `PV`; a generator-less bus is `PQ` (so a
402    ///   generator-less `REF` is demoted). The file's `REF` buses are kept, several
403    ///   included, and the consumer picks the slack. Only when no reference bus
404    ///   survives is the largest-`pmax` in-service generator's bus promoted to
405    ///   `REF`.
406    ///
407    /// This is a derived product, not a source for write-back: `source` is dropped
408    /// and `source_format` is [`SourceFormat::Normalized`], so writing it serializes
409    /// the per-unit/radian model instead of echoing the raw bytes, and a consumer
410    /// can tell it apart from a raw in-memory network.
411    ///
412    /// Scope is the universal canonicalization only. It does not pad angle bounds,
413    /// synthesize a missing `rate_a`, or restrict the gen-cost model — those are
414    /// solver-prep choices a consumer applies on top. The cost *rescale* is
415    /// universal and lives here; the model *restriction* does not.
416    ///
417    /// # Errors
418    /// [`Error::InvalidBaseMva`] if `base_mva` is not a positive, finite number
419    /// (every per-unit divisor), so a malformed base can't silently poison the
420    /// whole network with `NaN`/`Inf` or sign-flipped values.
421    /// [`Error::ReferenceBusCount`] if no reference bus can be established — no `REF`
422    /// survives and there is no in-service generator to anchor one.
423    pub fn to_normalized(&self) -> Result<Network> {
424        self.check_base_mva()?;
425        let base = self.base_mva;
426
427        // Kept buses keep their original `kind` for now (the reference scan below
428        // reads it) and their source ids. Isolated buses are dropped.
429        let mut id_map: HashMap<BusId, BusId> = HashMap::with_capacity(self.buses.len());
430        let mut buses: Vec<Bus> = Vec::with_capacity(self.buses.len());
431        for b in &self.buses {
432            if b.kind == BusType::Isolated {
433                continue;
434            }
435            id_map.insert(b.id, b.id);
436            buses.push(Bus {
437                va: b.va * DEG_TO_RAD,
438                ..b.clone()
439            });
440        }
441        let loads = norm_loads(&self.loads, base, &id_map);
442        let shunts = norm_shunts(&self.shunts, base, &id_map);
443        let branches = norm_branches(&self.branches, base, &id_map);
444        let switches = norm_switches(&self.switches, base, &id_map);
445        let generators = norm_gens(&self.generators, base, &id_map);
446        let storage = norm_storage(&self.storage, base, &id_map);
447        let hvdc = norm_hvdc(&self.hvdc, base, &id_map);
448        let transformers_3w = norm_transformers_3w(&self.transformers_3w, base, &id_map);
449
450        // Bus types: a bus hosting an in-service generator keeps `Ref` if the
451        // file marked it `Ref`, else becomes `Pv`; a gen-less bus is `Pq`.
452        // Multiple file `Ref` buses are kept as-is, and only when no `Ref`
453        // survives is the largest-pmax generator's bus promoted.
454        let gen_buses: HashSet<BusId> = generators.iter().map(|g| g.bus).collect();
455        for b in &mut buses {
456            b.kind = match (gen_buses.contains(&b.id), b.kind) {
457                (true, BusType::Ref) => BusType::Ref,
458                (true, _) => BusType::Pv,
459                (false, _) => BusType::Pq,
460            };
461        }
462        if !buses.iter().any(|b| b.kind == BusType::Ref) {
463            // No reference survived: anchor the slack at the largest-pmax in-service
464            // generator's bus, or error when there is no generator to anchor it.
465            let slack = generators
466                .iter()
467                .max_by(|a, b| {
468                    // A NaN pmax must never win the slack: map it below every real
469                    // bound so the choice stays deterministic (an unbounded +Inf
470                    // pmax still wins, as the largest capacity).
471                    let key = |p: f64| if p.is_nan() { f64::NEG_INFINITY } else { p };
472                    key(a.pmax).total_cmp(&key(b.pmax))
473                })
474                .map(|g| g.bus)
475                .ok_or(Error::ReferenceBusCount { found: 0 })?;
476            if let Some(b) = buses.iter_mut().find(|b| b.id == slack) {
477                b.kind = BusType::Ref;
478            }
479        }
480
481        let net = Network {
482            name: self.name.clone(),
483            base_mva: base,
484            base_frequency: self.base_frequency,
485            buses,
486            loads,
487            shunts,
488            branches,
489            switches,
490            generators,
491            storage,
492            hvdc,
493            transformers_3w,
494            // Areas (interchange schedule, per-area swing) are interchange metadata,
495            // not part of the per unit electrical view, so they are not carried.
496            areas: Vec::new(),
497            solver: None,
498            source_format: SourceFormat::Normalized,
499            source: None,
500        };
501        // The filter drops every reference to a dropped bus by
502        // construction, so the result is reference-consistent. Assert it in
503        // debug builds to catch a future regression in the filtering logic.
504        debug_assert!(
505            net.validate().is_ok(),
506            "to_normalized produced a dangling reference"
507        );
508        Ok(net)
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    fn approx(a: f64, b: f64) -> bool {
517        (a - b).abs() < 1e-9
518    }
519
520    #[test]
521    fn to_normalized_drops_a_control_bus_whose_target_was_filtered_out() {
522        use crate::network::{Extras, SwitchedShuntControl, SwitchedShuntMode};
523
524        let mkbus = |id: usize, kind: BusType| Bus {
525            id: BusId(id),
526            kind,
527            vm: 1.0,
528            va: 0.0,
529            base_kv: 230.0,
530            vmax: 1.1,
531            vmin: 0.9,
532            evhi: None,
533            evlo: None,
534            area: 1,
535            zone: 1,
536            name: None,
537            uid: None,
538            extras: Extras::new(),
539        };
540        let branch = Branch {
541            from: BusId(1),
542            to: BusId(2),
543            r: 0.0,
544            x: 0.1,
545            b: 0.0,
546            charging: None,
547            rate_a: 0.0,
548            rate_b: 0.0,
549            rate_c: 0.0,
550            rating_sets: Vec::new(),
551            current_ratings: None,
552            tap: 0.0,
553            shift: 0.0,
554            in_service: true,
555            angmin: -360.0,
556            angmax: 360.0,
557            control: None,
558            solution: None,
559            uid: None,
560            extras: Extras::new(),
561        };
562        // Bus 3 is isolated, so to_normalized drops it.
563        let mut net = Network::in_memory(
564            "n",
565            100.0,
566            vec![
567                mkbus(1, BusType::Ref),
568                mkbus(2, BusType::Pq),
569                mkbus(3, BusType::Isolated),
570            ],
571            vec![branch],
572        );
573        net.generators.push(Generator {
574            bus: BusId(1),
575            pg: 10.0,
576            qg: 0.0,
577            pmax: 100.0,
578            pmin: 0.0,
579            qmax: 50.0,
580            qmin: -50.0,
581            vg: 1.0,
582            mbase: 100.0,
583            in_service: true,
584            cost: None,
585            caps: Default::default(),
586            regulated_bus: None,
587            uid: None,
588        });
589        // A switched shunt on bus 2 whose control bus is the (dropped) isolated bus 3.
590        net.shunts.push(Shunt {
591            bus: BusId(2),
592            g: 0.0,
593            b: 10.0,
594            in_service: true,
595            control: Some(SwitchedShuntControl {
596                mode: SwitchedShuntMode::Discrete,
597                vhigh: 1.05,
598                vlow: 0.95,
599                control_bus: Some(BusId(3)),
600                rmpct: 100.0,
601                blocks: Vec::new(),
602            }),
603            uid: None,
604            extras: Extras::new(),
605        });
606
607        let norm = net.to_normalized().unwrap();
608        norm.validate().unwrap();
609        let c = norm.shunts[0].control.as_ref().expect("control retained");
610        assert_eq!(
611            c.control_bus, None,
612            "a control bus pointing at a filtered-out isolated bus is dropped, not left dangling"
613        );
614    }
615
616    #[test]
617    fn normalized_slack_tiebreak_ignores_nan_pmax() {
618        use crate::network::Extras;
619
620        let mkbus = |id: usize| Bus {
621            id: BusId(id),
622            kind: BusType::Pq,
623            vm: 1.0,
624            va: 0.0,
625            base_kv: 230.0,
626            vmax: 1.1,
627            vmin: 0.9,
628            evhi: None,
629            evlo: None,
630            area: 1,
631            zone: 1,
632            name: None,
633            uid: None,
634            extras: Extras::new(),
635        };
636        let mkgen = |bus: usize, pmax: f64| Generator {
637            bus: BusId(bus),
638            pg: 0.0,
639            qg: 0.0,
640            pmax,
641            pmin: 0.0,
642            qmax: 0.0,
643            qmin: 0.0,
644            vg: 1.0,
645            mbase: 100.0,
646            in_service: true,
647            cost: None,
648            caps: Default::default(),
649            regulated_bus: None,
650            uid: None,
651        };
652        let mut net = Network::in_memory("n", 100.0, vec![mkbus(1), mkbus(2)], Vec::new());
653        net.generators = vec![mkgen(1, f64::NAN), mkgen(2, 10.0)];
654
655        let norm = net.to_normalized().unwrap();
656
657        assert_eq!(
658            norm.buses.iter().find(|b| b.id == BusId(1)).unwrap().kind,
659            BusType::Pv
660        );
661        assert_eq!(
662            norm.buses.iter().find(|b| b.id == BusId(2)).unwrap().kind,
663            BusType::Ref
664        );
665    }
666
667    #[test]
668    fn cost_to_pu_polynomial_scales_and_trims() {
669        // Model 2: the coeff of p^j scales by base^j; MATPOWER's trailing-zero
670        // padding (beyond ncost) is dropped.
671        let cost = GenCost {
672            model: 2,
673            startup: 0.0,
674            shutdown: 0.0,
675            ncost: 2,
676            coeffs: vec![24.035, -403.5, 0.0, 0.0, 0.0, 0.0],
677        };
678        let out = cost_to_pu(&cost, 100.0);
679        assert_eq!(out.len(), 2, "padding dropped");
680        assert!(approx(out[0], 2403.5)); // 24.035 · 100^1
681        assert!(approx(out[1], -403.5)); // -403.5 · 100^0
682    }
683
684    #[test]
685    fn cost_to_pu_piecewise_scales_mw_only_and_trims() {
686        // Model 1: MW breakpoints (even positions) ÷ base; dollar costs (odd) raw.
687        let cost = GenCost {
688            model: 1,
689            startup: 0.0,
690            shutdown: 0.0,
691            ncost: 4,
692            coeffs: vec![
693                0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0, 0.0, 0.0,
694            ],
695        };
696        let out = cost_to_pu(&cost, 100.0);
697        assert_eq!(out.len(), 8, "trimmed to 2·ncost, padding dropped");
698        assert!(
699            approx(out[0], 0.0)
700                && approx(out[2], 1.0)
701                && approx(out[4], 2.0)
702                && approx(out[6], 2.5)
703        );
704        assert!(
705            approx(out[1], 0.0)
706                && approx(out[3], 2500.0)
707                && approx(out[5], 5500.0)
708                && approx(out[7], 7250.0)
709        );
710    }
711
712    #[test]
713    fn cost_rescale_round_trips() {
714        // c2 p² + c1 p + c0 with base 100: per unit then back is the identity.
715        let cost = GenCost {
716            model: 2,
717            startup: 0.0,
718            shutdown: 0.0,
719            ncost: 3,
720            coeffs: vec![0.11, 5.0, 150.0],
721        };
722        let pu = cost_to_pu(&cost, 100.0);
723        // p^2 coeff scales by 100^2, p^1 by 100, constant unchanged.
724        assert!((pu[0] - 0.11 * 100.0 * 100.0).abs() < 1e-9);
725        assert!((pu[1] - 5.0 * 100.0).abs() < 1e-9);
726        assert!((pu[2] - 150.0).abs() < 1e-9);
727        let back = cost_from_pu(&pu, 2, 100.0);
728        for (a, b) in back.iter().zip(&cost.coeffs) {
729            assert!((a - b).abs() < 1e-9);
730        }
731    }
732
733    #[test]
734    fn cost_rescale_passes_through_unknown_model() {
735        // A model outside {1,2} has unknown coefficient semantics, so neither
736        // direction may touch it; to_pu and from_pu must both be the identity,
737        // or the round trip silently corrupts a curve we don't understand.
738        let cost = GenCost {
739            model: 0,
740            startup: 0.0,
741            shutdown: 0.0,
742            ncost: 2,
743            coeffs: vec![3.0, 7.0, 9.0],
744        };
745        let pu = cost_to_pu(&cost, 100.0);
746        assert_eq!(pu, cost.coeffs, "to_pu must not scale an unknown model");
747        let back = cost_from_pu(&pu, cost.model, 100.0);
748        assert_eq!(back, cost.coeffs, "from_pu must not scale an unknown model");
749    }
750
751    #[test]
752    fn cost_rescale_round_trips_piecewise() {
753        // Model 1: cost_from_pu multiplies the MW breakpoints back by base and
754        // leaves the dollar costs, the exact inverse of cost_to_pu's even/odd
755        // split. (cost_to_pu trims, cost_from_pu doesn't, so feed a trimmed row.)
756        let cost = GenCost {
757            model: 1,
758            startup: 0.0,
759            shutdown: 0.0,
760            ncost: 4,
761            coeffs: vec![0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0],
762        };
763        let pu = cost_to_pu(&cost, 100.0);
764        let back = cost_from_pu(&pu, 1, 100.0);
765        for (a, b) in back.iter().zip(&cost.coeffs) {
766            assert!((a - b).abs() < 1e-9, "{a} != {b}");
767        }
768    }
769}