Skip to main content

powerio/format/
pandapower.rs

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