1#![expect(
21 rustdoc::broken_intra_doc_links,
22 reason = "the frozen 0.9 schema records these doc strings byte for byte"
23)]
24
25use std::collections::BTreeMap;
26
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29
30use crate::geo::{GeoMeta, Location};
31use crate::{Error, Result};
32
33pub type Extras = BTreeMap<String, Value>;
36
37pub const DEFAULT_BASE_FREQUENCY: f64 = 60.0;
41
42fn default_base_frequency() -> f64 {
46 DEFAULT_BASE_FREQUENCY
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
56#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
57#[serde(transparent)]
58pub struct BusId(pub usize);
59
60impl BusId {
61 pub const MAX: Self = Self(i64::MAX as usize);
65
66 #[must_use]
67 pub const fn new(id: usize) -> Self {
68 Self(id)
69 }
70}
71
72impl std::fmt::Display for BusId {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 self.0.fmt(f)
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81#[serde(rename_all = "UPPERCASE")]
82#[repr(u8)]
83#[non_exhaustive]
84pub enum BusType {
85 Pq = 1,
86 Pv = 2,
87 Ref = 3,
88 Isolated = 4,
89}
90
91impl BusType {
92 pub(crate) fn from_f64(v: f64) -> Self {
94 match v as i32 {
95 2 => Self::Pv,
96 3 => Self::Ref,
97 4 => Self::Isolated,
98 _ => Self::Pq,
99 }
100 }
101
102 #[must_use]
105 pub fn as_str(self) -> &'static str {
106 match self {
107 Self::Pq => "PQ",
108 Self::Pv => "PV",
109 Self::Ref => "REF",
110 Self::Isolated => "ISOLATED",
111 }
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118#[non_exhaustive]
119pub struct GenCost {
120 pub model: u8,
122 pub startup: f64,
123 pub shutdown: f64,
124 pub ncost: usize,
126 pub coeffs: Vec<f64>,
129}
130
131impl GenCost {
132 #[must_use]
140 pub fn new(model: u8, startup: f64, shutdown: f64, coeffs: Vec<f64>) -> Self {
141 let ncost = if model == 1 {
142 coeffs.len() / 2
143 } else {
144 coeffs.len()
145 };
146 Self {
147 model,
148 startup,
149 shutdown,
150 ncost,
151 coeffs,
152 }
153 }
154
155 #[must_use]
156 pub fn with_ncost(
157 model: u8,
158 startup: f64,
159 shutdown: f64,
160 ncost: usize,
161 coeffs: Vec<f64>,
162 ) -> Self {
163 Self {
164 model,
165 startup,
166 shutdown,
167 ncost,
168 coeffs,
169 }
170 }
171
172 pub fn quadratic(&self) -> Option<(f64, f64)> {
177 self.quadratic_with_constant().map(|(q, c, _)| (q, c))
178 }
179
180 pub fn quadratic_with_constant(&self) -> Option<(f64, f64, f64)> {
186 if self.model != 2 {
187 return None;
188 }
189 if self.coeffs.len() < self.ncost {
192 return None;
193 }
194 match self.ncost {
198 3 => Some((2.0 * self.coeffs[0], self.coeffs[1], self.coeffs[2])),
199 2 => Some((0.0, self.coeffs[0], self.coeffs[1])),
200 1 => Some((0.0, 0.0, self.coeffs[0])),
201 _ => None,
202 }
203 }
204
205 pub const LEADING_COEFF_TOL: f64 = 1e-12;
209
210 pub fn quadratic_with_constant_tol(&self, tol: f64) -> Option<(f64, f64, f64)> {
220 if self.model != 2 {
221 return None;
222 }
223 if self.coeffs.len() < self.ncost {
224 return None;
225 }
226 let row = &self.coeffs[..self.ncost];
227 let mut first = 0;
228 while first + 1 < row.len() && row[first].abs() <= tol {
229 first += 1;
230 }
231 match row.len() - first {
232 3 => Some((2.0 * row[first], row[first + 1], row[first + 2])),
233 2 => Some((0.0, row[first], row[first + 1])),
234 1 => Some((0.0, 0.0, row[first])),
235 _ => None,
236 }
237 }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
249#[non_exhaustive]
250pub enum SourceFormat {
251 #[serde(rename = "matpower", alias = "Matpower")]
252 Matpower,
253 #[serde(rename = "powermodels-json", alias = "PowerModelsJson")]
254 PowerModelsJson,
255 #[serde(rename = "egret-json", alias = "EgretJson")]
256 EgretJson,
257 #[serde(rename = "psse", alias = "Psse")]
258 Psse,
259 #[serde(rename = "powerworld", alias = "PowerWorld")]
260 PowerWorld,
261 #[serde(rename = "pandapower-json", alias = "PandapowerJson")]
262 PandapowerJson,
263 #[serde(rename = "pslf", alias = "Pslf")]
268 Pslf,
269 #[serde(rename = "powerworld-pwb", alias = "PowerWorldBinary")]
273 PowerWorldBinary,
274 #[serde(rename = "in-memory", alias = "InMemory")]
276 InMemory,
277 #[serde(rename = "normalized", alias = "Normalized")]
283 Normalized,
284 #[serde(rename = "gridfm", alias = "Gridfm")]
290 Gridfm,
291 #[serde(rename = "pypsa-csv", alias = "PypsaCsv")]
294 PypsaCsv,
295 #[serde(rename = "goc3-json", alias = "Goc3Json")]
299 Goc3Json,
300 #[serde(rename = "surge-json", alias = "SurgeJson")]
302 SurgeJson,
303 #[serde(rename = "opfdata-json", alias = "DeepMindOpfDataJson")]
308 DeepMindOpfDataJson,
309}
310
311impl SourceFormat {
312 #[must_use]
317 pub fn name(self) -> &'static str {
318 match self {
319 SourceFormat::Matpower => "matpower",
320 SourceFormat::PowerModelsJson => "powermodels-json",
321 SourceFormat::EgretJson => "egret-json",
322 SourceFormat::Psse => "psse",
323 SourceFormat::PowerWorld => "powerworld",
324 SourceFormat::PandapowerJson => "pandapower-json",
325 SourceFormat::Pslf => "pslf",
326 SourceFormat::PowerWorldBinary => "powerworld-pwb",
327 SourceFormat::InMemory => "in-memory",
328 SourceFormat::Normalized => "normalized",
329 SourceFormat::Gridfm => "gridfm",
330 SourceFormat::PypsaCsv => "pypsa-csv",
331 SourceFormat::Goc3Json => "goc3-json",
332 SourceFormat::SurgeJson => "surge-json",
333 SourceFormat::DeepMindOpfDataJson => "opfdata-json",
334 }
335 }
336}
337
338#[derive(Debug, Clone)]
348pub struct BalancedNetwork {
349 tables: std::sync::Arc<BalancedNetworkTables>,
350}
351
352impl BalancedNetwork {
353 pub(crate) fn from_tables(tables: BalancedNetworkTables) -> Self {
354 Self {
355 tables: std::sync::Arc::new(tables),
356 }
357 }
358
359 pub(crate) fn tables_mut(&mut self) -> &mut BalancedNetworkTables {
362 std::sync::Arc::make_mut(&mut self.tables)
363 }
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
376#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
377#[cfg_attr(feature = "schema", schemars(rename = "BalancedNetwork"))]
378#[serde(remote = "Self")]
379pub(crate) struct BalancedNetworkTables {
380 pub name: String,
381 pub base_mva: f64,
382 #[serde(default = "default_base_frequency")]
389 pub base_frequency: f64,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub geo: Option<GeoMeta>,
392 pub buses: std::sync::Arc<Vec<Bus>>,
393 pub loads: std::sync::Arc<Vec<Load>>,
394 pub shunts: std::sync::Arc<Vec<Shunt>>,
395 pub branches: std::sync::Arc<Vec<Branch>>,
396 #[serde(default)]
397 pub switches: std::sync::Arc<Vec<Switch>>,
398 pub generators: std::sync::Arc<Vec<Generator>>,
399 pub storage: std::sync::Arc<Vec<Storage>>,
400 pub hvdc: std::sync::Arc<Vec<Hvdc>>,
401 #[serde(default)]
410 pub transformers_3w: std::sync::Arc<Vec<Transformer3W>>,
411 #[serde(default)]
416 pub areas: std::sync::Arc<Vec<Area>>,
417 #[serde(default)]
420 pub solver: Option<SolverParams>,
421 pub source_format: SourceFormat,
422}
423
424impl Serialize for BalancedNetwork {
425 fn serialize<S: serde::Serializer>(
426 &self,
427 serializer: S,
428 ) -> std::result::Result<S::Ok, S::Error> {
429 BalancedNetworkTables::serialize(
430 &self.tables,
431 powerio_core::__implementation::nonfinite::NonFiniteSer(serializer),
432 )
433 }
434}
435
436impl<'de> Deserialize<'de> for BalancedNetwork {
437 fn deserialize<D: serde::Deserializer<'de>>(
438 deserializer: D,
439 ) -> std::result::Result<Self, D::Error> {
440 BalancedNetworkTables::deserialize(powerio_core::__implementation::nonfinite::NonFiniteDe(
441 deserializer,
442 ))
443 .map(BalancedNetwork::from_tables)
444 }
445}
446
447#[cfg(feature = "schema")]
448impl schemars::JsonSchema for BalancedNetwork {
449 fn schema_name() -> std::borrow::Cow<'static, str> {
450 <BalancedNetworkTables as schemars::JsonSchema>::schema_name()
451 }
452
453 fn schema_id() -> std::borrow::Cow<'static, str> {
454 <BalancedNetworkTables as schemars::JsonSchema>::schema_id()
455 }
456
457 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
458 <BalancedNetworkTables as schemars::JsonSchema>::json_schema(generator)
459 }
460}
461
462macro_rules! table_accessors {
463 ($($(#[$doc:meta])* $field:ident, $field_mut:ident: $ty:ty;)+) => {
464 impl BalancedNetwork {
465 $(
466 $(#[$doc])*
467 #[must_use]
468 pub fn $field(&self) -> &$ty {
469 &self.tables.$field
470 }
471
472 #[must_use]
476 pub fn $field_mut(&mut self) -> &mut $ty {
477 &mut self.tables_mut().$field
478 }
479 )+
480 }
481 };
482}
483
484table_accessors! {
485 name, name_mut: String;
487 geo, geo_mut: Option<GeoMeta>;
489 solver, solver_mut: Option<SolverParams>;
491}
492
493macro_rules! shared_table_accessors {
498 ($($(#[$doc:meta])* $field:ident, $field_mut:ident: $ty:ty;)+) => {
499 impl BalancedNetwork {
500 $(
501 $(#[$doc])*
502 #[must_use]
503 pub fn $field(&self) -> &$ty {
504 &self.tables.$field
505 }
506
507 #[must_use]
512 pub fn $field_mut(&mut self) -> &mut $ty {
513 std::sync::Arc::make_mut(&mut self.tables_mut().$field)
514 }
515 )+
516 }
517 };
518}
519
520impl BalancedNetwork {
521 pub fn share_equal_tables(&mut self, donor: &Self) {
527 macro_rules! share {
528 ($($field:ident),+) => {
529 $(
530 if !std::sync::Arc::ptr_eq(&self.tables.$field, &donor.tables.$field)
531 && self.tables.$field == donor.tables.$field
532 {
533 self.tables_mut().$field = donor.tables.$field.clone();
534 }
535 )+
536 };
537 }
538 share!(
539 buses,
540 loads,
541 shunts,
542 branches,
543 switches,
544 generators,
545 storage,
546 hvdc,
547 transformers_3w,
548 areas
549 );
550 }
551}
552
553shared_table_accessors! {
554 buses, buses_mut: Vec<Bus>;
555 loads, loads_mut: Vec<Load>;
556 shunts, shunts_mut: Vec<Shunt>;
557 branches, branches_mut: Vec<Branch>;
558 switches, switches_mut: Vec<Switch>;
559 generators, generators_mut: Vec<Generator>;
560 storage, storage_mut: Vec<Storage>;
561 hvdc, hvdc_mut: Vec<Hvdc>;
562 transformers_3w, transformers_3w_mut: Vec<Transformer3W>;
564 areas, areas_mut: Vec<Area>;
566}
567
568impl BalancedNetwork {
569 #[must_use]
571 pub fn base_mva(&self) -> f64 {
572 self.tables.base_mva
573 }
574
575 #[must_use]
576 pub fn base_mva_mut(&mut self) -> &mut f64 {
577 &mut self.tables_mut().base_mva
578 }
579
580 #[must_use]
582 pub fn base_frequency(&self) -> f64 {
583 self.tables.base_frequency
584 }
585
586 #[must_use]
587 pub fn base_frequency_mut(&mut self) -> &mut f64 {
588 &mut self.tables_mut().base_frequency
589 }
590
591 #[must_use]
593 pub fn source_format(&self) -> SourceFormat {
594 self.tables.source_format
595 }
596
597 #[must_use]
598 pub fn source_format_mut(&mut self) -> &mut SourceFormat {
599 &mut self.tables_mut().source_format
600 }
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
604#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
605#[non_exhaustive]
606pub struct Bus {
607 pub id: BusId,
609 pub kind: BusType,
610 pub vm: f64,
612 pub va: f64,
614 pub base_kv: f64,
615 pub vmax: f64,
616 pub vmin: f64,
617 #[serde(default)]
623 pub evhi: Option<f64>,
624 #[serde(default)]
625 pub evlo: Option<f64>,
626 pub area: usize,
627 pub zone: usize,
628 pub name: Option<String>,
629 #[serde(default, skip_serializing_if = "Option::is_none")]
634 pub uid: Option<String>,
635 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub location: Option<Location>,
638 pub extras: Extras,
639}
640
641impl Bus {
642 #[must_use]
643 pub fn new(id: BusId, kind: BusType, base_kv: f64) -> Self {
644 Self {
645 id,
646 kind,
647 vm: 1.0,
648 va: 0.0,
649 base_kv,
650 vmax: 1.1,
651 vmin: 0.9,
652 evhi: None,
653 evlo: None,
654 area: 1,
655 zone: 1,
656 name: None,
657 uid: None,
658 location: None,
659 extras: Extras::new(),
660 }
661 }
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
665#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
666#[non_exhaustive]
667pub struct Load {
668 pub bus: BusId,
669 pub p: f64,
671 pub q: f64,
673 #[serde(default)]
675 pub voltage_model: Option<LoadVoltageModel>,
676 pub in_service: bool,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub uid: Option<String>,
680 pub extras: Extras,
681}
682
683impl Load {
684 #[must_use]
685 pub fn new(bus: BusId, p: f64, q: f64) -> Self {
686 Self {
687 bus,
688 p,
689 q,
690 voltage_model: None,
691 in_service: true,
692 uid: None,
693 extras: Extras::new(),
694 }
695 }
696}
697
698#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
700#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
701#[serde(tag = "kind", rename_all = "snake_case")]
702#[non_exhaustive]
703pub enum LoadVoltageModel {
704 ConstantPower,
706 Zip {
709 p_constant_power: f64,
710 q_constant_power: f64,
711 p_constant_current: f64,
712 q_constant_current: f64,
713 p_constant_impedance: f64,
714 q_constant_impedance: f64,
715 #[serde(default)]
716 v_nom: Option<f64>,
717 #[serde(default)]
720 load_type: Option<i32>,
721 #[serde(default)]
723 scaling: Option<f64>,
724 },
725 Exponential {
728 p: f64,
729 q: f64,
730 #[serde(default)]
731 v_nom: Option<f64>,
732 gamma_p: f64,
733 gamma_q: f64,
734 },
735}
736
737impl LoadVoltageModel {
738 #[must_use]
739 pub fn has_non_matpower_fields(&self) -> bool {
740 match self {
741 Self::ConstantPower => false,
742 Self::Zip {
743 p_constant_current,
744 q_constant_current,
745 p_constant_impedance,
746 q_constant_impedance,
747 v_nom,
748 load_type,
749 scaling,
750 ..
751 } => {
752 *p_constant_current != 0.0
753 || *q_constant_current != 0.0
754 || *p_constant_impedance != 0.0
755 || *q_constant_impedance != 0.0
756 || v_nom.is_some()
757 || load_type.is_some()
758 || scaling.is_some()
759 }
760 Self::Exponential { .. } => true,
761 }
762 }
763}
764
765#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
766#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
767#[non_exhaustive]
768pub struct Shunt {
769 pub bus: BusId,
770 pub g: f64,
772 pub b: f64,
775 pub in_service: bool,
776 #[serde(default)]
780 pub control: Option<SwitchedShuntControl>,
781 #[serde(default, skip_serializing_if = "Option::is_none")]
783 pub uid: Option<String>,
784 pub extras: Extras,
785}
786
787impl Shunt {
788 #[must_use]
789 pub fn new(bus: BusId, g: f64, b: f64) -> Self {
790 Self {
791 bus,
792 g,
793 b,
794 in_service: true,
795 control: None,
796 uid: None,
797 extras: Extras::new(),
798 }
799 }
800}
801
802#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
804#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
805#[serde(rename_all = "snake_case")]
806#[non_exhaustive]
807pub enum SwitchedShuntMode {
808 Locked,
810 Continuous,
812 Discrete,
814}
815
816#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
818#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
819#[non_exhaustive]
820pub struct ShuntBlock {
821 pub steps: u32,
822 pub b: f64,
824}
825
826impl ShuntBlock {
827 #[must_use]
828 pub const fn new(steps: u32, b: f64) -> Self {
829 Self { steps, b }
830 }
831}
832
833#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
838#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
839#[non_exhaustive]
840pub struct SwitchedShuntControl {
841 pub mode: SwitchedShuntMode,
842 pub vhigh: f64,
844 pub vlow: f64,
845 pub control_bus: Option<BusId>,
847 pub rmpct: f64,
849 pub blocks: Vec<ShuntBlock>,
850}
851
852impl SwitchedShuntControl {
853 #[must_use]
854 pub fn new(mode: SwitchedShuntMode, vhigh: f64, vlow: f64, blocks: Vec<ShuntBlock>) -> Self {
855 Self {
856 mode,
857 vhigh,
858 vlow,
859 control_bus: None,
860 rmpct: 100.0,
861 blocks,
862 }
863 }
864}
865
866#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
867#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
868#[non_exhaustive]
869pub struct Branch {
870 pub from: BusId,
871 pub to: BusId,
872 pub r: f64,
874 pub x: f64,
876 pub b: f64,
880 #[serde(default)]
883 pub charging: Option<BranchCharging>,
884 pub rate_a: f64,
885 pub rate_b: f64,
886 pub rate_c: f64,
887 #[serde(default)]
890 pub rating_sets: Vec<BranchRatingSet>,
891 #[serde(default)]
893 pub current_ratings: Option<BranchCurrentRatings>,
894 pub tap: f64,
896 pub shift: f64,
898 pub in_service: bool,
899 pub angmin: f64,
900 pub angmax: f64,
901 #[serde(default)]
906 pub control: Option<TransformerControl>,
907 #[serde(default)]
909 pub solution: Option<BranchSolution>,
910 #[serde(default, skip_serializing_if = "Option::is_none")]
912 pub uid: Option<String>,
913 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub route: Option<Vec<Location>>,
919 pub extras: Extras,
920}
921
922#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
924#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
925#[non_exhaustive]
926pub struct BranchRatingSet {
927 pub name: String,
928 pub rate_mva: f64,
929}
930
931impl BranchRatingSet {
932 #[must_use]
933 pub fn new(name: impl Into<String>, rate_mva: f64) -> Self {
934 Self {
935 name: name.into(),
936 rate_mva,
937 }
938 }
939}
940
941#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
944#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
945#[non_exhaustive]
946pub struct BranchCharging {
947 pub g_fr: f64,
948 pub b_fr: f64,
949 pub g_to: f64,
950 pub b_to: f64,
951}
952
953impl BranchCharging {
954 #[must_use]
955 pub const fn new(g_fr: f64, b_fr: f64, g_to: f64, b_to: f64) -> Self {
956 Self {
957 g_fr,
958 b_fr,
959 g_to,
960 b_to,
961 }
962 }
963
964 #[must_use]
965 pub fn from_total_b(b: f64) -> Self {
966 Self {
967 g_fr: 0.0,
968 b_fr: b / 2.0,
969 g_to: 0.0,
970 b_to: b / 2.0,
971 }
972 }
973
974 #[must_use]
975 pub fn total_b(self) -> f64 {
976 self.b_fr + self.b_to
977 }
978
979 #[must_use]
980 pub fn total_g(self) -> f64 {
981 self.g_fr + self.g_to
982 }
983
984 #[must_use]
985 pub fn is_matpower_symmetric(self) -> bool {
986 self.g_fr.abs() <= f64::EPSILON
987 && self.g_to.abs() <= f64::EPSILON
988 && (self.b_fr - self.b_to).abs() <= f64::EPSILON
989 }
990}
991
992#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
994#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
995#[non_exhaustive]
996pub struct BranchCurrentRatings {
997 pub c_rating_a: f64,
998 pub c_rating_b: f64,
999 pub c_rating_c: f64,
1000}
1001
1002impl BranchCurrentRatings {
1003 #[must_use]
1004 pub const fn new(c_rating_a: f64, c_rating_b: f64, c_rating_c: f64) -> Self {
1005 Self {
1006 c_rating_a,
1007 c_rating_b,
1008 c_rating_c,
1009 }
1010 }
1011}
1012
1013#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1015#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1016#[non_exhaustive]
1017pub struct BranchSolution {
1018 pub pf: f64,
1019 pub qf: f64,
1020 pub pt: f64,
1021 pub qt: f64,
1022}
1023
1024impl BranchSolution {
1025 #[must_use]
1026 pub const fn new(pf: f64, qf: f64, pt: f64, qt: f64) -> Self {
1027 Self { pf, qf, pt, qt }
1028 }
1029}
1030
1031impl Branch {
1032 #[must_use]
1033 pub fn new(from: BusId, to: BusId, r: f64, x: f64) -> Self {
1034 Self {
1035 from,
1036 to,
1037 r,
1038 x,
1039 b: 0.0,
1040 charging: None,
1041 rate_a: 0.0,
1042 rate_b: 0.0,
1043 rate_c: 0.0,
1044 rating_sets: Vec::new(),
1045 current_ratings: None,
1046 tap: 0.0,
1047 shift: 0.0,
1048 in_service: true,
1049 angmin: -360.0,
1050 angmax: 360.0,
1051 control: None,
1052 solution: None,
1053 uid: None,
1054 route: None,
1055 extras: Extras::new(),
1056 }
1057 }
1058
1059 #[must_use]
1061 pub fn effective_tap(&self) -> f64 {
1062 if self.tap == 0.0 { 1.0 } else { self.tap }
1063 }
1064
1065 pub fn divisible_tap(&self, row: usize) -> Result<f64> {
1074 let tap = self.effective_tap();
1075 if !tap.is_finite() || tap.abs() < crate::dc::MIN_DIVISIBLE_MAGNITUDE {
1076 return Err(Error::DegenerateTap { row, tap });
1077 }
1078 Ok(tap)
1079 }
1080
1081 #[must_use]
1084 pub fn terminal_charging(&self) -> BranchCharging {
1085 self.charging
1086 .unwrap_or_else(|| BranchCharging::from_total_b(self.b))
1087 }
1088
1089 pub fn series_admittance(&self, row: usize) -> Result<Option<(f64, f64)>> {
1101 series_admittance_of(self.r, self.x, row)
1102 }
1103
1104 #[must_use]
1130 pub fn synthesize_rate_a(
1131 &self,
1132 angle_window_rad: f64,
1133 (fr_vmin, fr_vmax): (f64, f64),
1134 (to_vmin, to_vmax): (f64, f64),
1135 ) -> f64 {
1136 let zmag = self.r.hypot(self.x);
1139 if zmag < crate::dc::MIN_DIVISIBLE_MAGNITUDE {
1140 return 0.0;
1141 }
1142 let window = angle_window_rad.abs().min(std::f64::consts::PI);
1143 let cos_window = window.cos();
1144 let separation = |vf: f64, vt: f64| {
1148 (vf * vf + vt * vt - 2.0 * vf * vt * cos_window)
1149 .max(0.0)
1150 .sqrt()
1151 };
1152 let widest = separation(fr_vmax, to_vmax)
1153 .max(separation(fr_vmax, to_vmin))
1154 .max(separation(fr_vmin, to_vmax))
1155 .max(separation(fr_vmin, to_vmin));
1156 fr_vmax.max(to_vmax) * widest / zmag
1157 }
1158
1159 #[must_use]
1162 pub fn total_charging_b(&self) -> f64 {
1163 self.terminal_charging().total_b()
1164 }
1165
1166 #[must_use]
1168 pub fn has_non_matpower_charging(&self) -> bool {
1169 self.charging
1170 .is_some_and(|charging| !charging.is_matpower_symmetric())
1171 }
1172
1173 #[must_use]
1176 pub fn is_transformer(&self) -> bool {
1177 self.tap != 0.0 || self.shift != 0.0
1178 }
1179
1180 #[must_use]
1184 pub fn has_angle_limits(&self) -> bool {
1185 self.angmin > -360.0 || self.angmax < 360.0
1186 }
1187}
1188
1189pub fn series_admittance_of(r: f64, x: f64, row: usize) -> Result<Option<(f64, f64)>> {
1206 let magnitude = r.hypot(x);
1207 if magnitude < crate::dc::MIN_DIVISIBLE_MAGNITUDE {
1208 return Ok(None);
1209 }
1210 if !magnitude.is_finite() {
1211 return Err(Error::NonFiniteSusceptance { row });
1212 }
1213 Ok(Some(crate::dc::series_admittance_parts(r, x)))
1214}
1215
1216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1219#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1220#[non_exhaustive]
1221pub struct Switch {
1222 pub from: BusId,
1223 pub to: BusId,
1224 pub closed: bool,
1225 #[serde(default)]
1226 pub thermal_rating: Option<f64>,
1227 #[serde(default)]
1228 pub current_rating: Option<f64>,
1229 #[serde(default)]
1230 pub pf: Option<f64>,
1231 #[serde(default)]
1232 pub qf: Option<f64>,
1233 #[serde(default)]
1234 pub pt: Option<f64>,
1235 #[serde(default)]
1236 pub qt: Option<f64>,
1237 #[serde(default, skip_serializing_if = "Option::is_none")]
1239 pub uid: Option<String>,
1240 pub extras: Extras,
1241}
1242
1243impl Switch {
1244 #[must_use]
1245 pub fn new(from: BusId, to: BusId, closed: bool) -> Self {
1246 Self {
1247 from,
1248 to,
1249 closed,
1250 thermal_rating: None,
1251 current_rating: None,
1252 pf: None,
1253 qf: None,
1254 pt: None,
1255 qt: None,
1256 uid: None,
1257 extras: Extras::new(),
1258 }
1259 }
1260}
1261
1262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1265#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1266#[serde(rename_all = "snake_case")]
1267#[non_exhaustive]
1268pub enum TransformerControlMode {
1269 Fixed,
1271 Voltage,
1273 ReactiveFlow,
1275 ActiveFlow,
1277}
1278
1279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1289#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1290#[non_exhaustive]
1291pub struct TransformerControl {
1292 pub mode: TransformerControlMode,
1293 pub controlled_bus: Option<BusId>,
1294 pub tap_min: f64,
1295 pub tap_max: f64,
1296 pub band_min: f64,
1297 pub band_max: f64,
1298 pub ntp: u32,
1299 pub mva_base: f64,
1300}
1301
1302impl Default for TransformerControl {
1303 fn default() -> Self {
1304 TransformerControl {
1306 mode: TransformerControlMode::Fixed,
1307 controlled_bus: None,
1308 tap_min: 0.9,
1309 tap_max: 1.1,
1310 band_min: 0.9,
1311 band_max: 1.1,
1312 ntp: 33,
1313 mva_base: 0.0,
1314 }
1315 }
1316}
1317
1318impl TransformerControl {
1319 #[must_use]
1320 pub fn new(mode: TransformerControlMode) -> Self {
1321 Self {
1322 mode,
1323 ..Self::default()
1324 }
1325 }
1326}
1327
1328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1329#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1330#[non_exhaustive]
1331pub struct Generator {
1332 pub bus: BusId,
1333 pub pg: f64,
1335 pub qg: f64,
1337 pub pmax: f64,
1338 pub pmin: f64,
1339 pub qmax: f64,
1340 pub qmin: f64,
1341 pub vg: f64,
1343 pub mbase: f64,
1344 pub in_service: bool,
1345 pub cost: Option<GenCost>,
1346 #[serde(default = "default_caps", with = "caps_serde")]
1355 #[cfg_attr(feature = "schema", schemars(with = "BTreeMap<String, f64>"))]
1356 pub caps: GenCaps,
1357 #[serde(default)]
1364 pub regulated_bus: Option<BusId>,
1365 #[serde(default, skip_serializing_if = "Option::is_none")]
1367 pub uid: Option<String>,
1368}
1369
1370impl Generator {
1371 #[must_use]
1372 pub fn new(bus: BusId) -> Self {
1373 Self {
1374 bus,
1375 pg: 0.0,
1376 qg: 0.0,
1377 pmax: 0.0,
1378 pmin: 0.0,
1379 qmax: 0.0,
1380 qmin: 0.0,
1381 vg: 1.0,
1382 mbase: 0.0,
1383 in_service: true,
1384 cost: None,
1385 caps: default_caps(),
1386 regulated_bus: None,
1387 uid: None,
1388 }
1389 }
1390
1391 #[must_use]
1394 pub fn has_caps(&self) -> bool {
1395 self.caps.iter().any(Option::is_some)
1396 }
1397}
1398
1399pub type GenCaps = [Option<f64>; GEN_EXTRA_KEYS.len()];
1401
1402fn default_caps() -> GenCaps {
1404 [None; GEN_EXTRA_KEYS.len()]
1405}
1406
1407mod caps_serde {
1418 use super::{GEN_EXTRA_KEYS, GenCaps};
1419 use serde::de::{Deserialize, Deserializer};
1420 use serde::ser::{SerializeMap, Serializer};
1421 use std::collections::BTreeMap;
1422
1423 pub(super) fn serialize<S: Serializer>(caps: &GenCaps, s: S) -> Result<S::Ok, S::Error> {
1424 let present = caps.iter().filter(|v| v.is_some()).count();
1425 let mut map = s.serialize_map(Some(present))?;
1426 for (key, slot) in GEN_EXTRA_KEYS.iter().zip(caps.iter()) {
1427 if let Some(value) = slot {
1428 map.serialize_entry(key, value)?;
1429 }
1430 }
1431 map.end()
1432 }
1433
1434 pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<GenCaps, D::Error> {
1435 let named = Option::<BTreeMap<String, f64>>::deserialize(d)?.unwrap_or_default();
1440 let mut caps: GenCaps = [None; GEN_EXTRA_KEYS.len()];
1441 for (slot, key) in caps.iter_mut().zip(GEN_EXTRA_KEYS.iter()) {
1442 *slot = named.get(*key).copied();
1443 }
1444 Ok(caps)
1445 }
1446}
1447
1448#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1449#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1450#[non_exhaustive]
1451pub struct Storage {
1452 pub bus: BusId,
1453 pub ps: f64,
1454 pub qs: f64,
1455 pub energy: f64,
1456 pub energy_rating: f64,
1457 pub charge_rating: f64,
1458 pub discharge_rating: f64,
1459 pub charge_efficiency: f64,
1460 pub discharge_efficiency: f64,
1461 pub thermal_rating: f64,
1462 #[serde(default)]
1463 pub current_rating: Option<f64>,
1464 pub qmin: f64,
1465 pub qmax: f64,
1466 pub r: f64,
1467 pub x: f64,
1468 pub p_loss: f64,
1469 pub q_loss: f64,
1470 pub in_service: bool,
1471 #[serde(default, skip_serializing_if = "Option::is_none")]
1473 pub uid: Option<String>,
1474 pub extras: Extras,
1475}
1476
1477impl Storage {
1478 #[must_use]
1479 pub fn new(bus: BusId) -> Self {
1480 Self {
1481 bus,
1482 ps: 0.0,
1483 qs: 0.0,
1484 energy: 0.0,
1485 energy_rating: 0.0,
1486 charge_rating: 0.0,
1487 discharge_rating: 0.0,
1488 charge_efficiency: 1.0,
1489 discharge_efficiency: 1.0,
1490 thermal_rating: 0.0,
1491 current_rating: None,
1492 qmin: 0.0,
1493 qmax: 0.0,
1494 r: 0.0,
1495 x: 0.0,
1496 p_loss: 0.0,
1497 q_loss: 0.0,
1498 in_service: true,
1499 uid: None,
1500 extras: Extras::new(),
1501 }
1502 }
1503}
1504
1505#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1513#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1514#[non_exhaustive]
1515pub struct Hvdc {
1516 pub from: BusId,
1517 pub to: BusId,
1518 pub in_service: bool,
1519 pub pf: f64,
1520 pub pt: f64,
1521 pub qf: f64,
1522 pub qt: f64,
1523 pub vf: f64,
1524 pub vt: f64,
1525 pub pmin: f64,
1526 pub pmax: f64,
1527 pub qminf: f64,
1528 pub qmaxf: f64,
1529 pub qmint: f64,
1530 pub qmaxt: f64,
1531 pub loss0: f64,
1532 pub loss1: f64,
1533 #[serde(default)]
1534 pub cost: Option<GenCost>,
1535 #[serde(default, skip_serializing_if = "Option::is_none")]
1537 pub uid: Option<String>,
1538 pub extras: Extras,
1539}
1540
1541impl Hvdc {
1542 #[must_use]
1551 pub fn delivered_power(pf: f64, loss0: f64, loss1: f64) -> f64 {
1552 pf - loss0 - loss1 * pf
1553 }
1554
1555 #[must_use]
1559 pub fn pt_matches_loss_model(&self, tol: f64) -> bool {
1560 (self.pt - Self::delivered_power(self.pf, self.loss0, self.loss1)).abs() <= tol
1561 }
1562
1563 #[must_use]
1564 pub fn new(from: BusId, to: BusId) -> Self {
1565 Self {
1566 from,
1567 to,
1568 in_service: true,
1569 pf: 0.0,
1570 pt: 0.0,
1571 qf: 0.0,
1572 qt: 0.0,
1573 vf: 1.0,
1574 vt: 1.0,
1575 pmin: 0.0,
1576 pmax: 0.0,
1577 qminf: 0.0,
1578 qmaxf: 0.0,
1579 qmint: 0.0,
1580 qmaxt: 0.0,
1581 loss0: 0.0,
1582 loss1: 0.0,
1583 cost: None,
1584 uid: None,
1585 extras: Extras::new(),
1586 }
1587 }
1588}
1589
1590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1597#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1598#[non_exhaustive]
1599pub struct Area {
1600 pub number: usize,
1601 pub slack_bus: Option<BusId>,
1603 pub net_interchange: f64,
1605 pub tolerance: f64,
1607 pub name: Option<String>,
1608}
1609
1610impl Area {
1611 #[must_use]
1612 pub fn new(number: usize) -> Self {
1613 Self {
1614 number,
1615 slack_bus: None,
1616 net_interchange: 0.0,
1617 tolerance: 0.0,
1618 name: None,
1619 }
1620 }
1621}
1622
1623#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1632#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1633#[non_exhaustive]
1634pub struct SolverParams {
1635 pub newton_tolerance: Option<f64>,
1637 pub max_iterations: Option<u32>,
1639 pub zero_impedance_threshold: Option<f64>,
1641 pub adjust_taps: Option<bool>,
1643 pub adjust_area_interchange: Option<bool>,
1645 pub adjust_phase_shift: Option<bool>,
1647 pub adjust_dc_taps: Option<bool>,
1649 pub adjust_switched_shunt: Option<bool>,
1651}
1652
1653impl SolverParams {
1654 #[must_use]
1655 pub fn new() -> Self {
1656 Self::default()
1657 }
1658
1659 #[must_use]
1661 pub fn is_empty(&self) -> bool {
1662 *self == SolverParams::default()
1663 }
1664}
1665
1666#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
1678#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1679#[non_exhaustive]
1680pub struct Impedance {
1681 pub r: f64,
1682 pub x: f64,
1683 pub base_mva: f64,
1684}
1685
1686impl Impedance {
1687 #[must_use]
1688 pub const fn new(r: f64, x: f64, base_mva: f64) -> Self {
1689 Self { r, x, base_mva }
1690 }
1691}
1692
1693#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1696#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1697#[non_exhaustive]
1698pub struct Winding {
1699 pub bus: BusId,
1700 pub tap: f64,
1702 pub shift: f64,
1704 pub nominal_kv: f64,
1706 pub rate_a: f64,
1707 pub rate_b: f64,
1708 pub rate_c: f64,
1709}
1710
1711impl Winding {
1712 #[must_use]
1713 pub fn new(bus: BusId) -> Self {
1714 Self {
1715 bus,
1716 tap: 1.0,
1717 shift: 0.0,
1718 nominal_kv: 0.0,
1719 rate_a: 0.0,
1720 rate_b: 0.0,
1721 rate_c: 0.0,
1722 }
1723 }
1724}
1725
1726#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1736#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1737#[non_exhaustive]
1738pub struct Transformer3W {
1739 pub windings: [Winding; 3],
1741 pub z: [Impedance; 3],
1745 pub star_vm: f64,
1747 pub star_va: f64,
1748 pub mag_g: f64,
1750 pub mag_b: f64,
1751 pub in_service: bool,
1752 pub name: Option<String>,
1753 #[serde(default, skip_serializing_if = "Option::is_none")]
1755 pub uid: Option<String>,
1756 pub extras: Extras,
1757}
1758
1759impl Transformer3W {
1760 #[must_use]
1761 pub fn new(windings: [Winding; 3], z: [Impedance; 3]) -> Self {
1762 Self {
1763 windings,
1764 z,
1765 star_vm: 1.0,
1766 star_va: 0.0,
1767 mag_g: 0.0,
1768 mag_b: 0.0,
1769 in_service: true,
1770 name: None,
1771 uid: None,
1772 extras: Extras::new(),
1773 }
1774 }
1775
1776 #[must_use]
1783 pub fn star_impedances(&self) -> [(f64, f64); 3] {
1784 let [z12, z23, z31] = self.z;
1785 let half = |a: f64, b: f64, c: f64| (a + b - c) / 2.0;
1786 [
1787 (half(z12.r, z31.r, z23.r), half(z12.x, z31.x, z23.x)),
1788 (half(z12.r, z23.r, z31.r), half(z12.x, z23.x, z31.x)),
1789 (half(z23.r, z31.r, z12.r), half(z23.x, z31.x, z12.x)),
1790 ]
1791 }
1792
1793 #[must_use]
1800 pub fn star_expansion(&self, star_id: BusId) -> (Bus, [Branch; 3]) {
1801 let star = Bus {
1802 id: star_id,
1803 kind: BusType::Pq,
1804 vm: self.star_vm,
1805 va: self.star_va,
1806 base_kv: self.windings[0].nominal_kv,
1807 vmax: 1.1,
1808 vmin: 0.9,
1809 evhi: None,
1810 evlo: None,
1811 area: 0,
1812 zone: 0,
1813 name: self.name.clone(),
1814 uid: self.uid.clone(),
1815 location: None,
1816 extras: Extras::new(),
1817 };
1818 let zs = self.star_impedances();
1819 let branch = |w: &Winding, (r, x): (f64, f64)| Branch {
1820 from: w.bus,
1821 to: star_id,
1822 r,
1823 x,
1824 b: 0.0,
1825 charging: None,
1826 rate_a: w.rate_a,
1827 rate_b: w.rate_b,
1828 rate_c: w.rate_c,
1829 rating_sets: Vec::new(),
1830 current_ratings: None,
1831 tap: w.tap,
1832 shift: w.shift,
1833 in_service: self.in_service,
1834 angmin: -360.0,
1835 angmax: 360.0,
1836 control: None,
1837 solution: None,
1838 uid: None,
1839 route: None,
1840 extras: Extras::new(),
1841 };
1842 let branches = [
1843 branch(&self.windings[0], zs[0]),
1844 branch(&self.windings[1], zs[1]),
1845 branch(&self.windings[2], zs[2]),
1846 ];
1847 (star, branches)
1848 }
1849}
1850
1851pub(crate) const GEN_EXTRA_KEYS: [&str; 11] = [
1854 "pc1", "pc2", "qc1min", "qc1max", "qc2min", "qc2max", "ramp_agc", "ramp_10", "ramp_30",
1855 "ramp_q", "apf",
1856];
1857
1858#[derive(Debug, Clone, PartialEq)]
1865pub(crate) struct ValueFinding {
1866 pub element: String,
1868 pub table: &'static str,
1873 pub index: usize,
1876 pub field: &'static str,
1877 pub old: f64,
1878 pub new: f64,
1879 pub reason: &'static str,
1880}
1881
1882impl ValueFinding {
1883 pub(crate) fn into_diagnostic(self) -> crate::Diagnostic {
1887 let mut details = serde_json::Map::new();
1888 details.insert("element".to_owned(), serde_json::json!(self.element));
1889 details.insert("field".to_owned(), serde_json::json!(self.field));
1890 details.insert("value".to_owned(), serde_json::json!(self.old));
1891 details.insert("repaired_value".to_owned(), serde_json::json!(self.new));
1892 details.insert("reason".to_owned(), serde_json::json!(self.reason));
1893 crate::Diagnostic::of(
1894 &crate::diagnostics::codes::VALIDATE_BALANCED_VALUE_DOMAIN,
1895 format!(
1896 "{}: `{}` is {} ({}); the repair sets {}",
1897 self.element, self.field, self.old, self.reason, self.new
1898 ),
1899 )
1900 .with_target(format!("/{}/{}/{}", self.table, self.index, self.field))
1901 .expect("scan-built targets are nonempty and bounded")
1902 .with_details(details)
1903 .expect("scan-built details stay within the record bounds")
1904 }
1905}
1906
1907pub fn repair_values(
1918 module: powerio_core::PioModule<BalancedNetwork>,
1919) -> std::result::Result<powerio_core::PioModule<BalancedNetwork>, powerio_core::Error> {
1920 let repair_ordinal = module
1921 .history()
1922 .iter()
1923 .filter(|entry| entry.kind() == powerio_core::HistoryKind::Repair)
1924 .count();
1925 let mut network_findings = Vec::new();
1926 let mut module = module.map_value(|mut network| {
1927 network_findings = network.repair_in_place();
1928 network
1929 });
1930 if network_findings.is_empty() {
1931 return Ok(module);
1932 }
1933 let mut parameters = std::collections::BTreeMap::new();
1934 parameters.insert(
1935 "repairs".to_owned(),
1936 serde_json::json!(
1937 network_findings
1938 .iter()
1939 .map(|finding| {
1940 serde_json::json!({
1941 "element": finding.element,
1942 "field": finding.field,
1943 "value": finding.old,
1944 "repaired_value": finding.new,
1945 })
1946 })
1947 .collect::<Vec<_>>()
1948 ),
1949 );
1950 let entry = powerio_core::HistoryEntry::new(
1951 powerio_core::HistoryId::new(format!("repair{repair_ordinal}"))?,
1952 powerio_core::HistoryKind::Repair,
1953 "value_domain_repair",
1954 )?
1955 .with_parameters(parameters)?;
1956 module.add_history_entry(entry)?;
1957 for finding in network_findings {
1958 module.add_diagnostic(finding.into_diagnostic())?;
1959 }
1960 module = module.sever_source();
1961 Ok(module)
1962}
1963
1964fn repair_vm(vm: f64) -> Option<f64> {
1968 (!vm.is_finite() || vm <= 0.0 || vm > 2.0).then_some(1.0)
1969}
1970
1971fn repair_va(va: f64) -> Option<f64> {
1973 (!va.is_finite() || va.abs() > 2000.0).then_some(0.0)
1974}
1975
1976fn repair_mbase(mbase: f64, sbase: f64) -> Option<f64> {
1978 (!mbase.is_finite() || mbase <= 0.0).then_some(sbase)
1979}
1980
1981fn repair_vg(vg: f64) -> Option<f64> {
1983 (!vg.is_finite() || vg <= 0.0).then_some(1.0)
1984}
1985
1986#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1991pub(crate) struct LoweredLengths {
1992 pub(crate) buses: usize,
1993 pub(crate) branches: usize,
1994 pub(crate) shunts: usize,
1995}
1996
1997impl BalancedNetwork {
1998 #[must_use]
1999 pub fn new(name: impl Into<String>, base_mva: f64) -> BalancedNetwork {
2000 BalancedNetwork::from_tables(BalancedNetworkTables {
2001 name: name.into(),
2002 base_mva,
2003 base_frequency: DEFAULT_BASE_FREQUENCY,
2004 geo: None,
2005 buses: Vec::new().into(),
2006 loads: Vec::new().into(),
2007 shunts: Vec::new().into(),
2008 branches: Vec::new().into(),
2009 switches: Vec::new().into(),
2010 generators: Vec::new().into(),
2011 storage: Vec::new().into(),
2012 hvdc: Vec::new().into(),
2013 transformers_3w: Vec::new().into(),
2014 areas: Vec::new().into(),
2015 solver: None,
2016 source_format: SourceFormat::InMemory,
2017 })
2018 }
2019
2020 #[must_use]
2026 pub fn in_memory(
2027 name: impl Into<String>,
2028 base_mva: f64,
2029 buses: Vec<Bus>,
2030 branches: Vec<Branch>,
2031 ) -> BalancedNetwork {
2032 let mut net = Self::new(name, base_mva);
2033 *net.buses_mut() = buses;
2034 *net.branches_mut() = branches;
2035 net
2036 }
2037
2038 pub fn to_json(&self) -> crate::Result<String> {
2055 serde_json::to_string(self).map_err(|e| Error::FormatRead {
2056 format: "JSON",
2057 message: e.to_string(),
2058 })
2059 }
2060
2061 pub fn to_json_with_diagnostics(
2070 &self,
2071 ) -> crate::Result<(String, Vec<crate::diagnostics::Diagnostic>)> {
2072 let text = self.to_json()?;
2073 Ok((text, Vec::new()))
2074 }
2075
2076 pub fn to_format(&self, format: crate::TargetFormat) -> crate::Result<crate::Conversion> {
2084 crate::format::write_conversion(self, format)
2085 }
2086
2087 pub fn to_canonical_format(
2095 &self,
2096 format: crate::TargetFormat,
2097 ) -> crate::Result<crate::Conversion> {
2098 crate::format::write_conversion(self, format)
2099 }
2100
2101 pub fn to_format_with_options(
2105 &self,
2106 format: crate::TargetFormat,
2107 options: &crate::WriteOptions,
2108 ) -> crate::Result<crate::Conversion> {
2109 if options.is_default() {
2110 return self.to_format(format);
2111 }
2112 let (working, policy_warnings) = crate::format::apply_write_cost_policy(self, options)?;
2113 let mut conv = crate::format::write_conversion(&working, format)?;
2114 conv.prepend(policy_warnings);
2115 Ok(conv)
2116 }
2117
2118 #[must_use]
2123 pub fn to_matpower(&self) -> String {
2124 crate::write_matpower(self)
2125 }
2126
2127 pub fn from_json(text: &str) -> crate::Result<BalancedNetwork> {
2137 let text = text.trim_start_matches('\u{feff}');
2139 let net: BalancedNetwork = serde_json::from_str(text).map_err(|e| Error::FormatRead {
2140 format: "JSON",
2141 message: e.to_string(),
2142 })?;
2143 net.check_references("JSON")?;
2144 if net.buses().is_empty() {
2145 return Err(Error::FormatRead {
2146 format: "JSON",
2147 message: "case has no buses".into(),
2148 });
2149 }
2150 Ok(net)
2151 }
2152
2153 pub fn from_json_bytes(bytes: &[u8]) -> crate::Result<BalancedNetwork> {
2162 let text = std::str::from_utf8(bytes).map_err(|error| Error::FormatRead {
2163 format: "JSON",
2164 message: format!("input is not valid UTF-8: {error}"),
2165 })?;
2166 Self::from_json(text)
2167 }
2168
2169 #[must_use]
2174 pub fn is_normalized(&self) -> bool {
2175 self.source_format() == SourceFormat::Normalized
2176 }
2177
2178 pub fn check_base_mva(&self) -> crate::Result<()> {
2184 if self.base_mva().is_finite() && self.base_mva() > 0.0 {
2185 Ok(())
2186 } else {
2187 Err(crate::Error::InvalidBaseMva {
2188 base: self.base_mva(),
2189 })
2190 }
2191 }
2192
2193 #[must_use]
2205 pub fn validate_values(&self) -> Vec<crate::Diagnostic> {
2206 self.value_findings()
2207 .into_iter()
2208 .map(ValueFinding::into_diagnostic)
2209 .collect()
2210 }
2211
2212 pub(crate) fn value_findings(&self) -> Vec<ValueFinding> {
2213 let mut out = Vec::new();
2214 for (index, b) in self.buses().iter().enumerate() {
2215 if let Some(new) = repair_vm(b.vm) {
2216 out.push(ValueFinding {
2217 element: format!("bus {}", b.id),
2218 table: "buses",
2219 index,
2220 field: "vm",
2221 old: b.vm,
2222 new,
2223 reason: "voltage magnitude outside [0, 2] p.u.",
2224 });
2225 }
2226 if let Some(new) = repair_va(b.va) {
2227 out.push(ValueFinding {
2228 element: format!("bus {}", b.id),
2229 table: "buses",
2230 index,
2231 field: "va",
2232 old: b.va,
2233 new,
2234 reason: "voltage angle outside ±2000°",
2235 });
2236 }
2237 }
2238 for (index, g) in self.generators().iter().enumerate() {
2239 if let Some(new) = repair_mbase(g.mbase, self.base_mva()) {
2240 out.push(ValueFinding {
2241 element: format!("generator at bus {}", g.bus),
2242 table: "generators",
2243 index,
2244 field: "mbase",
2245 old: g.mbase,
2246 new,
2247 reason: "non-positive generator MVA base",
2248 });
2249 }
2250 if let Some(new) = repair_vg(g.vg) {
2251 out.push(ValueFinding {
2252 element: format!("generator at bus {}", g.bus),
2253 table: "generators",
2254 index,
2255 field: "vg",
2256 old: g.vg,
2257 new,
2258 reason: "non-positive voltage setpoint",
2259 });
2260 }
2261 }
2262 out
2263 }
2264
2265 pub(crate) fn repair_in_place(&mut self) -> Vec<ValueFinding> {
2272 let findings = self.value_findings();
2273 let sbase = self.base_mva();
2274 for b in self.buses_mut() {
2275 if let Some(new) = repair_vm(b.vm) {
2276 b.vm = new;
2277 }
2278 if let Some(new) = repair_va(b.va) {
2279 b.va = new;
2280 }
2281 }
2282 for g in self.generators_mut() {
2283 if let Some(new) = repair_mbase(g.mbase, sbase) {
2284 g.mbase = new;
2285 }
2286 if let Some(new) = repair_vg(g.vg) {
2287 g.vg = new;
2288 }
2289 }
2290 findings
2291 }
2292
2293 pub(crate) fn lowered_lengths(&self) -> LoweredLengths {
2299 let mut lengths = LoweredLengths {
2300 buses: self.buses().len(),
2301 branches: self.branches().len(),
2302 shunts: self.shunts().len(),
2303 };
2304 for t in self.transformers_3w().iter().filter(|t| t.in_service) {
2305 lengths.buses += 1;
2306 lengths.branches += 3;
2307 if t.mag_g != 0.0 || t.mag_b != 0.0 {
2308 lengths.shunts += 1;
2309 }
2310 }
2311 lengths
2312 }
2313
2314 pub(crate) fn expand_transformers_3w(&self) -> std::borrow::Cow<'_, BalancedNetwork> {
2325 if self.transformers_3w().is_empty() {
2326 return std::borrow::Cow::Borrowed(self);
2327 }
2328 let mut net = self.clone();
2329 let scale = if net.is_normalized() {
2334 1.0
2335 } else {
2336 net.base_mva()
2337 };
2338 let base_id = net
2343 .buses()
2344 .iter()
2345 .map(|b| b.id.0)
2346 .max()
2347 .unwrap_or(0)
2348 .checked_add(1)
2349 .expect("bus id space exhausted for star expansion");
2350 for (k, t) in self
2351 .transformers_3w()
2352 .iter()
2353 .filter(|t| t.in_service)
2354 .enumerate()
2355 {
2356 let star_id = BusId(
2357 base_id
2358 .checked_add(k)
2359 .expect("bus id space exhausted for star expansion"),
2360 );
2361 let (star, branches) = t.star_expansion(star_id);
2362 net.buses_mut().push(star);
2363 net.branches_mut().extend(branches);
2364 if t.mag_g != 0.0 || t.mag_b != 0.0 {
2365 net.shunts_mut().push(Shunt {
2366 bus: star_id,
2367 g: t.mag_g * scale,
2368 b: t.mag_b * scale,
2369 in_service: true,
2370 control: None,
2371 uid: None,
2372 extras: Extras::new(),
2373 });
2374 }
2375 }
2376 net.transformers_3w_mut().clear();
2377 std::borrow::Cow::Owned(net)
2378 }
2379
2380 pub fn validate(&self) -> crate::Result<()> {
2386 self.check_references("network")
2387 }
2388
2389 pub(crate) fn check_references(&self, format: &'static str) -> crate::Result<()> {
2394 let mut ids = std::collections::HashSet::with_capacity(self.buses().len());
2399 for b in self.buses() {
2400 if b.id > BusId::MAX {
2405 return Err(Error::FormatRead {
2406 format,
2407 message: format!("bus id {} is outside the int64 id space", b.id),
2408 });
2409 }
2410 if !ids.insert(b.id) {
2411 return Err(Error::FormatRead {
2412 format,
2413 message: format!("duplicate bus id {}", b.id),
2414 });
2415 }
2416 }
2417 let check = |bus: BusId, what: &str| -> crate::Result<()> {
2418 if ids.contains(&bus) {
2419 Ok(())
2420 } else {
2421 Err(Error::FormatRead {
2422 format,
2423 message: format!("{what} references unknown bus {bus}"),
2424 })
2425 }
2426 };
2427 for (i, br) in self.branches().iter().enumerate() {
2429 for bus in [br.from, br.to] {
2430 if !ids.contains(&bus) {
2431 return Err(Error::FormatRead {
2432 format,
2433 message: format!("branch {i} references unknown bus {bus}"),
2434 });
2435 }
2436 }
2437 if let Some(bus) = br.control.as_ref().and_then(|c| c.controlled_bus) {
2438 check(bus, "transformer control")?;
2439 }
2440 }
2441 for (i, sw) in self.switches().iter().enumerate() {
2442 for bus in [sw.from, sw.to] {
2443 if !ids.contains(&bus) {
2444 return Err(Error::FormatRead {
2445 format,
2446 message: format!("switch {i} references unknown bus {bus}"),
2447 });
2448 }
2449 }
2450 }
2451 for l in self.loads() {
2452 check(l.bus, "load")?;
2453 }
2454 for s in self.shunts() {
2455 check(s.bus, "shunt")?;
2456 if let Some(bus) = s.control.as_ref().and_then(|c| c.control_bus) {
2457 check(bus, "switched-shunt control")?;
2458 }
2459 }
2460 for g in self.generators() {
2461 check(g.bus, "generator")?;
2462 if let Some(bus) = g.regulated_bus {
2463 check(bus, "generator voltage control")?;
2464 }
2465 }
2466 for d in self.hvdc() {
2467 check(d.from, "dcline")?;
2468 check(d.to, "dcline")?;
2469 }
2470 for s in self.storage() {
2471 check(s.bus, "storage")?;
2472 }
2473 for a in self.areas() {
2474 if let Some(slack) = a.slack_bus {
2475 check(slack, "area swing")?;
2476 }
2477 }
2478 for t in self.transformers_3w() {
2479 for w in &t.windings {
2480 check(w.bus, "3-winding transformer")?;
2481 }
2482 }
2483 self.check_star_expansion_headroom(format)
2484 }
2485
2486 fn check_star_expansion_headroom(&self, format: &'static str) -> crate::Result<()> {
2494 if self.transformers_3w().is_empty() {
2495 return Ok(());
2496 }
2497 let Some(max_id) = self.buses().iter().map(|b| b.id.0).max() else {
2498 return Ok(());
2499 };
2500 let needed = self
2501 .transformers_3w()
2502 .iter()
2503 .filter(|t| t.in_service)
2504 .count()
2505 .max(1);
2506 if max_id
2507 .checked_add(needed)
2508 .is_none_or(|top| top > BusId::MAX.0)
2509 {
2510 return Err(Error::FormatRead {
2511 format,
2512 message: format!(
2513 "bus id {max_id} leaves no room to allocate synthetic star bus ids \
2514 for 3-winding transformers"
2515 ),
2516 });
2517 }
2518 Ok(())
2519 }
2520}
2521
2522#[cfg(test)]
2523mod tests {
2524 use super::*;
2525
2526 fn close(actual: f64, expected: f64) {
2527 assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
2528 }
2529
2530 #[test]
2531 fn source_format_serializes_as_its_name_token_and_reads_the_legacy_spelling() {
2532 let all = [
2535 SourceFormat::Matpower,
2536 SourceFormat::PowerModelsJson,
2537 SourceFormat::EgretJson,
2538 SourceFormat::Psse,
2539 SourceFormat::PowerWorld,
2540 SourceFormat::PandapowerJson,
2541 SourceFormat::Pslf,
2542 SourceFormat::PowerWorldBinary,
2543 SourceFormat::InMemory,
2544 SourceFormat::Normalized,
2545 SourceFormat::Gridfm,
2546 SourceFormat::PypsaCsv,
2547 SourceFormat::Goc3Json,
2548 SourceFormat::SurgeJson,
2549 SourceFormat::DeepMindOpfDataJson,
2550 ];
2551 for f in all {
2552 match f {
2553 SourceFormat::Matpower
2554 | SourceFormat::PowerModelsJson
2555 | SourceFormat::EgretJson
2556 | SourceFormat::Psse
2557 | SourceFormat::PowerWorld
2558 | SourceFormat::PandapowerJson
2559 | SourceFormat::Pslf
2560 | SourceFormat::PowerWorldBinary
2561 | SourceFormat::InMemory
2562 | SourceFormat::Normalized
2563 | SourceFormat::Gridfm
2564 | SourceFormat::PypsaCsv
2565 | SourceFormat::Goc3Json
2566 | SourceFormat::SurgeJson
2567 | SourceFormat::DeepMindOpfDataJson => {}
2568 }
2569 let token = serde_json::to_value(f).unwrap();
2570 assert_eq!(token, serde_json::Value::String(f.name().to_owned()));
2571 let back: SourceFormat = serde_json::from_value(token).unwrap();
2572 assert_eq!(back, f);
2573 let legacy = serde_json::Value::String(format!("{f:?}"));
2574 let from_legacy: SourceFormat = serde_json::from_value(legacy).unwrap();
2575 assert_eq!(from_legacy, f);
2576 }
2577 }
2578
2579 #[test]
2580 fn quadratic_with_constant_keeps_c0_across_ncost() {
2581 let full = GenCost::new(2, 0.0, 0.0, vec![1.5, 2.0, 5.0]);
2582 assert_eq!(full.quadratic_with_constant(), Some((3.0, 2.0, 5.0)));
2583 assert_eq!(full.quadratic(), Some((3.0, 2.0)));
2584
2585 let linear = GenCost::new(2, 0.0, 0.0, vec![2.0, 5.0]);
2586 assert_eq!(linear.quadratic_with_constant(), Some((0.0, 2.0, 5.0)));
2587
2588 let constant = GenCost::new(2, 0.0, 0.0, vec![5.0]);
2589 assert_eq!(constant.quadratic_with_constant(), Some((0.0, 0.0, 5.0)));
2590
2591 let piecewise = GenCost::new(1, 0.0, 0.0, vec![0.0, 0.0, 1.0, 1.0]);
2592 assert_eq!(piecewise.quadratic_with_constant(), None);
2593
2594 let cubic = GenCost::new(2, 0.0, 0.0, vec![1.0, 1.0, 1.0, 1.0]);
2595 assert_eq!(cubic.quadratic_with_constant(), None);
2596
2597 let truncated = GenCost::with_ncost(2, 0.0, 0.0, 3, vec![1.0]);
2598 assert_eq!(truncated.quadratic_with_constant(), None);
2599 }
2600
2601 #[test]
2602 fn a_leading_coefficient_below_the_tolerance_comes_off_the_row() {
2603 let artifact = GenCost::new(2, 0.0, 0.0, vec![1e-17, 2.0, 5.0]);
2604 assert_eq!(
2605 artifact.quadratic_with_constant(),
2606 Some((2e-17, 2.0, 5.0)),
2607 "the untouched reader keeps the artifact"
2608 );
2609 assert_eq!(
2610 artifact.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
2611 Some((0.0, 2.0, 5.0))
2612 );
2613 assert_eq!(
2614 artifact.quadratic_with_constant_tol(0.0),
2615 Some((2e-17, 2.0, 5.0)),
2616 "a zero tolerance strips an exact zero alone"
2617 );
2618
2619 let padded = GenCost::new(2, 0.0, 0.0, vec![0.0, 1.5, 2.0, 5.0]);
2622 assert_eq!(padded.quadratic_with_constant(), None);
2623 assert_eq!(
2624 padded.quadratic_with_constant_tol(0.0),
2625 Some((3.0, 2.0, 5.0))
2626 );
2627
2628 let flat = GenCost::new(2, 0.0, 0.0, vec![1e-17, 1e-17, 1e-17]);
2629 assert_eq!(
2630 flat.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
2631 Some((0.0, 0.0, 1e-17)),
2632 "the last coefficient stays, whatever its magnitude"
2633 );
2634
2635 let piecewise = GenCost::new(1, 0.0, 0.0, vec![0.0, 0.0, 1.0, 1.0]);
2636 assert_eq!(
2637 piecewise.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
2638 None
2639 );
2640
2641 let truncated = GenCost::with_ncost(2, 0.0, 0.0, 3, vec![1.0]);
2642 assert_eq!(
2643 truncated.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
2644 None
2645 );
2646
2647 let quartic = GenCost::new(2, 0.0, 0.0, vec![1.0, 1.0, 1.0, 1.0, 1.0]);
2648 assert_eq!(
2649 quartic.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
2650 None
2651 );
2652 }
2653
2654 fn expected_rate(window: f64, fr: f64, to: f64, zmag: f64) -> f64 {
2657 let separation = (fr * fr + to * to - 2.0 * fr * to * window.cos()).sqrt();
2658 fr.max(to) * separation / zmag
2659 }
2660
2661 #[test]
2662 fn synthesized_rate_follows_the_angle_window_and_the_voltage_bands() {
2663 let br = Branch::new(BusId(1), BusId(2), 0.03, 0.04);
2664 let expected = |window: f64, fr: f64, to: f64| expected_rate(window, fr, to, 0.05);
2665 let at = |v: f64| (v, v);
2668 close(
2669 br.synthesize_rate_a(0.5, at(1.1), at(1.06)),
2670 expected(0.5, 1.1, 1.06),
2671 );
2672
2673 assert!(
2675 br.synthesize_rate_a(0.8, at(1.1), at(1.06))
2676 > br.synthesize_rate_a(0.5, at(1.1), at(1.06))
2677 );
2678
2679 close(
2681 br.synthesize_rate_a(-0.5, at(1.1), at(1.06)),
2682 expected(0.5, 1.1, 1.06),
2683 );
2684 for window in [6.0, 2.0 * std::f64::consts::PI, -360.0] {
2685 close(
2686 br.synthesize_rate_a(window, at(1.1), at(1.06)),
2687 expected(std::f64::consts::PI, 1.1, 1.06),
2688 );
2689 }
2690
2691 let ideal = Branch::new(BusId(1), BusId(2), 0.0, 0.0);
2692 close(ideal.synthesize_rate_a(0.5, at(1.1), at(1.1)), 0.0);
2693 }
2694
2695 #[test]
2696 fn a_narrow_window_bounds_at_the_mixed_voltage_corner() {
2697 let br = Branch::new(BusId(1), BusId(2), 0.0, 0.01);
2704 let (vmin, vmax) = (0.9, 1.1);
2705 let corner = |window: f64, fr: f64, to: f64| expected_rate(window, fr, to, 0.01);
2706
2707 let narrow = 2.0_f64.to_radians();
2708 let bound = br.synthesize_rate_a(narrow, (vmin, vmax), (vmin, vmax));
2709 close(bound, corner(narrow, vmax, vmin));
2710 assert!(
2711 bound > 5.0 * corner(narrow, vmax, vmax),
2712 "the mixed corner dominates here: {bound} vs {}",
2713 corner(narrow, vmax, vmax)
2714 );
2715
2716 let wide = 30.0_f64.to_radians();
2718 close(
2719 br.synthesize_rate_a(wide, (vmin, vmax), (vmin, vmax)),
2720 corner(wide, vmax, vmax),
2721 );
2722 }
2723
2724 fn bus(id: usize) -> Bus {
2725 Bus {
2726 id: BusId(id),
2727 kind: BusType::Pq,
2728 vm: 1.0,
2729 va: 0.0,
2730 base_kv: 230.0,
2731 vmax: 1.1,
2732 vmin: 0.9,
2733 evhi: None,
2734 evlo: None,
2735 area: 1,
2736 zone: 1,
2737 name: None,
2738 uid: None,
2739 location: None,
2740 extras: Extras::new(),
2741 }
2742 }
2743
2744 #[test]
2745 fn model_json_bytes_are_strict_utf8_and_keep_model_validation() {
2746 let net = BalancedNetwork::in_memory("bytes", 100.0, vec![bus(1)], Vec::new());
2747 let json = net.to_json().expect("serialize model JSON");
2748 let mut with_bom = b"\xef\xbb\xbf".to_vec();
2749 with_bom.extend_from_slice(json.as_bytes());
2750 let back = BalancedNetwork::from_json_bytes(&with_bom).expect("read BOM prefixed JSON");
2751 assert_eq!(back.name(), "bytes");
2752 assert_eq!(back.buses().len(), 1);
2753
2754 let error = BalancedNetwork::from_json_bytes(b"{\"buses\":[]\xff}")
2755 .expect_err("invalid UTF-8 must not be replaced");
2756 assert!(
2757 matches!(&error, crate::Error::FormatRead { format: "JSON", message } if message.starts_with("input is not valid UTF-8:")),
2758 "{error}"
2759 );
2760 assert_eq!(error.code().code, "PARSE.SOURCE.MALFORMED");
2761
2762 let empty = net
2763 .to_json()
2764 .expect("serialize model JSON")
2765 .replace(&serde_json::to_string(&net.buses()).unwrap(), "[]");
2766 let error = BalancedNetwork::from_json_bytes(empty.as_bytes())
2767 .expect_err("the byte API must keep no-bus validation");
2768 assert!(error.to_string().contains("case has no buses"), "{error}");
2769 }
2770
2771 fn winding(b: usize) -> Winding {
2772 Winding {
2773 bus: BusId(b),
2774 tap: 1.0,
2775 shift: 0.0,
2776 nominal_kv: 230.0,
2777 rate_a: 100.0,
2778 rate_b: 0.0,
2779 rate_c: 0.0,
2780 }
2781 }
2782
2783 fn transformer_3w() -> Transformer3W {
2784 let z = |r, x| Impedance {
2785 r,
2786 x,
2787 base_mva: 100.0,
2788 };
2789 Transformer3W {
2790 windings: [winding(1), winding(2), winding(3)],
2791 z: [z(0.01, 0.10), z(0.02, 0.20), z(0.03, 0.30)],
2792 star_vm: 0.98,
2793 star_va: -1.5,
2794 mag_g: 0.0,
2795 mag_b: 0.0,
2796 in_service: true,
2797 name: Some("T1".into()),
2798 uid: None,
2799 extras: Extras::new(),
2800 }
2801 }
2802
2803 #[test]
2804 fn star_impedances_split_the_pairwise_values() {
2805 let [(r1, x1), (r2, x2), (r3, x3)] = transformer_3w().star_impedances();
2807 close(r1, 0.01);
2808 close(x1, 0.10);
2809 close(r2, 0.0);
2810 close(x2, 0.0);
2811 close(r3, 0.02);
2812 close(x3, 0.20);
2813 }
2814
2815 #[test]
2816 fn star_expansion_builds_a_star_bus_and_three_branches() {
2817 let t = transformer_3w();
2818 let (star, branches) = t.star_expansion(BusId(99));
2819
2820 assert_eq!(star.id, BusId(99));
2821 close(star.vm, 0.98);
2822 close(star.va, -1.5);
2823 for (i, br) in branches.iter().enumerate() {
2826 assert_eq!(br.from, t.windings[i].bus);
2827 assert_eq!(br.to, BusId(99));
2828 close(br.tap, 1.0);
2829 close(br.rate_a, 100.0);
2830 }
2831 close(branches[2].r, 0.02);
2832 close(branches[2].x, 0.20);
2833 }
2834
2835 #[test]
2836 fn three_winding_transformer_survives_json_transport() {
2837 let mut net =
2838 BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
2839 net.transformers_3w_mut().push(transformer_3w());
2840 net.validate().unwrap();
2841
2842 let back = BalancedNetwork::from_json(&net.to_json().unwrap()).unwrap();
2843 assert_eq!(back.transformers_3w().len(), 1);
2844 close(back.transformers_3w()[0].z[1].x, 0.20);
2845 assert_eq!(back.transformers_3w()[0].windings[2].bus, BusId(3));
2846 }
2847
2848 #[test]
2849 fn lowered_lengths_match_the_expansion() {
2850 let mut magnetizing = transformer_3w();
2855 magnetizing.mag_b = 0.02;
2856 let mut out_of_service = transformer_3w();
2857 out_of_service.in_service = false;
2858 out_of_service.mag_g = 0.01;
2859
2860 for units in [
2861 vec![],
2862 vec![transformer_3w()],
2863 vec![magnetizing.clone()],
2864 vec![out_of_service.clone()],
2865 vec![transformer_3w(), magnetizing, out_of_service],
2866 ] {
2867 let mut net =
2868 BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
2869 net.shunts_mut().push(Shunt::new(BusId(1), 0.0, 0.5));
2870 *net.transformers_3w_mut() = units;
2871 let counted = net.lowered_lengths();
2872 let built = net.expand_transformers_3w();
2873 assert_eq!(counted.buses, built.buses().len());
2874 assert_eq!(counted.branches, built.branches().len());
2875 assert_eq!(counted.shunts, built.shunts().len());
2876 }
2877 }
2878
2879 #[test]
2880 fn check_references_rejects_bus_ids_without_star_expansion_headroom() {
2881 let mut net = BalancedNetwork::in_memory(
2885 "t",
2886 100.0,
2887 vec![bus(1), bus(2), bus(3), bus(i64::MAX as usize)],
2888 Vec::new(),
2889 );
2890 net.transformers_3w_mut().push(transformer_3w());
2891 let err = net.validate().unwrap_err().to_string();
2892 assert!(
2893 err.contains("no room to allocate synthetic star bus ids"),
2894 "got {err}"
2895 );
2896 }
2897
2898 #[test]
2899 fn star_expansion_headroom_counts_only_in_service_transformers() {
2900 let mut net = BalancedNetwork::in_memory(
2906 "t",
2907 100.0,
2908 vec![bus(1), bus(2), bus(3), bus(i64::MAX as usize - 1)],
2909 Vec::new(),
2910 );
2911 net.transformers_3w_mut().push(transformer_3w());
2912 let mut out_of_service = transformer_3w();
2913 out_of_service.in_service = false;
2914 net.transformers_3w_mut().push(out_of_service);
2915 net.validate()
2916 .expect("in-service count fits; must not be rejected");
2917 }
2918
2919 #[test]
2920 fn check_references_rejects_a_bus_id_past_the_int64_ceiling() {
2921 let mut net = BalancedNetwork::in_memory(
2926 "t",
2927 100.0,
2928 vec![bus(1), bus(i64::MAX as usize + 1)],
2929 Vec::new(),
2930 );
2931 let err = net.validate().unwrap_err().to_string();
2932 assert!(err.contains("outside the int64 id space"), "got {err}");
2933
2934 net.buses_mut()[1].id = BusId(i64::MAX as usize);
2936 net.validate().expect("the ceiling itself is representable");
2937 }
2938
2939 #[test]
2940 fn check_references_rejects_a_dangling_winding_bus() {
2941 let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
2942 net.transformers_3w_mut().push(transformer_3w()); let err = net.validate().unwrap_err().to_string();
2944 assert!(
2945 err.contains("3-winding transformer references unknown bus 3"),
2946 "got {err}"
2947 );
2948 }
2949
2950 fn regulating_branch(reg: usize) -> Branch {
2952 Branch {
2953 from: BusId(1),
2954 to: BusId(2),
2955 r: 0.0,
2956 x: 0.1,
2957 b: 0.0,
2958 charging: None,
2959 rate_a: 0.0,
2960 rate_b: 0.0,
2961 rate_c: 0.0,
2962 rating_sets: Vec::new(),
2963 current_ratings: None,
2964 tap: 1.0,
2965 shift: 0.0,
2966 in_service: true,
2967 angmin: -360.0,
2968 angmax: 360.0,
2969 control: Some(TransformerControl {
2970 mode: TransformerControlMode::Voltage,
2971 controlled_bus: Some(BusId(reg)),
2972 tap_min: 0.95,
2973 tap_max: 1.05,
2974 band_min: 1.0,
2975 band_max: 1.02,
2976 ntp: 17,
2977 mva_base: 100.0,
2978 }),
2979 solution: None,
2980 uid: None,
2981 route: None,
2982 extras: Extras::new(),
2983 }
2984 }
2985
2986 #[test]
2987 fn transformer_control_survives_json_transport() {
2988 let mut net =
2989 BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
2990 net.branches_mut().push(regulating_branch(3));
2991 net.validate().unwrap();
2992
2993 let back = BalancedNetwork::from_json(&net.to_json().unwrap()).unwrap();
2994 let c = back.branches()[0].control.as_ref().unwrap();
2995 assert_eq!(c.mode, TransformerControlMode::Voltage);
2996 assert_eq!(c.controlled_bus, Some(BusId(3)));
2997 close(c.tap_max, 1.05);
2998 assert_eq!(c.ntp, 17);
2999 }
3000
3001 #[test]
3002 fn gen_caps_serialize_as_a_named_map_that_grows_additively() {
3003 let mut caps: GenCaps = [None; GEN_EXTRA_KEYS.len()];
3004 caps[8] = Some(1.5); caps[10] = Some(0.5); let g = Generator {
3007 bus: BusId(1),
3008 pg: 10.0,
3009 qg: 0.0,
3010 pmax: 100.0,
3011 pmin: 0.0,
3012 qmax: 50.0,
3013 qmin: -50.0,
3014 vg: 1.0,
3015 mbase: 100.0,
3016 in_service: true,
3017 cost: None,
3018 caps,
3019 regulated_bus: None,
3020 uid: None,
3021 };
3022
3023 let json = serde_json::to_string(&g).unwrap();
3026 assert!(json.contains(r#""caps":{"#), "caps is an object: {json}");
3027 assert!(json.contains(r#""ramp_30":1.5"#) && json.contains(r#""apf":0.5"#));
3028 let back: Generator = serde_json::from_str(&json).unwrap();
3029 assert_eq!(back.caps, g.caps);
3030
3031 let with_future = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
3034 "vg":1,"mbase":100,"in_service":true,"cost":null,
3035 "caps":{"ramp_30":1.5,"future_ramp":9.9}}"#;
3036 let g2: Generator = serde_json::from_str(with_future).unwrap();
3037 assert_eq!(g2.caps[8], Some(1.5));
3038 assert_eq!(g2.caps.iter().filter(|v| v.is_some()).count(), 1);
3039 let no_caps = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
3040 "vg":1,"mbase":100,"in_service":true,"cost":null}"#;
3041 let g3: Generator = serde_json::from_str(no_caps).unwrap();
3042 assert!(!g3.has_caps());
3043
3044 let null_caps = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
3046 "vg":1,"mbase":100,"in_service":true,"cost":null,"caps":null}"#;
3047 let g4: Generator = serde_json::from_str(null_caps).unwrap();
3048 assert!(!g4.has_caps());
3049 }
3050
3051 #[test]
3052 #[allow(clippy::float_cmp)]
3053 fn nonfinite_values_round_trip_through_model_json() {
3054 let bus = |id, vm| Bus {
3055 id: BusId(id),
3056 kind: BusType::Pq,
3057 vm,
3058 va: 0.0,
3059 base_kv: 230.0,
3060 vmax: 1.1,
3061 vmin: 0.9,
3062 evhi: None,
3063 evlo: None,
3064 area: 1,
3065 zone: 1,
3066 name: None,
3067 uid: None,
3068 location: None,
3069 extras: Extras::new(),
3070 };
3071 let branch = Branch {
3072 from: BusId(1),
3073 to: BusId(2),
3074 r: 0.0,
3075 x: f64::INFINITY,
3076 b: 0.0,
3077 charging: None,
3078 rate_a: 0.0,
3079 rate_b: 0.0,
3080 rate_c: 0.0,
3081 rating_sets: Vec::new(),
3082 current_ratings: None,
3083 tap: 0.0,
3084 shift: 0.0,
3085 in_service: true,
3086 angmin: -360.0,
3087 angmax: 360.0,
3088 control: None,
3089 solution: None,
3090 uid: None,
3091 route: None,
3092 extras: Extras::new(),
3093 };
3094 let mut g = Generator {
3097 bus: BusId(1),
3098 pg: 0.0,
3099 qg: 0.0,
3100 pmax: 0.0,
3101 pmin: 0.0,
3102 qmax: 0.0,
3103 qmin: 0.0,
3104 vg: 1.0,
3105 mbase: 100.0,
3106 in_service: true,
3107 cost: None,
3108 caps: GenCaps::default(),
3109 regulated_bus: None,
3110 uid: None,
3111 };
3112 g.caps[8] = Some(f64::INFINITY); let mut net = BalancedNetwork::in_memory(
3117 "nf",
3118 100.0,
3119 vec![bus(1, f64::NAN), bus(2, 1.0)],
3120 vec![branch],
3121 );
3122 net.generators_mut().push(g);
3123
3124 let text = net.to_json().unwrap();
3125 assert!(text.contains(r#""vm":"NaN""#), "{text}");
3126 assert!(text.contains(r#""x":"Infinity""#), "{text}");
3127 assert!(text.contains(r#""ramp_30":"Infinity""#), "{text}");
3128
3129 let back = BalancedNetwork::from_json(&text).unwrap();
3130 assert!(back.buses()[0].vm.is_nan());
3131 assert_eq!(back.branches()[0].x, f64::INFINITY);
3132 assert_eq!(back.generators()[0].caps[8], Some(f64::INFINITY));
3133
3134 assert_eq!(back.to_json().unwrap(), text);
3137 let (_, diagnostics) = net.to_json_with_diagnostics().unwrap();
3138 assert!(diagnostics.is_empty());
3139 }
3140
3141 #[test]
3142 fn a_null_at_a_float_position_names_the_pre_090_spelling() {
3143 let net = BalancedNetwork::in_memory("nf", 100.0, vec![bus(1), bus(2)], Vec::new());
3144 let text = net
3145 .to_json()
3146 .unwrap()
3147 .replacen("\"vm\":1.0", "\"vm\":null", 1);
3148 assert!(text.contains("\"vm\":null"), "fixture edit failed: {text}");
3149 let err = BalancedNetwork::from_json(&text).unwrap_err().to_string();
3150 assert!(err.contains("before 0.9.0"), "{err}");
3151 }
3152
3153 #[test]
3154 fn check_references_rejects_a_dangling_controlled_bus() {
3155 let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
3156 net.branches_mut().push(regulating_branch(9)); let err = net.validate().unwrap_err().to_string();
3158 assert!(
3159 err.contains("transformer control references unknown bus 9"),
3160 "got {err}"
3161 );
3162 }
3163
3164 fn switched_shunt(reg: usize) -> Shunt {
3166 Shunt {
3167 bus: BusId(1),
3168 g: 0.0,
3169 b: 19.0,
3170 in_service: true,
3171 control: Some(SwitchedShuntControl {
3172 mode: SwitchedShuntMode::Discrete,
3173 vhigh: 1.05,
3174 vlow: 0.95,
3175 control_bus: Some(BusId(reg)),
3176 rmpct: 100.0,
3177 blocks: vec![
3178 ShuntBlock { steps: 2, b: 25.0 },
3179 ShuntBlock { steps: 1, b: 50.0 },
3180 ],
3181 }),
3182 uid: None,
3183 extras: Extras::new(),
3184 }
3185 }
3186
3187 #[test]
3188 fn switched_shunt_control_survives_json_transport() {
3189 let mut net =
3190 BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
3191 net.shunts_mut().push(switched_shunt(3));
3192 net.validate().unwrap();
3193
3194 let back = BalancedNetwork::from_json(&net.to_json().unwrap()).unwrap();
3195 let c = back.shunts()[0].control.as_ref().unwrap();
3196 assert_eq!(c.mode, SwitchedShuntMode::Discrete);
3197 assert_eq!(c.control_bus, Some(BusId(3)));
3198 assert_eq!(c.blocks.len(), 2);
3199 close(c.blocks[1].b, 50.0);
3200 }
3201
3202 #[test]
3203 fn check_references_rejects_a_dangling_switched_shunt_control_bus() {
3204 let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
3205 net.shunts_mut().push(switched_shunt(9)); let err = net.validate().unwrap_err().to_string();
3207 assert!(
3208 err.contains("switched-shunt control references unknown bus 9"),
3209 "got {err}"
3210 );
3211 }
3212
3213 #[test]
3214 fn validate_values_flags_and_repair_clamps_out_of_domain_values() {
3215 let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
3216 net.buses_mut()[0].vm = 0.0; net.buses_mut()[1].va = 9000.0; net.generators_mut().push(Generator {
3219 bus: BusId(1),
3220 pg: 10.0,
3221 qg: 0.0,
3222 pmax: 100.0,
3223 pmin: 0.0,
3224 qmax: 50.0,
3225 qmin: -50.0,
3226 vg: 0.0, mbase: 0.0, in_service: true,
3229 cost: None,
3230 caps: Default::default(),
3231 regulated_bus: None,
3232 uid: None,
3233 });
3234
3235 let diags = net.validate_values();
3236 let fields: std::collections::BTreeSet<_> = diags
3237 .iter()
3238 .map(|d| d.details()["field"].as_str().unwrap().to_owned())
3239 .collect();
3240 assert_eq!(
3241 fields,
3242 ["mbase", "va", "vg", "vm"]
3243 .into_iter()
3244 .map(str::to_owned)
3245 .collect(),
3246 "all four out-of-domain fields reported"
3247 );
3248 assert!(
3249 diags
3250 .iter()
3251 .all(|d| d.code() == "VALIDATE.BALANCED.VALUE_DOMAIN" && d.target().is_some())
3252 );
3253 close(net.buses()[0].vm, 0.0);
3255
3256 let module = powerio_core::PioModule::new(net);
3258 let module = repair_values(module).unwrap();
3259 let net = module.value();
3260 close(net.buses()[0].vm, 1.0);
3261 close(net.buses()[1].va, 0.0);
3262 close(net.generators()[0].mbase, 100.0); close(net.generators()[0].vg, 1.0);
3264 assert!(net.validate_values().is_empty());
3267 let entries = module.history();
3268 assert_eq!(entries.len(), 1);
3269 assert_eq!(entries[0].kind(), powerio_core::HistoryKind::Repair);
3270 assert_eq!(
3271 entries[0].parameters()["repairs"].as_array().unwrap().len(),
3272 diags.len()
3273 );
3274 assert_eq!(module.diagnostics().len(), diags.len());
3275 let module = repair_values(module).unwrap();
3276 assert_eq!(module.history().len(), 1);
3277 }
3278
3279 #[test]
3280 fn validate_values_is_empty_for_a_clean_network() {
3281 let net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
3282 assert!(net.validate_values().is_empty());
3283 }
3284}