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