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