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