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