Skip to main content

powerio/format/
pypsa.rs

1//! Read and write PyPSA CSV folders.
2//!
3//! PyPSA's CSV folder is a directory format, so it does not fit the
4//! `Conversion { text }` API used by single-file formats. The reader and writer
5//! are exposed as path-based helpers and through `parse_file(..., "pypsa-csv")`.
6
7use std::collections::{HashMap, HashSet};
8use std::fmt::Write as _;
9use std::path::{Path, PathBuf};
10
11use super::{Parsed, bus_kv, set_bus_kind, warn_extra_branch_rating_sets, zbase};
12use crate::network::{
13    BalancedNetwork, Branch, BranchCharging, Bus, BusId, BusType, Extras, GenCost, Generator, Hvdc,
14    Load, LoadVoltageModel, Shunt, SourceFormat, Storage,
15};
16use crate::{Error, Result};
17
18const FMT: &str = "PyPSA CSV";
19
20#[derive(Debug, Clone)]
21#[non_exhaustive]
22pub struct PypsaCsvOutputs {
23    pub dir: PathBuf,
24    pub files: Vec<PathBuf>,
25    pub warnings: Vec<String>,
26}
27
28/// Read a PyPSA CSV folder at `path`. Returns [`Parsed`]: the network plus the
29/// reader's fidelity warnings.
30pub fn read_pypsa_csv_folder(path: impl AsRef<Path>) -> Result<Parsed> {
31    let mut warnings = Vec::new();
32    let network = read_pypsa_csv_folder_inner(path.as_ref(), &mut warnings)?;
33    Ok(Parsed::without_document(network, warnings))
34}
35
36#[allow(clippy::too_many_lines)] // direct static-component CSV mapper; each block is one PyPSA table
37fn read_pypsa_csv_folder_inner(path: &Path, warnings: &mut Vec<String>) -> Result<BalancedNetwork> {
38    let network = read_csv_optional(&path.join("network.csv"))?;
39    let network_row = network.as_ref().and_then(|t| t.rows.first());
40    let name = network_row
41        .and_then(|r| r.get("name"))
42        .filter(|s| !s.is_empty())
43        .cloned()
44        .or_else(|| {
45            path.file_name()
46                .and_then(|s| s.to_str())
47                .map(str::to_string)
48        })
49        .unwrap_or_else(|| "pypsa".to_string());
50    let base_mva = network_row
51        .and_then(|r| r.f("powerio_base_mva"))
52        .unwrap_or(1.0);
53
54    let bus_table = read_csv_required(&path.join("buses.csv"), "buses.csv")?;
55    let mut raw_names = Vec::with_capacity(bus_table.rows.len());
56    let mut seen = HashSet::with_capacity(bus_table.rows.len());
57    for (i, row) in bus_table.rows.iter().enumerate() {
58        let raw = row
59            .get("name")
60            .cloned()
61            .ok_or_else(|| bad(format!("buses.csv row {}: missing bus name", i + 1)))?;
62        if !seen.insert(raw.clone()) {
63            return Err(bad(format!("buses.csv: duplicate bus name `{raw}`")));
64        }
65        raw_names.push(raw);
66    }
67    // Scheme A iff every name is a distinct positive integer: ids are the names
68    // and `bus.name` stays empty. Otherwise scheme B for ALL buses: ids are
69    // positions and every raw name is kept. Never mixed, so an element
70    // reference resolves by name only — no numeric fallback.
71    let numeric: Option<Vec<usize>> = raw_names
72        .iter()
73        .map(|s| s.parse::<usize>().ok().filter(|x| *x > 0))
74        .collect();
75    let numeric = numeric.filter(|ids| ids.iter().collect::<HashSet<_>>().len() == ids.len());
76
77    let mut buses = Vec::with_capacity(bus_table.rows.len());
78    let mut id_of_name = HashMap::with_capacity(bus_table.rows.len());
79    for (i, row) in bus_table.rows.iter().enumerate() {
80        let (id, bus_name) = match &numeric {
81            Some(ids) => (BusId(ids[i]), None),
82            None => (BusId(i + 1), Some(raw_names[i].clone())),
83        };
84        id_of_name.insert(raw_names[i].clone(), id);
85        // v_nom drives every ohm <-> per unit conversion; defaulting it would
86        // silently read line ohms as per unit (the pandapower reader holds the
87        // same line for vn_kv). PyPSA omits the column only when every bus
88        // keeps the default v_nom = 1, and erroring there beats misreading.
89        let v_nom = row.f("v_nom").filter(|v| v.is_finite()).ok_or_else(|| {
90            bad(format!(
91                "buses.csv row {}: required column `v_nom` is missing or not numeric",
92                i + 1
93            ))
94        })?;
95        buses.push(Bus {
96            id,
97            kind: BusType::Pq,
98            vm: row.f("v_mag_pu_set").unwrap_or(1.0),
99            va: 0.0,
100            base_kv: v_nom,
101            vmax: row.f("v_mag_pu_max").unwrap_or(1.1),
102            vmin: row.f("v_mag_pu_min").unwrap_or(0.9),
103            evhi: None,
104            evlo: None,
105            area: 1,
106            zone: 1,
107            name: bus_name,
108            uid: None,
109            // PyPSA `x`/`y` are longitude/latitude; both must be present, so
110            // a folder without the columns keeps `location = None`.
111            location: match (
112                row.f("x").filter(|v| v.is_finite()),
113                row.f("y").filter(|v| v.is_finite()),
114            ) {
115                (Some(x), Some(y)) => Some(crate::geo::Location { x, y, kind: None }),
116                _ => None,
117            },
118            extras: Extras::default(),
119        });
120    }
121    let bus_pos: HashMap<BusId, usize> = buses.iter().enumerate().map(|(i, b)| (b.id, i)).collect();
122
123    let mut loads = Vec::new();
124    if let Some(table) = read_csv_optional(&path.join("loads.csv"))? {
125        for (i, row) in table.rows.iter().enumerate() {
126            loads.push(Load {
127                bus: bus_ref("loads.csv", i + 1, row, "bus", &id_of_name)?,
128                p: row.f("p_set").unwrap_or(0.0),
129                q: row.f("q_set").unwrap_or(0.0),
130                voltage_model: None,
131                in_service: row.bool("active").unwrap_or(true),
132                uid: None,
133                extras: Extras::default(),
134            });
135        }
136    }
137
138    let mut shunts = Vec::new();
139    if let Some(table) = read_csv_optional(&path.join("shunt_impedances.csv"))? {
140        for (i, row) in table.rows.iter().enumerate() {
141            let bus = bus_ref("shunt_impedances.csv", i + 1, row, "bus", &id_of_name)?;
142            let zb = zbase(bus_kv(&buses, &bus_pos, bus), base_mva);
143            shunts.push(Shunt {
144                bus,
145                g: row.f("g").unwrap_or(0.0) * zb * base_mva,
146                b: row.f("b").unwrap_or(0.0) * zb * base_mva,
147                in_service: row.bool("active").unwrap_or(true),
148                control: None,
149                uid: None,
150                extras: Extras::default(),
151            });
152        }
153    }
154
155    let mut generators = Vec::new();
156    if let Some(table) = read_csv_optional(&path.join("generators.csv"))? {
157        for (i, row) in table.rows.iter().enumerate() {
158            let bus = bus_ref("generators.csv", i + 1, row, "bus", &id_of_name)?;
159            let control = row.get("control").map_or("", String::as_str);
160            // "PQ", empty, and anything unrecognized leave the bus kind alone.
161            if control.eq_ignore_ascii_case("slack") {
162                set_bus_kind(&mut buses, &bus_pos, bus, BusType::Ref);
163            } else if control.eq_ignore_ascii_case("pv") {
164                set_bus_kind(&mut buses, &bus_pos, bus, BusType::Pv);
165            }
166            let p_nom = row
167                .f("p_nom")
168                .unwrap_or_else(|| row.f("p_set").unwrap_or(0.0).abs());
169            let pmax = p_nom * row.f("p_max_pu").unwrap_or(1.0);
170            let pmin = p_nom * row.f("p_min_pu").unwrap_or(0.0);
171            let c1 = row.f("marginal_cost");
172            let c2 = row.f("marginal_cost_quadratic");
173            generators.push(Generator {
174                bus,
175                pg: row.f("p_set").unwrap_or(0.0),
176                qg: row.f("q_set").unwrap_or(0.0),
177                pmax,
178                pmin,
179                qmax: f64::INFINITY,
180                qmin: f64::NEG_INFINITY,
181                vg: row.f("v_mag_pu_set").unwrap_or(1.0),
182                mbase: base_mva,
183                in_service: row.bool("active").unwrap_or(true),
184                cost: match (c2, c1) {
185                    (Some(q), c) => Some(GenCost {
186                        model: 2,
187                        startup: 0.0,
188                        shutdown: 0.0,
189                        ncost: 3,
190                        // PyPSA defaults marginal_cost to 0, so a quadratic
191                        // without a linear column keeps the quadratic term.
192                        coeffs: vec![q, c.unwrap_or(0.0), 0.0],
193                    }),
194                    (None, Some(c)) => Some(GenCost {
195                        model: 2,
196                        startup: 0.0,
197                        shutdown: 0.0,
198                        ncost: 2,
199                        coeffs: vec![c, 0.0],
200                    }),
201                    (None, None) => None,
202                },
203                caps: [None; crate::network::GEN_EXTRA_KEYS.len()],
204                regulated_bus: None,
205                uid: None,
206            });
207        }
208    }
209
210    let mut branches = Vec::new();
211    if let Some(table) = read_csv_optional(&path.join("lines.csv"))? {
212        for (i, row) in table.rows.iter().enumerate() {
213            let from = bus_ref("lines.csv", i + 1, row, "bus0", &id_of_name)?;
214            let to = bus_ref("lines.csv", i + 1, row, "bus1", &id_of_name)?;
215            // PyPSA per-unitizes line ohms on the BUS0 v_nom
216            // (BalancedNetwork.calculate_dependent_values), not bus1.
217            let zb = zbase(bus_kv(&buses, &bus_pos, from), base_mva);
218            let b = row.f("b").unwrap_or(0.0) * zb;
219            let g = row.f("g").unwrap_or(0.0) * zb;
220            branches.push(Branch {
221                from,
222                to,
223                r: row.f("r").unwrap_or(0.0) / zb,
224                x: row.f("x").unwrap_or(0.0) / zb,
225                b,
226                charging: Some(BranchCharging {
227                    g_fr: g / 2.0,
228                    b_fr: b / 2.0,
229                    g_to: g / 2.0,
230                    b_to: b / 2.0,
231                }),
232                rate_a: row.f("s_nom").unwrap_or(0.0),
233                rate_b: 0.0,
234                rate_c: 0.0,
235                rating_sets: Vec::new(),
236                current_ratings: None,
237                tap: 0.0,
238                shift: 0.0,
239                in_service: row.bool("active").unwrap_or(true),
240                angmin: row.f("v_ang_min").unwrap_or(-360.0),
241                angmax: row.f("v_ang_max").unwrap_or(360.0),
242                control: None,
243                solution: None,
244                uid: None,
245                route: None,
246                extras: Extras::default(),
247            });
248        }
249    }
250    if let Some(table) = read_csv_optional(&path.join("transformers.csv"))? {
251        for (i, row) in table.rows.iter().enumerate() {
252            let from = bus_ref("transformers.csv", i + 1, row, "bus0", &id_of_name)?;
253            let to = bus_ref("transformers.csv", i + 1, row, "bus1", &id_of_name)?;
254            // PyPSA stores transformer impedances per unit on the transformer's
255            // own s_nom base; rebase to the system base.
256            let s_nom = row.f("s_nom").unwrap_or(0.0);
257            if s_nom <= 0.0 {
258                let xf_name = row.get("name").cloned().unwrap_or_default();
259                return Err(bad(format!(
260                    "transformers.csv row {} (`{xf_name}`): s_nom must be positive to rebase impedances (got {s_nom})",
261                    i + 1
262                )));
263            }
264            let k = base_mva / s_nom;
265            let b = row.f("b").unwrap_or(0.0) * s_nom / base_mva;
266            let g = row.f("g").unwrap_or(0.0) * s_nom / base_mva;
267            branches.push(Branch {
268                from,
269                to,
270                r: row.f("r").unwrap_or(0.0) * k,
271                x: row.f("x").unwrap_or(0.0) * k,
272                b,
273                charging: Some(BranchCharging {
274                    g_fr: g,
275                    b_fr: b,
276                    g_to: 0.0,
277                    b_to: 0.0,
278                }),
279                rate_a: s_nom,
280                rate_b: 0.0,
281                rate_c: 0.0,
282                rating_sets: Vec::new(),
283                current_ratings: None,
284                tap: row.f("tap_ratio").unwrap_or(1.0),
285                shift: row.f("phase_shift").unwrap_or(0.0),
286                in_service: row.bool("active").unwrap_or(true),
287                angmin: -360.0,
288                angmax: 360.0,
289                control: None,
290                solution: None,
291                uid: None,
292                route: None,
293                extras: Extras::default(),
294            });
295        }
296    }
297
298    let mut storage = Vec::new();
299    if let Some(table) = read_csv_optional(&path.join("storage_units.csv"))? {
300        for (i, row) in table.rows.iter().enumerate() {
301            let p_nom = row.f("p_nom").unwrap_or(0.0);
302            let max_hours = row.f("max_hours").unwrap_or(0.0);
303            storage.push(Storage {
304                bus: bus_ref("storage_units.csv", i + 1, row, "bus", &id_of_name)?,
305                ps: row.f("p_set").unwrap_or(0.0),
306                qs: row.f("q_set").unwrap_or(0.0),
307                energy: row.f("state_of_charge_initial").unwrap_or(0.0),
308                energy_rating: p_nom * max_hours,
309                charge_rating: p_nom,
310                discharge_rating: p_nom,
311                charge_efficiency: row.f("efficiency_store").unwrap_or(1.0),
312                discharge_efficiency: row.f("efficiency_dispatch").unwrap_or(1.0),
313                thermal_rating: p_nom,
314                current_rating: None,
315                qmin: f64::NEG_INFINITY,
316                qmax: f64::INFINITY,
317                r: 0.0,
318                x: 0.0,
319                p_loss: 0.0,
320                q_loss: 0.0,
321                in_service: row.bool("active").unwrap_or(true),
322                uid: None,
323                extras: Extras::default(),
324            });
325        }
326    }
327
328    let mut hvdc = Vec::new();
329    if let Some(table) = read_csv_optional(&path.join("links.csv"))? {
330        for (i, row) in table.rows.iter().enumerate() {
331            let from = bus_ref("links.csv", i + 1, row, "bus0", &id_of_name)?;
332            let to = bus_ref("links.csv", i + 1, row, "bus1", &id_of_name)?;
333            let efficiency = row.f("efficiency").unwrap_or(1.0);
334            let p_nom = row.f("p_nom").unwrap_or(0.0);
335            let pf = row.f("p_set").unwrap_or(0.0);
336            hvdc.push(Hvdc {
337                from,
338                to,
339                in_service: row.bool("active").unwrap_or(true),
340                pf,
341                pt: pf * efficiency,
342                qf: 0.0,
343                qt: 0.0,
344                vf: 1.0,
345                vt: 1.0,
346                pmin: p_nom * row.f("p_min_pu").unwrap_or(0.0),
347                pmax: p_nom * row.f("p_max_pu").unwrap_or(1.0),
348                qminf: 0.0,
349                qmaxf: 0.0,
350                qmint: 0.0,
351                qmaxt: 0.0,
352                loss0: 0.0,
353                loss1: 1.0 - efficiency,
354                cost: None,
355                uid: None,
356                extras: Extras::default(),
357            });
358        }
359        if !table.rows.is_empty() {
360            warnings.push(format!(
361                "links.csv: {} links read as HVDC lines; PyPSA links carry no reactive or voltage data (q limits 0, voltage setpoints 1.0)",
362                table.rows.len()
363            ));
364        }
365    }
366    if let Some(table) = read_csv_optional(&path.join("stores.csv"))? {
367        if !table.rows.is_empty() {
368            warnings.push(format!(
369                "stores.csv ignored ({} rows): PyPSA stores are not mapped",
370                table.rows.len()
371            ));
372        }
373    }
374
375    // A real PyPSA export can carry its data in time series siblings
376    // (`loads-p_set.csv`, `generators-p_max_pu.csv`, ...); reading only the
377    // static tables and saying nothing would present a zero-load network as a
378    // clean parse. Name every CSV this reader did not open.
379    let consumed = [
380        "network.csv",
381        "snapshots.csv",
382        "buses.csv",
383        "loads.csv",
384        "shunt_impedances.csv",
385        "generators.csv",
386        "lines.csv",
387        "transformers.csv",
388        "storage_units.csv",
389        "links.csv",
390        "stores.csv",
391    ];
392    let mut unread: Vec<String> = std::fs::read_dir(path)?
393        .filter_map(std::result::Result::ok)
394        .filter_map(|e| e.file_name().into_string().ok())
395        .filter(|n| {
396            Path::new(n)
397                .extension()
398                .is_some_and(|e| e.eq_ignore_ascii_case("csv"))
399                && !consumed.contains(&n.as_str())
400        })
401        .collect();
402    unread.sort();
403    for file in unread {
404        warnings.push(format!(
405            "`{file}` ignored: only the static element tables are read (time series and other tables are not modeled)"
406        ));
407    }
408
409    let net = BalancedNetwork {
410        name,
411        base_mva,
412        base_frequency: crate::network::DEFAULT_BASE_FREQUENCY,
413        geo: super::geographic_meta(&buses),
414        buses,
415        loads,
416        shunts,
417        branches,
418        switches: Vec::new(),
419        generators,
420        storage,
421        hvdc,
422        transformers_3w: Vec::new(),
423        areas: Vec::new(),
424        solver: None,
425        source_format: SourceFormat::PypsaCsv,
426        source: None,
427    };
428    // This reader bypasses the read_source funnel (directory input), so it
429    // guards against a hollow case itself.
430    crate::format::reject_empty_case(&net, FMT)?;
431    net.check_references(FMT)?;
432    Ok(net)
433}
434
435#[allow(clippy::too_many_lines)] // one fidelity warning block per dropped field family, then the table writes
436pub fn write_pypsa_csv_folder(
437    net: &BalancedNetwork,
438    out_dir: impl AsRef<Path>,
439) -> Result<PypsaCsvOutputs> {
440    let out_dir = out_dir.as_ref();
441    std::fs::create_dir_all(out_dir)?;
442    let mut files = Vec::new();
443    let mut warnings = Vec::new();
444    // Element tables must reference buses by the same key buses.csv is indexed
445    // on, and PyPSA requires those keys to be unique for its joins. A bus is
446    // keyed by its name only when the name collides with no other bus's name
447    // or id string; colliding buses fall back to their numeric id, which is
448    // unique by construction and (per the same rule) cannot displace a kept
449    // name.
450    let mut name_counts: HashMap<&str, usize> = HashMap::new();
451    for b in &net.buses {
452        if let Some(n) = &b.name {
453            *name_counts.entry(n.as_str()).or_insert(0) += 1;
454        }
455    }
456    let id_owner: HashMap<String, BusId> = net
457        .buses
458        .iter()
459        .map(|b| (b.id.0.to_string(), b.id))
460        .collect();
461    let mut displaced: Vec<String> = Vec::new();
462    let key_of: HashMap<BusId, String> = net
463        .buses
464        .iter()
465        .map(|b| {
466            let key = match &b.name {
467                Some(n)
468                    if name_counts[n.as_str()] == 1
469                        && id_owner.get(n).is_none_or(|&owner| owner == b.id) =>
470                {
471                    n.clone()
472                }
473                Some(n) => {
474                    displaced.push(format!("`{n}`"));
475                    b.id.0.to_string()
476                }
477                None => b.id.0.to_string(),
478            };
479            (b.id, key)
480        })
481        .collect();
482    if !displaced.is_empty() {
483        displaced.sort();
484        displaced.dedup();
485        warnings.push(format!(
486            "buses.csv: bus names {} collide with another bus name or id; those buses are keyed by their numeric id instead",
487            displaced.join(", ")
488        ));
489    }
490    if !net.hvdc.is_empty() {
491        warnings.push(format!(
492            "{} dcline(s) dropped: the PyPSA CSV writer does not model HVDC links",
493            net.hvdc.len()
494        ));
495    }
496    if !net.transformers_3w.is_empty() {
497        warnings.push(format!(
498            "{} 3-winding transformer(s) dropped: the PyPSA CSV writer emits no 3-winding transformer",
499            net.transformers_3w.len()
500        ));
501    }
502    if net
503        .buses
504        .iter()
505        .any(|b| b.evhi.is_some() || b.evlo.is_some())
506    {
507        warnings.push(
508            "emergency voltage band(s) (EVHI/EVLO) dropped: this writer carries one voltage band"
509                .into(),
510        );
511    }
512    if net.generators.iter().any(Generator::has_caps) {
513        warnings.push("generator capability/ramp columns dropped: PyPSA generator CSV has no MATPOWER capability columns".into());
514    }
515    let voltage_loads = net
516        .loads
517        .iter()
518        .filter(|l| {
519            l.voltage_model
520                .as_ref()
521                .is_some_and(LoadVoltageModel::has_non_matpower_fields)
522        })
523        .count();
524    if voltage_loads > 0 {
525        warnings.push(format!(
526            "{voltage_loads} voltage dependent load model(s) dropped: PyPSA loads.csv carries static p_set/q_set only"
527        ));
528    }
529    let isolated = net
530        .buses
531        .iter()
532        .filter(|b| b.kind == BusType::Isolated)
533        .count();
534    if isolated > 0 {
535        warnings.push(format!(
536            "{isolated} isolated bus(es) written without status: PyPSA buses carry no active flag, they read back in service"
537        ));
538    }
539    let xf_angles = net
540        .branches
541        .iter()
542        .filter(|b| b.is_transformer() && b.has_angle_limits())
543        .count();
544    if xf_angles > 0 {
545        warnings.push(format!(
546            "{xf_angles} transformer angle limit(s) dropped: transformers.csv carries no v_ang_min/v_ang_max"
547        ));
548    }
549    let rate_bc = net
550        .branches
551        .iter()
552        .filter(|b| {
553            super::nonzero_differs(b.rate_b, b.rate_a) || super::nonzero_differs(b.rate_c, b.rate_a)
554        })
555        .count();
556    if rate_bc > 0 {
557        warnings.push(format!(
558            "{rate_bc} branch rate_b/rate_c value set(s) dropped: PyPSA carries one s_nom rating"
559        ));
560    }
561    let current_ratings = net
562        .branches
563        .iter()
564        .filter(|b| b.current_ratings.is_some())
565        .count();
566    if current_ratings > 0 {
567        warnings.push(format!(
568            "{current_ratings} branch current rating record(s) dropped: PyPSA static branch tables carry s_nom, not source current ratings"
569        ));
570    }
571    warn_extra_branch_rating_sets("PyPSA CSV", net, &mut warnings);
572    let branch_solutions = net.branches.iter().filter(|b| b.solution.is_some()).count();
573    if branch_solutions > 0 {
574        warnings.push(format!(
575            "{branch_solutions} branch solution value set(s) dropped: PyPSA result time series are not written"
576        ));
577    }
578    let terminal_charging = net
579        .branches
580        .iter()
581        .filter(|b| pypsa_loses_terminal_charging(b))
582        .count();
583    if terminal_charging > 0 {
584        warnings.push(format!(
585            "{terminal_charging} branch terminal admittance record(s) collapsed: PyPSA CSV supports symmetric line shunts and one-sided transformer shunts only"
586        ));
587    }
588    warnings.extend(super::missing_reference_warning(net));
589    warnings.extend(super::normalized_tap_warning(net));
590    // Exact compares are the point: any deviation from the symmetric, no-loss
591    // shape the round trip preserves means a field is dropped on write.
592    #[allow(clippy::float_cmp)]
593    let lossy = net
594        .storage
595        .iter()
596        .filter(|st| {
597            let p_nom = st.charge_rating.max(st.discharge_rating);
598            st.charge_rating != st.discharge_rating
599                || st.thermal_rating != p_nom
600                || st.qmin.is_finite()
601                || st.qmax.is_finite()
602                || st.r != 0.0
603                || st.x != 0.0
604                || st.p_loss != 0.0
605                || st.q_loss != 0.0
606        })
607        .count();
608    if lossy > 0 {
609        warnings.push(format!(
610            "{lossy} storage units lose fields PyPSA storage_units cannot carry (asymmetric charge/discharge ratings collapse to p_nom = max; thermal_rating, qmin/qmax, r/x, p_loss/q_loss dropped)"
611        ));
612    }
613
614    write_file(out_dir, "network.csv", &network_csv(net), &mut files)?;
615    write_file(out_dir, "snapshots.csv", ",snapshot\n0,now\n", &mut files)?;
616    write_file(out_dir, "buses.csv", &buses_csv(net, &key_of), &mut files)?;
617    write_file(
618        out_dir,
619        "generators.csv",
620        &generators_csv(net, &key_of, &mut warnings),
621        &mut files,
622    )?;
623    // The v_nom per bus, shared by the writers that rebase impedances.
624    let kv_of: HashMap<BusId, f64> = net.buses.iter().map(|b| (b.id, b.base_kv)).collect();
625    write_file(out_dir, "loads.csv", &loads_csv(net, &key_of), &mut files)?;
626    write_file(
627        out_dir,
628        "lines.csv",
629        &lines_csv(net, &key_of, &kv_of),
630        &mut files,
631    )?;
632    let transformers = transformers_csv(net, &key_of);
633    if transformers.lines().count() > 1 {
634        write_file(out_dir, "transformers.csv", &transformers, &mut files)?;
635    }
636    if !net.shunts.is_empty() {
637        write_file(
638            out_dir,
639            "shunt_impedances.csv",
640            &shunts_csv(net, &key_of, &kv_of),
641            &mut files,
642        )?;
643    }
644    if !net.storage.is_empty() {
645        write_file(
646            out_dir,
647            "storage_units.csv",
648            &storage_csv(net, &key_of),
649            &mut files,
650        )?;
651    }
652    Ok(PypsaCsvOutputs {
653        dir: out_dir.to_path_buf(),
654        files,
655        warnings,
656    })
657}
658
659fn network_csv(net: &BalancedNetwork) -> String {
660    format!(
661        "name,srid,powerio_base_mva\n{},4326,{}\n",
662        esc(&net.name),
663        net.base_mva
664    )
665}
666
667fn buses_csv(net: &BalancedNetwork, key_of: &HashMap<BusId, String>) -> String {
668    // The coordinate columns appear only when the case carries locations, so
669    // a case without geometry writes exactly as before. PyPSA defaults a
670    // missing cell to 0, so a located case writes empty cells for the odd
671    // bus without a point.
672    let write_locations = net.buses.iter().any(|b| b.location.is_some());
673    let mut s = String::from(if write_locations {
674        "name,v_nom,v_mag_pu_set,v_mag_pu_min,v_mag_pu_max,x,y\n"
675    } else {
676        "name,v_nom,v_mag_pu_set,v_mag_pu_min,v_mag_pu_max\n"
677    });
678    for b in &net.buses {
679        let _ = write!(
680            s,
681            "{},{},{},{},{}",
682            key_for(key_of, b.id),
683            b.base_kv,
684            b.vm,
685            b.vmin,
686            b.vmax
687        );
688        if write_locations {
689            match b.location {
690                Some(location) => {
691                    let _ = write!(s, ",{},{}", location.x, location.y);
692                }
693                None => s.push_str(",,"),
694            }
695        }
696        s.push('\n');
697    }
698    s
699}
700
701#[allow(clippy::too_many_lines)]
702// one column expression per PyPSA generator attribute
703// The exact mbase compare is the point: any deviation from the system base is
704// information the PyPSA table cannot carry.
705#[allow(clippy::float_cmp)]
706fn generators_csv(
707    net: &BalancedNetwork,
708    key_of: &HashMap<BusId, String>,
709    warnings: &mut Vec<String>,
710) -> String {
711    let mut s = String::from(
712        "name,bus,control,p_nom,p_set,q_set,p_min_pu,p_max_pu,marginal_cost,marginal_cost_quadratic,active,v_mag_pu_set\n",
713    );
714    let bus_kind: HashMap<BusId, BusType> = net.buses.iter().map(|b| (b.id, b.kind)).collect();
715    let mut dropped = 0usize;
716    let mut truncated = 0usize;
717    let mut empty = 0usize;
718    let mut unbounded = 0usize;
719    for (i, g) in net.generators.iter().enumerate() {
720        let p_nom = if g.pmax.is_finite() && g.pmax > 0.0 {
721            g.pmax
722        } else {
723            g.pg.abs().max(1.0)
724        };
725        // Keep the LOWEST order terms: a polynomial's coeffs run high to low.
726        let (c2, c1) = match g.cost.as_ref() {
727            Some(c) if c.model == 2 => {
728                let n = c.coeffs.len();
729                if n == 0 {
730                    empty += 1;
731                } else if n > 3 {
732                    truncated += 1;
733                }
734                (
735                    if n >= 3 { c.coeffs[n - 3] } else { 0.0 },
736                    if n >= 2 { c.coeffs[n - 2] } else { 0.0 },
737                )
738            }
739            Some(_) => {
740                dropped += 1;
741                (0.0, 0.0)
742            }
743            None => (0.0, 0.0),
744        };
745        let _ = writeln!(
746            s,
747            "gen_{},{},{},{},{},{},{},{},{},{},{},{}",
748            i + 1,
749            key_for(key_of, g.bus),
750            match bus_kind.get(&g.bus).copied() {
751                Some(BusType::Ref) => "Slack",
752                Some(BusType::Pv) => "PV",
753                _ => "PQ",
754            },
755            p_nom,
756            g.pg,
757            g.qg,
758            if p_nom == 0.0 || !g.pmin.is_finite() {
759                if !g.pmin.is_finite() {
760                    unbounded += 1;
761                }
762                0.0
763            } else {
764                g.pmin / p_nom
765            },
766            if p_nom == 0.0 || !g.pmax.is_finite() {
767                if !g.pmax.is_finite() {
768                    unbounded += 1;
769                }
770                1.0
771            } else {
772                g.pmax / p_nom
773            },
774            c1,
775            c2,
776            g.in_service,
777            g.vg
778        );
779    }
780    if dropped > 0 {
781        warnings.push(format!(
782            "{dropped} generator costs dropped: PyPSA carries marginal_cost/marginal_cost_quadratic (model 2) only"
783        ));
784    }
785    if truncated > 0 {
786        warnings.push(format!(
787            "{truncated} generator costs truncated to quadratic for PyPSA marginal cost columns"
788        ));
789    }
790    if empty > 0 {
791        warnings.push(format!(
792            "{empty} generator costs had no coefficients and were written as zero"
793        ));
794    }
795    if unbounded > 0 {
796        warnings.push(format!(
797            "{unbounded} non-finite generator p limit(s) written as the PyPSA defaults (p_min_pu 0, p_max_pu 1)"
798        ));
799    }
800    let q_limited = net
801        .generators
802        .iter()
803        .filter(|g| g.qmin.is_finite() || g.qmax.is_finite())
804        .count();
805    if q_limited > 0 {
806        warnings.push(format!(
807            "{q_limited} generator reactive limit(s) dropped: PyPSA generators carry no q bounds"
808        ));
809    }
810    let off_base = net
811        .generators
812        .iter()
813        .filter(|g| g.mbase != 0.0 && g.mbase != net.base_mva)
814        .count();
815    if off_base > 0 {
816        warnings.push(format!(
817            "{off_base} generator machine base(s) (mbase) dropped: PyPSA carries no per generator MVA base"
818        ));
819    }
820    s
821}
822
823fn loads_csv(net: &BalancedNetwork, key_of: &HashMap<BusId, String>) -> String {
824    let mut s = String::from("name,bus,p_set,q_set,active\n");
825    for (i, l) in net.loads.iter().enumerate() {
826        let _ = writeln!(
827            s,
828            "load_{},{},{},{},{}",
829            i + 1,
830            key_for(key_of, l.bus),
831            l.p,
832            l.q,
833            l.in_service
834        );
835    }
836    s
837}
838
839fn pypsa_loses_terminal_charging(br: &Branch) -> bool {
840    let charging = br.terminal_charging();
841    if br.is_transformer() {
842        charging.g_to.abs() > f64::EPSILON || charging.b_to.abs() > f64::EPSILON
843    } else {
844        (charging.g_fr - charging.g_to).abs() > f64::EPSILON
845            || (charging.b_fr - charging.b_to).abs() > f64::EPSILON
846    }
847}
848
849fn lines_csv(
850    net: &BalancedNetwork,
851    key_of: &HashMap<BusId, String>,
852    kv_of: &HashMap<BusId, f64>,
853) -> String {
854    let mut s = String::from("name,bus0,bus1,r,x,b,g,s_nom,v_ang_min,v_ang_max,active\n");
855    for (i, br) in net
856        .branches
857        .iter()
858        .enumerate()
859        .filter(|(_, b)| !b.is_transformer())
860    {
861        // PyPSA per-unitizes line ohms on the BUS0 v_nom, not bus1.
862        let zb = zbase(*kv_of.get(&br.from).unwrap_or(&0.0), net.base_mva);
863        let charging = br.terminal_charging();
864        let _ = writeln!(
865            s,
866            "line_{},{},{},{},{},{},{},{},{},{},{}",
867            i + 1,
868            key_for(key_of, br.from),
869            key_for(key_of, br.to),
870            br.r * zb,
871            br.x * zb,
872            charging.total_b() / zb,
873            (charging.g_fr + charging.g_to) / zb,
874            br.rate_a,
875            br.angmin,
876            br.angmax,
877            br.in_service
878        );
879    }
880    s
881}
882
883fn transformers_csv(net: &BalancedNetwork, key_of: &HashMap<BusId, String>) -> String {
884    let mut s = String::from("name,bus0,bus1,r,x,b,g,s_nom,tap_ratio,phase_shift,active\n");
885    for (i, br) in net
886        .branches
887        .iter()
888        .enumerate()
889        .filter(|(_, b)| b.is_transformer())
890    {
891        // PyPSA wants impedances per unit on the transformer's own s_nom base
892        // and a positive s_nom; rate_a == 0 (unlimited) falls back to the
893        // system base so the rebase is the identity.
894        let s_nom = if br.rate_a > 0.0 {
895            br.rate_a
896        } else {
897            net.base_mva
898        };
899        let charging = br.charging.unwrap_or(BranchCharging {
900            g_fr: 0.0,
901            b_fr: br.total_charging_b(),
902            g_to: 0.0,
903            b_to: 0.0,
904        });
905        let _ = writeln!(
906            s,
907            "transformer_{},{},{},{},{},{},{},{},{},{},{}",
908            i + 1,
909            key_for(key_of, br.from),
910            key_for(key_of, br.to),
911            br.r * s_nom / net.base_mva,
912            br.x * s_nom / net.base_mva,
913            charging.b_fr * net.base_mva / s_nom,
914            charging.g_fr * net.base_mva / s_nom,
915            s_nom,
916            br.effective_tap(),
917            br.shift,
918            br.in_service
919        );
920    }
921    s
922}
923
924fn shunts_csv(
925    net: &BalancedNetwork,
926    key_of: &HashMap<BusId, String>,
927    kv_of: &HashMap<BusId, f64>,
928) -> String {
929    let mut s = String::from("name,bus,g,b,active\n");
930    for (i, sh) in net.shunts.iter().enumerate() {
931        let zb = zbase(*kv_of.get(&sh.bus).unwrap_or(&0.0), net.base_mva);
932        let _ = writeln!(
933            s,
934            "shunt_{},{},{},{},{}",
935            i + 1,
936            key_for(key_of, sh.bus),
937            sh.g / (zb * net.base_mva),
938            sh.b / (zb * net.base_mva),
939            sh.in_service
940        );
941    }
942    s
943}
944
945fn storage_csv(net: &BalancedNetwork, key_of: &HashMap<BusId, String>) -> String {
946    let mut s = String::from(
947        "name,bus,p_nom,max_hours,p_set,q_set,state_of_charge_initial,efficiency_store,efficiency_dispatch,cyclic_state_of_charge\n",
948    );
949    for (i, st) in net.storage.iter().enumerate() {
950        let p_nom = st.charge_rating.max(st.discharge_rating);
951        let max_hours = if p_nom > 0.0 {
952            st.energy_rating / p_nom
953        } else {
954            0.0
955        };
956        let _ = writeln!(
957            s,
958            "storage_{},{},{},{},{},{},{},{},{},false",
959            i + 1,
960            key_for(key_of, st.bus),
961            p_nom,
962            max_hours,
963            st.ps,
964            st.qs,
965            st.energy,
966            st.charge_efficiency,
967            st.discharge_efficiency
968        );
969    }
970    s
971}
972
973fn write_file(dir: &Path, name: &str, text: &str, files: &mut Vec<PathBuf>) -> Result<()> {
974    let path = dir.join(name);
975    std::fs::write(&path, text)?;
976    files.push(path);
977    Ok(())
978}
979
980#[derive(Debug)]
981struct CsvTable {
982    rows: Vec<CsvRow>,
983}
984
985#[derive(Debug)]
986struct CsvRow {
987    vals: HashMap<String, String>,
988}
989
990impl CsvRow {
991    fn get(&self, key: &str) -> Option<&String> {
992        self.vals.get(key).filter(|s| !s.is_empty())
993    }
994    fn f(&self, key: &str) -> Option<f64> {
995        self.get(key).and_then(|s| s.parse().ok())
996    }
997    fn bool(&self, key: &str) -> Option<bool> {
998        self.get(key)
999            .and_then(|s| match s.to_ascii_lowercase().as_str() {
1000                "true" | "1" => Some(true),
1001                "false" | "0" => Some(false),
1002                _ => None,
1003            })
1004    }
1005}
1006
1007fn bad(message: impl Into<String>) -> Error {
1008    Error::FormatRead {
1009        format: FMT,
1010        message: message.into(),
1011    }
1012}
1013
1014fn read_csv_required(path: &Path, label: &'static str) -> Result<CsvTable> {
1015    read_csv_optional(path)?.ok_or_else(|| bad(format!("missing required `{label}`")))
1016}
1017
1018fn read_csv_optional(path: &Path) -> Result<Option<CsvTable>> {
1019    // Only a missing file means an absent table; any other error (permissions,
1020    // a directory in the file's place) must surface, not read as an empty net.
1021    let text = match std::fs::read_to_string(path) {
1022        Ok(text) => text,
1023        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1024        Err(e) => return Err(e.into()),
1025    };
1026    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("csv");
1027    let mut records = parse_csv(&text, name)?
1028        .into_iter()
1029        .filter(|r| !(r.len() == 1 && r[0].trim().is_empty()));
1030    let Some(headers) = records.next() else {
1031        return Ok(Some(CsvTable { rows: Vec::new() }));
1032    };
1033    let mut rows = Vec::new();
1034    for fields in records {
1035        let vals = headers
1036            .iter()
1037            .enumerate()
1038            .map(|(i, h)| (h.clone(), fields.get(i).cloned().unwrap_or_default()))
1039            .collect();
1040        rows.push(CsvRow { vals });
1041    }
1042    Ok(Some(CsvTable { rows }))
1043}
1044
1045/// Split a whole CSV file into records, honoring quoted fields: an embedded
1046/// newline or comma inside `"..."` stays in the field (the writer's `esc` emits
1047/// those), and `""` is an escaped quote. A quote left open at end of input is
1048/// malformed CSV — everything after it would silently parse as one literal
1049/// field — so it is an error, not a best-effort record.
1050fn parse_csv(text: &str, name: &str) -> Result<Vec<Vec<String>>> {
1051    let mut records = Vec::new();
1052    let mut record = Vec::new();
1053    let mut cur = String::new();
1054    let mut quoted = false;
1055    let mut chars = text.chars().peekable();
1056    while let Some(c) = chars.next() {
1057        match c {
1058            '"' if quoted && chars.peek() == Some(&'"') => {
1059                cur.push('"');
1060                let _ = chars.next();
1061            }
1062            '"' => quoted = !quoted,
1063            ',' if !quoted => record.push(std::mem::take(&mut cur)),
1064            '\r' if !quoted && chars.peek() == Some(&'\n') => {}
1065            '\n' if !quoted => {
1066                record.push(std::mem::take(&mut cur));
1067                records.push(std::mem::take(&mut record));
1068            }
1069            _ => cur.push(c),
1070        }
1071    }
1072    if quoted {
1073        return Err(bad(format!(
1074            "{name}: unterminated quoted field (unbalanced `\"`)"
1075        )));
1076    }
1077    if !cur.is_empty() || !record.is_empty() {
1078        record.push(cur);
1079        records.push(record);
1080    }
1081    Ok(records)
1082}
1083
1084/// The collision-free PyPSA key for a bus: its name when it has one, else its
1085/// numeric id. Tests build `key_of` maps with it; the writer derives keys with
1086/// the collision fallback in `write_pypsa_csv_folder` instead.
1087#[cfg(test)]
1088fn bus_key(b: &Bus) -> String {
1089    b.name.clone().unwrap_or_else(|| b.id.0.to_string())
1090}
1091
1092/// The bus column an element table writes, escaped: the same key `buses.csv`
1093/// is indexed on, falling back to the raw id for a reference to a missing bus.
1094fn key_for(key_of: &HashMap<BusId, String>, bus: BusId) -> String {
1095    key_of
1096        .get(&bus)
1097        .map_or_else(|| bus.0.to_string(), |k| esc(k))
1098}
1099
1100fn esc(s: &str) -> String {
1101    if s.contains([',', '"', '\n']) {
1102        format!("\"{}\"", s.replace('"', "\"\""))
1103    } else {
1104        s.to_string()
1105    }
1106}
1107
1108fn bus_ref(
1109    file: &'static str,
1110    n: usize,
1111    row: &CsvRow,
1112    key: &str,
1113    id_of_name: &HashMap<String, BusId>,
1114) -> Result<BusId> {
1115    let raw = row
1116        .get(key)
1117        .ok_or_else(|| bad(format!("{file} row {n}: missing bus reference `{key}`")))?;
1118    id_of_name.get(raw).copied().ok_or_else(|| {
1119        bad(format!(
1120            "{file} row {n}: column `{key}` references unknown bus `{raw}`"
1121        ))
1122    })
1123}
1124
1125#[cfg(test)]
1126// Exact float compares are the point: a mapped value deviating from the
1127// fixture arithmetic means a column was misread.
1128#[allow(clippy::float_cmp)]
1129mod tests {
1130    use super::*;
1131    use std::fs;
1132
1133    fn tmp_dir(label: &str) -> PathBuf {
1134        let p =
1135            std::env::temp_dir().join(format!("powerio-pypsa-unit-{label}-{}", std::process::id()));
1136        let _ = fs::remove_dir_all(&p);
1137        fs::create_dir_all(&p).unwrap();
1138        p
1139    }
1140
1141    fn folder(label: &str, files: &[(&str, &str)]) -> PathBuf {
1142        let dir = tmp_dir(label);
1143        for (name, text) in files {
1144            fs::write(dir.join(name), text).unwrap();
1145        }
1146        dir
1147    }
1148
1149    fn close(a: f64, b: f64) {
1150        assert!((a - b).abs() < 1e-12, "{a} vs {b}");
1151    }
1152
1153    fn bus(id: usize, name: Option<&str>) -> Bus {
1154        Bus {
1155            id: BusId(id),
1156            kind: BusType::Pq,
1157            vm: 1.0,
1158            va: 0.0,
1159            base_kv: 110.0,
1160            vmax: 1.1,
1161            vmin: 0.9,
1162            evhi: None,
1163            evlo: None,
1164            area: 1,
1165            zone: 1,
1166            name: name.map(str::to_string),
1167            uid: None,
1168            location: None,
1169            extras: Extras::default(),
1170        }
1171    }
1172
1173    fn make_gen(bus: usize, cost: Option<GenCost>) -> Generator {
1174        Generator {
1175            bus: BusId(bus),
1176            pg: 1.0,
1177            qg: 0.0,
1178            pmax: 10.0,
1179            pmin: 0.0,
1180            qmax: f64::INFINITY,
1181            qmin: f64::NEG_INFINITY,
1182            vg: 1.0,
1183            mbase: 100.0,
1184            in_service: true,
1185            cost,
1186            caps: [None; crate::network::GEN_EXTRA_KEYS.len()],
1187            regulated_bus: None,
1188            uid: None,
1189        }
1190    }
1191
1192    fn storage_unit(bus: usize) -> Storage {
1193        Storage {
1194            bus: BusId(bus),
1195            ps: 3.0,
1196            qs: 1.5,
1197            energy: 20.0,
1198            energy_rating: 100.0,
1199            charge_rating: 25.0,
1200            discharge_rating: 25.0,
1201            charge_efficiency: 0.91,
1202            discharge_efficiency: 0.92,
1203            thermal_rating: 25.0,
1204            current_rating: None,
1205            qmin: f64::NEG_INFINITY,
1206            qmax: f64::INFINITY,
1207            r: 0.0,
1208            x: 0.0,
1209            p_loss: 0.0,
1210            q_loss: 0.0,
1211            in_service: true,
1212            uid: None,
1213            extras: Extras::default(),
1214        }
1215    }
1216
1217    fn xfmr(from: usize, to: usize, rate_a: f64) -> Branch {
1218        Branch {
1219            from: BusId(from),
1220            to: BusId(to),
1221            r: 0.125,
1222            x: 0.5,
1223            b: 0.25,
1224            charging: None,
1225            rate_a,
1226            rate_b: 0.0,
1227            rate_c: 0.0,
1228            rating_sets: Vec::new(),
1229            current_ratings: None,
1230            tap: 1.05,
1231            shift: 0.0,
1232            in_service: true,
1233            angmin: -360.0,
1234            angmax: 360.0,
1235            control: None,
1236            solution: None,
1237            uid: None,
1238            route: None,
1239            extras: Extras::default(),
1240        }
1241    }
1242
1243    fn line(from: usize, to: usize) -> Branch {
1244        Branch {
1245            from: BusId(from),
1246            to: BusId(to),
1247            r: 0.01,
1248            x: 0.1,
1249            b: 0.2,
1250            charging: None,
1251            rate_a: 100.0,
1252            rate_b: 0.0,
1253            rate_c: 0.0,
1254            rating_sets: Vec::new(),
1255            current_ratings: None,
1256            tap: 0.0,
1257            shift: 0.0,
1258            in_service: true,
1259            angmin: -360.0,
1260            angmax: 360.0,
1261            control: None,
1262            solution: None,
1263            uid: None,
1264            route: None,
1265            extras: Extras::default(),
1266        }
1267    }
1268
1269    fn net_with(buses: Vec<Bus>) -> BalancedNetwork {
1270        BalancedNetwork::in_memory("t", 100.0, buses, Vec::new())
1271    }
1272
1273    #[test]
1274    fn scheme_a_keeps_numeric_ids() {
1275        let dir = folder(
1276            "scheme-a",
1277            &[
1278                ("buses.csv", "name,v_nom\n5,110\n2,110\n"),
1279                ("loads.csv", "name,bus,p_set\nd1,5,7\n"),
1280            ],
1281        );
1282        let net = read_pypsa_csv_folder(&dir).unwrap().network;
1283        assert_eq!(net.buses[0].id, BusId(5));
1284        assert_eq!(net.buses[1].id, BusId(2));
1285        assert!(net.buses[0].name.is_none());
1286        assert_eq!(net.loads[0].bus, BusId(5));
1287    }
1288
1289    #[test]
1290    fn scheme_b_on_mixed_names_never_mixes() {
1291        let dir = folder(
1292            "scheme-b",
1293            &[
1294                ("buses.csv", "name,v_nom\n2,110\nb,110\n"),
1295                ("loads.csv", "name,bus,p_set\nd1,2,7\n"),
1296            ],
1297        );
1298        let net = read_pypsa_csv_folder(&dir).unwrap().network;
1299        assert_eq!(net.buses[0].id, BusId(1));
1300        assert_eq!(net.buses[1].id, BusId(2));
1301        assert_eq!(net.buses[0].name.as_deref(), Some("2"));
1302        assert_eq!(net.buses[1].name.as_deref(), Some("b"));
1303        // "2" resolves by name to the first bus, not numerically to the second.
1304        assert_eq!(net.loads[0].bus, BusId(1));
1305    }
1306
1307    #[test]
1308    fn duplicate_bus_name_errors() {
1309        let dir = folder("dup-name", &[("buses.csv", "name,v_nom\nn1,110\nn1,110\n")]);
1310        let err = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
1311        assert!(err.contains("duplicate bus name `n1`"), "{err}");
1312    }
1313
1314    #[test]
1315    fn missing_bus_name_errors() {
1316        let dir = folder("no-name", &[("buses.csv", "name,v_nom\n,110\n")]);
1317        let err = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
1318        assert!(err.contains("buses.csv row 1: missing bus name"), "{err}");
1319    }
1320
1321    #[test]
1322    fn unknown_bus_reference_errors_no_numeric_fallback() {
1323        let dir = folder(
1324            "unknown-ref",
1325            &[
1326                ("buses.csv", "name,v_nom\n1,110\n"),
1327                ("loads.csv", "name,bus,p_set\nd1,7,5\n"),
1328            ],
1329        );
1330        let err = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
1331        assert!(
1332            err.contains("loads.csv row 1: column `bus` references unknown bus `7`"),
1333            "{err}"
1334        );
1335    }
1336
1337    #[test]
1338    fn missing_bus_reference_errors() {
1339        let dir = folder(
1340            "missing-ref",
1341            &[
1342                ("buses.csv", "name,v_nom\n1,110\n"),
1343                ("loads.csv", "name,p_set\nd1,5\n"),
1344            ],
1345        );
1346        let err = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
1347        assert!(
1348            err.contains("loads.csv row 1: missing bus reference `bus`"),
1349            "{err}"
1350        );
1351    }
1352
1353    #[test]
1354    fn control_sets_bus_kind_pq_untouched() {
1355        let dir = folder(
1356            "control",
1357            &[
1358                ("buses.csv", "name,v_nom\n1,110\n2,110\n3,110\n"),
1359                (
1360                    "generators.csv",
1361                    "name,bus,control,p_set\ng1,1,slack,1\ng2,2,pv,1\ng3,3,PQ,1\n",
1362                ),
1363            ],
1364        );
1365        let net = read_pypsa_csv_folder(&dir).unwrap().network;
1366        assert_eq!(net.buses[0].kind, BusType::Ref);
1367        assert_eq!(net.buses[1].kind, BusType::Pv);
1368        assert_eq!(net.buses[2].kind, BusType::Pq);
1369    }
1370
1371    #[test]
1372    fn transformer_read_rebases_to_system_base() {
1373        let dir = folder(
1374            "xf-read",
1375            &[
1376                ("network.csv", "name,powerio_base_mva\nt,100\n"),
1377                ("buses.csv", "name,v_nom\n1,110\n2,110\n"),
1378                (
1379                    "transformers.csv",
1380                    "name,bus0,bus1,r,x,b,g,s_nom,tap_ratio,phase_shift,active\nt1,1,2,0.0625,0.25,0.5,0.1,50,1.05,0,True\n",
1381                ),
1382            ],
1383        );
1384        let parsed = read_pypsa_csv_folder(&dir).unwrap();
1385        let br = &parsed.network.branches[0];
1386        close(br.r, 0.125); // 0.0625 * 100/50
1387        close(br.x, 0.5);
1388        close(br.b, 0.25); // 0.5 * 50/100
1389        close(br.terminal_charging().g_fr, 0.05);
1390        close(br.terminal_charging().b_fr, 0.25);
1391        close(br.terminal_charging().g_to, 0.0);
1392        assert_eq!(br.rate_a, 50.0);
1393        assert_eq!(br.tap, 1.05);
1394        assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
1395    }
1396
1397    #[test]
1398    fn transformer_read_rejects_nonpositive_s_nom() {
1399        let dir = folder(
1400            "xf-snom",
1401            &[
1402                ("buses.csv", "name,v_nom\n1,110\n2,110\n"),
1403                (
1404                    "transformers.csv",
1405                    "name,bus0,bus1,r,x,s_nom,tap_ratio\nt1,1,2,0.1,0.2,0,1.05\n",
1406                ),
1407            ],
1408        );
1409        let err = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
1410        assert!(
1411            err.contains(
1412                "transformers.csv row 1 (`t1`): s_nom must be positive to rebase impedances (got 0)"
1413            ),
1414            "{err}"
1415        );
1416    }
1417
1418    #[test]
1419    fn line_g_maps_to_terminal_conductance() {
1420        let dir = folder(
1421            "line-g",
1422            &[
1423                ("buses.csv", "name,v_nom\n1,110\n2,110\n"),
1424                (
1425                    "lines.csv",
1426                    "name,bus0,bus1,r,x,g,s_nom\nl1,1,2,0.1,0.2,0.3,100\n",
1427                ),
1428            ],
1429        );
1430        let parsed = read_pypsa_csv_folder(&dir).unwrap();
1431        let charging = parsed.network.branches[0].terminal_charging();
1432        close(charging.g_fr, 1815.0);
1433        close(charging.g_to, 1815.0);
1434        assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
1435    }
1436
1437    #[test]
1438    fn transformer_write_rebases_to_s_nom_base() {
1439        let mut net = net_with(vec![bus(1, None), bus(2, None)]);
1440        net.branches = vec![xfmr(1, 2, 50.0)];
1441        let key_of: HashMap<BusId, String> = net.buses.iter().map(|b| (b.id, bus_key(b))).collect();
1442        let csv = transformers_csv(&net, &key_of);
1443        assert_eq!(
1444            csv.lines().nth(1).unwrap(),
1445            "transformer_1,1,2,0.0625,0.25,0.5,0,50,1.05,0,true"
1446        );
1447    }
1448
1449    #[test]
1450    fn transformer_write_zero_rate_a_uses_base_mva() {
1451        let mut net = net_with(vec![bus(1, None), bus(2, None)]);
1452        net.branches = vec![xfmr(1, 2, 0.0)];
1453        let key_of: HashMap<BusId, String> = net.buses.iter().map(|b| (b.id, bus_key(b))).collect();
1454        let csv = transformers_csv(&net, &key_of);
1455        assert_eq!(
1456            csv.lines().nth(1).unwrap(),
1457            "transformer_1,1,2,0.125,0.5,0.25,0,100,1.05,0,true"
1458        );
1459    }
1460
1461    #[test]
1462    fn transformer_legacy_b_warns_about_terminal_charging_collapse() {
1463        let mut net = net_with(vec![bus(1, None), bus(2, None)]);
1464        net.branches = vec![xfmr(1, 2, 50.0)];
1465
1466        let out = write_pypsa_csv_folder(&net, tmp_dir("xf-legacy-b-warning")).unwrap();
1467
1468        assert!(
1469            out.warnings
1470                .iter()
1471                .any(|w| w.contains("terminal admittance")),
1472            "{:?}",
1473            out.warnings
1474        );
1475    }
1476
1477    #[test]
1478    fn line_conductance_writes_and_round_trips() {
1479        let mut net = net_with(vec![bus(1, None), bus(2, None)]);
1480        let mut br = line(1, 2);
1481        br.charging = Some(BranchCharging {
1482            g_fr: 0.4,
1483            b_fr: 0.1,
1484            g_to: 0.4,
1485            b_to: 0.1,
1486        });
1487        net.branches = vec![br];
1488        let dir = tmp_dir("line-g-write");
1489        let out = write_pypsa_csv_folder(&net, &dir).unwrap();
1490        assert!(
1491            !out.warnings
1492                .iter()
1493                .any(|w| w.contains("terminal admittance")),
1494            "{:?}",
1495            out.warnings
1496        );
1497        let text = fs::read_to_string(dir.join("lines.csv")).unwrap();
1498        assert_eq!(
1499            text.lines().next().unwrap(),
1500            "name,bus0,bus1,r,x,b,g,s_nom,v_ang_min,v_ang_max,active"
1501        );
1502
1503        let back = read_pypsa_csv_folder(&dir).unwrap().network;
1504        let charging = back.branches[0].terminal_charging();
1505        close(charging.g_fr, 0.4);
1506        close(charging.g_to, 0.4);
1507        close(charging.b_fr, 0.1);
1508        close(charging.b_to, 0.1);
1509    }
1510
1511    #[test]
1512    fn transformer_conductance_writes_and_round_trips() {
1513        let mut net = net_with(vec![bus(1, None), bus(2, None)]);
1514        let mut br = xfmr(1, 2, 50.0);
1515        br.charging = Some(BranchCharging {
1516            g_fr: 0.05,
1517            b_fr: 0.25,
1518            g_to: 0.0,
1519            b_to: 0.0,
1520        });
1521        net.branches = vec![br];
1522        let dir = tmp_dir("xf-g-write");
1523        let out = write_pypsa_csv_folder(&net, &dir).unwrap();
1524        assert!(
1525            !out.warnings
1526                .iter()
1527                .any(|w| w.contains("terminal admittance")),
1528            "{:?}",
1529            out.warnings
1530        );
1531
1532        let back = read_pypsa_csv_folder(&dir).unwrap().network;
1533        let charging = back.branches[0].terminal_charging();
1534        close(charging.g_fr, 0.05);
1535        close(charging.g_to, 0.0);
1536        close(charging.b_fr, 0.25);
1537        close(charging.b_to, 0.0);
1538    }
1539
1540    #[test]
1541    fn storage_write_fields_and_round_trip() {
1542        let mut net = net_with(vec![bus(1, None)]);
1543        net.storage = vec![storage_unit(1)];
1544        let dir = tmp_dir("storage-rt");
1545        let out = write_pypsa_csv_folder(&net, &dir).unwrap();
1546        assert!(
1547            !out.warnings.iter().any(|w| w.contains("storage units")),
1548            "{:?}",
1549            out.warnings
1550        );
1551        let text = fs::read_to_string(dir.join("storage_units.csv")).unwrap();
1552        assert_eq!(
1553            text.lines().next().unwrap(),
1554            "name,bus,p_nom,max_hours,p_set,q_set,state_of_charge_initial,efficiency_store,efficiency_dispatch,cyclic_state_of_charge"
1555        );
1556        assert_eq!(
1557            text.lines().nth(1).unwrap(),
1558            "storage_1,1,25,4,3,1.5,20,0.91,0.92,false"
1559        );
1560        let back = read_pypsa_csv_folder(&dir).unwrap().network;
1561        let st = &back.storage[0];
1562        assert_eq!(st.charge_rating, 25.0);
1563        assert_eq!(st.discharge_rating, 25.0);
1564        assert_eq!(st.energy_rating, 100.0);
1565        assert_eq!(st.ps, 3.0);
1566        assert_eq!(st.qs, 1.5);
1567        assert_eq!(st.energy, 20.0);
1568    }
1569
1570    #[test]
1571    fn storage_write_lossy_warning_counts() {
1572        let mut net = net_with(vec![bus(1, None)]);
1573        let mut st = storage_unit(1);
1574        st.charge_rating = 10.0;
1575        st.discharge_rating = 20.0;
1576        st.thermal_rating = 20.0;
1577        net.storage = vec![st];
1578        let out = write_pypsa_csv_folder(&net, tmp_dir("storage-lossy")).unwrap();
1579        assert!(
1580            out.warnings.iter().any(|w| w
1581                == "1 storage units lose fields PyPSA storage_units cannot carry (asymmetric charge/discharge ratings collapse to p_nom = max; thermal_rating, qmin/qmax, r/x, p_loss/q_loss dropped)"),
1582            "{:?}",
1583            out.warnings
1584        );
1585    }
1586
1587    #[test]
1588    fn named_buses_join_on_write() {
1589        let mut net = net_with(vec![bus(1, Some("North")), bus(2, None)]);
1590        net.generators = vec![make_gen(1, None)];
1591        net.loads = vec![Load {
1592            bus: BusId(2),
1593            p: 5.0,
1594            q: 1.0,
1595            voltage_model: None,
1596            in_service: true,
1597            uid: None,
1598            extras: Extras::default(),
1599        }];
1600        let dir = tmp_dir("named-join");
1601        write_pypsa_csv_folder(&net, &dir).unwrap();
1602        let buses = fs::read_to_string(dir.join("buses.csv")).unwrap();
1603        assert!(buses.lines().nth(1).unwrap().starts_with("North,"));
1604        let gens = fs::read_to_string(dir.join("generators.csv")).unwrap();
1605        assert!(gens.lines().nth(1).unwrap().contains(",North,"), "{gens}");
1606        let back = read_pypsa_csv_folder(&dir).unwrap().network;
1607        assert_eq!(back.buses[0].name.as_deref(), Some("North"));
1608        assert_eq!(back.loads[0].bus, back.buses[1].id);
1609    }
1610
1611    #[test]
1612    fn duplicate_bus_names_fall_back_to_ids() {
1613        let mut net = net_with(vec![bus(1, Some("X")), bus(2, Some("X"))]);
1614        net.loads = vec![Load {
1615            bus: BusId(2),
1616            p: 5.0,
1617            q: 1.0,
1618            voltage_model: None,
1619            in_service: true,
1620            uid: None,
1621            extras: Extras::default(),
1622        }];
1623        let dir = tmp_dir("dup-keys");
1624        let out = write_pypsa_csv_folder(&net, &dir).unwrap();
1625        assert!(
1626            out.warnings.iter().any(|w| w
1627                == "buses.csv: bus names `X` collide with another bus name or id; those buses are keyed by their numeric id instead"),
1628            "{:?}",
1629            out.warnings
1630        );
1631        let buses = fs::read_to_string(dir.join("buses.csv")).unwrap();
1632        let keys: Vec<&str> = buses
1633            .lines()
1634            .skip(1)
1635            .map(|l| l.split(',').next().unwrap())
1636            .collect();
1637        assert_eq!(keys, ["1", "2"]);
1638        // The folder is importable: elements join on the fallback keys.
1639        let back = read_pypsa_csv_folder(&dir).unwrap().network;
1640        assert_eq!(back.loads[0].bus, back.buses[1].id);
1641    }
1642
1643    #[test]
1644    fn unterminated_quote_is_an_error() {
1645        let dir = folder(
1646            "bad-quote",
1647            &[("buses.csv", "name,v_nom\n\"bus one,110\n2,110\n")],
1648        );
1649        let msg = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
1650        assert!(
1651            msg.contains("buses.csv: unterminated quoted field (unbalanced `\"`)"),
1652            "{msg}"
1653        );
1654    }
1655
1656    #[test]
1657    fn quadratic_only_marginal_cost_is_kept() {
1658        // PyPSA defaults marginal_cost to 0; a quadratic-only file still
1659        // carries a real cost curve.
1660        let dir = folder(
1661            "quad-cost",
1662            &[
1663                ("buses.csv", "name,v_nom\n1,110\n"),
1664                (
1665                    "generators.csv",
1666                    "name,bus,p_nom,marginal_cost_quadratic\ng1,1,50,0.25\n",
1667                ),
1668            ],
1669        );
1670        let parsed = read_pypsa_csv_folder(&dir).unwrap();
1671        let cost = parsed.network.generators[0].cost.as_ref().unwrap();
1672        assert_eq!(cost.coeffs, vec![0.25, 0.0, 0.0]);
1673    }
1674
1675    #[test]
1676    fn bus_name_matching_another_bus_id_falls_back() {
1677        // A bus literally named "2" would collide with bus id 2's key.
1678        let net = net_with(vec![bus(1, Some("2")), bus(2, None)]);
1679        let dir = tmp_dir("name-id-clash");
1680        let out = write_pypsa_csv_folder(&net, &dir).unwrap();
1681        assert!(
1682            out.warnings.iter().any(|w| w.contains("`2`")),
1683            "{:?}",
1684            out.warnings
1685        );
1686        let buses = fs::read_to_string(dir.join("buses.csv")).unwrap();
1687        let keys: Vec<&str> = buses
1688            .lines()
1689            .skip(1)
1690            .map(|l| l.split(',').next().unwrap())
1691            .collect();
1692        assert_eq!(keys, ["1", "2"]);
1693    }
1694
1695    #[test]
1696    fn links_read_as_hvdc_with_warning() {
1697        let dir = folder(
1698            "links",
1699            &[
1700                ("buses.csv", "name,v_nom\n1,110\n2,110\n"),
1701                (
1702                    "links.csv",
1703                    "name,bus0,bus1,p_set,p_nom,p_min_pu,p_max_pu,efficiency,active\nl1,1,2,10,50,-1,1,0.97,True\n",
1704                ),
1705            ],
1706        );
1707        let parsed = read_pypsa_csv_folder(&dir).unwrap();
1708        let h = &parsed.network.hvdc[0];
1709        assert_eq!(h.from, BusId(1));
1710        assert_eq!(h.to, BusId(2));
1711        assert_eq!(h.pf, 10.0);
1712        close(h.pt, 9.7);
1713        close(h.pmin, -50.0);
1714        close(h.pmax, 50.0);
1715        assert_eq!(h.loss0, 0.0);
1716        close(h.loss1, 0.03);
1717        assert_eq!(h.vf, 1.0);
1718        assert_eq!(h.qf, 0.0);
1719        assert!(h.in_service);
1720        assert!(
1721            parsed.warnings.iter().any(|w| w
1722                == "links.csv: 1 links read as HVDC lines; PyPSA links carry no reactive or voltage data (q limits 0, voltage setpoints 1.0)"),
1723            "{:?}",
1724            parsed.warnings
1725        );
1726    }
1727
1728    #[test]
1729    fn stores_warning_gated_on_nonempty() {
1730        let dir = folder(
1731            "stores-empty",
1732            &[
1733                ("buses.csv", "name,v_nom\n1,110\n"),
1734                ("stores.csv", "name,bus,e_nom\n"),
1735            ],
1736        );
1737        assert!(read_pypsa_csv_folder(&dir).unwrap().warnings.is_empty());
1738        let dir = folder(
1739            "stores-nonempty",
1740            &[
1741                ("buses.csv", "name,v_nom\n1,110\n"),
1742                ("stores.csv", "name,bus,e_nom\ns1,1,10\n"),
1743            ],
1744        );
1745        let parsed = read_pypsa_csv_folder(&dir).unwrap();
1746        assert!(
1747            parsed
1748                .warnings
1749                .iter()
1750                .any(|w| w == "stores.csv ignored (1 rows): PyPSA stores are not mapped"),
1751            "{:?}",
1752            parsed.warnings
1753        );
1754    }
1755
1756    #[test]
1757    fn header_only_buses_is_an_empty_case() {
1758        let dir = folder("empty", &[("buses.csv", "name,v_nom\n")]);
1759        let err = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
1760        assert!(err.contains("case has no buses"), "{err}");
1761    }
1762
1763    #[test]
1764    fn cost_write_keeps_low_order_terms_and_warns() {
1765        let mut net = net_with(vec![bus(1, None), bus(2, None)]);
1766        net.generators = vec![
1767            make_gen(
1768                1,
1769                Some(GenCost {
1770                    model: 2,
1771                    startup: 0.0,
1772                    shutdown: 0.0,
1773                    ncost: 4,
1774                    coeffs: vec![5.0, 4.0, 3.0, 2.0], // cubic: keep (c2, c1) = (4, 3)
1775                }),
1776            ),
1777            make_gen(
1778                2,
1779                Some(GenCost {
1780                    model: 1,
1781                    startup: 0.0,
1782                    shutdown: 0.0,
1783                    ncost: 2,
1784                    coeffs: vec![1.0, 2.0, 3.0, 4.0],
1785                }),
1786            ),
1787            make_gen(
1788                1,
1789                Some(GenCost {
1790                    model: 2,
1791                    startup: 0.0,
1792                    shutdown: 0.0,
1793                    ncost: 0,
1794                    coeffs: Vec::new(),
1795                }),
1796            ),
1797        ];
1798        let key_of: HashMap<BusId, String> = net.buses.iter().map(|b| (b.id, bus_key(b))).collect();
1799        let mut warnings = Vec::new();
1800        let csv = generators_csv(&net, &key_of, &mut warnings);
1801        assert_eq!(
1802            csv.lines().nth(1).unwrap(),
1803            "gen_1,1,PQ,10,1,0,0,1,3,4,true,1"
1804        );
1805        assert_eq!(
1806            csv.lines().nth(2).unwrap(),
1807            "gen_2,2,PQ,10,1,0,0,1,0,0,true,1"
1808        );
1809        assert_eq!(
1810            csv.lines().nth(3).unwrap(),
1811            "gen_3,1,PQ,10,1,0,0,1,0,0,true,1"
1812        );
1813        for expected in [
1814            "1 generator costs dropped: PyPSA carries marginal_cost/marginal_cost_quadratic (model 2) only",
1815            "1 generator costs truncated to quadratic for PyPSA marginal cost columns",
1816            "1 generator costs had no coefficients and were written as zero",
1817        ] {
1818            assert!(
1819                warnings.iter().any(|w| w == expected),
1820                "missing {expected:?} in {warnings:?}"
1821            );
1822        }
1823    }
1824}