Skip to main content

powerio/format/matpower/
writer.rs

1//! Write a [`Network`] back out as a MATPOWER `.m` file.
2//!
3//! When the network was read from MATPOWER text it carries its original source,
4//! and the writer echoes it verbatim — an exact round-trip that preserves every
5//! field, comment, and numeric token. A network built in memory (e.g. by
6//! `synth`) or read from another format has no MATPOWER source, so the writer
7//! falls back to canonical serialization, folding loads and shunts back onto the
8//! bus row.
9
10use std::collections::BTreeMap;
11use std::fmt::Write as _;
12
13use crate::format::{Conversion, warn_extra_branch_rating_sets};
14use crate::network::{BusId, Network, SourceFormat};
15
16/// Serialize `net` to MATPOWER `.m` text. Echoes the retained source verbatim
17/// when `net` came from MATPOWER; otherwise emits canonical `.m`.
18#[must_use]
19pub fn write_matpower(net: &Network) -> String {
20    match &net.source {
21        Some(text) if net.source_format == SourceFormat::Matpower => text.to_string(),
22        _ => canonical(net),
23    }
24}
25
26/// MATPOWER conversion with fidelity warnings. The byte-exact echo path (a
27/// network that kept its MATPOWER source) drops nothing; the canonical path
28/// can't carry everything the neutral model holds, so it itemizes what it leaves
29/// out — the cross-format leg of the fidelity behavior (see [`Conversion`]).
30pub(crate) fn write_matpower_conversion(net: &Network) -> Conversion {
31    let text = write_matpower(net);
32    // Echoed retained MATPOWER source: byte-exact, nothing dropped.
33    if net.source.is_some() && net.source_format == SourceFormat::Matpower {
34        return Conversion {
35            text,
36            warnings: Vec::new(),
37        };
38    }
39
40    let warnings = canonical_warnings(net);
41    Conversion { text, warnings }
42}
43
44#[expect(clippy::too_many_lines)]
45fn canonical_warnings(net: &Network) -> Vec<String> {
46    // The canonical writer (see `canonical`) emits the standard bus/branch/gen/
47    // gencost/storage blocks only. Report every neutral-model field it can't.
48    let mut warnings = Vec::new();
49    if !net.hvdc.is_empty() {
50        warnings.push(format!(
51            "{} HVDC dcline(s) dropped: the canonical MATPOWER writer emits no `mpc.dcline` block",
52            net.hvdc.len()
53        ));
54    }
55    if !net.switches.is_empty() {
56        warnings.push(format!(
57            "{} switch(es) dropped: MATPOWER has no switch table",
58            net.switches.len()
59        ));
60    }
61    if !net.transformers_3w.is_empty() {
62        warnings.push(format!(
63            "{} 3-winding transformer(s) dropped: the canonical MATPOWER writer emits no \
64             3-winding record (star-expand them into branches before writing to keep them)",
65            net.transformers_3w.len()
66        ));
67    }
68    if net
69        .buses
70        .iter()
71        .any(|b| b.evhi.is_some() || b.evlo.is_some())
72    {
73        warnings.push(
74            "emergency voltage band(s) (EVHI/EVLO) dropped: this writer carries one voltage band"
75                .into(),
76        );
77    }
78    let with_caps = net.generators.iter().filter(|g| g.has_caps()).count();
79    if with_caps > 0 {
80        warnings.push(format!(
81            "generator capability/ramp columns dropped for {with_caps} generator(s): the canonical MATPOWER writer emits only the standard gen columns"
82        ));
83    }
84    let non_matpower_charging = net
85        .branches
86        .iter()
87        .filter(|b| b.has_non_matpower_charging())
88        .count();
89    if non_matpower_charging > 0 {
90        warnings.push(format!(
91            "{non_matpower_charging} branch terminal admittance record(s) collapsed to total susceptance: MATPOWER cannot carry conductance or asymmetric terminal charging"
92        ));
93    }
94    let current_ratings = net
95        .branches
96        .iter()
97        .filter(|b| b.current_ratings.is_some())
98        .count();
99    if current_ratings > 0 {
100        warnings.push(format!(
101            "{current_ratings} branch current rating record(s) dropped: MATPOWER branch rows carry MVA ratings only"
102        ));
103    }
104    warn_extra_branch_rating_sets("MATPOWER .m", net, &mut warnings);
105    let branch_solutions = net.branches.iter().filter(|b| b.solution.is_some()).count();
106    if branch_solutions > 0 {
107        warnings.push(format!(
108            "{branch_solutions} branch solution value set(s) dropped: MATPOWER branch rows do not carry solved flow columns"
109        ));
110    }
111    let voltage_loads = net
112        .loads
113        .iter()
114        .filter(|l| {
115            l.voltage_model
116                .as_ref()
117                .is_some_and(crate::network::LoadVoltageModel::has_non_matpower_fields)
118        })
119        .count();
120    if voltage_loads > 0 {
121        warnings.push(format!(
122            "{voltage_loads} voltage dependent load model(s) dropped: MATPOWER carries only static Pd/Qd"
123        ));
124    }
125    let with_cost = net.generators.iter().filter(|g| g.cost.is_some()).count();
126    if !net.generators.is_empty() && with_cost == 0 {
127        warnings.push(format!(
128            "generator costs absent for {} generator(s): omitted `mpc.gencost`; no zero costs synthesized",
129            net.generators.len()
130        ));
131    } else if with_cost > 0 && with_cost < net.generators.len() {
132        warnings.push(format!(
133            "gen cost dropped: {with_cost} of {} generators carry cost data, but MATPOWER's `mpc.gencost` block is all-or-nothing",
134            net.generators.len()
135        ));
136    }
137    let has_extras = net.buses.iter().any(|b| !b.extras.is_empty())
138        || net.branches.iter().any(|b| !b.extras.is_empty())
139        || net.loads.iter().any(|l| !l.extras.is_empty())
140        || net.shunts.iter().any(|s| !s.extras.is_empty())
141        || net.storage.iter().any(|s| !s.extras.is_empty())
142        || net.hvdc.iter().any(|d| !d.extras.is_empty());
143    if has_extras {
144        warnings.push(
145            "source-format passthrough fields (extras) dropped: the canonical MATPOWER writer emits only named columns".to_string(),
146        );
147    }
148    warnings
149}
150
151/// Canonical MATPOWER from the neutral model, for networks with no MATPOWER
152/// source. Loads and shunts are summed back onto their bus (MATPOWER carries one
153/// of each per bus). Emits valid `.m` (values equal, formatting normalized); not
154/// byte-exact. HVDC lines are not emitted.
155#[allow(clippy::too_many_lines)] // flat per-section serializer; splitting adds noise
156fn canonical(net: &Network) -> String {
157    // Aggregate demand and shunts onto their bus.
158    let mut demand: BTreeMap<BusId, (f64, f64)> = BTreeMap::new();
159    for l in &net.loads {
160        let e = demand.entry(l.bus).or_default();
161        e.0 += l.p;
162        e.1 += l.q;
163    }
164    let mut shunt: BTreeMap<BusId, (f64, f64)> = BTreeMap::new();
165    for s in &net.shunts {
166        let e = shunt.entry(s.bus).or_default();
167        e.0 += s.g;
168        e.1 += s.b;
169    }
170
171    let mut s = String::new();
172    let _ = writeln!(s, "function mpc = {}", matlab_ident(&net.name));
173    let _ = writeln!(s, "mpc.version = '2';");
174    let _ = writeln!(s, "mpc.baseMVA = {};", net.base_mva);
175
176    let _ = writeln!(s, "mpc.bus = [");
177    for b in &net.buses {
178        let (pd, qd) = demand.get(&b.id).copied().unwrap_or((0.0, 0.0));
179        let (gs, bs) = shunt.get(&b.id).copied().unwrap_or((0.0, 0.0));
180        let _ = writeln!(
181            s,
182            "\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{};",
183            b.id,
184            b.kind as u8,
185            pd,
186            qd,
187            gs,
188            bs,
189            b.area,
190            b.vm,
191            b.va,
192            b.base_kv,
193            b.zone,
194            b.vmax,
195            b.vmin
196        );
197    }
198    let _ = writeln!(s, "];");
199
200    let _ = writeln!(s, "mpc.branch = [");
201    for br in &net.branches {
202        let _ = writeln!(
203            s,
204            "\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{};",
205            br.from,
206            br.to,
207            br.r,
208            br.x,
209            br.terminal_charging().total_b(),
210            br.rate_a,
211            br.rate_b,
212            br.rate_c,
213            br.tap,
214            br.shift,
215            f64::from(br.in_service),
216            br.angmin,
217            br.angmax
218        );
219    }
220    let _ = writeln!(s, "];");
221
222    if !net.generators.is_empty() {
223        let _ = writeln!(s, "mpc.gen = [");
224        for g in &net.generators {
225            let _ = writeln!(
226                s,
227                "\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{};",
228                g.bus,
229                g.pg,
230                g.qg,
231                g.qmax,
232                g.qmin,
233                g.vg,
234                g.mbase,
235                f64::from(g.in_service),
236                g.pmax,
237                g.pmin
238            );
239        }
240        let _ = writeln!(s, "];");
241
242        if net.generators.iter().all(|g| g.cost.is_some()) {
243            let _ = writeln!(s, "mpc.gencost = [");
244            // MATPOWER's gencost is a rectangular matrix: pad every row's cost
245            // values to the widest one with trailing zeros (a case that mixes
246            // piecewise and polynomial models has rows of different lengths).
247            let width = net
248                .generators
249                .iter()
250                .filter_map(|g| g.cost.as_ref())
251                .map(|c| c.coeffs.len())
252                .max()
253                .unwrap_or(0);
254            for g in &net.generators {
255                let c = g.cost.as_ref().expect("checked all gens have cost");
256                let _ = write!(
257                    s,
258                    "\t{}\t{}\t{}\t{}",
259                    c.model, c.startup, c.shutdown, c.ncost
260                );
261                for j in 0..width {
262                    let _ = write!(s, "\t{}", c.coeffs.get(j).copied().unwrap_or(0.0));
263                }
264                let _ = writeln!(s, ";");
265            }
266            let _ = writeln!(s, "];");
267        }
268    }
269
270    if !net.storage.is_empty() {
271        let _ = writeln!(s, "mpc.storage = [");
272        for st in &net.storage {
273            let _ = writeln!(
274                s,
275                "\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{};",
276                st.bus,
277                st.ps,
278                st.qs,
279                st.energy,
280                st.energy_rating,
281                st.charge_rating,
282                st.discharge_rating,
283                st.charge_efficiency,
284                st.discharge_efficiency,
285                st.thermal_rating,
286                st.qmin,
287                st.qmax,
288                st.r,
289                st.x,
290                st.p_loss,
291                st.q_loss,
292                f64::from(st.in_service)
293            );
294        }
295        let _ = writeln!(s, "];");
296    }
297
298    s
299}
300
301/// Coerce a case name into a legal MATLAB identifier for the `function` header:
302/// non-alphanumeric chars become `_`, and a leading non-letter is prefixed so
303/// a synth case named e.g. `"grid-1"` still writes a parseable `.m`.
304fn matlab_ident(name: &str) -> String {
305    let mut ident: String = name
306        .chars()
307        .map(|c| {
308            if c.is_ascii_alphanumeric() || c == '_' {
309                c
310            } else {
311                '_'
312            }
313        })
314        .collect();
315    if !ident.starts_with(|c: char| c.is_ascii_alphabetic()) {
316        ident.insert(0, 'c');
317    }
318    ident
319}