1use std::collections::{BTreeMap, HashMap};
8use std::sync::Arc;
9
10use serde_json::{Map, Value};
11
12use super::{Conversion, Parsed, bus_kv, finish, jnum, nonzero_differs, set_bus_kind, zbase};
13use crate::network::{
14 Branch, Bus, BusId, BusType, Extras, GenCost, Generator, Hvdc, Load, Network, Shunt,
15 SourceFormat, Storage,
16};
17use crate::{Error, Result};
18
19const FMT: &str = "pandapower JSON";
20const F_HZ: f64 = 50.0;
21const MAX_I_KA: f64 = 99_999.0;
22
23pub fn parse_pandapower_json(content: &str) -> Result<Parsed> {
26 let mut warnings = Vec::new();
27 let network = parse_pandapower_source(Arc::new(content.to_owned()), None, &mut warnings)?;
28 Ok(Parsed { network, warnings })
29}
30
31#[allow(clippy::too_many_lines)] pub(crate) fn parse_pandapower_source(
33 source: Arc<String>,
34 name_hint: Option<&str>,
35 warnings: &mut Vec<String>,
36) -> Result<Network> {
37 let content: &str = &source;
38 let root: Value = serde_json::from_str(content).map_err(|e| bad(e.to_string()))?;
39 let root = root
40 .as_object()
41 .ok_or_else(|| bad("top level is not a JSON object"))?;
42 if root.get("_class").and_then(Value::as_str) != Some("pandapowerNet") {
43 return Err(bad("top level `_class` is not `pandapowerNet`"));
44 }
45 let object_from_string;
46 let obj = match root.get("_object") {
47 Some(Value::Object(obj)) => obj,
48 Some(Value::String(raw)) => {
49 object_from_string = serde_json::from_str::<Value>(raw)
50 .map_err(|e| bad(format!("top level `_object`: {e}")))?;
51 object_from_string
52 .as_object()
53 .ok_or_else(|| bad("top level `_object` string is not a network map"))?
54 }
55 Some(_) => return Err(bad("top level `_object` is not a network map")),
56 None => return Err(bad("missing `_object` network map")),
57 };
58
59 let base_mva = match obj.get("sn_mva") {
63 None => 1.0,
64 Some(v) => value_f64(v)
65 .filter(|b| b.is_finite() && *b > 0.0)
66 .ok_or_else(|| {
67 bad(format!(
68 "`sn_mva` is not a positive number (`{}`)",
69 value_repr(v)
70 ))
71 })?,
72 };
73 let f_hz = match obj.get("f_hz") {
74 None => F_HZ,
75 Some(v) => value_f64(v)
76 .filter(|f| f.is_finite() && *f > 0.0)
77 .ok_or_else(|| {
78 bad(format!(
79 "`f_hz` is not a positive number (`{}`)",
80 value_repr(v)
81 ))
82 })?,
83 };
84 let name = obj
85 .get("name")
86 .and_then(Value::as_str)
87 .filter(|s| !s.is_empty())
88 .or(name_hint)
89 .unwrap_or("case")
90 .to_string();
91
92 let bus_frame = read_frame(obj, "bus")?.ok_or_else(|| bad("missing `bus` table"))?;
93 let mut buses = Vec::with_capacity(bus_frame.data.len());
94 let mut bus_of_pp = HashMap::with_capacity(bus_frame.data.len());
95 for row in bus_frame.rows() {
96 let pp_idx = row.index_usize()?;
97 let id = BusId(pp_idx + 1);
100 if bus_of_pp.insert(pp_idx, id).is_some() {
101 return Err(bad(format!("`bus` table: duplicate index {pp_idx}")));
102 }
103 buses.push(Bus {
104 id,
105 kind: if row.bool_or("in_service", true) {
106 BusType::Pq
107 } else {
108 BusType::Isolated
109 },
110 vm: 1.0,
111 va: 0.0,
112 base_kv: row.req_f("vn_kv")?,
113 vmax: row.f_or("max_vm_pu", 1.1),
114 vmin: row.f_or("min_vm_pu", 0.9),
115 area: 1,
116 zone: row.usize_or("zone", 1),
117 name: row.string("name"),
118 extras: Extras::default(),
119 });
120 }
121 let bus_pos: HashMap<BusId, usize> = buses.iter().enumerate().map(|(i, b)| (b.id, i)).collect();
122
123 let mut loads = Vec::new();
124 if let Some(load_frame) = read_frame(obj, "load")? {
125 let mut zip_rows = 0_usize;
126 for row in load_frame.rows() {
127 let scale = row.f_or("scaling", 1.0);
128 let has_zip = row.f_or("const_z_percent", 0.0) != 0.0
132 || row.f_or("const_i_percent", 0.0) != 0.0
133 || row.f_or("const_z_p_percent", 0.0) != 0.0
134 || row.f_or("const_i_p_percent", 0.0) != 0.0
135 || row.f_or("const_z_q_percent", 0.0) != 0.0
136 || row.f_or("const_i_q_percent", 0.0) != 0.0;
137 if has_zip {
138 zip_rows += 1;
139 }
140 loads.push(Load {
141 bus: bus_ref("load", &row, "bus", &bus_of_pp)?,
142 p: row.f_or("p_mw", 0.0) * scale,
143 q: row.f_or("q_mvar", 0.0) * scale,
144 in_service: row.bool_or("in_service", true),
145 extras: Extras::default(),
146 });
147 }
148 if zip_rows > 0 {
149 warnings.push(format!(
150 "`load`: ZIP composition (const_z_percent/const_i_percent/const_z_p_percent/const_i_p_percent/const_z_q_percent/const_i_q_percent) nonzero on {zip_rows} rows; loads are read as constant power"
151 ));
152 }
153 }
154
155 let mut shunts = Vec::new();
156 if let Some(shunt_frame) = read_frame(obj, "shunt")? {
157 for row in shunt_frame.rows() {
158 let step = row.f_or("step", 1.0);
159 let bus = bus_ref("shunt", &row, "bus", &bus_of_pp)?;
160 let bus_v = bus_kv(&buses, &bus_pos, bus);
164 let vn = row.f_finite("vn_kv").filter(|v| *v > 0.0).unwrap_or(bus_v);
165 let v_ratio = if vn > 0.0 && bus_v > 0.0 {
166 (bus_v / vn).powi(2)
167 } else {
168 1.0
169 };
170 shunts.push(Shunt {
171 bus,
172 g: row.f_or("p_mw", 0.0) * step * v_ratio,
173 b: -row.f_or("q_mvar", 0.0) * step * v_ratio,
174 in_service: row.bool_or("in_service", true),
175 extras: Extras::default(),
176 });
177 }
178 }
179
180 let costs = read_poly_costs(obj, warnings)?;
181 let mut generators = Vec::new();
182 if let Some(gen_frame) = read_frame(obj, "gen")? {
183 for row in gen_frame.rows() {
184 let idx = row.index_usize()?;
185 let bus = bus_ref("gen", &row, "bus", &bus_of_pp)?;
186 let slack = row.bool_or("slack", false);
187 set_bus_kind(
188 &mut buses,
189 &bus_pos,
190 bus,
191 if slack { BusType::Ref } else { BusType::Pv },
192 );
193 generators.push(Generator {
194 bus,
195 pg: row.f_or("p_mw", 0.0) * row.f_or("scaling", 1.0),
196 qg: 0.0,
197 pmax: row.f_or("max_p_mw", row.f_or("p_mw", 0.0)),
198 pmin: row.f_or("min_p_mw", 0.0),
199 qmax: row.f_or("max_q_mvar", f64::INFINITY),
200 qmin: row.f_or("min_q_mvar", f64::NEG_INFINITY),
201 vg: row.f_or("vm_pu", 1.0),
202 mbase: row.f_or("sn_mva", base_mva),
203 in_service: row.bool_or("in_service", true),
204 cost: costs.get(&(CostElement::Gen, idx)).cloned(),
205 caps: [None; crate::network::GEN_EXTRA_KEYS.len()],
206 });
207 }
208 }
209 if let Some(ext_grid_frame) = read_frame(obj, "ext_grid")? {
210 for row in ext_grid_frame.rows() {
211 let idx = row.index_usize()?;
212 let bus = bus_ref("ext_grid", &row, "bus", &bus_of_pp)?;
213 set_bus_kind(&mut buses, &bus_pos, bus, BusType::Ref);
214 generators.push(Generator {
215 bus,
216 pg: 0.0,
217 qg: 0.0,
218 pmax: row.f_or("max_p_mw", f64::INFINITY),
219 pmin: row.f_or("min_p_mw", f64::NEG_INFINITY),
220 qmax: row.f_or("max_q_mvar", f64::INFINITY),
221 qmin: row.f_or("min_q_mvar", f64::NEG_INFINITY),
222 vg: row.f_or("vm_pu", 1.0),
223 mbase: base_mva,
224 in_service: row.bool_or("in_service", true),
225 cost: costs.get(&(CostElement::ExtGrid, idx)).cloned(),
226 caps: [None; crate::network::GEN_EXTRA_KEYS.len()],
227 });
228 }
229 }
230 if let Some(sgen_frame) = read_frame(obj, "sgen")? {
233 for row in sgen_frame.rows() {
234 let idx = row.index_usize()?;
235 let bus = bus_ref("sgen", &row, "bus", &bus_of_pp)?;
236 let scale = row.f_or("scaling", 1.0);
237 let p = row.f_or("p_mw", 0.0);
238 generators.push(Generator {
239 bus,
240 pg: p * scale,
241 qg: row.f_or("q_mvar", 0.0) * scale,
242 pmax: row.f_or("max_p_mw", p),
243 pmin: row.f_or("min_p_mw", 0.0),
244 qmax: row.f_or("max_q_mvar", f64::INFINITY),
245 qmin: row.f_or("min_q_mvar", f64::NEG_INFINITY),
246 vg: 1.0,
247 mbase: row.f_or("sn_mva", base_mva),
248 in_service: row.bool_or("in_service", true),
249 cost: costs.get(&(CostElement::Sgen, idx)).cloned(),
250 caps: [None; crate::network::GEN_EXTRA_KEYS.len()],
251 });
252 }
253 }
254
255 let mut branches = Vec::new();
256 if let Some(line_frame) = read_frame(obj, "line")? {
257 let mut g_rows = 0_usize;
258 for row in line_frame.rows() {
259 let from = bus_ref("line", &row, "from_bus", &bus_of_pp)?;
260 let to = bus_ref("line", &row, "to_bus", &bus_of_pp)?;
261 let v_from = bus_kv(&buses, &bus_pos, from);
264 let zbase = zbase(v_from, base_mva);
265 let par = parallel_or_one(&row);
266 let max_i_ka = row.f_or("max_i_ka", 0.0);
267 if row.f_or("g_us_per_km", 0.0) != 0.0 {
268 g_rows += 1;
269 }
270 branches.push(Branch {
271 from,
272 to,
273 r: row.f_or("r_ohm_per_km", 0.0) * row.f_or("length_km", 1.0) / zbase / par,
274 x: row.f_or("x_ohm_per_km", 0.0) * row.f_or("length_km", 1.0) / zbase / par,
275 b: row.f_or("c_nf_per_km", 0.0)
276 * row.f_or("length_km", 1.0)
277 * 1e-9
278 * 2.0
279 * std::f64::consts::PI
280 * f_hz
281 * zbase
282 * par,
283 rate_a: if max_i_ka >= MAX_I_KA {
284 0.0
285 } else {
286 max_i_ka * v_from * 3.0_f64.sqrt() * par
287 },
288 rate_b: 0.0,
289 rate_c: 0.0,
290 tap: 0.0,
291 shift: 0.0,
292 in_service: row.bool_or("in_service", true),
293 angmin: -360.0,
294 angmax: 360.0,
295 extras: Extras::default(),
296 });
297 }
298 if g_rows > 0 {
299 warnings.push(format!(
300 "`line`: g_us_per_km nonzero on {g_rows} rows; line shunt conductance is not representable and was ignored"
301 ));
302 }
303 }
304 if let Some(trafo_frame) = read_frame(obj, "trafo")? {
305 let has_changer = trafo_frame.col("tap_changer_type").is_some();
306 let mut mag_rows = 0_usize;
307 let mut tabular_rows = 0_usize;
308 for row in trafo_frame.rows() {
309 let from = bus_ref("trafo", &row, "hv_bus", &bus_of_pp)?;
310 let to = bus_ref("trafo", &row, "lv_bus", &bus_of_pp)?;
311 let sn = row.f_or("sn_mva", base_mva);
312 let par = parallel_or_one(&row);
313 if row.f_or("i0_percent", 0.0) != 0.0 || row.f_or("pfe_kw", 0.0) != 0.0 {
314 mag_rows += 1;
315 }
316
317 let v_bus_hv = bus_kv(&buses, &bus_pos, from);
324 let v_bus_lv = bus_kv(&buses, &bus_pos, to);
325 let vn_hv = row
326 .f_finite("vn_hv_kv")
327 .filter(|v| *v > 0.0)
328 .unwrap_or(v_bus_hv);
329 let vn_lv = row
330 .f_finite("vn_lv_kv")
331 .filter(|v| *v > 0.0)
332 .unwrap_or(v_bus_lv);
333 let tap_neutral = row.f_or("tap_neutral", 0.0);
334 let diff = row.f_or("tap_pos", tap_neutral) - tap_neutral;
335 let step_percent = row.f_or("tap_step_percent", 0.0);
336 let step_degree = row.f_or("tap_step_degree", 0.0);
337 let lv_side = row
338 .string("tap_side")
339 .is_some_and(|s| s.eq_ignore_ascii_case("lv"));
340 let changer = if row.bool_or("tap_dependency_table", false) {
345 Changer::Tabular
346 } else if has_changer {
347 match row.string("tap_changer_type") {
348 Some(t)
349 if t.eq_ignore_ascii_case("ratio")
350 || t.eq_ignore_ascii_case("symmetrical") =>
351 {
352 Changer::Ratio
353 }
354 Some(t) if t.eq_ignore_ascii_case("ideal") => Changer::Ideal,
355 Some(_) => Changer::Tabular,
356 None => Changer::Inactive,
357 }
358 } else if row.bool_or("tap_phase_shifter", false) {
359 Changer::Ideal
360 } else {
361 Changer::Ratio
362 };
363 let mut tap_factor_hv = 1.0;
364 let mut tap_factor_lv = 1.0;
365 let mut shift = row.f_or("shift_degree", 0.0);
366 let direction = if lv_side { -1.0 } else { 1.0 };
367 match changer {
368 Changer::Ratio => {
369 let du = diff * step_percent / 100.0;
370 let th = step_degree.to_radians();
371 let mag = (1.0 + du * th.cos()).hypot(du * th.sin());
372 shift += (direction * du * th.sin())
373 .atan2(1.0 + du * th.cos())
374 .to_degrees();
375 if lv_side {
376 tap_factor_lv = mag;
377 } else {
378 tap_factor_hv = mag;
379 }
380 }
381 Changer::Ideal => {
382 shift += if step_degree == 0.0 {
384 direction * 2.0 * (diff * step_percent / 200.0).asin().to_degrees()
385 } else {
386 direction * diff * step_degree
387 };
388 }
389 Changer::Inactive => {}
390 Changer::Tabular => tabular_rows += 1,
391 }
392 let nominal = if vn_hv > 0.0 && vn_lv > 0.0 && v_bus_hv > 0.0 && v_bus_lv > 0.0 {
395 (vn_hv / v_bus_hv) / (vn_lv / v_bus_lv)
396 } else {
397 1.0
398 };
399 let tap = nominal * tap_factor_hv / tap_factor_lv;
400 let z_corr = tap_factor_lv.powi(2)
401 * if vn_lv > 0.0 && v_bus_lv > 0.0 {
402 (vn_lv / v_bus_lv).powi(2)
403 } else {
404 1.0
405 };
406
407 let r = row.f_or("vkr_percent", 0.0) * base_mva / (sn * 100.0) * z_corr;
408 let z = row.f_or("vk_percent", 0.0).abs() * base_mva / (sn * 100.0) * z_corr;
409 let x = (z * z - r * r).max(0.0).sqrt() * row.f_or("vk_percent", 0.0).signum();
410 branches.push(Branch {
411 from,
412 to,
413 r: r / par,
414 x: x / par,
415 b: 0.0,
416 rate_a: sn * par,
417 rate_b: 0.0,
418 rate_c: 0.0,
419 tap,
420 shift,
421 in_service: row.bool_or("in_service", true),
422 angmin: -360.0,
423 angmax: 360.0,
424 extras: Extras::default(),
425 });
426 }
427 if mag_rows > 0 {
428 warnings.push(format!(
429 "`trafo`: i0_percent/pfe_kw nonzero on {mag_rows} rows; the magnetizing branch is not representable and was ignored"
430 ));
431 }
432 if tabular_rows > 0 {
433 warnings.push(format!(
434 "`trafo`: {tabular_rows} row(s) have a tabular or unrecognized tap changer; those taps were ignored"
435 ));
436 }
437 }
438
439 let mut storage = Vec::new();
440 if let Some(storage_frame) = read_frame(obj, "storage")? {
441 for row in storage_frame.rows() {
442 let bus = bus_ref("storage", &row, "bus", &bus_of_pp)?;
443 let scale = row.f_or("scaling", 1.0);
444 let ps = row.f_or("p_mw", 0.0) * scale;
446 let qs = row.f_or("q_mvar", 0.0) * scale;
447 let min_e = row.f_or("min_e_mwh", 0.0);
448 let max_e = row.f_or("max_e_mwh", 0.0);
449 let charge_rating = row.f_finite("max_p_mw").unwrap_or_else(|| ps.abs());
450 let discharge_rating = row.f_finite("min_p_mw").map_or(ps.abs(), |v| (-v).max(0.0));
451 storage.push(Storage {
452 bus,
453 ps,
454 qs,
455 energy: min_e + (max_e - min_e) * row.f_or("soc_percent", 0.0) / 100.0,
456 energy_rating: max_e,
457 charge_rating,
458 discharge_rating,
459 charge_efficiency: 1.0,
460 discharge_efficiency: 1.0,
461 thermal_rating: row
462 .f_finite("sn_mva")
463 .unwrap_or_else(|| charge_rating.max(discharge_rating)),
464 qmin: row.f_or("min_q_mvar", f64::NEG_INFINITY),
465 qmax: row.f_or("max_q_mvar", f64::INFINITY),
466 r: 0.0,
467 x: 0.0,
468 p_loss: 0.0,
469 q_loss: 0.0,
470 in_service: row.bool_or("in_service", true),
471 extras: Extras::default(),
472 });
473 }
474 }
475
476 let mut hvdc = Vec::new();
477 if let Some(dcline_frame) = read_frame(obj, "dcline")? {
478 for row in dcline_frame.rows() {
479 let from = bus_ref("dcline", &row, "from_bus", &bus_of_pp)?;
480 let to = bus_ref("dcline", &row, "to_bus", &bus_of_pp)?;
481 let pf = row.f_or("p_mw", 0.0);
482 let loss_mw = row.f_or("loss_mw", 0.0);
483 let loss_percent = row.f_or("loss_percent", 0.0);
484 hvdc.push(Hvdc {
485 from,
486 to,
487 in_service: row.bool_or("in_service", true),
488 pf,
489 pt: pf - loss_mw - pf * loss_percent / 100.0,
491 qf: 0.0,
492 qt: 0.0,
493 vf: row.f_or("vm_from_pu", 1.0),
494 vt: row.f_or("vm_to_pu", 1.0),
495 pmin: 0.0,
496 pmax: row.f_or("max_p_mw", f64::INFINITY),
497 qminf: row.f_or("min_q_from_mvar", f64::NEG_INFINITY),
498 qmaxf: row.f_or("max_q_from_mvar", f64::INFINITY),
499 qmint: row.f_or("min_q_to_mvar", f64::NEG_INFINITY),
500 qmaxt: row.f_or("max_q_to_mvar", f64::INFINITY),
501 loss0: loss_mw,
502 loss1: loss_percent / 100.0,
503 extras: Extras::default(),
504 });
505 }
506 }
507
508 warn_nonempty_table(
509 obj,
510 "trafo3w",
511 "three winding transformers are not mapped",
512 warnings,
513 )?;
514 warn_nonempty_table(obj, "ward", "Ward equivalents are not mapped", warnings)?;
515 warn_nonempty_table(
516 obj,
517 "xward",
518 "extended Ward equivalents are not mapped",
519 warnings,
520 )?;
521 warn_nonempty_table(
522 obj,
523 "impedance",
524 "bus-to-bus impedance elements are not mapped",
525 warnings,
526 )?;
527 warn_nonempty_table(obj, "motor", "motors are not mapped", warnings)?;
528 warn_nonempty_table(
529 obj,
530 "switch",
531 "switches are not modeled; open switches are not applied",
532 warnings,
533 )?;
534 warn_nonempty_table(obj, "pwl_cost", "piecewise costs are not mapped", warnings)?;
535
536 for key in obj.keys() {
541 if HANDLED_TABLES.contains(&key.as_str()) {
542 continue;
543 }
544 let looks_like_frame = obj
545 .get(key)
546 .and_then(Value::as_object)
547 .is_some_and(|m| m.get("_class").and_then(Value::as_str) == Some("DataFrame"));
548 if !looks_like_frame {
549 continue;
550 }
551 if let Ok(Some(frame)) = read_frame(obj, key) {
552 if !frame.data.is_empty() {
553 warnings.push(format!(
554 "`{key}` table ignored ({} rows): not mapped",
555 frame.data.len()
556 ));
557 }
558 }
559 }
560
561 let net = Network {
562 name,
563 base_mva,
564 buses,
565 loads,
566 shunts,
567 branches,
568 generators,
569 storage,
570 hvdc,
571 source_format: SourceFormat::PandapowerJson,
572 source: Some(source),
573 };
574 net.check_references(FMT)?;
575 Ok(net)
576}
577
578const HANDLED_TABLES: [&str; 18] = [
581 "bus",
582 "load",
583 "sgen",
584 "shunt",
585 "gen",
586 "ext_grid",
587 "line",
588 "trafo",
589 "storage",
590 "dcline",
591 "poly_cost",
592 "trafo3w",
593 "ward",
594 "xward",
595 "impedance",
596 "motor",
597 "switch",
598 "pwl_cost",
599];
600
601enum Changer {
606 Inactive,
607 Ratio,
608 Ideal,
609 Tabular,
610}
611
612fn parallel_or_one(row: &Row<'_>) -> f64 {
614 let par = row.f_or("parallel", 1.0);
615 if par <= 0.0 { 1.0 } else { par }
616}
617
618fn warn_nonempty_table(
619 obj: &Map<String, Value>,
620 name: &str,
621 reason: &str,
622 warnings: &mut Vec<String>,
623) -> Result<()> {
624 if let Some(frame) = read_frame(obj, name)? {
625 if !frame.data.is_empty() {
626 warnings.push(format!(
627 "`{name}` table ignored ({} rows): {reason}",
628 frame.data.len()
629 ));
630 }
631 }
632 Ok(())
633}
634
635#[must_use]
636pub fn write_pandapower_json(net: &Network) -> Conversion {
637 if net.source_format == SourceFormat::PandapowerJson {
638 if let Some(source) = &net.source {
639 return Conversion {
640 text: source.to_string(),
641 warnings: Vec::new(),
642 };
643 }
644 }
645
646 let mut warnings = Vec::new();
647 if !net.hvdc.is_empty() {
648 warnings.push(format!(
649 "{} dcline(s) dropped: the pandapower JSON writer does not model HVDC",
650 net.hvdc.len()
651 ));
652 }
653 if !net.storage.is_empty() {
654 warnings.push(format!(
655 "{} storage unit(s) dropped: the pandapower JSON writer does not model storage",
656 net.storage.len()
657 ));
658 }
659 let with_caps = net.generators.iter().filter(|g| g.has_caps()).count();
660 if with_caps > 0 {
661 warnings.push(format!("generator capability/ramp columns dropped for {with_caps} generator(s): pandapower gen tables have no MATPOWER capability columns"));
662 }
663 let constrained = net.branches.iter().filter(|b| b.has_angle_limits()).count();
664 if constrained > 0 {
665 warnings.push(format!("{constrained} branch angle limit(s) dropped: pandapower line/trafo tables do not carry MATPOWER angle limits"));
666 }
667 let rate_bc = net
668 .branches
669 .iter()
670 .filter(|b| nonzero_differs(b.rate_b, b.rate_a) || nonzero_differs(b.rate_c, b.rate_a))
671 .count();
672 if rate_bc > 0 {
673 warnings.push(format!("{rate_bc} branch rate_b/rate_c value set(s) dropped: pandapower carries one loading limit"));
674 }
675 let no_kv = net.buses.iter().filter(|b| b.base_kv <= 0.0).count();
676 if no_kv > 0 {
677 warnings.push(format!(
678 "{no_kv} bus(es) carry no base_kv; written with vn_kv = 1 so pandapower's \
679 ohm-based model stays defined (per-unit impedances are preserved exactly)"
680 ));
681 }
682
683 let mut object = Map::new();
684 let kv_of: HashMap<BusId, f64> = net
687 .buses
688 .iter()
689 .map(|b| (b.id, written_kv(b.base_kv)))
690 .collect();
691 let (line, trafo, charging) = branch_frames(net, &kv_of, &mut warnings);
692 if !charging.is_empty() {
693 warnings.push(format!(
694 "{} transformer terminal charging shunt(s) written into `shunt`: pandapower's \
695 trafo magnetizing model is inductive only, so MATPOWER transformer line \
696 charging b rides as bus shunts (Y_bus exact)",
697 charging.len()
698 ));
699 }
700 object.insert("bus".into(), bus_frame(net, &mut warnings));
701 object.insert("load".into(), load_frame(net, &mut warnings));
702 object.insert(
703 "shunt".into(),
704 shunt_frame(net, &charging, &kv_of, &mut warnings),
705 );
706 object.insert("gen".into(), gen_frame(net, &mut warnings));
707 object.insert("ext_grid".into(), ext_grid_frame(net, &mut warnings));
708 object.insert("line".into(), line);
709 object.insert("trafo".into(), trafo);
710 object.insert("poly_cost".into(), poly_cost_frame(net, &mut warnings));
711 object.insert("name".into(), Value::String(net.name.clone()));
712 object.insert("f_hz".into(), jnum(F_HZ));
716 object.insert("sn_mva".into(), jnum(net.base_mva));
717 object.insert("version".into(), Value::String("3.0.0".into()));
718 object.insert("format_version".into(), Value::String("3.0.0".into()));
719
720 let mut root = Map::new();
721 root.insert(
722 "_module".into(),
723 Value::String("pandapower.auxiliary".into()),
724 );
725 root.insert("_class".into(), Value::String("pandapowerNet".into()));
726 root.insert("_object".into(), Value::Object(object));
727 finish(root, warnings)
728}
729
730fn bus_frame(net: &Network, warnings: &mut Vec<String>) -> Value {
731 let columns = [
732 "name",
733 "vn_kv",
734 "type",
735 "zone",
736 "in_service",
737 "geo",
738 "min_vm_pu",
739 "max_vm_pu",
740 ];
741 let mut index = Vec::with_capacity(net.buses.len());
742 let mut data = Vec::with_capacity(net.buses.len());
743 for b in &net.buses {
744 index.push(pp_bus(b.id));
745 data.push(vec![
746 b.name.clone().map_or(Value::Null, Value::String),
747 jnum(written_kv(b.base_kv)),
748 Value::String("b".into()),
749 Value::from(b.zone as u64),
750 Value::Bool(b.kind != BusType::Isolated),
751 Value::Null,
752 jnum(b.vmin),
753 jnum(b.vmax),
754 ]);
755 }
756 frame("bus", &columns, index, data, warnings)
757}
758
759fn load_frame(net: &Network, warnings: &mut Vec<String>) -> Value {
760 let columns = [
761 "name",
762 "bus",
763 "p_mw",
764 "q_mvar",
765 "const_z_percent",
769 "const_i_percent",
770 "const_z_p_percent",
771 "const_i_p_percent",
772 "const_z_q_percent",
773 "const_i_q_percent",
774 "sn_mva",
775 "scaling",
776 "in_service",
777 "type",
778 ];
779 let mut index = Vec::with_capacity(net.loads.len());
780 let mut data = Vec::with_capacity(net.loads.len());
781 for l in &net.loads {
782 index.push(Value::from(data.len() as u64));
783 data.push(vec![
784 Value::Null,
785 pp_bus(l.bus),
786 jnum(l.p),
787 jnum(l.q),
788 jnum(0.0),
789 jnum(0.0),
790 jnum(0.0),
791 jnum(0.0),
792 jnum(0.0),
793 jnum(0.0),
794 Value::Null,
795 jnum(1.0),
796 Value::Bool(l.in_service),
797 Value::String("wye".into()),
798 ]);
799 }
800 frame("load", &columns, index, data, warnings)
801}
802
803fn shunt_frame(
804 net: &Network,
805 charging: &[(BusId, f64, bool)],
806 kv_of: &HashMap<BusId, f64>,
807 warnings: &mut Vec<String>,
808) -> Value {
809 let columns = [
810 "bus",
811 "name",
812 "q_mvar",
813 "p_mw",
814 "vn_kv",
815 "step",
816 "max_step",
817 "in_service",
818 ];
819 let mut index = Vec::with_capacity(net.shunts.len());
820 let mut data = Vec::with_capacity(net.shunts.len());
821 for s in &net.shunts {
822 index.push(Value::from(data.len() as u64));
823 data.push(vec![
824 pp_bus(s.bus),
825 Value::Null,
826 jnum(-s.b),
827 jnum(s.g),
828 jnum(*kv_of.get(&s.bus).unwrap_or(&1.0)),
829 Value::from(1_u64),
830 Value::from(1_u64),
831 Value::Bool(s.in_service),
832 ]);
833 }
834 for (bus, b_half_pu, in_service) in charging {
835 index.push(Value::from(data.len() as u64));
836 data.push(vec![
837 pp_bus(*bus),
838 Value::String("trafo charging".into()),
839 jnum(-b_half_pu * net.base_mva),
840 jnum(0.0),
841 jnum(*kv_of.get(bus).unwrap_or(&1.0)),
842 Value::from(1_u64),
843 Value::from(1_u64),
844 Value::Bool(*in_service),
845 ]);
846 }
847 frame("shunt", &columns, index, data, warnings)
848}
849
850fn gen_frame(net: &Network, warnings: &mut Vec<String>) -> Value {
851 let columns = [
852 "name",
853 "bus",
854 "p_mw",
855 "vm_pu",
856 "sn_mva",
857 "min_q_mvar",
858 "max_q_mvar",
859 "scaling",
860 "slack",
861 "controllable",
862 "in_service",
863 "slack_weight",
864 "type",
865 "min_p_mw",
866 "max_p_mw",
867 ];
868 let bus_kind: HashMap<BusId, BusType> = net.buses.iter().map(|b| (b.id, b.kind)).collect();
869 let mut index = Vec::with_capacity(net.generators.len());
870 let mut data = Vec::with_capacity(net.generators.len());
871 for g in &net.generators {
872 index.push(Value::from(data.len() as u64));
873 data.push(vec![
874 Value::Null,
875 pp_bus(g.bus),
876 jnum(g.pg),
877 jnum(g.vg),
878 jnum(g.mbase),
879 jnum(g.qmin),
880 jnum(g.qmax),
881 jnum(1.0),
882 Value::Bool(bus_kind.get(&g.bus).copied() == Some(BusType::Ref)),
883 Value::Bool(true),
884 Value::Bool(g.in_service),
885 jnum(1.0),
886 Value::Null,
887 jnum(g.pmin),
888 jnum(g.pmax),
889 ]);
890 }
891 frame("gen", &columns, index, data, warnings)
892}
893
894#[allow(clippy::too_many_lines)] #[allow(clippy::type_complexity)]
899#[allow(clippy::float_cmp)]
902fn branch_frames(
903 net: &Network,
904 kv_of: &HashMap<BusId, f64>,
905 warnings: &mut Vec<String>,
906) -> (Value, Value, Vec<(BusId, f64, bool)>) {
907 let line_columns = [
908 "name",
909 "std_type",
910 "from_bus",
911 "to_bus",
912 "length_km",
913 "r_ohm_per_km",
914 "x_ohm_per_km",
915 "c_nf_per_km",
916 "g_us_per_km",
917 "max_i_ka",
918 "df",
919 "parallel",
920 "type",
921 "in_service",
922 "geo",
923 ];
924 let trafo_columns = [
925 "name",
926 "std_type",
927 "hv_bus",
928 "lv_bus",
929 "sn_mva",
930 "vn_hv_kv",
931 "vn_lv_kv",
932 "vk_percent",
933 "vkr_percent",
934 "pfe_kw",
935 "i0_percent",
936 "shift_degree",
937 "tap_side",
938 "tap_neutral",
939 "tap_step_percent",
940 "tap_step_degree",
941 "tap_pos",
942 "tap_changer_type",
945 "parallel",
946 "df",
947 "in_service",
948 ];
949 let mut line_index = Vec::new();
950 let mut line_data = Vec::new();
951 let mut trafo_index = Vec::new();
952 let mut trafo_data = Vec::new();
953 let mut charging = Vec::new();
954 for br in &net.branches {
955 let v_from = *kv_of.get(&br.from).unwrap_or(&1.0);
956 let v_to = *kv_of.get(&br.to).unwrap_or(&1.0);
957 let zb = zbase(v_from, net.base_mva);
962 if br.is_transformer() || v_from != v_to {
966 let sn = if br.rate_a > 0.0 {
967 br.rate_a
968 } else {
969 net.base_mva
970 };
971 let z = (br.r * br.r + br.x * br.x).sqrt();
972 let tap = br.effective_tap();
973 let tap_delta = tap - 1.0;
974 if br.b != 0.0 {
979 charging.push((br.from, br.b / 2.0 / (tap * tap), br.in_service));
980 charging.push((br.to, br.b / 2.0, br.in_service));
981 }
982 trafo_index.push(Value::from(trafo_data.len() as u64));
983 trafo_data.push(vec![
984 Value::Null,
985 Value::Null,
986 pp_bus(br.from),
987 pp_bus(br.to),
988 jnum(sn),
989 jnum(v_from),
990 jnum(v_to),
991 jnum(z * sn * 100.0 / net.base_mva),
992 jnum(br.r * sn * 100.0 / net.base_mva),
993 jnum(0.0),
994 jnum(0.0),
995 jnum(br.shift),
996 Value::String("hv".into()),
997 Value::from(0_i64),
998 jnum(tap_delta.abs() * 100.0),
999 jnum(0.0),
1000 jnum(tap_delta.signum()),
1001 Value::String("Ratio".into()),
1002 Value::from(1_u64),
1003 jnum(1.0),
1004 Value::Bool(br.in_service),
1005 ]);
1006 } else {
1007 line_index.push(Value::from(line_data.len() as u64));
1008 line_data.push(vec![
1009 Value::Null,
1010 Value::Null,
1011 pp_bus(br.from),
1012 pp_bus(br.to),
1013 jnum(1.0),
1014 jnum(br.r * zb),
1015 jnum(br.x * zb),
1016 jnum(br.b / zb / (2.0 * std::f64::consts::PI * F_HZ) * 1e9),
1017 jnum(0.0),
1018 jnum(if br.rate_a == 0.0 {
1019 0.0
1020 } else {
1021 br.rate_a / (v_from * 3.0_f64.sqrt())
1022 }),
1023 jnum(1.0),
1024 Value::from(1_u64),
1025 Value::Null,
1026 Value::Bool(br.in_service),
1027 Value::Null,
1028 ]);
1029 }
1030 }
1031 (
1032 frame("line", &line_columns, line_index, line_data, warnings),
1033 frame("trafo", &trafo_columns, trafo_index, trafo_data, warnings),
1034 charging,
1035 )
1036}
1037
1038fn ext_grid_frame(net: &Network, warnings: &mut Vec<String>) -> Value {
1039 let columns = [
1040 "name",
1041 "bus",
1042 "vm_pu",
1043 "va_degree",
1044 "slack_weight",
1045 "in_service",
1046 "controllable",
1047 ];
1048 let mut index = Vec::new();
1049 let mut data = Vec::new();
1050 for b in &net.buses {
1053 if b.kind != BusType::Ref || net.generators.iter().any(|g| g.bus == b.id) {
1054 continue;
1055 }
1056 index.push(Value::from(data.len() as u64));
1057 data.push(vec![
1058 b.name.clone().map_or(Value::Null, Value::String),
1059 pp_bus(b.id),
1060 jnum(b.vm),
1061 jnum(b.va),
1062 jnum(1.0),
1063 Value::Bool(true),
1064 Value::Bool(true),
1065 ]);
1066 }
1067 frame("ext_grid", &columns, index, data, warnings)
1068}
1069
1070fn poly_cost_frame(net: &Network, warnings: &mut Vec<String>) -> Value {
1071 let columns = [
1072 "element",
1073 "et",
1074 "cp0_eur",
1075 "cp1_eur_per_mw",
1076 "cp2_eur_per_mw2",
1077 "cq0_eur",
1078 "cq1_eur_per_mvar",
1079 "cq2_eur_per_mvar2",
1080 ];
1081 let mut index = Vec::new();
1082 let mut data = Vec::new();
1083 let mut dropped = 0_usize;
1084 let mut truncated = 0_usize;
1085 let mut empty = 0_usize;
1086 for (i, g) in net.generators.iter().enumerate() {
1087 let Some(cost) = &g.cost else {
1088 continue;
1089 };
1090 if cost.model != 2 {
1091 dropped += 1;
1092 continue;
1093 }
1094 let n = cost.coeffs.len();
1096 let (c2, c1, c0) = match n {
1097 0 => {
1098 empty += 1;
1099 (0.0, 0.0, 0.0)
1100 }
1101 1 => (0.0, 0.0, cost.coeffs[0]),
1102 2 => (0.0, cost.coeffs[0], cost.coeffs[1]),
1103 _ => {
1104 if n > 3 {
1105 truncated += 1;
1106 }
1107 (cost.coeffs[n - 3], cost.coeffs[n - 2], cost.coeffs[n - 1])
1108 }
1109 };
1110 index.push(Value::from(data.len() as u64));
1111 data.push(vec![
1112 Value::from(i as u64),
1113 Value::String("gen".into()),
1114 jnum(c0),
1115 jnum(c1),
1116 jnum(c2),
1117 jnum(0.0),
1118 jnum(0.0),
1119 jnum(0.0),
1120 ]);
1121 }
1122 if dropped > 0 {
1123 warnings.push(format!(
1124 "{dropped} generator costs dropped: pandapower poly_cost carries polynomial (model 2) costs only"
1125 ));
1126 }
1127 if truncated > 0 {
1128 warnings.push(format!(
1129 "{truncated} generator costs truncated to quadratic: poly_cost carries cp0/cp1/cp2 only"
1130 ));
1131 }
1132 if empty > 0 {
1133 warnings.push(format!(
1134 "{empty} generator costs had no coefficients and were written as zero"
1135 ));
1136 }
1137 frame("poly_cost", &columns, index, data, warnings)
1138}
1139
1140fn pp_bus(id: BusId) -> Value {
1143 Value::from(id.0.saturating_sub(1) as u64)
1144}
1145
1146#[allow(clippy::needless_pass_by_value)] fn frame(
1148 table: &str,
1149 columns: &[&str],
1150 index: Vec<Value>,
1151 data: Vec<Vec<Value>>,
1152 warnings: &mut Vec<String>,
1153) -> Value {
1154 let nonfinite: Vec<String> = columns
1160 .iter()
1161 .enumerate()
1162 .filter(|(_, c)| dtype_for(c) == "float64" && !(table == "load" && **c == "sn_mva"))
1163 .filter_map(|(ci, c)| {
1164 let n = data
1165 .iter()
1166 .filter(|row| row.get(ci) == Some(&Value::Null))
1167 .count();
1168 (n > 0).then(|| format!("`{c}` ({n})"))
1169 })
1170 .collect();
1171 if !nonfinite.is_empty() {
1172 warnings.push(format!(
1173 "`{table}`: non-finite value(s) written as null in column(s) {}; pandapower reads them as NaN",
1174 nonfinite.join(", ")
1175 ));
1176 }
1177 let inner = serde_json::json!({
1178 "columns": columns,
1179 "index": index,
1180 "data": data,
1181 });
1182 let dtype = columns
1183 .iter()
1184 .map(|c| ((*c).to_string(), Value::String(dtype_for(c).into())))
1185 .collect();
1186 let mut m = Map::new();
1187 m.insert("_module".into(), Value::String("pandas.core.frame".into()));
1188 m.insert("_class".into(), Value::String("DataFrame".into()));
1189 m.insert(
1190 "_object".into(),
1191 Value::String(serde_json::to_string(&inner).expect("frame inner serializes")),
1192 );
1193 m.insert("orient".into(), Value::String("split".into()));
1194 m.insert("dtype".into(), Value::Object(dtype));
1195 m.insert("is_multiindex".into(), Value::Bool(false));
1196 m.insert("is_multicolumn".into(), Value::Bool(false));
1197 Value::Object(m)
1198}
1199
1200fn dtype_for(column: &str) -> &'static str {
1201 match column {
1202 "bus" | "from_bus" | "to_bus" | "hv_bus" | "lv_bus" | "parallel" | "element" => "uint32",
1203 "in_service" | "slack" | "controllable" => "bool",
1204 "name" | "type" | "std_type" | "geo" | "et" | "tap_side" | "tap_changer_type" => "object",
1205 _ => "float64",
1206 }
1207}
1208
1209#[derive(Debug)]
1210struct DataFrame {
1211 name: String,
1213 columns: Vec<String>,
1214 index: Vec<Value>,
1215 data: Vec<Vec<Value>>,
1216}
1217
1218impl DataFrame {
1219 fn rows(&self) -> impl Iterator<Item = Row<'_>> {
1220 (0..self.data.len()).map(|i| Row { frame: self, i })
1221 }
1222 fn col(&self, key: &str) -> Option<usize> {
1223 self.columns.iter().position(|c| c == key)
1224 }
1225}
1226
1227struct Row<'a> {
1228 frame: &'a DataFrame,
1229 i: usize,
1230}
1231
1232impl Row<'_> {
1233 fn index_usize(&self) -> Result<usize> {
1238 let v = &self.frame.index[self.i];
1239 value_usize(v)
1240 .or_else(|| {
1241 v.as_f64()
1242 .filter(|f| f.fract() == 0.0 && *f >= 0.0 && *f < usize::MAX as f64)
1243 .map(|f| f as usize)
1244 })
1245 .filter(|&i| i < usize::MAX)
1246 .ok_or_else(|| {
1247 bad(format!(
1248 "`{}` row at position {}: index is not a non-negative integer (`{}`)",
1249 self.frame.name,
1250 self.i,
1251 value_repr(v)
1252 ))
1253 })
1254 }
1255 fn label(&self) -> String {
1258 match self.frame.index.get(self.i) {
1259 Some(Value::Number(n)) => n.to_string(),
1260 Some(Value::String(s)) => s.clone(),
1261 _ => format!("position {}", self.i),
1262 }
1263 }
1264 fn get(&self, key: &str) -> Option<&Value> {
1265 self.frame
1266 .col(key)
1267 .and_then(|c| self.frame.data.get(self.i).and_then(|r| r.get(c)))
1268 }
1269 fn f_or(&self, key: &str, default: f64) -> f64 {
1270 self.get(key).and_then(value_f64).unwrap_or(default)
1271 }
1272 fn req_f(&self, key: &str) -> Result<f64> {
1276 self.get(key).and_then(value_f64).ok_or_else(|| {
1277 bad(format!(
1278 "`{}` row {}: required column `{key}` is missing or not numeric",
1279 self.frame.name,
1280 self.label()
1281 ))
1282 })
1283 }
1284 fn f_finite(&self, key: &str) -> Option<f64> {
1285 self.get(key).and_then(value_f64).filter(|v| v.is_finite())
1286 }
1287 fn usize_or(&self, key: &str, default: usize) -> usize {
1288 self.get(key).and_then(value_usize).unwrap_or(default)
1289 }
1290 fn bool_or(&self, key: &str, default: bool) -> bool {
1291 self.get(key).and_then(value_bool).unwrap_or(default)
1292 }
1293 fn string(&self, key: &str) -> Option<String> {
1294 self.get(key)
1295 .and_then(Value::as_str)
1296 .filter(|s| !s.is_empty())
1297 .map(str::to_string)
1298 }
1299}
1300
1301fn read_frame(root: &Map<String, Value>, name: &str) -> Result<Option<DataFrame>> {
1302 let Some(v) = root.get(name) else {
1303 return Ok(None);
1304 };
1305 let obj = v
1306 .as_object()
1307 .ok_or_else(|| bad(format!("`{name}` table is not a DataFrame object")))?;
1308 if obj.get("is_multicolumn").and_then(Value::as_bool) == Some(true) {
1309 return Err(bad(format!(
1310 "`{name}` table: multi-column frames are unsupported"
1311 )));
1312 }
1313 let raw = obj
1314 .get("_object")
1315 .and_then(Value::as_str)
1316 .ok_or_else(|| bad(format!("`{name}` table missing string `_object`")))?;
1317 let inner: Value =
1318 serde_json::from_str(raw).map_err(|e| bad(format!("`{name}` table: {e}")))?;
1319 let inner = inner
1320 .as_object()
1321 .ok_or_else(|| bad(format!("`{name}` split payload is not an object")))?;
1322 let columns = inner
1323 .get("columns")
1324 .and_then(Value::as_array)
1325 .ok_or_else(|| bad(format!("`{name}` split payload missing columns")))?
1326 .iter()
1327 .map(|v| {
1328 v.as_str()
1329 .map(str::to_string)
1330 .ok_or_else(|| bad(format!("`{name}` table: column names must be strings")))
1331 })
1332 .collect::<Result<Vec<_>>>()?;
1333 let index = inner
1334 .get("index")
1335 .and_then(Value::as_array)
1336 .cloned()
1337 .unwrap_or_default();
1338 let raw_data = inner
1339 .get("data")
1340 .and_then(Value::as_array)
1341 .ok_or_else(|| bad(format!("`{name}` split payload missing data")))?;
1342 let mut data = Vec::with_capacity(raw_data.len());
1343 for (i, row) in raw_data.iter().enumerate() {
1344 data.push(
1345 row.as_array()
1346 .cloned()
1347 .ok_or_else(|| bad(format!("`{name}` table: row {i} is not an array")))?,
1348 );
1349 }
1350 if index.len() != data.len() {
1351 return Err(bad(format!(
1352 "`{name}` table: index length {} does not match data length {}",
1353 index.len(),
1354 data.len()
1355 )));
1356 }
1357 Ok(Some(DataFrame {
1358 name: name.to_string(),
1359 columns,
1360 index,
1361 data,
1362 }))
1363}
1364
1365#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
1370enum CostElement {
1371 Gen,
1372 ExtGrid,
1373 Sgen,
1374}
1375
1376impl CostElement {
1377 fn from_et(et: &str) -> Option<Self> {
1378 match et {
1379 "gen" => Some(Self::Gen),
1380 "ext_grid" => Some(Self::ExtGrid),
1381 "sgen" => Some(Self::Sgen),
1382 _ => None,
1383 }
1384 }
1385}
1386
1387fn read_poly_costs(
1388 root: &Map<String, Value>,
1389 warnings: &mut Vec<String>,
1390) -> Result<BTreeMap<(CostElement, usize), GenCost>> {
1391 let mut out = BTreeMap::new();
1392 let Some(frame) = read_frame(root, "poly_cost")? else {
1393 return Ok(out);
1394 };
1395 let mut cq_rows = 0_usize;
1396 let mut unmapped_rows = 0_usize;
1397 for row in frame.rows() {
1398 let et_raw = row.string("et").ok_or_else(|| {
1402 bad(format!(
1403 "`poly_cost` row {}: required column `et` is missing",
1404 row.label()
1405 ))
1406 })?;
1407 let element = row
1408 .get("element")
1409 .and_then(|v| {
1410 value_usize(v).or_else(|| {
1411 v.as_f64()
1412 .filter(|f| f.fract() == 0.0 && *f >= 0.0 && *f < usize::MAX as f64)
1413 .map(|f| f as usize)
1414 })
1415 })
1416 .ok_or_else(|| {
1417 bad(format!(
1418 "`poly_cost` row {}: required column `element` is missing or not a non-negative integer",
1419 row.label()
1420 ))
1421 })?;
1422 let Some(et) = CostElement::from_et(&et_raw) else {
1423 unmapped_rows += 1;
1424 continue;
1425 };
1426 if row.f_or("cq2_eur_per_mvar2", 0.0) != 0.0
1427 || row.f_or("cq1_eur_per_mvar", 0.0) != 0.0
1428 || row.f_or("cq0_eur", 0.0) != 0.0
1429 {
1430 cq_rows += 1;
1431 }
1432 let previous = out.insert(
1433 (et, element),
1434 GenCost {
1435 model: 2,
1436 startup: 0.0,
1437 shutdown: 0.0,
1438 ncost: 3,
1439 coeffs: vec![
1440 row.f_or("cp2_eur_per_mw2", 0.0),
1441 row.f_or("cp1_eur_per_mw", 0.0),
1442 row.f_or("cp0_eur", 0.0),
1443 ],
1444 },
1445 );
1446 if previous.is_some() {
1447 return Err(bad(format!(
1448 "`poly_cost` row {}: duplicate cost for et `{et_raw}` element {element}",
1449 row.label()
1450 )));
1451 }
1452 }
1453 if cq_rows > 0 {
1454 warnings.push(format!(
1455 "`poly_cost`: reactive cost coefficients (cq*) nonzero on {cq_rows} rows; only active power costs are read"
1456 ));
1457 }
1458 if unmapped_rows > 0 {
1459 warnings.push(format!(
1460 "`poly_cost`: {unmapped_rows} row(s) skipped; only gen/ext_grid/sgen costs map onto powerio generators"
1461 ));
1462 }
1463 Ok(out)
1464}
1465
1466fn bus_ref(
1470 table: &str,
1471 row: &Row<'_>,
1472 key: &str,
1473 bus_of_pp: &HashMap<usize, BusId>,
1474) -> Result<BusId> {
1475 let label = row.label();
1476 let cell = match row.get(key) {
1477 None | Some(Value::Null) => {
1478 return Err(bad(format!(
1479 "`{table}` row {label}: missing bus reference `{key}`"
1480 )));
1481 }
1482 Some(v) => v,
1483 };
1484 let idx = decode_bus_index(cell).map_err(|e| match e {
1485 BusRefError::Negative => bad(format!(
1486 "`{table}` row {label}: bus reference `{key}` is negative ({})",
1487 value_repr(cell)
1488 )),
1489 BusRefError::NotInteger => bad(format!(
1490 "`{table}` row {label}: bus reference `{key}` is not an integer (`{}`)",
1491 value_repr(cell)
1492 )),
1493 })?;
1494 bus_of_pp.get(&idx).copied().ok_or_else(|| {
1495 bad(format!(
1496 "`{table}` row {label}: bus reference `{key}` points to unknown bus {idx}"
1497 ))
1498 })
1499}
1500
1501enum BusRefError {
1502 Negative,
1503 NotInteger,
1504}
1505
1506fn decode_bus_index(v: &Value) -> std::result::Result<usize, BusRefError> {
1507 fn from_f64(f: f64) -> std::result::Result<usize, BusRefError> {
1508 if f.fract() != 0.0 || !f.is_finite() {
1509 Err(BusRefError::NotInteger)
1510 } else if f < 0.0 {
1511 Err(BusRefError::Negative)
1512 } else {
1513 Ok(f as usize)
1514 }
1515 }
1516 match v {
1517 Value::Number(n) => {
1518 if let Some(u) = n.as_u64() {
1519 Ok(u as usize)
1520 } else if n.as_i64().is_some() {
1521 Err(BusRefError::Negative)
1523 } else {
1524 from_f64(n.as_f64().ok_or(BusRefError::NotInteger)?)
1525 }
1526 }
1527 Value::String(s) => {
1528 let s = s.trim();
1529 if let Ok(u) = s.parse::<u64>() {
1530 Ok(u as usize)
1531 } else if s.parse::<i64>().is_ok() {
1532 Err(BusRefError::Negative)
1533 } else {
1534 from_f64(s.parse::<f64>().map_err(|_| BusRefError::NotInteger)?)
1535 }
1536 }
1537 _ => Err(BusRefError::NotInteger),
1538 }
1539}
1540
1541fn value_repr(v: &Value) -> String {
1544 match v {
1545 Value::String(s) => s.clone(),
1546 other => other.to_string(),
1547 }
1548}
1549
1550fn written_kv(base_kv: f64) -> f64 {
1556 if base_kv > 0.0 { base_kv } else { 1.0 }
1557}
1558
1559fn value_f64(v: &Value) -> Option<f64> {
1560 match v {
1561 Value::Number(_) => v.as_f64(),
1562 Value::String(s) => s.parse().ok(),
1563 _ => None,
1564 }
1565}
1566
1567fn value_usize(v: &Value) -> Option<usize> {
1568 match v {
1569 Value::Number(_) => v.as_u64().map(|x| x as usize),
1570 Value::String(s) => s.parse().ok(),
1571 _ => None,
1572 }
1573}
1574
1575fn value_bool(v: &Value) -> Option<bool> {
1576 match v {
1577 Value::Bool(b) => Some(*b),
1578 Value::Number(_) => v.as_f64().map(|x| x != 0.0),
1579 Value::String(s) => match s.to_ascii_lowercase().as_str() {
1580 "true" => Some(true),
1581 "false" => Some(false),
1582 _ => None,
1583 },
1584 _ => None,
1585 }
1586}
1587
1588fn bad(message: impl Into<String>) -> Error {
1589 Error::FormatRead {
1590 format: FMT,
1591 message: message.into(),
1592 }
1593}
1594
1595#[cfg(test)]
1596#[allow(clippy::float_cmp, clippy::needless_pass_by_value)]
1600mod tests {
1601 use super::*;
1602 use serde_json::json;
1603
1604 fn pp_frame_raw(columns: Value, index: Value, data: Value) -> Value {
1606 let inner = json!({ "columns": columns, "index": index, "data": data });
1607 json!({
1608 "_module": "pandas.core.frame",
1609 "_class": "DataFrame",
1610 "_object": serde_json::to_string(&inner).unwrap(),
1611 "orient": "split",
1612 "dtype": {},
1613 "is_multiindex": false,
1614 "is_multicolumn": false,
1615 })
1616 }
1617
1618 fn pp_frame(columns: &[&str], index: Value, data: Value) -> Value {
1619 pp_frame_raw(json!(columns), index, data)
1620 }
1621
1622 fn pp_net(tables: Vec<(&str, Value)>) -> String {
1623 let mut object = Map::new();
1624 object.insert("sn_mva".into(), json!(100.0));
1625 object.insert("f_hz".into(), json!(50.0));
1626 for (name, frame) in tables {
1627 object.insert(name.into(), frame);
1628 }
1629 serde_json::to_string(&json!({
1630 "_module": "pandapower.auxiliary",
1631 "_class": "pandapowerNet",
1632 "_object": object,
1633 }))
1634 .unwrap()
1635 }
1636
1637 fn bus_table(indices: Value) -> (&'static str, Value) {
1639 let n = indices.as_array().unwrap().len();
1640 let data: Vec<Value> = (0..n).map(|_| json!([null, 110.0, true])).collect();
1641 (
1642 "bus",
1643 pp_frame(&["name", "vn_kv", "in_service"], indices, json!(data)),
1644 )
1645 }
1646
1647 fn err(text: &str) -> String {
1648 parse_pandapower_json(text).unwrap_err().to_string()
1649 }
1650
1651 #[test]
1652 fn bus_ids_shift_pandas_index_by_one() {
1653 let parsed = parse_pandapower_json(&pp_net(vec![bus_table(json!([0, 1, 2]))])).unwrap();
1654 let ids: Vec<usize> = parsed.network.buses.iter().map(|b| b.id.0).collect();
1655 assert_eq!(ids, vec![1, 2, 3]);
1656 }
1657
1658 #[test]
1659 fn top_level_object_may_be_json_encoded_string() {
1660 let mut root: Value =
1661 serde_json::from_str(&pp_net(vec![bus_table(json!([0, 1]))])).unwrap();
1662 let object = root.as_object_mut().unwrap().remove("_object").unwrap();
1663 root.as_object_mut().unwrap().insert(
1664 "_object".into(),
1665 Value::String(serde_json::to_string(&object).unwrap()),
1666 );
1667
1668 let parsed = parse_pandapower_json(&root.to_string()).unwrap();
1669
1670 assert_eq!(parsed.network.buses.len(), 2);
1671 }
1672
1673 #[test]
1674 fn duplicate_bus_index_errors() {
1675 let msg = err(&pp_net(vec![bus_table(json!([0, 0]))]));
1676 assert!(msg.contains("`bus` table: duplicate index 0"), "{msg}");
1677 }
1678
1679 #[test]
1680 fn bus_index_must_be_non_negative_integer() {
1681 let msg = err(&pp_net(vec![bus_table(json!(["x"]))]));
1682 assert!(
1683 msg.contains("`bus` row at position 0: index is not a non-negative integer (`x`)"),
1684 "{msg}"
1685 );
1686 }
1687
1688 fn load_with_bus(bus: Value) -> Vec<(&'static str, Value)> {
1689 vec![
1690 bus_table(json!([0, 1])),
1691 (
1692 "load",
1693 pp_frame(&["bus", "p_mw"], json!([0]), json!([[bus, 1.0]])),
1694 ),
1695 ]
1696 }
1697
1698 #[test]
1699 fn bus_missing_vn_kv_is_an_error() {
1700 let msg = err(&pp_net(vec![(
1702 "bus",
1703 pp_frame(&["name", "in_service"], json!([0]), json!([[null, true]])),
1704 )]));
1705 assert!(
1706 msg.contains("`bus` row 0: required column `vn_kv` is missing or not numeric"),
1707 "{msg}"
1708 );
1709 let msg = err(&pp_net(vec![(
1710 "bus",
1711 pp_frame(&["vn_kv", "in_service"], json!([0]), json!([[null, true]])),
1712 )]));
1713 assert!(
1714 msg.contains("`bus` row 0: required column `vn_kv` is missing or not numeric"),
1715 "{msg}"
1716 );
1717 }
1718
1719 #[test]
1720 fn bus_ref_missing_column() {
1721 let msg = err(&pp_net(vec![
1722 bus_table(json!([0])),
1723 ("load", pp_frame(&["p_mw"], json!([0]), json!([[1.0]]))),
1724 ]));
1725 assert!(
1726 msg.contains("`load` row 0: missing bus reference `bus`"),
1727 "{msg}"
1728 );
1729 }
1730
1731 #[test]
1732 fn bus_ref_null_cell() {
1733 let msg = err(&pp_net(load_with_bus(json!(null))));
1734 assert!(
1735 msg.contains("`load` row 0: missing bus reference `bus`"),
1736 "{msg}"
1737 );
1738 }
1739
1740 #[test]
1741 fn bus_ref_negative() {
1742 let msg = err(&pp_net(load_with_bus(json!(-1))));
1743 assert!(
1744 msg.contains("`load` row 0: bus reference `bus` is negative (-1)"),
1745 "{msg}"
1746 );
1747 }
1748
1749 #[test]
1750 fn bus_ref_fractional() {
1751 let msg = err(&pp_net(load_with_bus(json!(1.5))));
1752 assert!(
1753 msg.contains("`load` row 0: bus reference `bus` is not an integer (`1.5`)"),
1754 "{msg}"
1755 );
1756 }
1757
1758 #[test]
1759 fn bus_ref_unparsable_string() {
1760 let msg = err(&pp_net(load_with_bus(json!("abc"))));
1761 assert!(
1762 msg.contains("`load` row 0: bus reference `bus` is not an integer (`abc`)"),
1763 "{msg}"
1764 );
1765 }
1766
1767 #[test]
1768 fn bus_ref_unknown_bus() {
1769 let msg = err(&pp_net(load_with_bus(json!(7))));
1770 assert!(
1771 msg.contains("`load` row 0: bus reference `bus` points to unknown bus 7"),
1772 "{msg}"
1773 );
1774 }
1775
1776 #[test]
1777 fn bus_ref_accepts_float_encoded_integer() {
1778 let parsed = parse_pandapower_json(&pp_net(load_with_bus(json!(1.0)))).unwrap();
1779 assert_eq!(parsed.network.loads[0].bus, BusId(2));
1780 }
1781
1782 #[test]
1783 fn read_frame_rejects_non_string_columns() {
1784 let frame = pp_frame_raw(json!([1, 2]), json!([0]), json!([[1.0, 2.0]]));
1785 let msg = err(&pp_net(vec![("bus", frame)]));
1786 assert!(
1787 msg.contains("`bus` table: column names must be strings"),
1788 "{msg}"
1789 );
1790 }
1791
1792 #[test]
1793 fn read_frame_rejects_multicolumn() {
1794 let (_, mut frame) = bus_table(json!([0]));
1795 frame["is_multicolumn"] = json!(true);
1796 let msg = err(&pp_net(vec![("bus", frame)]));
1797 assert!(
1798 msg.contains("`bus` table: multi-column frames are unsupported"),
1799 "{msg}"
1800 );
1801 }
1802
1803 #[test]
1804 fn read_frame_rejects_non_array_row() {
1805 let frame = pp_frame(&["vn_kv"], json!([0]), json!([42]));
1806 let msg = err(&pp_net(vec![("bus", frame)]));
1807 assert!(msg.contains("`bus` table: row 0 is not an array"), "{msg}");
1808 }
1809
1810 #[test]
1811 fn read_frame_rejects_index_data_length_mismatch() {
1812 let frame = pp_frame(&["vn_kv"], json!([0]), json!([[110.0], [110.0]]));
1813 let msg = err(&pp_net(vec![("bus", frame)]));
1814 assert!(
1815 msg.contains("`bus` table: index length 1 does not match data length 2"),
1816 "{msg}"
1817 );
1818 }
1819
1820 #[test]
1821 fn sgen_reads_as_pq_generator() {
1822 let parsed = parse_pandapower_json(&pp_net(vec![
1823 bus_table(json!([0])),
1824 (
1825 "sgen",
1826 pp_frame(
1827 &["bus", "p_mw", "q_mvar", "scaling", "in_service"],
1828 json!([0]),
1829 json!([[0, 10.0, 2.0, 0.5, true]]),
1830 ),
1831 ),
1832 ]))
1833 .unwrap();
1834 let net = &parsed.network;
1835 assert_eq!(net.generators.len(), 1);
1836 let g = &net.generators[0];
1837 assert_eq!(g.bus, BusId(1));
1838 assert_eq!(g.pg, 5.0);
1839 assert_eq!(g.qg, 1.0);
1840 assert_eq!(g.pmax, 10.0);
1841 assert_eq!(g.pmin, 0.0);
1842 assert_eq!(g.qmax, f64::INFINITY);
1843 assert_eq!(g.qmin, f64::NEG_INFINITY);
1844 assert_eq!(g.vg, 1.0);
1845 assert_eq!(g.mbase, 100.0);
1846 assert_eq!(net.buses[0].kind, BusType::Pq);
1848 }
1849
1850 #[test]
1851 fn storage_maps_soc_and_ratings() {
1852 let parsed = parse_pandapower_json(&pp_net(vec![
1853 bus_table(json!([0])),
1854 (
1855 "storage",
1856 pp_frame(
1857 &[
1858 "bus",
1859 "p_mw",
1860 "q_mvar",
1861 "scaling",
1862 "min_e_mwh",
1863 "max_e_mwh",
1864 "soc_percent",
1865 "max_p_mw",
1866 "min_p_mw",
1867 "sn_mva",
1868 "min_q_mvar",
1869 "max_q_mvar",
1870 "in_service",
1871 ],
1872 json!([0]),
1873 json!([[
1874 0, 2.0, 0.5, 1.0, 10.0, 50.0, 25.0, 4.0, -3.0, 6.0, -1.0, 1.0, true
1875 ]]),
1876 ),
1877 ),
1878 ]))
1879 .unwrap();
1880 let st = &parsed.network.storage[0];
1881 assert_eq!(st.bus, BusId(1));
1882 assert_eq!(st.ps, 2.0);
1883 assert_eq!(st.qs, 0.5);
1884 assert_eq!(st.energy, 10.0 + (50.0 - 10.0) * 25.0 / 100.0);
1885 assert_eq!(st.energy_rating, 50.0);
1886 assert_eq!(st.charge_rating, 4.0);
1887 assert_eq!(st.discharge_rating, 3.0);
1888 assert_eq!(st.thermal_rating, 6.0);
1889 assert_eq!(st.qmin, -1.0);
1890 assert_eq!(st.qmax, 1.0);
1891 assert_eq!(st.charge_efficiency, 1.0);
1892 assert_eq!(st.discharge_efficiency, 1.0);
1893 assert_eq!(st.r, 0.0);
1894 assert_eq!(st.x, 0.0);
1895 }
1896
1897 #[test]
1898 fn storage_rating_fallbacks() {
1899 let parsed = parse_pandapower_json(&pp_net(vec![
1900 bus_table(json!([0])),
1901 (
1902 "storage",
1903 pp_frame(
1904 &["bus", "p_mw", "max_e_mwh"],
1905 json!([0]),
1906 json!([[0, -2.5, 8.0]]),
1907 ),
1908 ),
1909 ]))
1910 .unwrap();
1911 let st = &parsed.network.storage[0];
1912 assert_eq!(st.charge_rating, 2.5);
1913 assert_eq!(st.discharge_rating, 2.5);
1914 assert_eq!(st.thermal_rating, 2.5);
1915 assert_eq!(st.energy, 8.0 * 0.0 / 100.0);
1916 }
1917
1918 #[test]
1919 fn dcline_maps_to_hvdc() {
1920 let parsed = parse_pandapower_json(&pp_net(vec![
1921 bus_table(json!([0, 1])),
1922 (
1923 "dcline",
1924 pp_frame(
1925 &[
1926 "from_bus",
1927 "to_bus",
1928 "p_mw",
1929 "loss_mw",
1930 "loss_percent",
1931 "vm_from_pu",
1932 "vm_to_pu",
1933 "max_p_mw",
1934 "min_q_from_mvar",
1935 "max_q_from_mvar",
1936 "min_q_to_mvar",
1937 "max_q_to_mvar",
1938 "in_service",
1939 ],
1940 json!([0]),
1941 json!([[
1942 0, 1, 2.0, 0.05, 1.0, 1.01, 1.0, 3.0, -1.0, 1.0, -2.0, 2.0, true
1943 ]]),
1944 ),
1945 ),
1946 ]))
1947 .unwrap();
1948 let d = &parsed.network.hvdc[0];
1949 assert_eq!(d.from, BusId(1));
1950 assert_eq!(d.to, BusId(2));
1951 assert_eq!(d.pf, 2.0);
1952 assert_eq!(d.pt, 2.0 - 0.05 - 2.0 * 1.0 / 100.0);
1953 assert_eq!(d.loss0, 0.05);
1954 assert_eq!(d.loss1, 0.01);
1955 assert_eq!(d.vf, 1.01);
1956 assert_eq!(d.vt, 1.0);
1957 assert_eq!(d.pmin, 0.0);
1958 assert_eq!(d.pmax, 3.0);
1959 assert_eq!((d.qminf, d.qmaxf), (-1.0, 1.0));
1960 assert_eq!((d.qmint, d.qmaxt), (-2.0, 2.0));
1961 assert_eq!((d.qf, d.qt), (0.0, 0.0));
1962 }
1963
1964 #[test]
1965 fn dcline_defaults() {
1966 let parsed = parse_pandapower_json(&pp_net(vec![
1967 bus_table(json!([0, 1])),
1968 (
1969 "dcline",
1970 pp_frame(
1971 &["from_bus", "to_bus", "p_mw"],
1972 json!([0]),
1973 json!([[0, 1, 5.0]]),
1974 ),
1975 ),
1976 ]))
1977 .unwrap();
1978 let d = &parsed.network.hvdc[0];
1979 assert_eq!(d.pt, 5.0);
1980 assert_eq!((d.vf, d.vt), (1.0, 1.0));
1981 assert_eq!(d.pmax, f64::INFINITY);
1982 assert_eq!(d.qminf, f64::NEG_INFINITY);
1983 assert_eq!(d.qmaxt, f64::INFINITY);
1984 assert!(d.in_service);
1985 }
1986
1987 #[test]
1988 fn line_parallel_scales_impedance_and_rating() {
1989 let parsed = parse_pandapower_json(&pp_net(vec![
1990 bus_table(json!([0, 1])),
1991 (
1992 "line",
1993 pp_frame(
1994 &[
1995 "from_bus",
1996 "to_bus",
1997 "length_km",
1998 "r_ohm_per_km",
1999 "x_ohm_per_km",
2000 "c_nf_per_km",
2001 "max_i_ka",
2002 "parallel",
2003 ],
2004 json!([0]),
2005 json!([[0, 1, 4.0, 1.0, 2.0, 100.0, 0.5, 2.0]]),
2006 ),
2007 ),
2008 ]))
2009 .unwrap();
2010 let br = &parsed.network.branches[0];
2013 let zb = 110.0 * 110.0 / 100.0;
2014 assert!((br.r - 1.0 * 4.0 / zb / 2.0).abs() < 1e-12);
2015 assert!((br.x - 2.0 * 4.0 / zb / 2.0).abs() < 1e-12);
2016 let b = 100.0e-9 * 4.0 * 2.0 * std::f64::consts::PI * 50.0 * zb * 2.0;
2017 assert!((br.b - b).abs() < 1e-12);
2018 assert!((br.rate_a - 0.5 * 110.0 * 3.0_f64.sqrt() * 2.0).abs() < 1e-9);
2019 }
2020
2021 fn trafo_net(columns: &[&str], row: Value) -> String {
2022 pp_net(vec![
2023 bus_table(json!([0, 1])),
2024 ("trafo", pp_frame(columns, json!([0]), json!([row]))),
2025 ])
2026 }
2027
2028 #[test]
2029 fn trafo_parallel_scales_impedance_and_rating() {
2030 let parsed = parse_pandapower_json(&trafo_net(
2031 &[
2032 "hv_bus",
2033 "lv_bus",
2034 "sn_mva",
2035 "vk_percent",
2036 "vkr_percent",
2037 "parallel",
2038 ],
2039 json!([0, 1, 50.0, 10.0, 4.0, 2.0]),
2040 ))
2041 .unwrap();
2042 let br = &parsed.network.branches[0];
2043 let r0: f64 = 4.0 * 100.0 / (50.0 * 100.0);
2044 let z0: f64 = 10.0 * 100.0 / (50.0 * 100.0);
2045 let x0 = (z0 * z0 - r0 * r0).sqrt();
2046 assert!((br.r - r0 / 2.0).abs() < 1e-12);
2047 assert!((br.x - x0 / 2.0).abs() < 1e-12);
2048 assert_eq!(br.rate_a, 100.0);
2049 }
2050
2051 #[test]
2052 fn trafo_tap_uses_neutral_offset() {
2053 let parsed = parse_pandapower_json(&trafo_net(
2054 &[
2055 "hv_bus",
2056 "lv_bus",
2057 "vk_percent",
2058 "tap_neutral",
2059 "tap_pos",
2060 "tap_step_percent",
2061 ],
2062 json!([0, 1, 10.0, 1.0, 3.0, 2.0]),
2063 ))
2064 .unwrap();
2065 let br = &parsed.network.branches[0];
2066 assert!((br.tap - 1.04).abs() < 1e-12);
2067 }
2068
2069 #[test]
2070 fn trafo_without_tap_columns_keeps_tap_one() {
2071 let parsed = parse_pandapower_json(&trafo_net(
2072 &["hv_bus", "lv_bus", "vk_percent"],
2073 json!([0, 1, 10.0]),
2074 ))
2075 .unwrap();
2076 assert_eq!(parsed.network.branches[0].tap, 1.0);
2077 }
2078
2079 #[test]
2080 fn trafo_lv_tap_adjusts_ratio_and_impedance() {
2081 let parsed = parse_pandapower_json(&trafo_net(
2084 &[
2085 "hv_bus",
2086 "lv_bus",
2087 "vk_percent",
2088 "tap_side",
2089 "tap_pos",
2090 "tap_step_percent",
2091 ],
2092 json!([0, 1, 10.0, "LV", 3.0, 2.0]),
2093 ))
2094 .unwrap();
2095 let br = &parsed.network.branches[0];
2096 assert!((br.tap - 1.0 / 1.06).abs() < 1e-12);
2097 assert!((br.x - 0.1 * 1.06 * 1.06).abs() < 1e-12);
2098 assert!(
2099 !parsed.warnings.iter().any(|w| w.contains("tap")),
2100 "{:?}",
2101 parsed.warnings
2102 );
2103 }
2104
2105 const TAP_COLUMNS: [&str; 6] = [
2106 "hv_bus",
2107 "lv_bus",
2108 "vk_percent",
2109 "tap_pos",
2110 "tap_step_percent",
2111 "tap_changer_type",
2112 ];
2113
2114 #[test]
2115 fn trafo_null_tap_changer_type_deactivates_tap() {
2116 let parsed = parse_pandapower_json(&trafo_net(
2119 &TAP_COLUMNS,
2120 json!([0, 1, 10.0, 3.0, 2.0, null]),
2121 ))
2122 .unwrap();
2123 assert_eq!(parsed.network.branches[0].tap, 1.0);
2124 assert!(
2125 !parsed.warnings.iter().any(|w| w.contains("tap")),
2126 "{:?}",
2127 parsed.warnings
2128 );
2129 }
2130
2131 #[test]
2132 fn trafo_ratio_tap_changer_applies_tap() {
2133 let parsed = parse_pandapower_json(&trafo_net(
2134 &TAP_COLUMNS,
2135 json!([0, 1, 10.0, 3.0, 2.0, "Ratio"]),
2136 ))
2137 .unwrap();
2138 assert!((parsed.network.branches[0].tap - 1.06).abs() < 1e-12);
2139 }
2140
2141 #[test]
2142 fn trafo_ideal_tap_changer_becomes_phase_shift() {
2143 let parsed = parse_pandapower_json(&trafo_net(
2146 &TAP_COLUMNS,
2147 json!([0, 1, 10.0, 3.0, 2.0, "Ideal"]),
2148 ))
2149 .unwrap();
2150 let br = &parsed.network.branches[0];
2151 assert_eq!(br.tap, 1.0);
2152 let want = 2.0 * (3.0 * 2.0 / 200.0_f64).asin().to_degrees();
2153 assert!((br.shift - want).abs() < 1e-12, "{}", br.shift);
2154 }
2155
2156 #[test]
2157 fn trafo_ideal_tap_changer_with_degrees_shifts_by_step() {
2158 let parsed = parse_pandapower_json(&trafo_net(
2159 &[
2160 "hv_bus",
2161 "lv_bus",
2162 "vk_percent",
2163 "tap_pos",
2164 "tap_step_degree",
2165 "tap_changer_type",
2166 ],
2167 json!([0, 1, 10.0, 2.0, 1.5, "Ideal"]),
2168 ))
2169 .unwrap();
2170 let br = &parsed.network.branches[0];
2171 assert_eq!(br.tap, 1.0);
2172 assert!((br.shift - 3.0).abs() < 1e-12, "{}", br.shift);
2173 }
2174
2175 #[test]
2176 fn trafo_tap_phase_shifter_bool_becomes_phase_shift() {
2177 let parsed = parse_pandapower_json(&trafo_net(
2179 &[
2180 "hv_bus",
2181 "lv_bus",
2182 "vk_percent",
2183 "tap_pos",
2184 "tap_step_percent",
2185 "tap_phase_shifter",
2186 ],
2187 json!([0, 1, 10.0, 3.0, 2.0, true]),
2188 ))
2189 .unwrap();
2190 let br = &parsed.network.branches[0];
2191 assert_eq!(br.tap, 1.0);
2192 let want = 2.0 * (3.0 * 2.0 / 200.0_f64).asin().to_degrees();
2193 assert!((br.shift - want).abs() < 1e-12, "{}", br.shift);
2194 }
2195
2196 #[test]
2197 fn trafo_tabular_tap_changer_ignored_with_warning() {
2198 let parsed = parse_pandapower_json(&trafo_net(
2199 &TAP_COLUMNS,
2200 json!([0, 1, 10.0, 3.0, 2.0, "Tabular"]),
2201 ))
2202 .unwrap();
2203 assert_eq!(parsed.network.branches[0].tap, 1.0);
2204 assert!(
2205 parsed.warnings.iter().any(|w| w
2206 == "`trafo`: 1 row(s) have a tabular or unrecognized tap changer; those taps were ignored"),
2207 "{:?}",
2208 parsed.warnings
2209 );
2210 }
2211
2212 #[test]
2213 fn sixty_hz_file_scales_line_charging() {
2214 let mut object = Map::new();
2215 object.insert("sn_mva".into(), json!(100.0));
2216 object.insert("f_hz".into(), json!(60.0));
2217 let (k, v) = bus_table(json!([0, 1]));
2218 object.insert(k.into(), v);
2219 object.insert(
2220 "line".into(),
2221 pp_frame(
2222 &["from_bus", "to_bus", "c_nf_per_km", "length_km"],
2223 json!([0]),
2224 json!([[0, 1, 100.0, 1.0]]),
2225 ),
2226 );
2227 let text = serde_json::to_string(&json!({
2228 "_module": "pandapower.auxiliary",
2229 "_class": "pandapowerNet",
2230 "_object": object,
2231 }))
2232 .unwrap();
2233 let parsed = parse_pandapower_json(&text).unwrap();
2234 let zb = 110.0 * 110.0 / 100.0;
2235 let want = 100.0e-9 * 2.0 * std::f64::consts::PI * 60.0 * zb;
2236 assert!((parsed.network.branches[0].b - want).abs() < 1e-15);
2237 }
2238
2239 #[test]
2240 fn out_of_service_bus_round_trips_as_isolated() {
2241 let parsed = parse_pandapower_json(&pp_net(vec![(
2242 "bus",
2243 pp_frame(
2244 &["name", "vn_kv", "in_service"],
2245 json!([0, 1]),
2246 json!([[null, 110.0, true], [null, 110.0, false]]),
2247 ),
2248 )]))
2249 .unwrap();
2250 assert_eq!(parsed.network.buses[1].kind, BusType::Isolated);
2251 let conv = write_pandapower_json(&parsed.network);
2252 let bus = written_frame(&conv.text, "bus");
2253 assert_eq!(col(&bus, "in_service"), vec![json!(true), json!(false)]);
2254 }
2255
2256 #[test]
2257 fn shunt_vn_kv_scales_power_by_voltage_ratio() {
2258 let parsed = parse_pandapower_json(&pp_net(vec![
2261 bus_table(json!([0])),
2262 (
2263 "shunt",
2264 pp_frame(
2265 &["bus", "p_mw", "q_mvar", "vn_kv"],
2266 json!([0]),
2267 json!([[0, 2.0, 5.0, 10.0]]),
2268 ),
2269 ),
2270 ]))
2271 .unwrap();
2272 let s = &parsed.network.shunts[0];
2273 let ratio = (110.0_f64 / 10.0).powi(2);
2274 assert!((s.g - 2.0 * ratio).abs() < 1e-9);
2275 assert!((s.b + 5.0 * ratio).abs() < 1e-9);
2276 }
2277
2278 #[test]
2279 fn unknown_nonempty_table_warns() {
2280 let frame = pp_frame(&["bus", "x_l_ohm"], json!([0]), json!([[0, 1.0]]));
2281 let parsed =
2282 parse_pandapower_json(&pp_net(vec![bus_table(json!([0])), ("svc", frame)])).unwrap();
2283 assert!(
2284 parsed
2285 .warnings
2286 .iter()
2287 .any(|w| w == "`svc` table ignored (1 rows): not mapped"),
2288 "{:?}",
2289 parsed.warnings
2290 );
2291 }
2292
2293 #[test]
2294 fn poly_cost_missing_element_is_an_error() {
2295 let msg = err(&pp_net(vec![
2296 bus_table(json!([0])),
2297 (
2298 "gen",
2299 pp_frame(&["bus", "p_mw"], json!([0]), json!([[0, 1.0]])),
2300 ),
2301 (
2302 "poly_cost",
2303 pp_frame(&["et", "cp1_eur_per_mw"], json!([0]), json!([["gen", 3.0]])),
2304 ),
2305 ]));
2306 assert!(
2307 msg.contains("`poly_cost` row 0: required column `element` is missing"),
2308 "{msg}"
2309 );
2310 }
2311
2312 #[test]
2313 fn writer_does_not_warn_on_finite_loads() {
2314 let mut net = test_net(vec![test_bus(1, BusType::Ref)]);
2317 net.loads.push(Load {
2318 bus: BusId(1),
2319 p: 1.0,
2320 q: 0.5,
2321 in_service: true,
2322 extras: Extras::default(),
2323 });
2324 let conv = write_pandapower_json(&net);
2325 assert!(
2326 !conv.warnings.iter().any(|w| w.contains("non-finite")),
2327 "{:?}",
2328 conv.warnings
2329 );
2330 }
2331
2332 #[test]
2333 fn writer_warns_on_non_finite_values() {
2334 let mut net = test_net(vec![test_bus(1, BusType::Ref)]);
2335 let mut g = test_gen(1, None);
2336 g.qmax = f64::INFINITY;
2337 g.qmin = f64::NEG_INFINITY;
2338 net.generators.push(g);
2339 let conv = write_pandapower_json(&net);
2340 assert!(
2341 conv.warnings.iter().any(|w| w
2342 == "`gen`: non-finite value(s) written as null in column(s) `min_q_mvar` (1), `max_q_mvar` (1); pandapower reads them as NaN"),
2343 "{:?}",
2344 conv.warnings
2345 );
2346 }
2347
2348 #[test]
2349 fn trafo_off_nominal_vn_adjusts_ratio_and_impedance() {
2350 let parsed = parse_pandapower_json(&trafo_net(
2354 &["hv_bus", "lv_bus", "vk_percent", "vn_hv_kv", "vn_lv_kv"],
2355 json!([0, 1, 10.0, 110.0, 104.5]),
2356 ))
2357 .unwrap();
2358 let br = &parsed.network.branches[0];
2359 let k: f64 = 104.5 / 110.0;
2360 assert!((br.tap - 1.0 / k).abs() < 1e-12);
2361 assert!((br.x - 0.1 * k * k).abs() < 1e-12);
2362 }
2363
2364 #[test]
2365 fn ignored_tables_warn_with_counts() {
2366 let one_row = || pp_frame(&["x"], json!([0]), json!([[1]]));
2367 let parsed = parse_pandapower_json(&pp_net(vec![
2368 bus_table(json!([0])),
2369 ("trafo3w", one_row()),
2370 ("ward", one_row()),
2371 ("xward", one_row()),
2372 ("impedance", one_row()),
2373 ("motor", one_row()),
2374 ("switch", one_row()),
2375 ("pwl_cost", one_row()),
2376 ]))
2377 .unwrap();
2378 for expected in [
2379 "`trafo3w` table ignored (1 rows): three winding transformers are not mapped",
2380 "`ward` table ignored (1 rows): Ward equivalents are not mapped",
2381 "`xward` table ignored (1 rows): extended Ward equivalents are not mapped",
2382 "`impedance` table ignored (1 rows): bus-to-bus impedance elements are not mapped",
2383 "`motor` table ignored (1 rows): motors are not mapped",
2384 "`switch` table ignored (1 rows): switches are not modeled; open switches are not applied",
2385 "`pwl_cost` table ignored (1 rows): piecewise costs are not mapped",
2386 ] {
2387 assert!(
2388 parsed.warnings.iter().any(|w| w == expected),
2389 "missing {expected:?} in {:?}",
2390 parsed.warnings
2391 );
2392 }
2393 }
2394
2395 #[test]
2396 fn poly_cost_cq_coefficients_warn() {
2397 let parsed = parse_pandapower_json(&pp_net(vec![
2398 bus_table(json!([0])),
2399 (
2400 "gen",
2401 pp_frame(&["bus", "p_mw"], json!([0]), json!([[0, 1.0]])),
2402 ),
2403 (
2404 "poly_cost",
2405 pp_frame(
2406 &["et", "element", "cp1_eur_per_mw", "cq1_eur_per_mvar"],
2407 json!([0]),
2408 json!([["gen", 0, 2.5, 1.0]]),
2409 ),
2410 ),
2411 ]))
2412 .unwrap();
2413 let cost = parsed.network.generators[0].cost.as_ref().expect("cost");
2414 assert_eq!(cost.coeffs, vec![0.0, 2.5, 0.0]);
2415 assert!(
2416 parsed.warnings.iter().any(|w| w
2417 == "`poly_cost`: reactive cost coefficients (cq*) nonzero on 1 rows; only active power costs are read"),
2418 "{:?}",
2419 parsed.warnings
2420 );
2421 }
2422
2423 #[test]
2424 fn empty_switch_table_does_not_warn() {
2425 let parsed = parse_pandapower_json(&pp_net(vec![
2426 bus_table(json!([0])),
2427 ("switch", pp_frame(&["bus"], json!([]), json!([]))),
2428 ]))
2429 .unwrap();
2430 assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
2431 }
2432
2433 #[test]
2434 fn column_semantics_warn_with_counts() {
2435 let parsed = parse_pandapower_json(&pp_net(vec![
2436 bus_table(json!([0, 1])),
2437 (
2438 "load",
2439 pp_frame(
2440 &["bus", "p_mw", "const_z_percent", "const_i_percent"],
2441 json!([0, 1]),
2442 json!([[0, 1.0, 20.0, 0.0], [0, 1.0, 0.0, 0.0]]),
2443 ),
2444 ),
2445 (
2446 "line",
2447 pp_frame(
2448 &["from_bus", "to_bus", "g_us_per_km"],
2449 json!([0]),
2450 json!([[0, 1, 1.0]]),
2451 ),
2452 ),
2453 (
2454 "trafo",
2455 pp_frame(
2456 &["hv_bus", "lv_bus", "vk_percent", "i0_percent", "pfe_kw"],
2457 json!([0]),
2458 json!([[0, 1, 10.0, 0.1, 0.0]]),
2459 ),
2460 ),
2461 ]))
2462 .unwrap();
2463 for expected in [
2464 "`load`: ZIP composition (const_z_percent/const_i_percent/const_z_p_percent/const_i_p_percent/const_z_q_percent/const_i_q_percent) nonzero on 1 rows; loads are read as constant power",
2465 "`line`: g_us_per_km nonzero on 1 rows; line shunt conductance is not representable and was ignored",
2466 "`trafo`: i0_percent/pfe_kw nonzero on 1 rows; the magnetizing branch is not representable and was ignored",
2467 ] {
2468 assert!(
2469 parsed.warnings.iter().any(|w| w == expected),
2470 "missing {expected:?} in {:?}",
2471 parsed.warnings
2472 );
2473 }
2474 }
2475
2476 #[test]
2477 fn zip_split_columns_warn_when_nonzero() {
2478 let parsed = parse_pandapower_json(&pp_net(vec![
2482 bus_table(json!([0])),
2483 (
2484 "load",
2485 pp_frame(
2486 &[
2487 "bus",
2488 "p_mw",
2489 "const_z_p_percent",
2490 "const_i_p_percent",
2491 "const_z_q_percent",
2492 "const_i_q_percent",
2493 ],
2494 json!([0]),
2495 json!([[0, 1.0, 10.0, 0.0, 0.0, 0.0]]),
2496 ),
2497 ),
2498 ]))
2499 .unwrap();
2500 assert!(
2501 parsed
2502 .warnings
2503 .iter()
2504 .any(|w| w.starts_with("`load`: ZIP composition")),
2505 "expected ZIP warning in {:?}",
2506 parsed.warnings
2507 );
2508 }
2509
2510 fn test_bus(id: usize, kind: BusType) -> Bus {
2513 Bus {
2514 id: BusId(id),
2515 kind,
2516 vm: 1.02,
2517 va: 3.0,
2518 base_kv: 110.0,
2519 vmax: 1.1,
2520 vmin: 0.9,
2521 area: 1,
2522 zone: 1,
2523 name: None,
2524 extras: Extras::default(),
2525 }
2526 }
2527
2528 fn test_net(buses: Vec<Bus>) -> Network {
2529 Network {
2530 name: "t".into(),
2531 base_mva: 100.0,
2532 buses,
2533 loads: Vec::new(),
2534 shunts: Vec::new(),
2535 branches: Vec::new(),
2536 generators: Vec::new(),
2537 storage: Vec::new(),
2538 hvdc: Vec::new(),
2539 source_format: SourceFormat::InMemory,
2540 source: None,
2541 }
2542 }
2543
2544 fn test_gen(bus: usize, cost: Option<GenCost>) -> Generator {
2545 Generator {
2546 bus: BusId(bus),
2547 pg: 1.0,
2548 qg: 0.0,
2549 pmax: 2.0,
2550 pmin: 0.0,
2551 qmax: 1.0,
2552 qmin: -1.0,
2553 vg: 1.0,
2554 mbase: 100.0,
2555 in_service: true,
2556 cost,
2557 caps: [None; crate::network::GEN_EXTRA_KEYS.len()],
2558 }
2559 }
2560
2561 fn test_branch(from: usize, to: usize, tap: f64) -> Branch {
2562 Branch {
2563 from: BusId(from),
2564 to: BusId(to),
2565 r: 0.01,
2566 x: 0.1,
2567 b: 0.0,
2568 rate_a: 0.0,
2569 rate_b: 0.0,
2570 rate_c: 0.0,
2571 tap,
2572 shift: 0.0,
2573 in_service: true,
2574 angmin: -360.0,
2575 angmax: 360.0,
2576 extras: Extras::default(),
2577 }
2578 }
2579
2580 fn poly(coeffs: Vec<f64>) -> GenCost {
2581 GenCost {
2582 model: 2,
2583 startup: 0.0,
2584 shutdown: 0.0,
2585 ncost: coeffs.len(),
2586 coeffs,
2587 }
2588 }
2589
2590 fn written_frame(text: &str, table: &str) -> DataFrame {
2592 let root: Value = serde_json::from_str(text).unwrap();
2593 let obj = root["_object"].as_object().unwrap();
2594 read_frame(obj, table).unwrap().unwrap()
2595 }
2596
2597 fn col(frame: &DataFrame, key: &str) -> Vec<Value> {
2598 let c = frame.col(key).unwrap();
2599 frame.data.iter().map(|r| r[c].clone()).collect()
2600 }
2601
2602 #[test]
2603 fn writer_emits_zero_based_frames() {
2604 let mut net = test_net(vec![
2605 test_bus(1, BusType::Pq),
2606 test_bus(2, BusType::Pq),
2607 test_bus(3, BusType::Ref),
2608 ]);
2609 net.loads.push(Load {
2610 bus: BusId(2),
2611 p: 1.0,
2612 q: 0.0,
2613 in_service: true,
2614 extras: Extras::default(),
2615 });
2616 net.generators.push(test_gen(3, None));
2617 net.branches.push(test_branch(1, 2, 0.0));
2619 net.branches.push(test_branch(2, 3, 1.05));
2620 net.branches.push(test_branch(1, 3, 0.0));
2621 let conv = write_pandapower_json(&net);
2622
2623 let bus = written_frame(&conv.text, "bus");
2624 assert_eq!(bus.index, vec![json!(0), json!(1), json!(2)]);
2625 let load = written_frame(&conv.text, "load");
2626 assert_eq!(load.index, vec![json!(0)]);
2627 assert_eq!(col(&load, "bus"), vec![json!(1)]);
2628 let gen_tbl = written_frame(&conv.text, "gen");
2629 assert_eq!(gen_tbl.index, vec![json!(0)]);
2630 assert_eq!(col(&gen_tbl, "bus"), vec![json!(2)]);
2631 let line = written_frame(&conv.text, "line");
2632 assert_eq!(line.index, vec![json!(0), json!(1)]);
2633 assert_eq!(col(&line, "from_bus"), vec![json!(0), json!(0)]);
2634 assert_eq!(col(&line, "to_bus"), vec![json!(1), json!(2)]);
2635 let trafo = written_frame(&conv.text, "trafo");
2636 assert_eq!(trafo.index, vec![json!(0)]);
2637 assert_eq!(col(&trafo, "hv_bus"), vec![json!(1)]);
2638 assert_eq!(col(&trafo, "lv_bus"), vec![json!(2)]);
2639 }
2640
2641 #[test]
2642 fn writer_tapped_trafo_carries_ratio_tap_changer_type() {
2643 let mut net = test_net(vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)]);
2644 net.branches.push(test_branch(1, 2, 1.05));
2645 let conv = write_pandapower_json(&net);
2646 let trafo = written_frame(&conv.text, "trafo");
2647 assert_eq!(col(&trafo, "tap_changer_type"), vec![json!("Ratio")]);
2648 let rt = parse_pandapower_json(&conv.text).unwrap();
2649 assert!((rt.network.branches[0].tap - 1.05).abs() < 1e-12);
2650 }
2651
2652 #[test]
2653 fn writer_trafo_charging_rides_as_bus_shunts() {
2654 let mut net = test_net(vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)]);
2658 let mut br = test_branch(1, 2, 1.05);
2659 br.b = 0.04;
2660 net.branches.push(br);
2661 let conv = write_pandapower_json(&net);
2662 assert!(
2663 conv.warnings.iter().any(|w| w
2664 .starts_with("1 transformer terminal charging shunt(s) written into `shunt`")
2665 || w.starts_with("2 transformer terminal charging shunt(s) written into `shunt`")),
2666 "{:?}",
2667 conv.warnings
2668 );
2669 let shunt = written_frame(&conv.text, "shunt");
2670 assert_eq!(shunt.data.len(), 2);
2671 let rt = parse_pandapower_json(&conv.text).unwrap();
2672 assert_eq!(rt.network.shunts.len(), 2);
2673 let total_b: f64 = rt.network.shunts.iter().map(|s| s.b).sum();
2674 let want = (0.04 / 2.0 / (1.05 * 1.05) + 0.04 / 2.0) * 100.0;
2677 assert!((total_b - want).abs() < 1e-12, "{total_b}");
2678 assert_eq!(rt.network.branches[0].b, 0.0);
2679 }
2680
2681 #[test]
2682 fn writer_substitutes_one_kv_for_zero_base_kv() {
2683 let mut net = test_net(vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)]);
2684 net.buses[0].base_kv = 0.0;
2685 net.buses[1].base_kv = 0.0;
2686 net.branches.push(test_branch(1, 2, 0.0));
2687 let conv = write_pandapower_json(&net);
2688 let bus = written_frame(&conv.text, "bus");
2689 assert_eq!(col(&bus, "vn_kv"), vec![json!(1.0), json!(1.0)]);
2690 assert!(
2691 conv.warnings
2692 .iter()
2693 .any(|w| w.starts_with("2 bus(es) carry no base_kv; written with vn_kv = 1")),
2694 "{:?}",
2695 conv.warnings
2696 );
2697 let rt = parse_pandapower_json(&conv.text).unwrap();
2698 let b = &rt.network.branches[0];
2699 assert!((b.r - 0.01).abs() < 1e-12);
2700 assert!((b.x - 0.1).abs() < 1e-12);
2701 }
2702
2703 #[test]
2704 fn writer_cross_voltage_level_branch_becomes_trafo() {
2705 let mut net = test_net(vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)]);
2709 net.buses[0].base_kv = 380.0;
2710 net.buses[1].base_kv = 150.0;
2711 let mut br = test_branch(1, 2, 0.0);
2712 br.rate_a = 100.0;
2713 net.branches.push(br);
2714 let conv = write_pandapower_json(&net);
2715 assert!(written_frame(&conv.text, "line").data.is_empty());
2716 assert_eq!(written_frame(&conv.text, "trafo").data.len(), 1);
2717 let rt = parse_pandapower_json(&conv.text).unwrap();
2718 let b = &rt.network.branches[0];
2719 assert!((b.r - 0.01).abs() < 1e-12);
2720 assert!((b.x - 0.1).abs() < 1e-12);
2721 assert!((b.rate_a - 100.0).abs() < 1e-9);
2722 }
2723
2724 #[test]
2725 fn writer_ext_grid_row_for_generator_less_ref_bus() {
2726 let mut net = test_net(vec![test_bus(1, BusType::Pq), test_bus(2, BusType::Ref)]);
2727 net.buses[1].name = Some("slack".into());
2728 let conv = write_pandapower_json(&net);
2729 let eg = written_frame(&conv.text, "ext_grid");
2730 assert_eq!(eg.index, vec![json!(0)]);
2731 assert_eq!(
2732 eg.data[0],
2733 vec![
2734 json!("slack"),
2735 json!(1),
2736 json!(1.02),
2737 json!(3.0),
2738 json!(1.0),
2739 json!(true),
2740 json!(true),
2741 ]
2742 );
2743 }
2744
2745 #[test]
2746 fn writer_ext_grid_empty_when_ref_bus_has_generator() {
2747 let mut net = test_net(vec![test_bus(1, BusType::Ref)]);
2748 net.generators.push(test_gen(1, None));
2749 let conv = write_pandapower_json(&net);
2750 let eg = written_frame(&conv.text, "ext_grid");
2751 assert!(eg.data.is_empty());
2752 let gen_tbl = written_frame(&conv.text, "gen");
2754 assert_eq!(col(&gen_tbl, "slack"), vec![json!(true)]);
2755 }
2756
2757 #[test]
2758 fn poly_cost_keeps_lowest_order_terms() {
2759 let mut net = test_net(vec![test_bus(1, BusType::Ref)]);
2760 net.generators
2761 .push(test_gen(1, Some(poly(vec![9.0, 3.0, 2.0, 1.0]))));
2762 let conv = write_pandapower_json(&net);
2763 let pc = written_frame(&conv.text, "poly_cost");
2764 assert_eq!(col(&pc, "cp0_eur"), vec![json!(1.0)]);
2765 assert_eq!(col(&pc, "cp1_eur_per_mw"), vec![json!(2.0)]);
2766 assert_eq!(col(&pc, "cp2_eur_per_mw2"), vec![json!(3.0)]);
2767 assert!(
2768 conv.warnings.iter().any(|w| w
2769 == "1 generator costs truncated to quadratic: poly_cost carries cp0/cp1/cp2 only"),
2770 "{:?}",
2771 conv.warnings
2772 );
2773 }
2774
2775 #[test]
2776 fn poly_cost_warnings_and_zero_based_keys() {
2777 let mut net = test_net(vec![test_bus(1, BusType::Ref)]);
2778 let piecewise = GenCost {
2779 model: 1,
2780 startup: 0.0,
2781 shutdown: 0.0,
2782 ncost: 2,
2783 coeffs: vec![0.0, 0.0, 1.0, 1.0],
2784 };
2785 net.generators.push(test_gen(1, Some(piecewise)));
2786 net.generators
2787 .push(test_gen(1, Some(poly(vec![4.0, 3.0, 2.0, 1.0]))));
2788 net.generators.push(test_gen(1, Some(poly(Vec::new()))));
2789 let conv = write_pandapower_json(&net);
2790 let pc = written_frame(&conv.text, "poly_cost");
2791 assert_eq!(pc.index, vec![json!(0), json!(1)]);
2794 assert_eq!(col(&pc, "element"), vec![json!(1), json!(2)]);
2795 for expected in [
2796 "1 generator costs dropped: pandapower poly_cost carries polynomial (model 2) costs only",
2797 "1 generator costs truncated to quadratic: poly_cost carries cp0/cp1/cp2 only",
2798 "1 generator costs had no coefficients and were written as zero",
2799 ] {
2800 assert!(
2801 conv.warnings.iter().any(|w| w == expected),
2802 "missing {expected:?} in {:?}",
2803 conv.warnings
2804 );
2805 }
2806 }
2807}