1use 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 pub diagnostics: Vec<crate::diagnostics::Diagnostic>,
29}
30
31impl PypsaCsvOutputs {
32 #[must_use]
34 pub fn rendered_diagnostics(&self) -> Vec<String> {
35 crate::diagnostics::render_diagnostics(&self.diagnostics)
36 }
37}
38
39struct 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#[allow(clippy::too_many_lines)] pub(crate) fn read_pypsa_csv_source(
81 source: &powerio_core::Source,
82 warnings: &mut Diagnostics,
83) -> Result<BalancedNetwork> {
84 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#[allow(clippy::too_many_lines)] fn 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 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 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 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 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 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 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 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 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 crate::format::reject_empty_case(&net, FMT)?;
503 net.check_references(FMT)?;
504 Ok(net)
505}
506
507#[allow(clippy::missing_panics_doc)] pub 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#[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 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 #[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 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 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#[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 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 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 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#[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#[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#[derive(Debug, Clone)]
1145#[non_exhaustive]
1146pub struct PypsaCsvSequence {
1147 pub series: powerio_core::TimeSeries<BalancedNetwork>,
1148 pub inputs_vary: bool,
1151 pub has_varying_columns: bool,
1155 pub diagnostics: Vec<crate::diagnostics::Diagnostic>,
1156}
1157
1158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1163pub enum PypsaAxis {
1164 SingleSnapshot,
1165 Series,
1166}
1167
1168pub 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
1203fn 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
1231struct SeriesColumn {
1234 field: SeriesField,
1235 input: bool,
1237 row: usize,
1238 values: Vec<f64>,
1239}
1240
1241#[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 let entries = source
1262 .entry_names()
1263 .map_err(|error| acquisition_error(&error))?;
1264
1265 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 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 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 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
1480fn 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#[cfg(test)]
1523fn bus_key(b: &Bus) -> String {
1524 b.name.clone().unwrap_or_else(|| b.id.0.to_string())
1525}
1526
1527fn 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#[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 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 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); close(br.x, 0.5);
1848 close(br.b, 0.25); 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 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 #[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 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 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 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 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], }),
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}