1use 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#[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
26pub(crate) fn write_matpower_conversion(net: &Network) -> Conversion {
31 let text = write_matpower(net);
32 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 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#[allow(clippy::too_many_lines)] fn canonical(net: &Network) -> String {
157 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 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
301fn 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}