Skip to main content

powerio_tx/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::{bus_kv, set_bus_kind, warn_extra_branch_rating_sets, zbase};
12use crate::diagnostics::codes::EMIT_PYPSA as F;
13use crate::diagnostics::{Diagnostics, codes};
14use crate::network::{
15    BalancedNetwork, BalancedNetworkTables, Branch, BranchCharging, Bus, BusId, BusType, Extras,
16    GenCost, Generator, Hvdc, Load, LoadVoltageModel, Shunt, SourceFormat, Storage,
17};
18use crate::{Error, Result};
19
20const FMT: &str = "PyPSA CSV";
21
22#[derive(Debug, Clone)]
23#[non_exhaustive]
24pub struct PypsaCsvOutputs {
25    pub dir: PathBuf,
26    pub files: Vec<PathBuf>,
27    /// The writer's findings as structured records.
28    pub diagnostics: Vec<crate::diagnostics::Diagnostic>,
29}
30
31impl PypsaCsvOutputs {
32    /// The findings as `CODE: message` lines, rendered on request.
33    #[must_use]
34    pub fn rendered_diagnostics(&self) -> Vec<String> {
35        crate::diagnostics::render_diagnostics(&self.diagnostics)
36    }
37}
38
39/// A directory source's file listing plus lazy acquisition, the reader's view
40/// of the folder: table names resolve against the listing, and bytes come
41/// through the source so the reader never touches the filesystem itself.
42struct PypsaFolder<'a> {
43    source: &'a powerio_core::Source,
44    entries: Vec<powerio_core::ArtifactPath>,
45}
46
47impl PypsaFolder<'_> {
48    fn optional(&self, name: &str) -> Result<Option<CsvTable>> {
49        if !self.entries.iter().any(|entry| entry.as_str() == name) {
50            return Ok(None);
51        }
52        let path =
53            powerio_core::ArtifactPath::new(name).map_err(|error| acquisition_error(&error))?;
54        let buffer = self
55            .source
56            .buffer(&path)
57            .map_err(|error| acquisition_error(&error))?;
58        let text = std::str::from_utf8(buffer.content_bytes()).map_err(|e| Error::FormatRead {
59            format: FMT,
60            message: format!("`{name}` is not valid UTF-8: {e}"),
61        })?;
62        parse_csv_table(text, name)
63    }
64
65    fn required(&self, name: &'static str) -> Result<CsvTable> {
66        self.optional(name)?
67            .ok_or_else(|| bad(format!("missing required `{name}`")))
68    }
69}
70
71fn acquisition_error(error: &powerio_core::Error) -> Error {
72    Error::FormatRead {
73        format: FMT,
74        message: error.to_string(),
75    }
76}
77
78/// Read a PyPSA CSV folder source into the typed network.
79#[allow(clippy::too_many_lines)] // direct static-component CSV mapper; each block is one PyPSA table
80pub(crate) fn read_pypsa_csv_source(
81    source: &powerio_core::Source,
82    warnings: &mut Diagnostics,
83) -> Result<BalancedNetwork> {
84    // A directory source yields its walk once, so the one listing threads
85    // through every consumer of it.
86    let entries = source
87        .entry_names()
88        .map_err(|error| acquisition_error(&error))?;
89    read_pypsa_csv_static(source, entries, warnings, &HashSet::new())
90}
91
92/// The static read body; `entries` is the directory's one listing and
93/// `series_consumed` names the sibling series files a sequence entry
94/// interprets itself, so they are not reported as ignored.
95#[allow(clippy::too_many_lines)] // direct static-component CSV mapper; each block is one PyPSA table
96fn read_pypsa_csv_static(
97    source: &powerio_core::Source,
98    entries: Vec<powerio_core::ArtifactPath>,
99    warnings: &mut Diagnostics,
100    series_consumed: &HashSet<String>,
101) -> Result<BalancedNetwork> {
102    let folder = PypsaFolder { source, entries };
103    let path = Path::new(source.name());
104    let network = folder.optional("network.csv")?;
105    let network_row = network.as_ref().and_then(|t| t.rows.first());
106    let name = network_row
107        .and_then(|r| r.get("name"))
108        .filter(|s| !s.is_empty())
109        .cloned()
110        .or_else(|| {
111            path.file_name()
112                .and_then(|s| s.to_str())
113                .map(str::to_string)
114        })
115        .unwrap_or_else(|| "pypsa".to_string());
116    let base_mva = network_row
117        .and_then(|r| r.f("powerio_base_mva"))
118        .unwrap_or(1.0);
119
120    let bus_table = folder.required("buses.csv")?;
121    let mut raw_names = Vec::with_capacity(bus_table.rows.len());
122    let mut seen = HashSet::with_capacity(bus_table.rows.len());
123    for (i, row) in bus_table.rows.iter().enumerate() {
124        let raw = row
125            .get("name")
126            .cloned()
127            .ok_or_else(|| bad(format!("buses.csv row {}: missing bus name", i + 1)))?;
128        if !seen.insert(raw.clone()) {
129            return Err(bad(format!("buses.csv: duplicate bus name `{raw}`")));
130        }
131        raw_names.push(raw);
132    }
133    // Scheme A iff every name is a distinct positive integer: ids are the names
134    // and `bus.name` stays empty. Otherwise scheme B for ALL buses: ids are
135    // positions and every raw name is kept. Never mixed, so an element
136    // reference resolves by name only — no numeric fallback.
137    let numeric: Option<Vec<usize>> = raw_names
138        .iter()
139        .map(|s| s.parse::<usize>().ok().filter(|x| *x > 0))
140        .collect();
141    let numeric = numeric.filter(|ids| ids.iter().collect::<HashSet<_>>().len() == ids.len());
142
143    let mut buses = Vec::with_capacity(bus_table.rows.len());
144    let mut id_of_name = HashMap::with_capacity(bus_table.rows.len());
145    for (i, row) in bus_table.rows.iter().enumerate() {
146        let (id, bus_name) = match &numeric {
147            Some(ids) => (BusId(ids[i]), None),
148            None => (BusId(i + 1), Some(raw_names[i].clone())),
149        };
150        id_of_name.insert(raw_names[i].clone(), id);
151        // v_nom drives every ohm <-> per unit conversion; defaulting it would
152        // silently read line ohms as per unit (the pandapower reader holds the
153        // same line for vn_kv). PyPSA omits the column only when every bus
154        // keeps the default v_nom = 1, and erroring there beats misreading.
155        let v_nom = row.f("v_nom").filter(|v| v.is_finite()).ok_or_else(|| {
156            bad(format!(
157                "buses.csv row {}: required column `v_nom` is missing or not numeric",
158                i + 1
159            ))
160        })?;
161        buses.push(Bus {
162            id,
163            kind: BusType::Pq,
164            vm: row.f("v_mag_pu_set").unwrap_or(1.0),
165            va: 0.0,
166            base_kv: v_nom,
167            vmax: row.f("v_mag_pu_max").unwrap_or(1.1),
168            vmin: row.f("v_mag_pu_min").unwrap_or(0.9),
169            evhi: None,
170            evlo: None,
171            area: 1,
172            zone: 1,
173            name: bus_name,
174            uid: None,
175            // PyPSA `x`/`y` are longitude/latitude; both must be present, so
176            // a folder without the columns keeps `location = None`.
177            location: match (
178                row.f("x").filter(|v| v.is_finite()),
179                row.f("y").filter(|v| v.is_finite()),
180            ) {
181                (Some(x), Some(y)) => Some(crate::geo::Location { x, y, kind: None }),
182                _ => None,
183            },
184            extras: Extras::default(),
185        });
186    }
187    let bus_pos: HashMap<BusId, usize> = buses.iter().enumerate().map(|(i, b)| (b.id, i)).collect();
188
189    let mut loads = Vec::new();
190    if let Some(table) = folder.optional("loads.csv")? {
191        for (i, row) in table.rows.iter().enumerate() {
192            loads.push(Load {
193                bus: bus_ref("loads.csv", i + 1, row, "bus", &id_of_name)?,
194                p: row.f("p_set").unwrap_or(0.0),
195                q: row.f("q_set").unwrap_or(0.0),
196                voltage_model: None,
197                in_service: row.bool("active").unwrap_or(true),
198                uid: None,
199                extras: Extras::default(),
200            });
201        }
202    }
203
204    let mut shunts = Vec::new();
205    if let Some(table) = folder.optional("shunt_impedances.csv")? {
206        for (i, row) in table.rows.iter().enumerate() {
207            let bus = bus_ref("shunt_impedances.csv", i + 1, row, "bus", &id_of_name)?;
208            let zb = zbase(bus_kv(&buses, &bus_pos, bus), base_mva);
209            shunts.push(Shunt {
210                bus,
211                g: row.f("g").unwrap_or(0.0) * zb * base_mva,
212                b: row.f("b").unwrap_or(0.0) * zb * base_mva,
213                in_service: row.bool("active").unwrap_or(true),
214                control: None,
215                uid: None,
216                extras: Extras::default(),
217            });
218        }
219    }
220
221    let mut generators = Vec::new();
222    if let Some(table) = folder.optional("generators.csv")? {
223        for (i, row) in table.rows.iter().enumerate() {
224            let bus = bus_ref("generators.csv", i + 1, row, "bus", &id_of_name)?;
225            let control = row.get("control").map_or("", String::as_str);
226            // "PQ", empty, and anything unrecognized leave the bus kind alone.
227            if control.eq_ignore_ascii_case("slack") {
228                set_bus_kind(&mut buses, &bus_pos, bus, BusType::Ref);
229            } else if control.eq_ignore_ascii_case("pv") {
230                set_bus_kind(&mut buses, &bus_pos, bus, BusType::Pv);
231            }
232            let p_nom = row
233                .f("p_nom")
234                .unwrap_or_else(|| row.f("p_set").unwrap_or(0.0).abs());
235            let pmax = p_nom * row.f("p_max_pu").unwrap_or(1.0);
236            let pmin = p_nom * row.f("p_min_pu").unwrap_or(0.0);
237            let c1 = row.f("marginal_cost");
238            let c2 = row.f("marginal_cost_quadratic");
239            generators.push(Generator {
240                bus,
241                pg: row.f("p_set").unwrap_or(0.0),
242                qg: row.f("q_set").unwrap_or(0.0),
243                pmax,
244                pmin,
245                qmax: f64::INFINITY,
246                qmin: f64::NEG_INFINITY,
247                vg: row.f("v_mag_pu_set").unwrap_or(1.0),
248                mbase: base_mva,
249                in_service: row.bool("active").unwrap_or(true),
250                cost: match (c2, c1) {
251                    (Some(q), c) => Some(GenCost {
252                        model: 2,
253                        startup: 0.0,
254                        shutdown: 0.0,
255                        ncost: 3,
256                        // PyPSA defaults marginal_cost to 0, so a quadratic
257                        // without a linear column keeps the quadratic term.
258                        coeffs: vec![q, c.unwrap_or(0.0), 0.0],
259                    }),
260                    (None, Some(c)) => Some(GenCost {
261                        model: 2,
262                        startup: 0.0,
263                        shutdown: 0.0,
264                        ncost: 2,
265                        coeffs: vec![c, 0.0],
266                    }),
267                    (None, None) => None,
268                },
269                caps: [None; crate::network::GEN_EXTRA_KEYS.len()],
270                regulated_bus: None,
271                uid: None,
272            });
273        }
274    }
275
276    let mut branches = Vec::new();
277    if let Some(table) = folder.optional("lines.csv")? {
278        for (i, row) in table.rows.iter().enumerate() {
279            let from = bus_ref("lines.csv", i + 1, row, "bus0", &id_of_name)?;
280            let to = bus_ref("lines.csv", i + 1, row, "bus1", &id_of_name)?;
281            // PyPSA per-unitizes line ohms on the BUS0 v_nom
282            // (BalancedNetwork.calculate_dependent_values), not bus1.
283            let zb = zbase(bus_kv(&buses, &bus_pos, from), base_mva);
284            let b = row.f("b").unwrap_or(0.0) * zb;
285            let g = row.f("g").unwrap_or(0.0) * zb;
286            branches.push(Branch {
287                from,
288                to,
289                r: row.f("r").unwrap_or(0.0) / zb,
290                x: row.f("x").unwrap_or(0.0) / zb,
291                b,
292                charging: Some(BranchCharging {
293                    g_fr: g / 2.0,
294                    b_fr: b / 2.0,
295                    g_to: g / 2.0,
296                    b_to: b / 2.0,
297                }),
298                rate_a: row.f("s_nom").unwrap_or(0.0),
299                rate_b: 0.0,
300                rate_c: 0.0,
301                rating_sets: Vec::new(),
302                current_ratings: None,
303                tap: 0.0,
304                shift: 0.0,
305                in_service: row.bool("active").unwrap_or(true),
306                angmin: row.f("v_ang_min").unwrap_or(-360.0),
307                angmax: row.f("v_ang_max").unwrap_or(360.0),
308                control: None,
309                solution: None,
310                uid: None,
311                route: None,
312                extras: Extras::default(),
313            });
314        }
315    }
316    if let Some(table) = folder.optional("transformers.csv")? {
317        for (i, row) in table.rows.iter().enumerate() {
318            let from = bus_ref("transformers.csv", i + 1, row, "bus0", &id_of_name)?;
319            let to = bus_ref("transformers.csv", i + 1, row, "bus1", &id_of_name)?;
320            // PyPSA stores transformer impedances per unit on the transformer's
321            // own s_nom base; rebase to the system base.
322            let s_nom = row.f("s_nom").unwrap_or(0.0);
323            if s_nom <= 0.0 {
324                let xf_name = row.get("name").cloned().unwrap_or_default();
325                return Err(bad(format!(
326                    "transformers.csv row {} (`{xf_name}`): s_nom must be positive to rebase impedances (got {s_nom})",
327                    i + 1
328                )));
329            }
330            let k = base_mva / s_nom;
331            let b = row.f("b").unwrap_or(0.0) * s_nom / base_mva;
332            let g = row.f("g").unwrap_or(0.0) * s_nom / base_mva;
333            branches.push(Branch {
334                from,
335                to,
336                r: row.f("r").unwrap_or(0.0) * k,
337                x: row.f("x").unwrap_or(0.0) * k,
338                b,
339                charging: Some(BranchCharging {
340                    g_fr: g,
341                    b_fr: b,
342                    g_to: 0.0,
343                    b_to: 0.0,
344                }),
345                rate_a: s_nom,
346                rate_b: 0.0,
347                rate_c: 0.0,
348                rating_sets: Vec::new(),
349                current_ratings: None,
350                tap: row.f("tap_ratio").unwrap_or(1.0),
351                shift: row.f("phase_shift").unwrap_or(0.0),
352                in_service: row.bool("active").unwrap_or(true),
353                angmin: -360.0,
354                angmax: 360.0,
355                control: None,
356                solution: None,
357                uid: None,
358                route: None,
359                extras: Extras::default(),
360            });
361        }
362    }
363
364    let mut storage = Vec::new();
365    if let Some(table) = folder.optional("storage_units.csv")? {
366        for (i, row) in table.rows.iter().enumerate() {
367            let p_nom = row.f("p_nom").unwrap_or(0.0);
368            let max_hours = row.f("max_hours").unwrap_or(0.0);
369            storage.push(Storage {
370                bus: bus_ref("storage_units.csv", i + 1, row, "bus", &id_of_name)?,
371                ps: row.f("p_set").unwrap_or(0.0),
372                qs: row.f("q_set").unwrap_or(0.0),
373                energy: row.f("state_of_charge_initial").unwrap_or(0.0),
374                energy_rating: p_nom * max_hours,
375                charge_rating: p_nom,
376                discharge_rating: p_nom,
377                charge_efficiency: row.f("efficiency_store").unwrap_or(1.0),
378                discharge_efficiency: row.f("efficiency_dispatch").unwrap_or(1.0),
379                thermal_rating: p_nom,
380                current_rating: None,
381                qmin: f64::NEG_INFINITY,
382                qmax: f64::INFINITY,
383                r: 0.0,
384                x: 0.0,
385                p_loss: 0.0,
386                q_loss: 0.0,
387                in_service: row.bool("active").unwrap_or(true),
388                uid: None,
389                extras: Extras::default(),
390            });
391        }
392    }
393
394    let mut hvdc = Vec::new();
395    if let Some(table) = folder.optional("links.csv")? {
396        for (i, row) in table.rows.iter().enumerate() {
397            let from = bus_ref("links.csv", i + 1, row, "bus0", &id_of_name)?;
398            let to = bus_ref("links.csv", i + 1, row, "bus1", &id_of_name)?;
399            let efficiency = row.f("efficiency").unwrap_or(1.0);
400            let p_nom = row.f("p_nom").unwrap_or(0.0);
401            let pf = row.f("p_set").unwrap_or(0.0);
402            hvdc.push(Hvdc {
403                from,
404                to,
405                in_service: row.bool("active").unwrap_or(true),
406                pf,
407                pt: Hvdc::delivered_power(pf, 0.0, 1.0 - efficiency),
408                qf: 0.0,
409                qt: 0.0,
410                vf: 1.0,
411                vt: 1.0,
412                pmin: p_nom * row.f("p_min_pu").unwrap_or(0.0),
413                pmax: p_nom * row.f("p_max_pu").unwrap_or(1.0),
414                qminf: 0.0,
415                qmaxf: 0.0,
416                qmint: 0.0,
417                qmaxt: 0.0,
418                loss0: 0.0,
419                loss1: 1.0 - efficiency,
420                cost: None,
421                uid: None,
422                extras: Extras::default(),
423            });
424        }
425        if !table.rows.is_empty() {
426            warnings.push(&codes::READ_PYPSA_VALUE_APPROXIMATED, format!(
427                "links.csv: {} links read as HVDC lines; PyPSA links carry no reactive or voltage data (q limits 0, voltage setpoints 1.0)",
428                table.rows.len()
429            ));
430        }
431    }
432    if let Some(table) = folder.optional("stores.csv")? {
433        if !table.rows.is_empty() {
434            warnings.push(
435                &codes::READ_PYPSA_TABLE_UNSUPPORTED,
436                format!(
437                    "stores.csv ignored ({} rows): PyPSA stores are not mapped",
438                    table.rows.len()
439                ),
440            );
441        }
442    }
443
444    // A real PyPSA export can carry its data in time series siblings
445    // (`loads-p_set.csv`, `generators-p_max_pu.csv`, ...); reading only the
446    // static tables and saying nothing would present a zero-load network as a
447    // clean parse. Name every CSV this reader did not open.
448    let consumed = [
449        "network.csv",
450        "snapshots.csv",
451        "buses.csv",
452        "loads.csv",
453        "shunt_impedances.csv",
454        "generators.csv",
455        "lines.csv",
456        "transformers.csv",
457        "storage_units.csv",
458        "links.csv",
459        "stores.csv",
460    ];
461    let mut unread: Vec<String> = folder
462        .entries
463        .iter()
464        .map(powerio_core::ArtifactPath::as_str)
465        .filter(|name| {
466            !name.contains('/')
467                && Path::new(name)
468                    .extension()
469                    .is_some_and(|e| e.eq_ignore_ascii_case("csv"))
470                && !consumed.contains(name)
471                && !series_consumed.contains(*name)
472        })
473        .map(str::to_owned)
474        .collect();
475    unread.sort();
476    for file in unread {
477        warnings.push(&codes::READ_PYPSA_TABLE_UNSUPPORTED, format!(
478            "`{file}` ignored: only the static element tables are read (time series and other tables are not modeled)"
479        ));
480    }
481
482    let net = BalancedNetwork::from_tables(BalancedNetworkTables {
483        name,
484        base_mva,
485        base_frequency: crate::network::DEFAULT_BASE_FREQUENCY,
486        geo: super::geographic_meta(&buses),
487        buses: buses.into(),
488        loads: loads.into(),
489        shunts: shunts.into(),
490        branches: branches.into(),
491        switches: Vec::new().into(),
492        generators: generators.into(),
493        storage: storage.into(),
494        hvdc: hvdc.into(),
495        transformers_3w: Vec::new().into(),
496        areas: Vec::new().into(),
497        solver: None,
498        source_format: SourceFormat::PypsaCsv,
499    });
500    // This reader bypasses the read_source funnel (directory input), so it
501    // guards against a hollow case itself.
502    crate::format::reject_empty_case(&net, FMT)?;
503    net.check_references(FMT)?;
504    Ok(net)
505}
506
507/// Write the complete PyPSA CSV folder at `out_dir` through the no-replace
508/// destination commit: the inventory is staged completely and moved onto
509/// `out_dir` only when no entry exists there, so a refused write leaves the
510/// caller's filesystem byte for byte as it was.
511///
512/// # Errors
513/// A refused commit: `out_dir` already exists, cannot be staged, or the
514/// destination cannot commit without risking replacement.
515#[allow(clippy::missing_panics_doc)] // the destination kind is ours by construction
516pub fn write_pypsa_csv_folder(
517    net: &BalancedNetwork,
518    out_dir: impl AsRef<Path>,
519) -> std::result::Result<PypsaCsvOutputs, powerio_core::Error> {
520    let (artifacts, diagnostics) = pypsa_csv_artifacts(net);
521    let inventory = artifacts
522        .into_iter()
523        .map(|(name, text)| {
524            powerio_core::MemoryArtifact::new(
525                powerio_core::ArtifactPath::new(name).expect("the writer emits fixed valid names"),
526                text.into_bytes(),
527            )
528        })
529        .collect();
530    let result = powerio_core::Destination::path(out_dir.as_ref()).__commit_artifacts(
531        true,
532        inventory,
533        Vec::new(),
534    )?;
535    let powerio_core::WrittenOutput::Path { root, artifacts } = result.into_output() else {
536        unreachable!("a path destination returns a path output")
537    };
538    Ok(PypsaCsvOutputs {
539        dir: root,
540        files: artifacts,
541        diagnostics,
542    })
543}
544
545/// The complete PyPSA CSV folder as an in-memory artifact inventory:
546/// `(file name, content)` pairs in emission order, plus the writer's
547/// findings. Both the folder writer and the `Destination` write commit these
548/// as one atomic inventory.
549// One emission body shared by the streaming and inventory writers; the
550// length is the format's table count, not branching depth.
551#[allow(clippy::too_many_lines)]
552pub(crate) fn pypsa_csv_artifacts(
553    net: &BalancedNetwork,
554) -> (
555    Vec<(&'static str, String)>,
556    Vec<crate::diagnostics::Diagnostic>,
557) {
558    let mut files: Vec<(&'static str, String)> = Vec::new();
559    let mut write_file = |name: &'static str, text: String| {
560        files.push((name, text));
561    };
562    let mut warnings = Diagnostics::new();
563    // Element tables must reference buses by the same key buses.csv is indexed
564    // on, and PyPSA requires those keys to be unique for its joins. A bus is
565    // keyed by its name only when the name collides with no other bus's name
566    // or id string; colliding buses fall back to their numeric id, which is
567    // unique by construction and (per the same rule) cannot displace a kept
568    // name.
569    let mut name_counts: HashMap<&str, usize> = HashMap::new();
570    for b in net.buses() {
571        if let Some(n) = &b.name {
572            *name_counts.entry(n.as_str()).or_insert(0) += 1;
573        }
574    }
575    let id_owner: HashMap<String, BusId> = net
576        .buses()
577        .iter()
578        .map(|b| (b.id.0.to_string(), b.id))
579        .collect();
580    let mut displaced: Vec<String> = Vec::new();
581    let key_of: HashMap<BusId, String> = net
582        .buses()
583        .iter()
584        .map(|b| {
585            let key = match &b.name {
586                Some(n)
587                    if name_counts[n.as_str()] == 1
588                        && id_owner.get(n).is_none_or(|&owner| owner == b.id) =>
589                {
590                    n.clone()
591                }
592                Some(n) => {
593                    displaced.push(format!("`{n}`"));
594                    b.id.0.to_string()
595                }
596                None => b.id.0.to_string(),
597            };
598            (b.id, key)
599        })
600        .collect();
601    if !displaced.is_empty() {
602        displaced.sort();
603        displaced.dedup();
604        warnings.push(&codes::READ_PYPSA_NAME_REMAPPED, format!(
605            "buses.csv: bus names {} collide with another bus name or id; those buses are keyed by their numeric id instead",
606            displaced.join(", ")
607        ));
608    }
609    if !net.hvdc().is_empty() {
610        warnings.push(
611            &F.record_dropped,
612            format!(
613                "{} dcline(s) dropped: the PyPSA CSV writer does not model HVDC links",
614                net.hvdc().len()
615            ),
616        );
617    }
618    if !net.transformers_3w().is_empty() {
619        warnings.push(&F.record_dropped, format!(
620            "{} 3-winding transformer(s) dropped: the PyPSA CSV writer emits no 3-winding transformer",
621            net.transformers_3w().len()
622        ));
623    }
624    if net
625        .buses()
626        .iter()
627        .any(|b| b.evhi.is_some() || b.evlo.is_some())
628    {
629        warnings.push(
630            &F.field_dropped,
631            "emergency voltage band(s) (EVHI/EVLO) dropped: this writer carries one voltage band",
632        );
633    }
634    if net.generators().iter().any(Generator::has_caps) {
635        warnings.push(&F.field_dropped, "generator capability/ramp columns dropped: PyPSA generator CSV has no MATPOWER capability columns");
636    }
637    let voltage_loads = net
638        .loads()
639        .iter()
640        .filter(|l| {
641            l.voltage_model
642                .as_ref()
643                .is_some_and(LoadVoltageModel::has_non_matpower_fields)
644        })
645        .count();
646    if voltage_loads > 0 {
647        warnings.push(&F.field_dropped, format!(
648            "{voltage_loads} voltage dependent load model(s) dropped: PyPSA loads.csv carries static p_set/q_set only"
649        ));
650    }
651    let isolated = net
652        .buses()
653        .iter()
654        .filter(|b| b.kind == BusType::Isolated)
655        .count();
656    if isolated > 0 {
657        warnings.push(&F.field_dropped, format!(
658            "{isolated} isolated bus(es) written without status: PyPSA buses carry no active flag, they read back in service"
659        ));
660    }
661    let xf_angles = net
662        .branches()
663        .iter()
664        .filter(|b| b.is_transformer() && b.has_angle_limits())
665        .count();
666    if xf_angles > 0 {
667        warnings.push(&F.field_dropped, format!(
668            "{xf_angles} transformer angle limit(s) dropped: transformers.csv carries no v_ang_min/v_ang_max"
669        ));
670    }
671    let rate_bc = net
672        .branches()
673        .iter()
674        .filter(|b| {
675            super::nonzero_differs(b.rate_b, b.rate_a) || super::nonzero_differs(b.rate_c, b.rate_a)
676        })
677        .count();
678    if rate_bc > 0 {
679        warnings.push(&F.field_dropped, format!(
680            "{rate_bc} branch rate_b/rate_c value set(s) dropped: PyPSA carries one s_nom rating"
681        ));
682    }
683    let current_ratings = net
684        .branches()
685        .iter()
686        .filter(|b| b.current_ratings.is_some())
687        .count();
688    if current_ratings > 0 {
689        warnings.push(&F.field_dropped, format!(
690            "{current_ratings} branch current rating record(s) dropped: PyPSA static branch tables carry s_nom, not source current ratings"
691        ));
692    }
693    warn_extra_branch_rating_sets(&F, "PyPSA CSV", net, &mut warnings);
694    super::warn_dropped_areas(&F, "PyPSA CSV", net, &mut warnings);
695    let branch_solutions = net
696        .branches()
697        .iter()
698        .filter(|b| b.solution.is_some())
699        .count();
700    if branch_solutions > 0 {
701        warnings.push(&F.field_dropped, format!(
702            "{branch_solutions} branch solution value set(s) dropped: PyPSA result time series are not written"
703        ));
704    }
705    let terminal_charging = net
706        .branches()
707        .iter()
708        .filter(|b| pypsa_loses_terminal_charging(b))
709        .count();
710    if terminal_charging > 0 {
711        warnings.push(&F.value_collapsed, format!(
712            "{terminal_charging} branch terminal admittance record(s) collapsed: PyPSA CSV supports symmetric line shunts and one-sided transformer shunts only"
713        ));
714    }
715    if let Some(message) = super::missing_reference_warning(net) {
716        warnings.push(&F.reference_missing, message);
717    }
718    if let Some(message) = super::normalized_tap_warning(net) {
719        warnings.push(&F.element_relabeled, message);
720    }
721    // Exact compares are the point: any deviation from the symmetric, no-loss
722    // shape the round trip preserves means a field is dropped on write.
723    #[allow(clippy::float_cmp)]
724    let lossy = net
725        .storage()
726        .iter()
727        .filter(|st| {
728            let p_nom = st.charge_rating.max(st.discharge_rating);
729            st.charge_rating != st.discharge_rating
730                || st.thermal_rating != p_nom
731                || st.qmin.is_finite()
732                || st.qmax.is_finite()
733                || st.r != 0.0
734                || st.x != 0.0
735                || st.p_loss != 0.0
736                || st.q_loss != 0.0
737        })
738        .count();
739    if lossy > 0 {
740        warnings.push(&F.value_collapsed, format!(
741            "{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)"
742        ));
743    }
744
745    write_file("network.csv", network_csv(net));
746    write_file("snapshots.csv", ",snapshot\n0,now\n".to_owned());
747    write_file("buses.csv", buses_csv(net, &key_of));
748    write_file(
749        "generators.csv",
750        generators_csv(net, &key_of, &mut warnings),
751    );
752    // The v_nom per bus, shared by the writers that rebase impedances.
753    let kv_of: HashMap<BusId, f64> = net.buses().iter().map(|b| (b.id, b.base_kv)).collect();
754    write_file("loads.csv", loads_csv(net, &key_of));
755    write_file("lines.csv", lines_csv(net, &key_of, &kv_of));
756    let transformers = transformers_csv(net, &key_of);
757    if transformers.lines().count() > 1 {
758        write_file("transformers.csv", transformers);
759    }
760    if !net.shunts().is_empty() {
761        write_file("shunt_impedances.csv", shunts_csv(net, &key_of, &kv_of));
762    }
763    if !net.storage().is_empty() {
764        write_file("storage_units.csv", storage_csv(net, &key_of));
765    }
766    (files, warnings.into_records())
767}
768
769fn network_csv(net: &BalancedNetwork) -> String {
770    format!(
771        "name,srid,powerio_base_mva\n{},4326,{}\n",
772        esc(net.name()),
773        net.base_mva()
774    )
775}
776
777fn buses_csv(net: &BalancedNetwork, key_of: &HashMap<BusId, String>) -> String {
778    // The coordinate columns appear only when the case carries locations, so
779    // a case without geometry writes exactly as before. PyPSA defaults a
780    // missing cell to 0, so a located case writes empty cells for the odd
781    // bus without a point.
782    let write_locations = net.buses().iter().any(|b| b.location.is_some());
783    let mut s = String::from(if write_locations {
784        "name,v_nom,v_mag_pu_set,v_mag_pu_min,v_mag_pu_max,x,y\n"
785    } else {
786        "name,v_nom,v_mag_pu_set,v_mag_pu_min,v_mag_pu_max\n"
787    });
788    for b in net.buses() {
789        let _ = write!(
790            s,
791            "{},{},{},{},{}",
792            key_for(key_of, b.id),
793            b.base_kv,
794            b.vm,
795            b.vmin,
796            b.vmax
797        );
798        if write_locations {
799            match b.location {
800                Some(location) => {
801                    let _ = write!(s, ",{},{}", location.x, location.y);
802                }
803                None => s.push_str(",,"),
804            }
805        }
806        s.push('\n');
807    }
808    s
809}
810
811#[allow(clippy::too_many_lines)]
812// one column expression per PyPSA generator attribute
813// The exact mbase compare is the point: any deviation from the system base is
814// information the PyPSA table cannot carry.
815#[allow(clippy::float_cmp)]
816fn generators_csv(
817    net: &BalancedNetwork,
818    key_of: &HashMap<BusId, String>,
819    warnings: &mut Diagnostics,
820) -> String {
821    let mut s = String::from(
822        "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",
823    );
824    let bus_kind: HashMap<BusId, BusType> = net.buses().iter().map(|b| (b.id, b.kind)).collect();
825    let mut dropped = 0usize;
826    let mut truncated = 0usize;
827    let mut empty = 0usize;
828    let mut unbounded = 0usize;
829    for (i, g) in net.generators().iter().enumerate() {
830        let p_nom = if g.pmax.is_finite() && g.pmax > 0.0 {
831            g.pmax
832        } else {
833            g.pg.abs().max(1.0)
834        };
835        // Keep the LOWEST order terms: a polynomial's coeffs run high to low.
836        let (c2, c1) = match g.cost.as_ref() {
837            Some(c) if c.model == 2 => {
838                let n = c.coeffs.len();
839                if n == 0 {
840                    empty += 1;
841                } else if n > 3 {
842                    truncated += 1;
843                }
844                (
845                    if n >= 3 { c.coeffs[n - 3] } else { 0.0 },
846                    if n >= 2 { c.coeffs[n - 2] } else { 0.0 },
847                )
848            }
849            Some(_) => {
850                dropped += 1;
851                (0.0, 0.0)
852            }
853            None => (0.0, 0.0),
854        };
855        let _ = writeln!(
856            s,
857            "gen_{},{},{},{},{},{},{},{},{},{},{},{}",
858            i + 1,
859            key_for(key_of, g.bus),
860            match bus_kind.get(&g.bus).copied() {
861                Some(BusType::Ref) => "Slack",
862                Some(BusType::Pv) => "PV",
863                _ => "PQ",
864            },
865            p_nom,
866            g.pg,
867            g.qg,
868            if p_nom == 0.0 || !g.pmin.is_finite() {
869                if !g.pmin.is_finite() {
870                    unbounded += 1;
871                }
872                0.0
873            } else {
874                g.pmin / p_nom
875            },
876            if p_nom == 0.0 || !g.pmax.is_finite() {
877                if !g.pmax.is_finite() {
878                    unbounded += 1;
879                }
880                1.0
881            } else {
882                g.pmax / p_nom
883            },
884            c1,
885            c2,
886            g.in_service,
887            g.vg
888        );
889    }
890    if dropped > 0 {
891        warnings.push(&F.field_dropped, format!(
892            "{dropped} generator costs dropped: PyPSA carries marginal_cost/marginal_cost_quadratic (model 2) only"
893        ));
894    }
895    if truncated > 0 {
896        warnings.push(
897            &F.value_truncated,
898            format!(
899                "{truncated} generator costs truncated to quadratic for PyPSA marginal cost columns"
900            ),
901        );
902    }
903    if empty > 0 {
904        warnings.push(
905            &F.value_defaulted,
906            format!("{empty} generator costs had no coefficients and were written as zero"),
907        );
908    }
909    if unbounded > 0 {
910        warnings.push(&F.value_defaulted, format!(
911            "{unbounded} non-finite generator p limit(s) written as the PyPSA defaults (p_min_pu 0, p_max_pu 1)"
912        ));
913    }
914    let q_limited = net
915        .generators()
916        .iter()
917        .filter(|g| g.qmin.is_finite() || g.qmax.is_finite())
918        .count();
919    if q_limited > 0 {
920        warnings.push(&F.field_dropped, format!(
921            "{q_limited} generator reactive limit(s) dropped: PyPSA generators carry no q bounds"
922        ));
923    }
924    let off_base = net
925        .generators()
926        .iter()
927        .filter(|g| g.mbase != 0.0 && g.mbase != net.base_mva())
928        .count();
929    if off_base > 0 {
930        warnings.push(&F.field_dropped, format!(
931            "{off_base} generator machine base(s) (mbase) dropped: PyPSA carries no per generator MVA base"
932        ));
933    }
934    s
935}
936
937fn loads_csv(net: &BalancedNetwork, key_of: &HashMap<BusId, String>) -> String {
938    let mut s = String::from("name,bus,p_set,q_set,active\n");
939    for (i, l) in net.loads().iter().enumerate() {
940        let _ = writeln!(
941            s,
942            "load_{},{},{},{},{}",
943            i + 1,
944            key_for(key_of, l.bus),
945            l.p,
946            l.q,
947            l.in_service
948        );
949    }
950    s
951}
952
953fn pypsa_loses_terminal_charging(br: &Branch) -> bool {
954    let charging = br.terminal_charging();
955    if br.is_transformer() {
956        charging.g_to.abs() > f64::EPSILON || charging.b_to.abs() > f64::EPSILON
957    } else {
958        (charging.g_fr - charging.g_to).abs() > f64::EPSILON
959            || (charging.b_fr - charging.b_to).abs() > f64::EPSILON
960    }
961}
962
963fn lines_csv(
964    net: &BalancedNetwork,
965    key_of: &HashMap<BusId, String>,
966    kv_of: &HashMap<BusId, f64>,
967) -> String {
968    let mut s = String::from("name,bus0,bus1,r,x,b,g,s_nom,v_ang_min,v_ang_max,active\n");
969    for (i, br) in net
970        .branches()
971        .iter()
972        .enumerate()
973        .filter(|(_, b)| !b.is_transformer())
974    {
975        // PyPSA per-unitizes line ohms on the BUS0 v_nom, not bus1.
976        let zb = zbase(*kv_of.get(&br.from).unwrap_or(&0.0), net.base_mva());
977        let charging = br.terminal_charging();
978        let _ = writeln!(
979            s,
980            "line_{},{},{},{},{},{},{},{},{},{},{}",
981            i + 1,
982            key_for(key_of, br.from),
983            key_for(key_of, br.to),
984            br.r * zb,
985            br.x * zb,
986            charging.total_b() / zb,
987            (charging.g_fr + charging.g_to) / zb,
988            br.rate_a,
989            br.angmin,
990            br.angmax,
991            br.in_service
992        );
993    }
994    s
995}
996
997fn transformers_csv(net: &BalancedNetwork, key_of: &HashMap<BusId, String>) -> String {
998    let mut s = String::from("name,bus0,bus1,r,x,b,g,s_nom,tap_ratio,phase_shift,active\n");
999    for (i, br) in net
1000        .branches()
1001        .iter()
1002        .enumerate()
1003        .filter(|(_, b)| b.is_transformer())
1004    {
1005        // PyPSA wants impedances per unit on the transformer's own s_nom base
1006        // and a positive s_nom; rate_a == 0 (unlimited) falls back to the
1007        // system base so the rebase is the identity.
1008        let s_nom = if br.rate_a > 0.0 {
1009            br.rate_a
1010        } else {
1011            net.base_mva()
1012        };
1013        let charging = br.charging.unwrap_or(BranchCharging {
1014            g_fr: 0.0,
1015            b_fr: br.total_charging_b(),
1016            g_to: 0.0,
1017            b_to: 0.0,
1018        });
1019        let _ = writeln!(
1020            s,
1021            "transformer_{},{},{},{},{},{},{},{},{},{},{}",
1022            i + 1,
1023            key_for(key_of, br.from),
1024            key_for(key_of, br.to),
1025            br.r * s_nom / net.base_mva(),
1026            br.x * s_nom / net.base_mva(),
1027            charging.b_fr * net.base_mva() / s_nom,
1028            charging.g_fr * net.base_mva() / s_nom,
1029            s_nom,
1030            br.effective_tap(),
1031            br.shift,
1032            br.in_service
1033        );
1034    }
1035    s
1036}
1037
1038fn shunts_csv(
1039    net: &BalancedNetwork,
1040    key_of: &HashMap<BusId, String>,
1041    kv_of: &HashMap<BusId, f64>,
1042) -> String {
1043    let mut s = String::from("name,bus,g,b,active\n");
1044    for (i, sh) in net.shunts().iter().enumerate() {
1045        let zb = zbase(*kv_of.get(&sh.bus).unwrap_or(&0.0), net.base_mva());
1046        let _ = writeln!(
1047            s,
1048            "shunt_{},{},{},{},{}",
1049            i + 1,
1050            key_for(key_of, sh.bus),
1051            sh.g / (zb * net.base_mva()),
1052            sh.b / (zb * net.base_mva()),
1053            sh.in_service
1054        );
1055    }
1056    s
1057}
1058
1059fn storage_csv(net: &BalancedNetwork, key_of: &HashMap<BusId, String>) -> String {
1060    let mut s = String::from(
1061        "name,bus,p_nom,max_hours,p_set,q_set,state_of_charge_initial,efficiency_store,efficiency_dispatch,cyclic_state_of_charge\n",
1062    );
1063    for (i, st) in net.storage().iter().enumerate() {
1064        let p_nom = st.charge_rating.max(st.discharge_rating);
1065        let max_hours = if p_nom > 0.0 {
1066            st.energy_rating / p_nom
1067        } else {
1068            0.0
1069        };
1070        let _ = writeln!(
1071            s,
1072            "storage_{},{},{},{},{},{},{},{},{},false",
1073            i + 1,
1074            key_for(key_of, st.bus),
1075            p_nom,
1076            max_hours,
1077            st.ps,
1078            st.qs,
1079            st.energy,
1080            st.charge_efficiency,
1081            st.discharge_efficiency
1082        );
1083    }
1084    s
1085}
1086
1087#[derive(Debug)]
1088struct CsvTable {
1089    headers: Vec<String>,
1090    rows: Vec<CsvRow>,
1091}
1092
1093/// One record: the fields actually present, resolved through the table's
1094/// shared header index, so a table's retained size is its own field count
1095/// rather than header count times row count.
1096#[derive(Debug)]
1097struct CsvRow {
1098    column_of: std::sync::Arc<HashMap<String, usize>>,
1099    fields: Vec<String>,
1100}
1101
1102impl CsvRow {
1103    fn get(&self, key: &str) -> Option<&String> {
1104        let column = *self.column_of.get(key)?;
1105        self.fields.get(column).filter(|s| !s.is_empty())
1106    }
1107    fn f(&self, key: &str) -> Option<f64> {
1108        self.get(key).and_then(|s| s.parse().ok())
1109    }
1110    fn bool(&self, key: &str) -> Option<bool> {
1111        self.get(key)
1112            .and_then(|s| match s.to_ascii_lowercase().as_str() {
1113                "true" | "1" => Some(true),
1114                "false" | "0" => Some(false),
1115                _ => None,
1116            })
1117    }
1118}
1119
1120fn bad(message: impl Into<String>) -> Error {
1121    Error::FormatRead {
1122        format: FMT,
1123        message: message.into(),
1124    }
1125}
1126
1127/// Which per point field one supported `{component}-{attribute}.csv` column
1128/// patches.
1129#[derive(Clone, Copy)]
1130enum SeriesField {
1131    LoadP,
1132    LoadQ,
1133    GenPg,
1134    GenQg,
1135    GenPmax,
1136    GenPmin,
1137    GenVg,
1138    BusVm,
1139    BusVa,
1140}
1141
1142/// A parsed PyPSA sequence: the per snapshot networks, whether any problem
1143/// input varied (else only solved state did), and the reader's findings.
1144#[derive(Debug, Clone)]
1145#[non_exhaustive]
1146pub struct PypsaCsvSequence {
1147    pub series: powerio_core::TimeSeries<BalancedNetwork>,
1148    /// False when every varying column is solved electrical state, so the
1149    /// sequence is one fixed network under changing state.
1150    pub inputs_vary: bool,
1151    /// Whether any recognized series column varied at all. A declared
1152    /// snapshot axis with no series siblings preserves the axis as networks
1153    /// sharing every table; it is not an operating point series.
1154    pub has_varying_columns: bool,
1155    pub diagnostics: Vec<crate::diagnostics::Diagnostic>,
1156}
1157
1158/// The declared snapshot axis of a PyPSA CSV folder, probed from entry names
1159/// and `snapshots.csv` alone: a recognized series sibling or more than one
1160/// declared snapshot selects the sequence reader, and one snapshot with no
1161/// series is the scalar profile.
1162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1163pub enum PypsaAxis {
1164    SingleSnapshot,
1165    Series,
1166}
1167
1168/// Probe the folder's declared snapshot axis without reading the component
1169/// tables.
1170///
1171/// # Errors
1172/// The folder listing or `snapshots.csv` could not be acquired or decoded.
1173pub fn pypsa_axis(source: &powerio_core::Source) -> Result<PypsaAxis> {
1174    let entries = source
1175        .entry_names()
1176        .map_err(|error| acquisition_error(&error))?;
1177    for entry in &entries {
1178        let name = entry.as_str();
1179        if name.contains('/') {
1180            continue;
1181        }
1182        let Some(stem) = name.strip_suffix(".csv") else {
1183            continue;
1184        };
1185        let Some((component, attribute)) = stem.split_once('-') else {
1186            continue;
1187        };
1188        if series_field(component, attribute).is_some() {
1189            return Ok(PypsaAxis::Series);
1190        }
1191    }
1192    let folder = PypsaFolder { source, entries };
1193    let Some(snapshot_table) = folder.optional("snapshots.csv")? else {
1194        return Ok(PypsaAxis::SingleSnapshot);
1195    };
1196    if snapshot_table.rows.len() > 1 {
1197        Ok(PypsaAxis::Series)
1198    } else {
1199        Ok(PypsaAxis::SingleSnapshot)
1200    }
1201}
1202
1203/// The snapshot-local series files the sequence reader interprets: input
1204/// setpoints and bounds, and complete electrical state output. Everything
1205/// else stays reported rather than silently reduced.
1206/// The field plus whether the column is problem input (a setpoint or bound,
1207/// the `*_set`/`*_pu` spellings) rather than solved electrical state output
1208/// (the bare `p`/`q`/voltage spellings). The distinction picks the
1209/// sequence's value type: input changes produce a network per point, while a
1210/// fixed network with only state output varying is an operating point
1211/// series.
1212fn series_field(component: &str, attribute: &str) -> Option<(SeriesField, bool)> {
1213    match (component, attribute) {
1214        ("loads", "p_set") => Some((SeriesField::LoadP, true)),
1215        ("loads", "p") => Some((SeriesField::LoadP, false)),
1216        ("loads", "q_set") => Some((SeriesField::LoadQ, true)),
1217        ("loads", "q") => Some((SeriesField::LoadQ, false)),
1218        ("generators", "p_set") => Some((SeriesField::GenPg, true)),
1219        ("generators", "p") => Some((SeriesField::GenPg, false)),
1220        ("generators", "q_set") => Some((SeriesField::GenQg, true)),
1221        ("generators", "q") => Some((SeriesField::GenQg, false)),
1222        ("generators", "p_max_pu") => Some((SeriesField::GenPmax, true)),
1223        ("generators", "p_min_pu") => Some((SeriesField::GenPmin, true)),
1224        ("generators", "v_mag_pu_set") => Some((SeriesField::GenVg, true)),
1225        ("buses", "v_mag_pu") => Some((SeriesField::BusVm, false)),
1226        ("buses", "v_ang") => Some((SeriesField::BusVa, false)),
1227        _ => None,
1228    }
1229}
1230
1231/// One resolved series column: the field it patches, the element row, and one
1232/// value per snapshot.
1233struct SeriesColumn {
1234    field: SeriesField,
1235    /// Problem input rather than solved state output.
1236    input: bool,
1237    row: usize,
1238    values: Vec<f64>,
1239}
1240
1241/// Read a PyPSA CSV folder with time series siblings into a balanced network
1242/// time series: one network handle per snapshot, static tables shared across
1243/// the whole series, and the supported snapshot-local columns patched typed
1244/// per point — load and generator setpoints, per unit dispatch bounds scaled
1245/// by `p_nom`, voltage setpoints, and the solved bus voltage state. A series
1246/// file outside that profile is reported and retained rather than silently
1247/// reduced; a series column naming an unknown element, a non-numeric value,
1248/// or a row axis that disagrees with `snapshots.csv` is refused.
1249///
1250/// # Errors
1251/// A folder without `snapshots.csv`, a malformed series table, or any static
1252/// profile error.
1253// The listing scan, snapshot axis, column resolution, and per point patching
1254// read as one sequence; splitting them would thread six locals through
1255// helpers.
1256#[allow(clippy::too_many_lines)]
1257pub fn parse_pypsa_csv_time_series(source: &powerio_core::Source) -> Result<PypsaCsvSequence> {
1258    let mut warnings = Diagnostics::new();
1259    // A directory source yields its walk once; this one listing serves the
1260    // series scan, the static read, and the series tables.
1261    let entries = source
1262        .entry_names()
1263        .map_err(|error| acquisition_error(&error))?;
1264
1265    // Series siblings by name shape, before the static read so it does not
1266    // report the interpreted ones as ignored.
1267    let mut series_files: Vec<(String, String, String)> = Vec::new();
1268    let mut consumed = HashSet::new();
1269    for entry in &entries {
1270        let name = entry.as_str();
1271        if name.contains('/') {
1272            continue;
1273        }
1274        let Some(stem) = name.strip_suffix(".csv") else {
1275            continue;
1276        };
1277        let Some((component, attribute)) = stem.split_once('-') else {
1278            continue;
1279        };
1280        if series_field(component, attribute).is_some() {
1281            consumed.insert(name.to_string());
1282        }
1283        series_files.push((
1284            name.to_string(),
1285            component.to_string(),
1286            attribute.to_string(),
1287        ));
1288    }
1289
1290    let base = read_pypsa_csv_static(source, entries.clone(), &mut warnings, &consumed)?;
1291    let folder = PypsaFolder { source, entries };
1292
1293    // The snapshot axis. The label column is `snapshot` (the writer's and
1294    // pandas' spelling), else `name`, else the leading index column.
1295    let snapshot_table = folder
1296        .optional("snapshots.csv")?
1297        .ok_or_else(|| bad("a time series folder needs `snapshots.csv`"))?;
1298    let label_column = ["snapshot", "name"]
1299        .into_iter()
1300        .find(|c| snapshot_table.headers.iter().any(|h| h == c))
1301        .map(str::to_string)
1302        .or_else(|| snapshot_table.headers.first().cloned())
1303        .ok_or_else(|| bad("`snapshots.csv` has no columns"))?;
1304    let snapshots: Vec<String> = snapshot_table
1305        .rows
1306        .iter()
1307        .enumerate()
1308        .map(|(i, row)| {
1309            row.get(&label_column)
1310                .cloned()
1311                .ok_or_else(|| bad(format!("snapshots.csv row {}: empty snapshot label", i + 1)))
1312        })
1313        .collect::<Result<_>>()?;
1314    if snapshots.is_empty() {
1315        return Err(bad("`snapshots.csv` states no snapshots"));
1316    }
1317
1318    // Element rows resolve by name in table order — the same order the static
1319    // read built each table in.
1320    let name_rows = |file: &str| -> Result<HashMap<String, usize>> {
1321        let Some(table) = folder.optional(file)? else {
1322            return Ok(HashMap::new());
1323        };
1324        Ok(table
1325            .rows
1326            .iter()
1327            .enumerate()
1328            .filter_map(|(i, row)| row.get("name").map(|n| (n.clone(), i)))
1329            .collect())
1330    };
1331    let bus_rows: HashMap<String, usize> = folder
1332        .required("buses.csv")?
1333        .rows
1334        .iter()
1335        .enumerate()
1336        .filter_map(|(i, row)| row.get("name").map(|n| (n.clone(), i)))
1337        .collect();
1338    let load_rows = name_rows("loads.csv")?;
1339    let generator_rows = name_rows("generators.csv")?;
1340    // `p_nom` scales the per unit dispatch bound series, with the static
1341    // read's own fallback.
1342    let p_nom: Vec<f64> = folder
1343        .optional("generators.csv")?
1344        .map_or_else(Vec::new, |t| {
1345            t.rows
1346                .iter()
1347                .map(|row| {
1348                    row.f("p_nom")
1349                        .unwrap_or_else(|| row.f("p_set").unwrap_or(0.0).abs())
1350                })
1351                .collect()
1352        });
1353
1354    let mut columns: Vec<SeriesColumn> = Vec::new();
1355    for (file, component, attribute) in &series_files {
1356        let Some((field, input)) = series_field(component, attribute) else {
1357            warnings.push(&codes::READ_PYPSA_TABLE_UNSUPPORTED, format!(
1358                "`{file}` is outside the snapshot-local series profile; retained for exact same format writing"
1359            ));
1360            continue;
1361        };
1362        let table = folder
1363            .optional(file)?
1364            .ok_or_else(|| bad(format!("`{file}` vanished between listing and read")))?;
1365        if table.rows.len() != snapshots.len() {
1366            return Err(bad(format!(
1367                "`{file}` states {} rows for {} snapshots",
1368                table.rows.len(),
1369                snapshots.len()
1370            )));
1371        }
1372        let rows_of: &HashMap<String, usize> = match field {
1373            SeriesField::BusVm | SeriesField::BusVa => &bus_rows,
1374            SeriesField::LoadP | SeriesField::LoadQ => &load_rows,
1375            _ => &generator_rows,
1376        };
1377        for header in table.headers.iter().skip(1) {
1378            if header.is_empty() {
1379                continue;
1380            }
1381            let Some(&row) = rows_of.get(header) else {
1382                return Err(bad(format!(
1383                    "`{file}` column `{header}` names no element of its table"
1384                )));
1385            };
1386            let values = table
1387                .rows
1388                .iter()
1389                .enumerate()
1390                .map(|(k, r)| {
1391                    r.f(header).ok_or_else(|| {
1392                        bad(format!(
1393                            "`{file}` column `{header}` row {}: not a number",
1394                            k + 1
1395                        ))
1396                    })
1397                })
1398                .collect::<Result<Vec<f64>>>()?;
1399            columns.push(SeriesColumn {
1400                field,
1401                input,
1402                row,
1403                values,
1404            });
1405        }
1406    }
1407
1408    let mut networks = Vec::with_capacity(snapshots.len());
1409    for point in 0..snapshots.len() {
1410        let mut network = base.clone();
1411        for column in &columns {
1412            let value = column.values[point];
1413            match column.field {
1414                SeriesField::LoadP => network.loads_mut()[column.row].p = value,
1415                SeriesField::LoadQ => network.loads_mut()[column.row].q = value,
1416                SeriesField::GenPg => network.generators_mut()[column.row].pg = value,
1417                SeriesField::GenQg => network.generators_mut()[column.row].qg = value,
1418                SeriesField::GenPmax => {
1419                    network.generators_mut()[column.row].pmax =
1420                        value * p_nom.get(column.row).copied().unwrap_or(0.0);
1421                }
1422                SeriesField::GenPmin => {
1423                    network.generators_mut()[column.row].pmin =
1424                        value * p_nom.get(column.row).copied().unwrap_or(0.0);
1425                }
1426                SeriesField::GenVg => network.generators_mut()[column.row].vg = value,
1427                SeriesField::BusVm => network.buses_mut()[column.row].vm = value,
1428                SeriesField::BusVa => {
1429                    network.buses_mut()[column.row].va = value * crate::normalize::RAD_TO_DEG;
1430                }
1431            }
1432        }
1433        networks.push(network);
1434    }
1435
1436    let time_points = snapshots
1437        .iter()
1438        .map(|label| powerio_core::TimePoint::new(label.clone(), None))
1439        .collect::<std::result::Result<Vec<_>, powerio_core::Error>>()
1440        .map_err(|e| bad(e.to_string()))?;
1441    let series =
1442        powerio_core::TimeSeries::new(time_points, networks).map_err(|e| bad(e.to_string()))?;
1443    let inputs_vary = columns.iter().any(|column| column.input);
1444    let has_varying_columns = !columns.is_empty();
1445    Ok(PypsaCsvSequence {
1446        series,
1447        inputs_vary,
1448        has_varying_columns,
1449        diagnostics: warnings.into_records(),
1450    })
1451}
1452
1453fn parse_csv_table(text: &str, name: &str) -> Result<Option<CsvTable>> {
1454    let mut records = parse_csv(text, name)?
1455        .into_iter()
1456        .filter(|r| !(r.len() == 1 && r[0].trim().is_empty()));
1457    let Some(headers) = records.next() else {
1458        return Ok(Some(CsvTable {
1459            headers: Vec::new(),
1460            rows: Vec::new(),
1461        }));
1462    };
1463    let column_of: std::sync::Arc<HashMap<String, usize>> = std::sync::Arc::new(
1464        headers
1465            .iter()
1466            .enumerate()
1467            .map(|(column, header)| (header.clone(), column))
1468            .collect(),
1469    );
1470    let mut rows = Vec::new();
1471    for fields in records {
1472        rows.push(CsvRow {
1473            column_of: std::sync::Arc::clone(&column_of),
1474            fields,
1475        });
1476    }
1477    Ok(Some(CsvTable { headers, rows }))
1478}
1479
1480/// Split a whole CSV file into records, honoring quoted fields: an embedded
1481/// newline or comma inside `"..."` stays in the field (the writer's `esc` emits
1482/// those), and `""` is an escaped quote. A quote left open at end of input is
1483/// malformed CSV — everything after it would silently parse as one literal
1484/// field — so it is an error, not a best-effort record.
1485fn parse_csv(text: &str, name: &str) -> Result<Vec<Vec<String>>> {
1486    let mut records = Vec::new();
1487    let mut record = Vec::new();
1488    let mut cur = String::new();
1489    let mut quoted = false;
1490    let mut chars = text.chars().peekable();
1491    while let Some(c) = chars.next() {
1492        match c {
1493            '"' if quoted && chars.peek() == Some(&'"') => {
1494                cur.push('"');
1495                let _ = chars.next();
1496            }
1497            '"' => quoted = !quoted,
1498            ',' if !quoted => record.push(std::mem::take(&mut cur)),
1499            '\r' if !quoted && chars.peek() == Some(&'\n') => {}
1500            '\n' if !quoted => {
1501                record.push(std::mem::take(&mut cur));
1502                records.push(std::mem::take(&mut record));
1503            }
1504            _ => cur.push(c),
1505        }
1506    }
1507    if quoted {
1508        return Err(bad(format!(
1509            "{name}: unterminated quoted field (unbalanced `\"`)"
1510        )));
1511    }
1512    if !cur.is_empty() || !record.is_empty() {
1513        record.push(cur);
1514        records.push(record);
1515    }
1516    Ok(records)
1517}
1518
1519/// The collision-free PyPSA key for a bus: its name when it has one, else its
1520/// numeric id. Tests build `key_of` maps with it; the writer derives keys with
1521/// the collision fallback in `write_pypsa_csv_folder` instead.
1522#[cfg(test)]
1523fn bus_key(b: &Bus) -> String {
1524    b.name.clone().unwrap_or_else(|| b.id.0.to_string())
1525}
1526
1527/// The bus column an element table writes, escaped: the same key `buses.csv`
1528/// is indexed on, falling back to the raw id for a reference to a missing bus.
1529fn key_for(key_of: &HashMap<BusId, String>, bus: BusId) -> String {
1530    key_of
1531        .get(&bus)
1532        .map_or_else(|| bus.0.to_string(), |k| esc(k))
1533}
1534
1535fn esc(s: &str) -> String {
1536    if s.contains([',', '"', '\n']) {
1537        format!("\"{}\"", s.replace('"', "\"\""))
1538    } else {
1539        s.to_string()
1540    }
1541}
1542
1543fn bus_ref(
1544    file: &'static str,
1545    n: usize,
1546    row: &CsvRow,
1547    key: &str,
1548    id_of_name: &HashMap<String, BusId>,
1549) -> Result<BusId> {
1550    let raw = row
1551        .get(key)
1552        .ok_or_else(|| bad(format!("{file} row {n}: missing bus reference `{key}`")))?;
1553    id_of_name.get(raw).copied().ok_or_else(|| {
1554        bad(format!(
1555            "{file} row {n}: column `{key}` references unknown bus `{raw}`"
1556        ))
1557    })
1558}
1559
1560#[cfg(test)]
1561// Exact float compares are the point: a mapped value deviating from the
1562// fixture arithmetic means a column was misread.
1563#[allow(clippy::float_cmp)]
1564mod tests {
1565    #[derive(Debug)]
1566    struct Parsed {
1567        network: BalancedNetwork,
1568        diagnostics: Vec<crate::diagnostics::Diagnostic>,
1569    }
1570
1571    impl Parsed {
1572        fn rendered_diagnostics(&self) -> Vec<String> {
1573            crate::diagnostics::render_diagnostics(&self.diagnostics)
1574        }
1575    }
1576
1577    fn read_pypsa_csv_folder(path: impl AsRef<Path>) -> Result<Parsed> {
1578        let source =
1579            powerio_core::Source::open(path.as_ref()).map_err(|error| acquisition_error(&error))?;
1580        let mut warnings = Diagnostics::new();
1581        let network = read_pypsa_csv_source(&source, &mut warnings)?;
1582        Ok(Parsed {
1583            network,
1584            diagnostics: warnings.into_records(),
1585        })
1586    }
1587
1588    use super::*;
1589    use std::fs;
1590
1591    /// A fresh, nonexistent target for the folder writer: the destination
1592    /// commit refuses an existing entry, so the target must not exist yet.
1593    fn tmp_dir(label: &str) -> PathBuf {
1594        let p =
1595            std::env::temp_dir().join(format!("powerio-pypsa-unit-{label}-{}", std::process::id()));
1596        let _ = fs::remove_dir_all(&p);
1597        p
1598    }
1599
1600    fn folder(label: &str, files: &[(&str, &str)]) -> PathBuf {
1601        let dir = tmp_dir(label);
1602        fs::create_dir_all(&dir).unwrap();
1603        for (name, text) in files {
1604            fs::write(dir.join(name), text).unwrap();
1605        }
1606        dir
1607    }
1608
1609    fn close(a: f64, b: f64) {
1610        assert!((a - b).abs() < 1e-12, "{a} vs {b}");
1611    }
1612
1613    fn bus(id: usize, name: Option<&str>) -> Bus {
1614        Bus {
1615            id: BusId(id),
1616            kind: BusType::Pq,
1617            vm: 1.0,
1618            va: 0.0,
1619            base_kv: 110.0,
1620            vmax: 1.1,
1621            vmin: 0.9,
1622            evhi: None,
1623            evlo: None,
1624            area: 1,
1625            zone: 1,
1626            name: name.map(str::to_string),
1627            uid: None,
1628            location: None,
1629            extras: Extras::default(),
1630        }
1631    }
1632
1633    fn make_gen(bus: usize, cost: Option<GenCost>) -> Generator {
1634        Generator {
1635            bus: BusId(bus),
1636            pg: 1.0,
1637            qg: 0.0,
1638            pmax: 10.0,
1639            pmin: 0.0,
1640            qmax: f64::INFINITY,
1641            qmin: f64::NEG_INFINITY,
1642            vg: 1.0,
1643            mbase: 100.0,
1644            in_service: true,
1645            cost,
1646            caps: [None; crate::network::GEN_EXTRA_KEYS.len()],
1647            regulated_bus: None,
1648            uid: None,
1649        }
1650    }
1651
1652    fn storage_unit(bus: usize) -> Storage {
1653        Storage {
1654            bus: BusId(bus),
1655            ps: 3.0,
1656            qs: 1.5,
1657            energy: 20.0,
1658            energy_rating: 100.0,
1659            charge_rating: 25.0,
1660            discharge_rating: 25.0,
1661            charge_efficiency: 0.91,
1662            discharge_efficiency: 0.92,
1663            thermal_rating: 25.0,
1664            current_rating: None,
1665            qmin: f64::NEG_INFINITY,
1666            qmax: f64::INFINITY,
1667            r: 0.0,
1668            x: 0.0,
1669            p_loss: 0.0,
1670            q_loss: 0.0,
1671            in_service: true,
1672            uid: None,
1673            extras: Extras::default(),
1674        }
1675    }
1676
1677    fn xfmr(from: usize, to: usize, rate_a: f64) -> Branch {
1678        Branch {
1679            from: BusId(from),
1680            to: BusId(to),
1681            r: 0.125,
1682            x: 0.5,
1683            b: 0.25,
1684            charging: None,
1685            rate_a,
1686            rate_b: 0.0,
1687            rate_c: 0.0,
1688            rating_sets: Vec::new(),
1689            current_ratings: None,
1690            tap: 1.05,
1691            shift: 0.0,
1692            in_service: true,
1693            angmin: -360.0,
1694            angmax: 360.0,
1695            control: None,
1696            solution: None,
1697            uid: None,
1698            route: None,
1699            extras: Extras::default(),
1700        }
1701    }
1702
1703    fn line(from: usize, to: usize) -> Branch {
1704        Branch {
1705            from: BusId(from),
1706            to: BusId(to),
1707            r: 0.01,
1708            x: 0.1,
1709            b: 0.2,
1710            charging: None,
1711            rate_a: 100.0,
1712            rate_b: 0.0,
1713            rate_c: 0.0,
1714            rating_sets: Vec::new(),
1715            current_ratings: None,
1716            tap: 0.0,
1717            shift: 0.0,
1718            in_service: true,
1719            angmin: -360.0,
1720            angmax: 360.0,
1721            control: None,
1722            solution: None,
1723            uid: None,
1724            route: None,
1725            extras: Extras::default(),
1726        }
1727    }
1728
1729    fn net_with(buses: Vec<Bus>) -> BalancedNetwork {
1730        BalancedNetwork::in_memory("t", 100.0, buses, Vec::new())
1731    }
1732
1733    #[test]
1734    fn scheme_a_keeps_numeric_ids() {
1735        let dir = folder(
1736            "scheme-a",
1737            &[
1738                ("buses.csv", "name,v_nom\n5,110\n2,110\n"),
1739                ("loads.csv", "name,bus,p_set\nd1,5,7\n"),
1740            ],
1741        );
1742        let net = read_pypsa_csv_folder(&dir).unwrap().network;
1743        assert_eq!(net.buses()[0].id, BusId(5));
1744        assert_eq!(net.buses()[1].id, BusId(2));
1745        assert!(net.buses()[0].name.is_none());
1746        assert_eq!(net.loads()[0].bus, BusId(5));
1747    }
1748
1749    #[test]
1750    fn scheme_b_on_mixed_names_never_mixes() {
1751        let dir = folder(
1752            "scheme-b",
1753            &[
1754                ("buses.csv", "name,v_nom\n2,110\nb,110\n"),
1755                ("loads.csv", "name,bus,p_set\nd1,2,7\n"),
1756            ],
1757        );
1758        let net = read_pypsa_csv_folder(&dir).unwrap().network;
1759        assert_eq!(net.buses()[0].id, BusId(1));
1760        assert_eq!(net.buses()[1].id, BusId(2));
1761        assert_eq!(net.buses()[0].name.as_deref(), Some("2"));
1762        assert_eq!(net.buses()[1].name.as_deref(), Some("b"));
1763        // "2" resolves by name to the first bus, not numerically to the second.
1764        assert_eq!(net.loads()[0].bus, BusId(1));
1765    }
1766
1767    #[test]
1768    fn duplicate_bus_name_errors() {
1769        let dir = folder("dup-name", &[("buses.csv", "name,v_nom\nn1,110\nn1,110\n")]);
1770        let err = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
1771        assert!(err.contains("duplicate bus name `n1`"), "{err}");
1772    }
1773
1774    #[test]
1775    fn missing_bus_name_errors() {
1776        let dir = folder("no-name", &[("buses.csv", "name,v_nom\n,110\n")]);
1777        let err = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
1778        assert!(err.contains("buses.csv row 1: missing bus name"), "{err}");
1779    }
1780
1781    #[test]
1782    fn unknown_bus_reference_errors_no_numeric_fallback() {
1783        let dir = folder(
1784            "unknown-ref",
1785            &[
1786                ("buses.csv", "name,v_nom\n1,110\n"),
1787                ("loads.csv", "name,bus,p_set\nd1,7,5\n"),
1788            ],
1789        );
1790        let err = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
1791        assert!(
1792            err.contains("loads.csv row 1: column `bus` references unknown bus `7`"),
1793            "{err}"
1794        );
1795    }
1796
1797    #[test]
1798    fn missing_bus_reference_errors() {
1799        let dir = folder(
1800            "missing-ref",
1801            &[
1802                ("buses.csv", "name,v_nom\n1,110\n"),
1803                ("loads.csv", "name,p_set\nd1,5\n"),
1804            ],
1805        );
1806        let err = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
1807        assert!(
1808            err.contains("loads.csv row 1: missing bus reference `bus`"),
1809            "{err}"
1810        );
1811    }
1812
1813    #[test]
1814    fn control_sets_bus_kind_pq_untouched() {
1815        let dir = folder(
1816            "control",
1817            &[
1818                ("buses.csv", "name,v_nom\n1,110\n2,110\n3,110\n"),
1819                (
1820                    "generators.csv",
1821                    "name,bus,control,p_set\ng1,1,slack,1\ng2,2,pv,1\ng3,3,PQ,1\n",
1822                ),
1823            ],
1824        );
1825        let net = read_pypsa_csv_folder(&dir).unwrap().network;
1826        assert_eq!(net.buses()[0].kind, BusType::Ref);
1827        assert_eq!(net.buses()[1].kind, BusType::Pv);
1828        assert_eq!(net.buses()[2].kind, BusType::Pq);
1829    }
1830
1831    #[test]
1832    fn transformer_read_rebases_to_system_base() {
1833        let dir = folder(
1834            "xf-read",
1835            &[
1836                ("network.csv", "name,powerio_base_mva\nt,100\n"),
1837                ("buses.csv", "name,v_nom\n1,110\n2,110\n"),
1838                (
1839                    "transformers.csv",
1840                    "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",
1841                ),
1842            ],
1843        );
1844        let parsed = read_pypsa_csv_folder(&dir).unwrap();
1845        let br = &parsed.network.branches()[0];
1846        close(br.r, 0.125); // 0.0625 * 100/50
1847        close(br.x, 0.5);
1848        close(br.b, 0.25); // 0.5 * 50/100
1849        close(br.terminal_charging().g_fr, 0.05);
1850        close(br.terminal_charging().b_fr, 0.25);
1851        close(br.terminal_charging().g_to, 0.0);
1852        assert_eq!(br.rate_a, 50.0);
1853        assert_eq!(br.tap, 1.05);
1854        assert!(
1855            parsed.rendered_diagnostics().is_empty(),
1856            "{:?}",
1857            parsed.rendered_diagnostics()
1858        );
1859    }
1860
1861    #[test]
1862    fn transformer_read_rejects_nonpositive_s_nom() {
1863        let dir = folder(
1864            "xf-snom",
1865            &[
1866                ("buses.csv", "name,v_nom\n1,110\n2,110\n"),
1867                (
1868                    "transformers.csv",
1869                    "name,bus0,bus1,r,x,s_nom,tap_ratio\nt1,1,2,0.1,0.2,0,1.05\n",
1870                ),
1871            ],
1872        );
1873        let err = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
1874        assert!(
1875            err.contains(
1876                "transformers.csv row 1 (`t1`): s_nom must be positive to rebase impedances (got 0)"
1877            ),
1878            "{err}"
1879        );
1880    }
1881
1882    #[test]
1883    fn line_g_maps_to_terminal_conductance() {
1884        let dir = folder(
1885            "line-g",
1886            &[
1887                ("buses.csv", "name,v_nom\n1,110\n2,110\n"),
1888                (
1889                    "lines.csv",
1890                    "name,bus0,bus1,r,x,g,s_nom\nl1,1,2,0.1,0.2,0.3,100\n",
1891                ),
1892            ],
1893        );
1894        let parsed = read_pypsa_csv_folder(&dir).unwrap();
1895        let charging = parsed.network.branches()[0].terminal_charging();
1896        close(charging.g_fr, 1815.0);
1897        close(charging.g_to, 1815.0);
1898        assert!(
1899            parsed.rendered_diagnostics().is_empty(),
1900            "{:?}",
1901            parsed.rendered_diagnostics()
1902        );
1903    }
1904
1905    #[test]
1906    fn transformer_write_rebases_to_s_nom_base() {
1907        let mut net = net_with(vec![bus(1, None), bus(2, None)]);
1908        *net.branches_mut() = vec![xfmr(1, 2, 50.0)];
1909        let key_of: HashMap<BusId, String> =
1910            net.buses().iter().map(|b| (b.id, bus_key(b))).collect();
1911        let csv = transformers_csv(&net, &key_of);
1912        assert_eq!(
1913            csv.lines().nth(1).unwrap(),
1914            "transformer_1,1,2,0.0625,0.25,0.5,0,50,1.05,0,true"
1915        );
1916    }
1917
1918    #[test]
1919    fn transformer_write_zero_rate_a_uses_base_mva() {
1920        let mut net = net_with(vec![bus(1, None), bus(2, None)]);
1921        *net.branches_mut() = vec![xfmr(1, 2, 0.0)];
1922        let key_of: HashMap<BusId, String> =
1923            net.buses().iter().map(|b| (b.id, bus_key(b))).collect();
1924        let csv = transformers_csv(&net, &key_of);
1925        assert_eq!(
1926            csv.lines().nth(1).unwrap(),
1927            "transformer_1,1,2,0.125,0.5,0.25,0,100,1.05,0,true"
1928        );
1929    }
1930
1931    #[test]
1932    fn a_folder_write_never_replaces_an_existing_entry() {
1933        let net = net_with(vec![bus(1, None), bus(2, None)]);
1934
1935        // A regular file at a produced table name: the write is refused and
1936        // the file keeps its bytes.
1937        let blocked = tmp_dir("no-clobber-file");
1938        fs::create_dir_all(&blocked).unwrap();
1939        fs::write(blocked.join("buses.csv"), b"precious").unwrap();
1940        let error = write_pypsa_csv_folder(&net, &blocked).unwrap_err();
1941        assert_eq!(error.category(), powerio_core::ErrorCategory::Request);
1942        assert_eq!(fs::read(blocked.join("buses.csv")).unwrap(), b"precious");
1943
1944        // A symbolic link at a produced table name: the link survives and the
1945        // file it designates keeps its bytes and its length.
1946        #[cfg(unix)]
1947        {
1948            let linked = tmp_dir("no-clobber-link");
1949            fs::create_dir_all(&linked).unwrap();
1950            let designated = tmp_dir("no-clobber-designated");
1951            fs::create_dir_all(&designated).unwrap();
1952            let real = designated.join("real.csv");
1953            fs::write(&real, b"designated bytes").unwrap();
1954            std::os::unix::fs::symlink(&real, linked.join("buses.csv")).unwrap();
1955            let error = write_pypsa_csv_folder(&net, &linked).unwrap_err();
1956            assert_eq!(error.category(), powerio_core::ErrorCategory::Request);
1957            assert!(
1958                fs::symlink_metadata(linked.join("buses.csv"))
1959                    .unwrap()
1960                    .file_type()
1961                    .is_symlink()
1962            );
1963            assert_eq!(fs::read(&real).unwrap(), b"designated bytes");
1964            assert_eq!(fs::metadata(&real).unwrap().len(), 16);
1965            let _ = fs::remove_dir_all(&linked);
1966            let _ = fs::remove_dir_all(&designated);
1967        }
1968
1969        // The same write into a fresh directory produces the complete table
1970        // inventory the `Destination` write commits.
1971        let fresh = tmp_dir("no-clobber-fresh");
1972        let out = write_pypsa_csv_folder(&net, &fresh).unwrap();
1973        let mut folder_names: Vec<String> = out
1974            .files
1975            .iter()
1976            .map(|path| {
1977                path.strip_prefix(&out.dir)
1978                    .unwrap()
1979                    .to_string_lossy()
1980                    .into_owned()
1981            })
1982            .collect();
1983        folder_names.sort();
1984        let module = powerio_core::PioModule::new(net.clone());
1985        let committed = crate::format::write_pypsa_csv(
1986            &module,
1987            powerio_core::Destination::memory("case").unwrap(),
1988        )
1989        .unwrap();
1990        let powerio_core::WrittenOutput::Memory { artifacts } = committed.into_output() else {
1991            panic!("memory output")
1992        };
1993        let mut memory_names: Vec<String> = artifacts
1994            .iter()
1995            .map(|artifact| {
1996                artifact
1997                    .name()
1998                    .as_str()
1999                    .trim_start_matches("case/")
2000                    .to_owned()
2001            })
2002            .collect();
2003        memory_names.sort();
2004        assert_eq!(folder_names, memory_names);
2005        let _ = fs::remove_dir_all(&blocked);
2006        let _ = fs::remove_dir_all(&fresh);
2007    }
2008
2009    #[test]
2010    fn transformer_legacy_b_warns_about_terminal_charging_collapse() {
2011        let mut net = net_with(vec![bus(1, None), bus(2, None)]);
2012        *net.branches_mut() = vec![xfmr(1, 2, 50.0)];
2013        let out = write_pypsa_csv_folder(&net, tmp_dir("xf-legacy-b-warning")).unwrap();
2014
2015        assert!(
2016            out.rendered_diagnostics()
2017                .iter()
2018                .any(|w| w.contains("terminal admittance")),
2019            "{:?}",
2020            out.rendered_diagnostics()
2021        );
2022    }
2023
2024    #[test]
2025    fn line_conductance_writes_and_round_trips() {
2026        let mut net = net_with(vec![bus(1, None), bus(2, None)]);
2027        let mut br = line(1, 2);
2028        br.charging = Some(BranchCharging {
2029            g_fr: 0.4,
2030            b_fr: 0.1,
2031            g_to: 0.4,
2032            b_to: 0.1,
2033        });
2034        *net.branches_mut() = vec![br];
2035        let dir = tmp_dir("line-g-write");
2036        let out = write_pypsa_csv_folder(&net, &dir).unwrap();
2037        assert!(
2038            !out.rendered_diagnostics()
2039                .iter()
2040                .any(|w| w.contains("terminal admittance")),
2041            "{:?}",
2042            out.rendered_diagnostics()
2043        );
2044        let text = fs::read_to_string(dir.join("lines.csv")).unwrap();
2045        assert_eq!(
2046            text.lines().next().unwrap(),
2047            "name,bus0,bus1,r,x,b,g,s_nom,v_ang_min,v_ang_max,active"
2048        );
2049
2050        let back = read_pypsa_csv_folder(&dir).unwrap().network;
2051        let charging = back.branches()[0].terminal_charging();
2052        close(charging.g_fr, 0.4);
2053        close(charging.g_to, 0.4);
2054        close(charging.b_fr, 0.1);
2055        close(charging.b_to, 0.1);
2056    }
2057
2058    #[test]
2059    fn transformer_conductance_writes_and_round_trips() {
2060        let mut net = net_with(vec![bus(1, None), bus(2, None)]);
2061        let mut br = xfmr(1, 2, 50.0);
2062        br.charging = Some(BranchCharging {
2063            g_fr: 0.05,
2064            b_fr: 0.25,
2065            g_to: 0.0,
2066            b_to: 0.0,
2067        });
2068        *net.branches_mut() = vec![br];
2069        let dir = tmp_dir("xf-g-write");
2070        let out = write_pypsa_csv_folder(&net, &dir).unwrap();
2071        assert!(
2072            !out.rendered_diagnostics()
2073                .iter()
2074                .any(|w| w.contains("terminal admittance")),
2075            "{:?}",
2076            out.rendered_diagnostics()
2077        );
2078
2079        let back = read_pypsa_csv_folder(&dir).unwrap().network;
2080        let charging = back.branches()[0].terminal_charging();
2081        close(charging.g_fr, 0.05);
2082        close(charging.g_to, 0.0);
2083        close(charging.b_fr, 0.25);
2084        close(charging.b_to, 0.0);
2085    }
2086
2087    #[test]
2088    fn storage_write_fields_and_round_trip() {
2089        let mut net = net_with(vec![bus(1, None)]);
2090        *net.storage_mut() = vec![storage_unit(1)];
2091        let dir = tmp_dir("storage-rt");
2092        let out = write_pypsa_csv_folder(&net, &dir).unwrap();
2093        assert!(
2094            !out.rendered_diagnostics()
2095                .iter()
2096                .any(|w| w.contains("storage units")),
2097            "{:?}",
2098            out.rendered_diagnostics()
2099        );
2100        let text = fs::read_to_string(dir.join("storage_units.csv")).unwrap();
2101        assert_eq!(
2102            text.lines().next().unwrap(),
2103            "name,bus,p_nom,max_hours,p_set,q_set,state_of_charge_initial,efficiency_store,efficiency_dispatch,cyclic_state_of_charge"
2104        );
2105        assert_eq!(
2106            text.lines().nth(1).unwrap(),
2107            "storage_1,1,25,4,3,1.5,20,0.91,0.92,false"
2108        );
2109        let back = read_pypsa_csv_folder(&dir).unwrap().network;
2110        let st = &back.storage()[0];
2111        assert_eq!(st.charge_rating, 25.0);
2112        assert_eq!(st.discharge_rating, 25.0);
2113        assert_eq!(st.energy_rating, 100.0);
2114        assert_eq!(st.ps, 3.0);
2115        assert_eq!(st.qs, 1.5);
2116        assert_eq!(st.energy, 20.0);
2117    }
2118
2119    #[test]
2120    fn storage_write_lossy_warning_counts() {
2121        let mut net = net_with(vec![bus(1, None)]);
2122        let mut st = storage_unit(1);
2123        st.charge_rating = 10.0;
2124        st.discharge_rating = 20.0;
2125        st.thermal_rating = 20.0;
2126        *net.storage_mut() = vec![st];
2127        let out = write_pypsa_csv_folder(&net, tmp_dir("storage-lossy")).unwrap();
2128        assert!(
2129            out.diagnostics.iter().any(|d| d.message()
2130                == "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)"),
2131            "{:?}",
2132            out.rendered_diagnostics()
2133        );
2134    }
2135
2136    #[test]
2137    fn named_buses_join_on_write() {
2138        let mut net = net_with(vec![bus(1, Some("North")), bus(2, None)]);
2139        *net.generators_mut() = vec![make_gen(1, None)];
2140        *net.loads_mut() = vec![Load {
2141            bus: BusId(2),
2142            p: 5.0,
2143            q: 1.0,
2144            voltage_model: None,
2145            in_service: true,
2146            uid: None,
2147            extras: Extras::default(),
2148        }];
2149        let dir = tmp_dir("named-join");
2150        write_pypsa_csv_folder(&net, &dir).unwrap();
2151        let buses = fs::read_to_string(dir.join("buses.csv")).unwrap();
2152        assert!(buses.lines().nth(1).unwrap().starts_with("North,"));
2153        let gens = fs::read_to_string(dir.join("generators.csv")).unwrap();
2154        assert!(gens.lines().nth(1).unwrap().contains(",North,"), "{gens}");
2155        let back = read_pypsa_csv_folder(&dir).unwrap().network;
2156        assert_eq!(back.buses()[0].name.as_deref(), Some("North"));
2157        assert_eq!(back.loads()[0].bus, back.buses()[1].id);
2158    }
2159
2160    #[test]
2161    fn duplicate_bus_names_fall_back_to_ids() {
2162        let mut net = net_with(vec![bus(1, Some("X")), bus(2, Some("X"))]);
2163        *net.loads_mut() = vec![Load {
2164            bus: BusId(2),
2165            p: 5.0,
2166            q: 1.0,
2167            voltage_model: None,
2168            in_service: true,
2169            uid: None,
2170            extras: Extras::default(),
2171        }];
2172        let dir = tmp_dir("dup-keys");
2173        let out = write_pypsa_csv_folder(&net, &dir).unwrap();
2174        assert!(
2175            out.diagnostics.iter().any(|d| d.message()
2176                == "buses.csv: bus names `X` collide with another bus name or id; those buses are keyed by their numeric id instead"),
2177            "{:?}",
2178            out.rendered_diagnostics()
2179        );
2180        let buses = fs::read_to_string(dir.join("buses.csv")).unwrap();
2181        let keys: Vec<&str> = buses
2182            .lines()
2183            .skip(1)
2184            .map(|l| l.split(',').next().unwrap())
2185            .collect();
2186        assert_eq!(keys, ["1", "2"]);
2187        // The folder is importable: elements join on the fallback keys.
2188        let back = read_pypsa_csv_folder(&dir).unwrap().network;
2189        assert_eq!(back.loads()[0].bus, back.buses()[1].id);
2190    }
2191
2192    #[test]
2193    fn unterminated_quote_is_an_error() {
2194        let dir = folder(
2195            "bad-quote",
2196            &[("buses.csv", "name,v_nom\n\"bus one,110\n2,110\n")],
2197        );
2198        let msg = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
2199        assert!(
2200            msg.contains("buses.csv: unterminated quoted field (unbalanced `\"`)"),
2201            "{msg}"
2202        );
2203    }
2204
2205    #[test]
2206    fn quadratic_only_marginal_cost_is_kept() {
2207        // PyPSA defaults marginal_cost to 0; a quadratic-only file still
2208        // carries a real cost curve.
2209        let dir = folder(
2210            "quad-cost",
2211            &[
2212                ("buses.csv", "name,v_nom\n1,110\n"),
2213                (
2214                    "generators.csv",
2215                    "name,bus,p_nom,marginal_cost_quadratic\ng1,1,50,0.25\n",
2216                ),
2217            ],
2218        );
2219        let parsed = read_pypsa_csv_folder(&dir).unwrap();
2220        let cost = parsed.network.generators()[0].cost.as_ref().unwrap();
2221        assert_eq!(cost.coeffs, vec![0.25, 0.0, 0.0]);
2222    }
2223
2224    #[test]
2225    fn bus_name_matching_another_bus_id_falls_back() {
2226        // A bus literally named "2" would collide with bus id 2's key.
2227        let net = net_with(vec![bus(1, Some("2")), bus(2, None)]);
2228        let dir = tmp_dir("name-id-clash");
2229        let out = write_pypsa_csv_folder(&net, &dir).unwrap();
2230        assert!(
2231            out.rendered_diagnostics().iter().any(|w| w.contains("`2`")),
2232            "{:?}",
2233            out.rendered_diagnostics()
2234        );
2235        let buses = fs::read_to_string(dir.join("buses.csv")).unwrap();
2236        let keys: Vec<&str> = buses
2237            .lines()
2238            .skip(1)
2239            .map(|l| l.split(',').next().unwrap())
2240            .collect();
2241        assert_eq!(keys, ["1", "2"]);
2242    }
2243
2244    #[test]
2245    fn links_read_as_hvdc_with_warning() {
2246        let dir = folder(
2247            "links",
2248            &[
2249                ("buses.csv", "name,v_nom\n1,110\n2,110\n"),
2250                (
2251                    "links.csv",
2252                    "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",
2253                ),
2254            ],
2255        );
2256        let parsed = read_pypsa_csv_folder(&dir).unwrap();
2257        let h = &parsed.network.hvdc()[0];
2258        assert_eq!(h.from, BusId(1));
2259        assert_eq!(h.to, BusId(2));
2260        assert_eq!(h.pf, 10.0);
2261        close(h.pt, 9.7);
2262        close(h.pmin, -50.0);
2263        close(h.pmax, 50.0);
2264        assert_eq!(h.loss0, 0.0);
2265        close(h.loss1, 0.03);
2266        assert_eq!(h.vf, 1.0);
2267        assert_eq!(h.qf, 0.0);
2268        assert!(h.in_service);
2269        assert!(
2270            parsed.diagnostics.iter().any(|d| d.message()
2271                == "links.csv: 1 links read as HVDC lines; PyPSA links carry no reactive or voltage data (q limits 0, voltage setpoints 1.0)"),
2272            "{:?}",
2273            parsed.rendered_diagnostics()
2274        );
2275    }
2276
2277    #[test]
2278    fn stores_warning_gated_on_nonempty() {
2279        let dir = folder(
2280            "stores-empty",
2281            &[
2282                ("buses.csv", "name,v_nom\n1,110\n"),
2283                ("stores.csv", "name,bus,e_nom\n"),
2284            ],
2285        );
2286        assert!(
2287            read_pypsa_csv_folder(&dir)
2288                .unwrap()
2289                .rendered_diagnostics()
2290                .is_empty()
2291        );
2292        let dir = folder(
2293            "stores-nonempty",
2294            &[
2295                ("buses.csv", "name,v_nom\n1,110\n"),
2296                ("stores.csv", "name,bus,e_nom\ns1,1,10\n"),
2297            ],
2298        );
2299        let parsed = read_pypsa_csv_folder(&dir).unwrap();
2300        assert!(
2301            parsed
2302                .diagnostics
2303                .iter()
2304                .any(|d| d.message() == "stores.csv ignored (1 rows): PyPSA stores are not mapped"),
2305            "{:?}",
2306            parsed.rendered_diagnostics()
2307        );
2308    }
2309
2310    #[test]
2311    fn header_only_buses_is_an_empty_case() {
2312        let dir = folder("empty", &[("buses.csv", "name,v_nom\n")]);
2313        let err = read_pypsa_csv_folder(&dir).unwrap_err().to_string();
2314        assert!(err.contains("case has no buses"), "{err}");
2315    }
2316
2317    #[test]
2318    fn cost_write_keeps_low_order_terms_and_warns() {
2319        let mut net = net_with(vec![bus(1, None), bus(2, None)]);
2320        *net.generators_mut() = vec![
2321            make_gen(
2322                1,
2323                Some(GenCost {
2324                    model: 2,
2325                    startup: 0.0,
2326                    shutdown: 0.0,
2327                    ncost: 4,
2328                    coeffs: vec![5.0, 4.0, 3.0, 2.0], // cubic: keep (c2, c1) = (4, 3)
2329                }),
2330            ),
2331            make_gen(
2332                2,
2333                Some(GenCost {
2334                    model: 1,
2335                    startup: 0.0,
2336                    shutdown: 0.0,
2337                    ncost: 2,
2338                    coeffs: vec![1.0, 2.0, 3.0, 4.0],
2339                }),
2340            ),
2341            make_gen(
2342                1,
2343                Some(GenCost {
2344                    model: 2,
2345                    startup: 0.0,
2346                    shutdown: 0.0,
2347                    ncost: 0,
2348                    coeffs: Vec::new(),
2349                }),
2350            ),
2351        ];
2352        let key_of: HashMap<BusId, String> =
2353            net.buses().iter().map(|b| (b.id, bus_key(b))).collect();
2354        let mut warnings = Diagnostics::new();
2355        let csv = generators_csv(&net, &key_of, &mut warnings);
2356        assert_eq!(
2357            csv.lines().nth(1).unwrap(),
2358            "gen_1,1,PQ,10,1,0,0,1,3,4,true,1"
2359        );
2360        assert_eq!(
2361            csv.lines().nth(2).unwrap(),
2362            "gen_2,2,PQ,10,1,0,0,1,0,0,true,1"
2363        );
2364        assert_eq!(
2365            csv.lines().nth(3).unwrap(),
2366            "gen_3,1,PQ,10,1,0,0,1,0,0,true,1"
2367        );
2368        for expected in [
2369            "1 generator costs dropped: PyPSA carries marginal_cost/marginal_cost_quadratic (model 2) only",
2370            "1 generator costs truncated to quadratic for PyPSA marginal cost columns",
2371            "1 generator costs had no coefficients and were written as zero",
2372        ] {
2373            assert!(
2374                warnings.records().iter().any(|d| d.message() == expected),
2375                "missing {expected:?} in {warnings:?}"
2376            );
2377        }
2378    }
2379}