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