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