Skip to main content

powerio_tx/
solver_tables.rs

1//! Normalized dense tables for solver and compiler front ends.
2//!
3//! `BalancedNetwork::to_normalized` keeps source bus ids because it is still a network
4//! model. Solver inputs want dense row ids, stable row order, and enough
5//! provenance to map lowered data back to the source case. This module provides
6//! that table layout without changing the lossless `BalancedNetwork` representation.
7
8use serde::{Deserialize, Serialize};
9
10use crate::network::{
11    BalancedNetwork, BranchCurrentRatings, BranchRatingSet, BusId, BusType, GenCaps, GenCost, Hvdc,
12    LoadVoltageModel,
13};
14use crate::normalize::{NormalizeOptions, NormalizeSourceRows};
15use crate::{Error, IndexedNetwork, Result};
16
17/// Stable pass name for the balanced normalized solver table lowering.
18pub const NORMALIZED_SOLVER_TABLES_PASS: &str = "balanced-to-normalized-solver-tables";
19
20/// A row oriented, dense indexed, per unit/radian view of a balanced network.
21///
22/// The source `BalancedNetwork` is first normalized with [`BalancedNetwork::to_normalized`], then
23/// lowered through [`IndexedNetwork`] so 3-winding transformers appear as star
24/// buses and branches. Source ids are preserved as metadata; every reference used
25/// for computation is dense and zero based.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[non_exhaustive]
29// Frozen 0.9 reader plumbing: the legacy09 upgrade in the powerio crate is
30// the one remaining consumer, so the type stays reachable but leaves the
31// documented surface. It goes when legacy09 retires.
32#[doc(hidden)]
33pub struct NormalizedSolverTables {
34    pub pass: String,
35    pub network_name: String,
36    pub base_mva: f64,
37    pub base_frequency: f64,
38    pub units: SolverTableUnits,
39    pub index: SolverTableIndex,
40    pub buses: Vec<SolverBusRow>,
41    pub loads: Vec<SolverLoadRow>,
42    pub shunts: Vec<SolverShuntRow>,
43    pub branches: Vec<SolverBranchRow>,
44    pub switches: Vec<SolverSwitchRow>,
45    pub arcs: Vec<SolverArcRow>,
46    pub generators: Vec<SolverGeneratorRow>,
47    pub storage: Vec<SolverStorageRow>,
48    pub hvdc: Vec<SolverHvdcRow>,
49}
50
51/// Units carried by [`NormalizedSolverTables`].
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54#[non_exhaustive]
55pub struct SolverTableUnits {
56    pub power: String,
57    pub voltage: String,
58    pub angle: String,
59    pub impedance: String,
60    pub admittance: String,
61    pub dense_index_base: String,
62}
63
64impl Default for SolverTableUnits {
65    fn default() -> Self {
66        Self {
67            power: "per_unit".to_string(),
68            voltage: "per_unit".to_string(),
69            angle: "radian".to_string(),
70            impedance: "per_unit".to_string(),
71            admittance: "per_unit".to_string(),
72            dense_index_base: "zero".to_string(),
73        }
74    }
75}
76
77/// Identity and provenance vectors that apply across the tables.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
80#[non_exhaustive]
81pub struct SolverTableIndex {
82    /// Source bus id for each dense bus row. Synthetic 3-winding star buses also
83    /// receive a stable id in this vector, but have no source row.
84    pub bus_ids: Vec<BusId>,
85    pub reference_bus_indices: Vec<usize>,
86    pub component_labels: Vec<usize>,
87    pub branch_from_arc_indices: Vec<usize>,
88    pub branch_to_arc_indices: Vec<usize>,
89    pub bus_source_rows: Vec<Option<usize>>,
90    pub load_source_rows: Vec<Option<usize>>,
91    pub shunt_source_rows: Vec<Option<usize>>,
92    pub branch_source_rows: Vec<Option<usize>>,
93    pub switch_source_rows: Vec<Option<usize>>,
94    pub generator_source_rows: Vec<Option<usize>>,
95    pub storage_source_rows: Vec<Option<usize>>,
96    pub hvdc_source_rows: Vec<Option<usize>>,
97}
98
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
101#[non_exhaustive]
102// Frozen 0.9 reader plumbing: the legacy09 upgrade in the powerio crate is
103// the one remaining consumer, so the type stays reachable but leaves the
104// documented surface. It goes when legacy09 retires.
105#[doc(hidden)]
106pub struct SolverBusRow {
107    pub index: usize,
108    pub bus_id: BusId,
109    pub source_row: Option<usize>,
110    pub kind: BusType,
111    pub vm: f64,
112    pub va: f64,
113    pub base_kv: f64,
114    pub vmax: f64,
115    pub vmin: f64,
116    pub evhi: Option<f64>,
117    pub evlo: Option<f64>,
118    pub area: usize,
119    pub zone: usize,
120    pub pd: f64,
121    pub qd: f64,
122    pub gs: f64,
123    pub bs: f64,
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
128#[non_exhaustive]
129// Frozen 0.9 reader plumbing: the legacy09 upgrade in the powerio crate is
130// the one remaining consumer, so the type stays reachable but leaves the
131// documented surface. It goes when legacy09 retires.
132#[doc(hidden)]
133pub struct SolverLoadRow {
134    pub index: usize,
135    pub source_row: Option<usize>,
136    pub bus_index: usize,
137    pub p: f64,
138    pub q: f64,
139    pub voltage_model: Option<LoadVoltageModel>,
140}
141
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
144#[non_exhaustive]
145// Frozen 0.9 reader plumbing: the legacy09 upgrade in the powerio crate is
146// the one remaining consumer, so the type stays reachable but leaves the
147// documented surface. It goes when legacy09 retires.
148#[doc(hidden)]
149pub struct SolverShuntRow {
150    pub index: usize,
151    pub source_row: Option<usize>,
152    pub bus_index: usize,
153    pub g: f64,
154    pub b: f64,
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
159#[non_exhaustive]
160// Frozen 0.9 reader plumbing: the legacy09 upgrade in the powerio crate is
161// the one remaining consumer, so the type stays reachable but leaves the
162// documented surface. It goes when legacy09 retires.
163#[doc(hidden)]
164pub struct SolverBranchRow {
165    pub index: usize,
166    pub source_row: Option<usize>,
167    pub from_bus_index: usize,
168    pub to_bus_index: usize,
169    pub r: f64,
170    pub x: f64,
171    pub b: f64,
172    pub g_fr: f64,
173    pub b_fr: f64,
174    pub g_to: f64,
175    pub b_to: f64,
176    pub rate_a: f64,
177    pub rate_b: f64,
178    pub rate_c: f64,
179    pub rating_sets: Vec<BranchRatingSet>,
180    pub current_ratings: Option<BranchCurrentRatings>,
181    pub tap: f64,
182    pub shift: f64,
183    pub angmin: f64,
184    pub angmax: f64,
185}
186
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
189#[non_exhaustive]
190// Frozen 0.9 reader plumbing: the legacy09 upgrade in the powerio crate is
191// the one remaining consumer, so the type stays reachable but leaves the
192// documented surface. It goes when legacy09 retires.
193#[doc(hidden)]
194pub struct SolverSwitchRow {
195    pub index: usize,
196    pub source_row: Option<usize>,
197    pub from_bus_index: usize,
198    pub to_bus_index: usize,
199    pub closed: bool,
200    pub thermal_rating: Option<f64>,
201    pub current_rating: Option<f64>,
202    pub pf: Option<f64>,
203    pub qf: Option<f64>,
204    pub pt: Option<f64>,
205    pub qt: Option<f64>,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
210#[serde(rename_all = "snake_case")]
211pub enum SolverArcTerminal {
212    From,
213    To,
214}
215
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
217#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
218#[non_exhaustive]
219// Frozen 0.9 reader plumbing: the legacy09 upgrade in the powerio crate is
220// the one remaining consumer, so the type stays reachable but leaves the
221// documented surface. It goes when legacy09 retires.
222#[doc(hidden)]
223pub struct SolverArcRow {
224    pub index: usize,
225    pub branch_index: usize,
226    pub terminal: SolverArcTerminal,
227    pub from_bus_index: usize,
228    pub to_bus_index: usize,
229    pub tap: f64,
230    pub shift: f64,
231    pub g_shunt: f64,
232    pub b_shunt: f64,
233    pub rate_a: f64,
234}
235
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
238#[non_exhaustive]
239// Frozen 0.9 reader plumbing: the legacy09 upgrade in the powerio crate is
240// the one remaining consumer, so the type stays reachable but leaves the
241// documented surface. It goes when legacy09 retires.
242#[doc(hidden)]
243pub struct SolverGeneratorRow {
244    pub index: usize,
245    pub source_row: Option<usize>,
246    pub bus_index: usize,
247    pub pg: f64,
248    pub qg: f64,
249    pub pmax: f64,
250    pub pmin: f64,
251    pub qmax: f64,
252    pub qmin: f64,
253    pub vg: f64,
254    pub mbase: f64,
255    pub cost: Option<SolverCostRow>,
256    pub caps: GenCaps,
257    pub regulated_bus_index: Option<usize>,
258}
259
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
261#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
262#[non_exhaustive]
263// Frozen 0.9 reader plumbing: the legacy09 upgrade in the powerio crate is
264// the one remaining consumer, so the type stays reachable but leaves the
265// documented surface. It goes when legacy09 retires.
266#[doc(hidden)]
267pub struct SolverStorageRow {
268    pub index: usize,
269    pub source_row: Option<usize>,
270    pub bus_index: usize,
271    pub ps: f64,
272    pub qs: f64,
273    pub energy: f64,
274    pub energy_rating: f64,
275    pub charge_rating: f64,
276    pub discharge_rating: f64,
277    pub charge_efficiency: f64,
278    pub discharge_efficiency: f64,
279    pub thermal_rating: f64,
280    pub current_rating: Option<f64>,
281    pub qmin: f64,
282    pub qmax: f64,
283    pub r: f64,
284    pub x: f64,
285    pub p_loss: f64,
286    pub q_loss: f64,
287}
288
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
290#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
291#[non_exhaustive]
292// Frozen 0.9 reader plumbing: the legacy09 upgrade in the powerio crate is
293// the one remaining consumer, so the type stays reachable but leaves the
294// documented surface. It goes when legacy09 retires.
295#[doc(hidden)]
296pub struct SolverHvdcRow {
297    pub index: usize,
298    pub source_row: Option<usize>,
299    pub from_bus_index: usize,
300    pub to_bus_index: usize,
301    pub pf: f64,
302    pub pt: f64,
303    pub qf: f64,
304    pub qt: f64,
305    pub vf: f64,
306    pub vt: f64,
307    pub pmin: f64,
308    pub pmax: f64,
309    pub qminf: f64,
310    pub qmaxf: f64,
311    pub qmint: f64,
312    pub qmaxt: f64,
313    pub loss0: f64,
314    pub loss1: f64,
315    pub cost: Option<SolverCostRow>,
316}
317
318#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
319#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
320#[non_exhaustive]
321// Frozen 0.9 reader plumbing: the legacy09 upgrade in the powerio crate is
322// the one remaining consumer, so the type stays reachable but leaves the
323// documented surface. It goes when legacy09 retires.
324#[doc(hidden)]
325pub struct SolverCostRow {
326    pub model: u8,
327    pub startup: f64,
328    pub shutdown: f64,
329    pub ncost: usize,
330    pub coeffs: Vec<f64>,
331}
332
333impl From<&GenCost> for SolverCostRow {
334    fn from(cost: &GenCost) -> Self {
335        Self {
336            model: cost.model,
337            startup: cost.startup,
338            shutdown: cost.shutdown,
339            ncost: cost.ncost,
340            coeffs: cost.coeffs.clone(),
341        }
342    }
343}
344
345impl BalancedNetwork {
346    /// Lower this balanced network into normalized dense solver tables.
347    ///
348    /// # Errors
349    /// Propagates [`BalancedNetwork::to_normalized`] errors and reports
350    /// [`Error::UnknownBus`] if the derived normalized network contains an
351    /// internal dangling bus reference.
352    pub fn to_normalized_solver_tables(&self) -> Result<NormalizedSolverTables> {
353        NormalizedSolverTables::from_network(self)
354    }
355}
356
357impl NormalizedSolverTables {
358    pub fn from_network(source: &BalancedNetwork) -> Result<Self> {
359        let (normalized, provenance) = normalized_for_solver(source)?;
360        let view = IndexedNetwork::new(&normalized);
361        let net = view.network();
362
363        let branch_arcs = branch_and_arc_rows(&view, &provenance)?;
364        let buses = bus_rows(&view, &provenance);
365        let loads = load_rows(&view, &provenance)?;
366        let shunts = shunt_rows(&view, &provenance)?;
367        let switches = switch_rows(&view, &provenance)?;
368        let generators = generator_rows(&view, &provenance)?;
369        let storage = storage_rows(&view, &provenance)?;
370        let hvdc = hvdc_rows(&view, &provenance)?;
371
372        Ok(Self {
373            pass: NORMALIZED_SOLVER_TABLES_PASS.to_string(),
374            network_name: net.name().clone(),
375            base_mva: net.base_mva(),
376            base_frequency: net.base_frequency(),
377            units: SolverTableUnits::default(),
378            index: SolverTableIndex {
379                bus_ids: net.buses().iter().map(|b| b.id).collect(),
380                reference_bus_indices: view.reference_bus_indices(),
381                component_labels: view.connected_component_labels(),
382                branch_from_arc_indices: branch_arcs.branch_from_arc_indices,
383                branch_to_arc_indices: branch_arcs.branch_to_arc_indices,
384                bus_source_rows: provenance.buses,
385                load_source_rows: provenance.loads,
386                shunt_source_rows: provenance.shunts,
387                branch_source_rows: provenance.branches,
388                switch_source_rows: provenance.switches,
389                generator_source_rows: provenance.generators,
390                storage_source_rows: provenance.storage,
391                hvdc_source_rows: provenance.hvdc,
392            },
393            buses,
394            loads,
395            shunts,
396            branches: branch_arcs.branches,
397            switches,
398            arcs: branch_arcs.arcs,
399            generators,
400            storage,
401            hvdc,
402        })
403    }
404}
405
406/// The normalized network and the row provenance for its star-lowered view.
407/// A network that is already normalized is its own source, so every element
408/// maps to its own row.
409fn normalized_for_solver(
410    source: &BalancedNetwork,
411) -> Result<(BalancedNetwork, NormalizeSourceRows)> {
412    if source.is_normalized() {
413        let net = source.clone();
414        let mut rows = NormalizeSourceRows::identity(&net);
415        rows.pad_to_lowered(&net);
416        Ok((net, rows))
417    } else {
418        let (normalized, rows) =
419            source.to_normalized_with_source_rows(&NormalizeOptions::default())?;
420        Ok((normalized.network, rows))
421    }
422}
423
424fn bus_rows(view: &IndexedNetwork<'_>, provenance: &NormalizeSourceRows) -> Vec<SolverBusRow> {
425    view.network()
426        .buses()
427        .iter()
428        .enumerate()
429        .map(|(i, bus)| SolverBusRow {
430            index: i,
431            bus_id: bus.id,
432            source_row: provenance.buses[i],
433            kind: bus.kind,
434            vm: bus.vm,
435            va: bus.va,
436            base_kv: bus.base_kv,
437            vmax: bus.vmax,
438            vmin: bus.vmin,
439            evhi: bus.evhi,
440            evlo: bus.evlo,
441            area: bus.area,
442            zone: bus.zone,
443            pd: view.pd()[i],
444            qd: view.qd()[i],
445            gs: view.gs()[i],
446            bs: view.bs()[i],
447        })
448        .collect()
449}
450
451fn load_rows(
452    view: &IndexedNetwork<'_>,
453    provenance: &NormalizeSourceRows,
454) -> Result<Vec<SolverLoadRow>> {
455    view.network()
456        .loads()
457        .iter()
458        .enumerate()
459        .map(|(i, load)| {
460            Ok(SolverLoadRow {
461                index: i,
462                source_row: provenance.loads[i],
463                bus_index: dense_bus(view, load.bus, i)?,
464                p: load.p,
465                q: load.q,
466                voltage_model: load.voltage_model.clone(),
467            })
468        })
469        .collect()
470}
471
472fn shunt_rows(
473    view: &IndexedNetwork<'_>,
474    provenance: &NormalizeSourceRows,
475) -> Result<Vec<SolverShuntRow>> {
476    view.network()
477        .shunts()
478        .iter()
479        .enumerate()
480        .map(|(i, shunt)| {
481            Ok(SolverShuntRow {
482                index: i,
483                source_row: provenance.shunts[i],
484                bus_index: dense_bus(view, shunt.bus, i)?,
485                g: shunt.g,
486                b: shunt.b,
487            })
488        })
489        .collect()
490}
491
492struct BranchArcRows {
493    branches: Vec<SolverBranchRow>,
494    arcs: Vec<SolverArcRow>,
495    branch_from_arc_indices: Vec<usize>,
496    branch_to_arc_indices: Vec<usize>,
497}
498
499fn branch_and_arc_rows(
500    view: &IndexedNetwork<'_>,
501    provenance: &NormalizeSourceRows,
502) -> Result<BranchArcRows> {
503    let net = view.network();
504    let mut branch_from_arc_indices = Vec::with_capacity(net.branches().len());
505    let mut branch_to_arc_indices = Vec::with_capacity(net.branches().len());
506    let mut arcs = Vec::with_capacity(net.branches().len() * 2);
507    let branches = net
508        .branches()
509        .iter()
510        .enumerate()
511        .map(|(i, branch)| {
512            let from_bus_index = dense_bus(view, branch.from, i)?;
513            let to_bus_index = dense_bus(view, branch.to, i)?;
514            let charging = branch.terminal_charging();
515            let from_arc = arcs.len();
516            arcs.push(SolverArcRow {
517                index: from_arc,
518                branch_index: i,
519                terminal: SolverArcTerminal::From,
520                from_bus_index,
521                to_bus_index,
522                tap: branch.tap,
523                shift: branch.shift,
524                g_shunt: charging.g_fr,
525                b_shunt: charging.b_fr,
526                rate_a: branch.rate_a,
527            });
528            let to_arc = arcs.len();
529            arcs.push(SolverArcRow {
530                index: to_arc,
531                branch_index: i,
532                terminal: SolverArcTerminal::To,
533                from_bus_index: to_bus_index,
534                to_bus_index: from_bus_index,
535                tap: 1.0,
536                shift: 0.0,
537                g_shunt: charging.g_to,
538                b_shunt: charging.b_to,
539                rate_a: branch.rate_a,
540            });
541            branch_from_arc_indices.push(from_arc);
542            branch_to_arc_indices.push(to_arc);
543
544            Ok(SolverBranchRow {
545                index: i,
546                source_row: provenance.branches[i],
547                from_bus_index,
548                to_bus_index,
549                r: branch.r,
550                x: branch.x,
551                b: branch.b,
552                g_fr: charging.g_fr,
553                b_fr: charging.b_fr,
554                g_to: charging.g_to,
555                b_to: charging.b_to,
556                rate_a: branch.rate_a,
557                rate_b: branch.rate_b,
558                rate_c: branch.rate_c,
559                rating_sets: branch.rating_sets.clone(),
560                current_ratings: branch.current_ratings,
561                tap: branch.tap,
562                shift: branch.shift,
563                angmin: branch.angmin,
564                angmax: branch.angmax,
565            })
566        })
567        .collect::<Result<Vec<_>>>()?;
568
569    Ok(BranchArcRows {
570        branches,
571        arcs,
572        branch_from_arc_indices,
573        branch_to_arc_indices,
574    })
575}
576
577fn switch_rows(
578    view: &IndexedNetwork<'_>,
579    provenance: &NormalizeSourceRows,
580) -> Result<Vec<SolverSwitchRow>> {
581    view.network()
582        .switches()
583        .iter()
584        .enumerate()
585        .map(|(i, switch)| {
586            Ok(SolverSwitchRow {
587                index: i,
588                source_row: provenance.switches[i],
589                from_bus_index: dense_bus(view, switch.from, i)?,
590                to_bus_index: dense_bus(view, switch.to, i)?,
591                closed: switch.closed,
592                thermal_rating: switch.thermal_rating,
593                current_rating: switch.current_rating,
594                pf: switch.pf,
595                qf: switch.qf,
596                pt: switch.pt,
597                qt: switch.qt,
598            })
599        })
600        .collect()
601}
602
603fn generator_rows(
604    view: &IndexedNetwork<'_>,
605    provenance: &NormalizeSourceRows,
606) -> Result<Vec<SolverGeneratorRow>> {
607    view.network()
608        .generators()
609        .iter()
610        .enumerate()
611        .map(|(i, generator)| {
612            Ok(SolverGeneratorRow {
613                index: i,
614                source_row: provenance.generators[i],
615                bus_index: dense_bus(view, generator.bus, i)?,
616                pg: generator.pg,
617                qg: generator.qg,
618                pmax: generator.pmax,
619                pmin: generator.pmin,
620                qmax: generator.qmax,
621                qmin: generator.qmin,
622                vg: generator.vg,
623                mbase: generator.mbase,
624                cost: generator.cost.as_ref().map(SolverCostRow::from),
625                caps: generator.caps,
626                regulated_bus_index: generator
627                    .regulated_bus
628                    .map(|bus| dense_bus(view, bus, i))
629                    .transpose()?,
630            })
631        })
632        .collect()
633}
634
635fn storage_rows(
636    view: &IndexedNetwork<'_>,
637    provenance: &NormalizeSourceRows,
638) -> Result<Vec<SolverStorageRow>> {
639    let base_mva = view.network().base_mva();
640    view.network()
641        .storage()
642        .iter()
643        .enumerate()
644        .map(|(i, storage)| {
645            Ok(SolverStorageRow {
646                index: i,
647                source_row: provenance.storage[i],
648                bus_index: dense_bus(view, storage.bus, i)?,
649                ps: storage.ps / base_mva,
650                qs: storage.qs / base_mva,
651                energy: storage.energy,
652                energy_rating: storage.energy_rating,
653                charge_rating: storage.charge_rating,
654                discharge_rating: storage.discharge_rating,
655                charge_efficiency: storage.charge_efficiency,
656                discharge_efficiency: storage.discharge_efficiency,
657                thermal_rating: storage.thermal_rating,
658                current_rating: storage.current_rating,
659                qmin: storage.qmin,
660                qmax: storage.qmax,
661                r: storage.r,
662                x: storage.x,
663                p_loss: storage.p_loss,
664                q_loss: storage.q_loss,
665            })
666        })
667        .collect()
668}
669
670fn hvdc_rows(
671    view: &IndexedNetwork<'_>,
672    provenance: &NormalizeSourceRows,
673) -> Result<Vec<SolverHvdcRow>> {
674    view.network()
675        .hvdc()
676        .iter()
677        .enumerate()
678        .map(|(i, hvdc)| hvdc_row(view, provenance, i, hvdc))
679        .collect()
680}
681
682fn hvdc_row(
683    view: &IndexedNetwork<'_>,
684    provenance: &NormalizeSourceRows,
685    i: usize,
686    hvdc: &Hvdc,
687) -> Result<SolverHvdcRow> {
688    let base_mva = view.network().base_mva();
689    Ok(SolverHvdcRow {
690        index: i,
691        source_row: provenance.hvdc[i],
692        from_bus_index: dense_bus(view, hvdc.from, i)?,
693        to_bus_index: dense_bus(view, hvdc.to, i)?,
694        pf: hvdc.pf,
695        pt: hvdc.pt,
696        qf: hvdc.qf,
697        qt: hvdc.qt,
698        vf: hvdc.vf,
699        vt: hvdc.vt,
700        pmin: hvdc.pmin / base_mva,
701        pmax: hvdc.pmax / base_mva,
702        qminf: hvdc.qminf,
703        qmaxf: hvdc.qmaxf,
704        qmint: hvdc.qmint,
705        qmaxt: hvdc.qmaxt,
706        loss0: hvdc.loss0,
707        loss1: hvdc.loss1,
708        cost: hvdc.cost.as_ref().map(SolverCostRow::from),
709    })
710}
711
712fn dense_bus(view: &IndexedNetwork<'_>, bus_id: BusId, element_index: usize) -> Result<usize> {
713    view.bus_index(bus_id).ok_or(Error::UnknownBus {
714        bus_id,
715        element_index,
716    })
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use crate::network::{Branch, Bus, Extras, Generator, Hvdc, Load, SourceFormat, Storage};
723    use crate::parse_file;
724
725    fn approx(a: f64, b: f64) -> bool {
726        (a - b).abs() < 1e-12
727    }
728
729    fn bus(id: usize, kind: BusType) -> Bus {
730        Bus {
731            id: BusId(id),
732            kind,
733            vm: 1.0,
734            va: 0.0,
735            base_kv: 230.0,
736            vmax: 1.1,
737            vmin: 0.9,
738            evhi: None,
739            evlo: None,
740            area: 1,
741            zone: 1,
742            name: None,
743            uid: None,
744            location: None,
745            extras: Extras::new(),
746        }
747    }
748
749    fn branch(from: usize, to: usize, in_service: bool) -> Branch {
750        Branch {
751            from: BusId(from),
752            to: BusId(to),
753            r: 0.01,
754            x: 0.1,
755            b: 0.02,
756            charging: None,
757            rate_a: 100.0,
758            rate_b: 110.0,
759            rate_c: 120.0,
760            rating_sets: Vec::new(),
761            current_ratings: None,
762            tap: 0.0,
763            shift: 30.0,
764            in_service,
765            angmin: -360.0,
766            angmax: 360.0,
767            control: None,
768            solution: None,
769            uid: None,
770            route: None,
771            extras: Extras::new(),
772        }
773    }
774
775    fn generator(bus: usize, in_service: bool) -> Generator {
776        Generator {
777            bus: BusId(bus),
778            pg: 50.0,
779            qg: 5.0,
780            pmax: 80.0,
781            pmin: 0.0,
782            qmax: 40.0,
783            qmin: -40.0,
784            vg: 1.0,
785            mbase: 100.0,
786            in_service,
787            cost: None,
788            caps: [None; crate::network::GEN_EXTRA_KEYS.len()],
789            regulated_bus: None,
790            uid: None,
791        }
792    }
793
794    #[test]
795    fn solver_tables_are_dense_normalized_and_traceable() {
796        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case14.m");
797        let net = parse_file(path, None).unwrap().network;
798
799        let tables = net.to_normalized_solver_tables().unwrap();
800
801        assert_eq!(tables.pass, NORMALIZED_SOLVER_TABLES_PASS);
802        assert_eq!(tables.units.power, "per_unit");
803        assert_eq!(tables.units.angle, "radian");
804        assert_eq!(tables.buses.len(), 14);
805        assert_eq!(tables.branches.len(), 20);
806        assert_eq!(tables.arcs.len(), 40);
807        assert_eq!(tables.index.reference_bus_indices, vec![0]);
808        assert_eq!(tables.index.branch_from_arc_indices[0], 0);
809        assert_eq!(tables.index.branch_to_arc_indices[0], 1);
810        assert_eq!(tables.arcs[0].terminal, SolverArcTerminal::From);
811        assert_eq!(tables.arcs[1].terminal, SolverArcTerminal::To);
812        assert!(tables.index.bus_source_rows.iter().all(Option::is_some));
813        assert!(tables.index.branch_source_rows.iter().all(Option::is_some));
814
815        let bus_2 = &tables.buses[1];
816        assert_eq!(bus_2.bus_id, BusId(2));
817        assert!(approx(bus_2.pd, 21.7 / 100.0));
818        assert!(approx(bus_2.qd, 12.7 / 100.0));
819    }
820
821    #[test]
822    fn solver_tables_filter_out_of_service_rows_and_keep_source_rows() {
823        let mut net = BalancedNetwork::in_memory(
824            "filtered",
825            100.0,
826            vec![
827                bus(1, BusType::Ref),
828                bus(2, BusType::Pq),
829                bus(3, BusType::Isolated),
830            ],
831            vec![branch(1, 2, true), branch(1, 3, true), branch(1, 2, false)],
832        );
833        net.loads_mut().push(Load {
834            bus: BusId(2),
835            p: 10.0,
836            q: 5.0,
837            voltage_model: None,
838            in_service: true,
839            uid: None,
840            extras: Extras::new(),
841        });
842        net.loads_mut().push(Load {
843            bus: BusId(3),
844            p: 99.0,
845            q: 99.0,
846            voltage_model: None,
847            in_service: true,
848            uid: None,
849            extras: Extras::new(),
850        });
851        net.generators_mut().push(generator(1, true));
852        net.generators_mut().push(generator(2, false));
853        *net.source_format_mut() = SourceFormat::Matpower;
854        let tables = net.to_normalized_solver_tables().unwrap();
855
856        assert_eq!(tables.index.bus_ids, vec![BusId(1), BusId(2)]);
857        assert_eq!(tables.branches.len(), 1);
858        assert_eq!(tables.loads.len(), 1);
859        assert_eq!(tables.generators.len(), 1);
860        assert_eq!(tables.index.branch_source_rows, vec![Some(0)]);
861        assert_eq!(tables.index.load_source_rows, vec![Some(0)]);
862        assert_eq!(tables.index.generator_source_rows, vec![Some(0)]);
863        assert!(approx(tables.loads[0].p, 0.1));
864        assert!(approx(tables.branches[0].rate_a, 1.0));
865        assert!(approx(tables.branches[0].tap, 1.0));
866        assert!(approx(tables.branches[0].shift, 30.0_f64.to_radians()));
867    }
868
869    #[test]
870    fn solver_tables_map_an_already_normalized_network_to_its_own_rows() {
871        // An already-normalized network skips the pass, so nothing is filtered
872        // and every table row names the row it sits at. The out-of-service load
873        // and generator and the isolated bus are the cases that separate this
874        // from the filtered path: they survive here, so the map stays the
875        // identity across every family instead of shifting positions.
876        let mut net = BalancedNetwork::in_memory(
877            "identity",
878            100.0,
879            vec![
880                bus(1, BusType::Ref),
881                bus(2, BusType::Pq),
882                bus(3, BusType::Pq),
883                bus(4, BusType::Isolated),
884            ],
885            vec![branch(1, 2, true), branch(1, 3, false)],
886        );
887        net.loads_mut().push(Load {
888            bus: BusId(2),
889            p: 0.1,
890            q: 0.05,
891            voltage_model: None,
892            in_service: false,
893            uid: None,
894            extras: Extras::new(),
895        });
896        net.loads_mut().push(Load {
897            bus: BusId(3),
898            p: 0.2,
899            q: 0.1,
900            voltage_model: None,
901            in_service: true,
902            uid: None,
903            extras: Extras::new(),
904        });
905        net.generators_mut().push(generator(1, false));
906        net.generators_mut().push(generator(2, true));
907        *net.source_format_mut() = SourceFormat::Normalized;
908        let tables = net.to_normalized_solver_tables().unwrap();
909
910        assert_eq!(
911            tables.index.bus_source_rows,
912            vec![Some(0), Some(1), Some(2), Some(3)]
913        );
914        assert_eq!(tables.index.branch_source_rows, vec![Some(0), Some(1)]);
915        assert_eq!(tables.index.load_source_rows, vec![Some(0), Some(1)]);
916        assert_eq!(tables.index.generator_source_rows, vec![Some(0), Some(1)]);
917    }
918
919    #[test]
920    fn solver_tables_do_not_scale_an_already_normalized_network_twice() {
921        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case14.m");
922        let net = parse_file(path, None).unwrap().network;
923        let normalized = net.to_normalized().unwrap();
924
925        let tables = normalized.to_normalized_solver_tables().unwrap();
926
927        let bus_2 = &tables.buses[1];
928        assert!(approx(bus_2.pd, 21.7 / 100.0));
929        assert!(approx(bus_2.qd, 12.7 / 100.0));
930    }
931
932    #[test]
933    fn solver_tables_scale_storage_and_hvdc_power_fields_to_per_unit() {
934        let mut net = BalancedNetwork::in_memory(
935            "storage-hvdc",
936            100.0,
937            vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
938            Vec::new(),
939        );
940        net.generators_mut().push(generator(1, true));
941        net.storage_mut().push(Storage {
942            bus: BusId(2),
943            ps: 30.0,
944            qs: -10.0,
945            energy: 50.0,
946            energy_rating: 100.0,
947            charge_rating: 20.0,
948            discharge_rating: 25.0,
949            charge_efficiency: 0.9,
950            discharge_efficiency: 0.85,
951            thermal_rating: 40.0,
952            current_rating: None,
953            qmin: -15.0,
954            qmax: 15.0,
955            r: 0.01,
956            x: 0.02,
957            p_loss: 2.0,
958            q_loss: 1.0,
959            in_service: true,
960            uid: None,
961            extras: Extras::new(),
962        });
963        net.hvdc_mut().push(Hvdc {
964            from: BusId(1),
965            to: BusId(2),
966            in_service: true,
967            pf: 20.0,
968            pt: -19.0,
969            qf: 5.0,
970            qt: -4.0,
971            vf: 1.0,
972            vt: 1.0,
973            pmin: -40.0,
974            pmax: 75.0,
975            qminf: -25.0,
976            qmaxf: 30.0,
977            qmint: -20.0,
978            qmaxt: 22.0,
979            loss0: 1.5,
980            loss1: 0.02,
981            cost: None,
982            uid: None,
983            extras: Extras::new(),
984        });
985
986        let tables = net.to_normalized_solver_tables().unwrap();
987
988        let storage = &tables.storage[0];
989        assert!(approx(storage.ps, 0.3));
990        assert!(approx(storage.qs, -0.1));
991        assert!(approx(storage.energy, 0.5));
992        assert!(approx(storage.thermal_rating, 0.4));
993        assert!(approx(storage.p_loss, 0.02));
994
995        let hvdc = &tables.hvdc[0];
996        assert!(approx(hvdc.pf, 0.2));
997        assert!(approx(hvdc.pt, -0.19));
998        assert!(approx(hvdc.pmin, -0.4));
999        assert!(approx(hvdc.pmax, 0.75));
1000        assert!(approx(hvdc.qminf, -0.25));
1001        assert!(approx(hvdc.loss0, 0.015));
1002    }
1003}