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