1use std::fmt;
13use std::str::FromStr;
14
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
28pub enum CoreType {
29 ThreeLimbCore,
31 #[default]
33 FiveLimbCore,
34 FiveLimbShell,
36 SinglePhaseBank,
38 SinglePhaseShell,
40 Custom(f64),
42}
43
44impl CoreType {
45 pub fn k_factor(&self) -> f64 {
47 match self {
48 CoreType::ThreeLimbCore => 0.33,
49 CoreType::FiveLimbCore => 1.18,
50 CoreType::FiveLimbShell => 2.0,
51 CoreType::SinglePhaseBank => 1.18,
52 CoreType::SinglePhaseShell => 0.80,
53 CoreType::Custom(k) => {
54 if k.is_finite() && *k >= 0.0 {
55 *k
56 } else {
57 0.0
58 }
59 }
60 }
61 }
62}
63
64impl fmt::Display for CoreType {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 match self {
67 CoreType::ThreeLimbCore => write!(f, "3-limb"),
68 CoreType::FiveLimbCore => write!(f, "5-limb"),
69 CoreType::FiveLimbShell => write!(f, "5-limb-shell"),
70 CoreType::SinglePhaseBank => write!(f, "1ph-bank"),
71 CoreType::SinglePhaseShell => write!(f, "1ph-shell"),
72 CoreType::Custom(k) => write!(f, "custom({k})"),
73 }
74 }
75}
76
77impl FromStr for CoreType {
78 type Err = String;
79
80 fn from_str(s: &str) -> Result<Self, Self::Err> {
81 match s.to_lowercase().replace(' ', "-").as_str() {
82 "3-limb" | "3limb" | "three-limb" | "three-limb-core" => Ok(CoreType::ThreeLimbCore),
83 "5-limb" | "5limb" | "five-limb" | "five-limb-core" => Ok(CoreType::FiveLimbCore),
84 "5-limb-shell" | "5limb-shell" | "five-limb-shell" => Ok(CoreType::FiveLimbShell),
85 "1ph-bank" | "single-phase-bank" | "1ph-core" => Ok(CoreType::SinglePhaseBank),
86 "1ph-shell" | "single-phase-shell" => Ok(CoreType::SinglePhaseShell),
87 other => {
88 if let Some(rest) = other
89 .strip_prefix("custom(")
90 .and_then(|s| s.strip_suffix(')'))
91 {
92 rest.parse::<f64>()
93 .map(CoreType::Custom)
94 .map_err(|e| format!("invalid custom K-factor: {e}"))
95 } else {
96 Err(format!("unknown core type: '{other}'"))
97 }
98 }
99 }
100 }
101}
102
103#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
109pub struct SaturationPoint {
110 pub i_m_pu: f64,
112 pub phi_pu: f64,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct SaturationCurve {
125 pub points: Vec<SaturationPoint>,
128}
129
130impl SaturationCurve {
131 pub fn evaluate(&self, phi_pu: f64) -> f64 {
136 if phi_pu < 0.0 {
137 return -self.evaluate_positive(-phi_pu);
138 }
139 self.evaluate_positive(phi_pu)
140 }
141
142 fn evaluate_positive(&self, phi: f64) -> f64 {
144 let pts = &self.points;
145 if pts.is_empty() {
146 return 0.0;
147 }
148 if pts.len() == 1 {
149 if pts[0].phi_pu.abs() < 1e-30 {
151 return 0.0;
152 }
153 return phi * pts[0].i_m_pu / pts[0].phi_pu;
154 }
155
156 if phi <= pts[0].phi_pu {
158 if pts[0].phi_pu.abs() < 1e-30 {
159 return 0.0;
160 }
161 return phi * pts[0].i_m_pu / pts[0].phi_pu;
162 }
163
164 for i in 1..pts.len() {
166 if phi <= pts[i].phi_pu {
167 let dp = pts[i].phi_pu - pts[i - 1].phi_pu;
168 if dp.abs() < 1e-30 {
169 return pts[i].i_m_pu;
170 }
171 let t = (phi - pts[i - 1].phi_pu) / dp;
172 return pts[i - 1].i_m_pu + t * (pts[i].i_m_pu - pts[i - 1].i_m_pu);
173 }
174 }
175
176 let n = pts.len();
178 let dp = pts[n - 1].phi_pu - pts[n - 2].phi_pu;
179 if dp.abs() < 1e-30 {
180 return pts[n - 1].i_m_pu;
181 }
182 let slope = (pts[n - 1].i_m_pu - pts[n - 2].i_m_pu) / dp;
183 pts[n - 1].i_m_pu + slope * (phi - pts[n - 1].phi_pu)
184 }
185
186 pub fn evaluate_inverse(&self, i_m_pu: f64) -> f64 {
190 if i_m_pu < 0.0 {
191 return -self.evaluate_inverse_positive(-i_m_pu);
192 }
193 self.evaluate_inverse_positive(i_m_pu)
194 }
195
196 fn evaluate_inverse_positive(&self, i_m: f64) -> f64 {
197 let pts = &self.points;
198 if pts.is_empty() {
199 return 0.0;
200 }
201 if pts.len() == 1 {
202 if pts[0].i_m_pu.abs() < 1e-30 {
203 return 0.0;
204 }
205 return i_m * pts[0].phi_pu / pts[0].i_m_pu;
206 }
207
208 if i_m <= pts[0].i_m_pu {
209 if pts[0].i_m_pu.abs() < 1e-30 {
210 return 0.0;
211 }
212 return i_m * pts[0].phi_pu / pts[0].i_m_pu;
213 }
214
215 for i in 1..pts.len() {
216 if i_m <= pts[i].i_m_pu {
217 let di = pts[i].i_m_pu - pts[i - 1].i_m_pu;
218 if di.abs() < 1e-30 {
219 return pts[i].phi_pu;
220 }
221 let t = (i_m - pts[i - 1].i_m_pu) / di;
222 return pts[i - 1].phi_pu + t * (pts[i].phi_pu - pts[i - 1].phi_pu);
223 }
224 }
225
226 let n = pts.len();
228 let di = pts[n - 1].i_m_pu - pts[n - 2].i_m_pu;
229 if di.abs() < 1e-30 {
230 return pts[n - 1].phi_pu;
231 }
232 let slope = (pts[n - 1].phi_pu - pts[n - 2].phi_pu) / di;
233 pts[n - 1].phi_pu + slope * (i_m - pts[n - 1].i_m_pu)
234 }
235
236 pub fn validate(&self) -> Result<(), String> {
238 if self.points.len() < 2 {
239 return Err(format!(
240 "saturation curve requires at least 2 points, got {}",
241 self.points.len()
242 ));
243 }
244 for i in 1..self.points.len() {
245 if self.points[i].phi_pu <= self.points[i - 1].phi_pu {
246 return Err(format!(
247 "saturation curve phi_pu not monotonically increasing at index {}: {:.6} <= {:.6}",
248 i,
249 self.points[i].phi_pu,
250 self.points[i - 1].phi_pu
251 ));
252 }
253 if self.points[i].i_m_pu <= self.points[i - 1].i_m_pu {
254 return Err(format!(
255 "saturation curve i_m_pu not monotonically increasing at index {}: {:.6} <= {:.6}",
256 i,
257 self.points[i].i_m_pu,
258 self.points[i - 1].i_m_pu
259 ));
260 }
261 }
262 if self.points[0].phi_pu < 0.0 || self.points[0].i_m_pu < 0.0 {
263 return Err("saturation curve points must have non-negative values".to_string());
264 }
265 Ok(())
266 }
267
268 pub fn typical_power_transformer() -> Self {
272 Self {
273 points: vec![
274 SaturationPoint {
275 i_m_pu: 0.0,
276 phi_pu: 0.0,
277 },
278 SaturationPoint {
279 i_m_pu: 0.001,
280 phi_pu: 0.5,
281 },
282 SaturationPoint {
283 i_m_pu: 0.003,
284 phi_pu: 0.8,
285 },
286 SaturationPoint {
287 i_m_pu: 0.01,
288 phi_pu: 1.0,
289 },
290 SaturationPoint {
291 i_m_pu: 0.03,
292 phi_pu: 1.1,
293 },
294 SaturationPoint {
295 i_m_pu: 0.10,
296 phi_pu: 1.15,
297 },
298 SaturationPoint {
299 i_m_pu: 0.50,
300 phi_pu: 1.20,
301 },
302 SaturationPoint {
303 i_m_pu: 2.0,
304 phi_pu: 1.25,
305 },
306 SaturationPoint {
307 i_m_pu: 10.0,
308 phi_pu: 1.30,
309 },
310 ],
311 }
312 }
313
314 pub fn typical_distribution_transformer() -> Self {
318 Self {
319 points: vec![
320 SaturationPoint {
321 i_m_pu: 0.0,
322 phi_pu: 0.0,
323 },
324 SaturationPoint {
325 i_m_pu: 0.002,
326 phi_pu: 0.5,
327 },
328 SaturationPoint {
329 i_m_pu: 0.005,
330 phi_pu: 0.8,
331 },
332 SaturationPoint {
333 i_m_pu: 0.015,
334 phi_pu: 1.0,
335 },
336 SaturationPoint {
337 i_m_pu: 0.05,
338 phi_pu: 1.05,
339 },
340 SaturationPoint {
341 i_m_pu: 0.15,
342 phi_pu: 1.10,
343 },
344 SaturationPoint {
345 i_m_pu: 0.80,
346 phi_pu: 1.15,
347 },
348 SaturationPoint {
349 i_m_pu: 5.0,
350 phi_pu: 1.20,
351 },
352 ],
353 }
354 }
355}
356
357#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
366pub struct TwoSlopeSaturation {
367 pub phi_knee_pu: f64,
369 pub b_mag_unsat: f64,
371 pub x_air_pu: f64,
373}
374
375impl TwoSlopeSaturation {
376 pub fn to_piecewise_linear(&self) -> SaturationCurve {
378 let mut points = Vec::with_capacity(10);
379 points.push(SaturationPoint {
380 i_m_pu: 0.0,
381 phi_pu: 0.0,
382 });
383
384 let b = self.b_mag_unsat.abs().max(1e-6);
386 let n_unsat = 4;
387 for k in 1..=n_unsat {
388 let phi = self.phi_knee_pu * k as f64 / n_unsat as f64;
389 let i_m = phi * b;
390 points.push(SaturationPoint {
391 i_m_pu: i_m,
392 phi_pu: phi,
393 });
394 }
395
396 let i_knee = self.phi_knee_pu * b;
398 let x_air = self.x_air_pu.abs().max(1e-6);
399 let n_sat = 5;
400 for k in 1..=n_sat {
401 let dphi = 0.05 * k as f64; let phi = self.phi_knee_pu + dphi;
403 let i_m = i_knee + dphi / x_air;
404 points.push(SaturationPoint {
405 i_m_pu: i_m,
406 phi_pu: phi,
407 });
408 }
409
410 SaturationCurve { points }
411 }
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct PolynomialSaturation {
424 pub coefficients: Vec<f64>,
426}
427
428impl PolynomialSaturation {
429 pub fn evaluate(&self, phi: f64) -> f64 {
431 let mut result = 0.0;
432 let mut power = 1u32;
433 for &coeff in &self.coefficients {
434 result += coeff * phi.powi(power as i32);
435 power += 2;
436 }
437 result
438 }
439
440 pub fn to_piecewise_linear(&self, n_points: usize) -> SaturationCurve {
442 let n = n_points.max(3);
443 let phi_max = 1.5; let mut points = Vec::with_capacity(n);
445 for k in 0..n {
446 let phi = phi_max * k as f64 / (n - 1) as f64;
447 let i_m = self.evaluate(phi);
448 points.push(SaturationPoint {
449 i_m_pu: i_m.max(0.0),
450 phi_pu: phi,
451 });
452 }
453 SaturationCurve { points }
454 }
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
466pub enum TransformerSaturation {
467 PiecewiseLinear(SaturationCurve),
469 TwoSlope(TwoSlopeSaturation),
471 Polynomial(PolynomialSaturation),
473}
474
475impl TransformerSaturation {
476 pub fn as_pwl(&self) -> SaturationCurve {
478 match self {
479 TransformerSaturation::PiecewiseLinear(c) => c.clone(),
480 TransformerSaturation::TwoSlope(ts) => ts.to_piecewise_linear(),
481 TransformerSaturation::Polynomial(p) => p.to_piecewise_linear(20),
482 }
483 }
484}
485
486#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
503pub struct CoreLossModel {
504 pub f_eddy: f64,
506 pub f_hyst: f64,
508 pub f_excess: f64,
510}
511
512impl Default for CoreLossModel {
513 fn default() -> Self {
514 Self {
515 f_eddy: 0.5,
516 f_hyst: 0.5,
517 f_excess: 0.0,
518 }
519 }
520}
521
522impl CoreLossModel {
523 pub fn validate(&self) -> Result<(), String> {
525 if self.f_eddy < 0.0 || self.f_hyst < 0.0 || self.f_excess < 0.0 {
526 return Err("core loss fractions must be non-negative".to_string());
527 }
528 let sum = self.f_eddy + self.f_hyst + self.f_excess;
529 if (sum - 1.0).abs() > 1e-6 {
530 return Err(format!("core loss fractions must sum to 1.0, got {sum:.6}"));
531 }
532 Ok(())
533 }
534
535 pub fn scale(&self, h: f64) -> f64 {
542 self.f_eddy * h * h + self.f_hyst * h.powf(1.6) + self.f_excess * h.powf(1.5)
543 }
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize)]
559pub struct ConverterCommutationModel {
560 pub bus: u32,
562 pub pulse_number: u32,
564 pub firing_angle_deg: f64,
566 pub x_commutation_pu: f64,
568 pub i_dc_pu: f64,
570 pub transformer_ratio: f64,
572 pub rated_mva: f64,
574}
575
576#[cfg(test)]
581mod tests {
582 use super::*;
583
584 fn typical_curve() -> SaturationCurve {
585 SaturationCurve::typical_power_transformer()
586 }
587
588 #[test]
589 fn pwl_interpolation_interior() {
590 let c = typical_curve();
591 let i_m = c.evaluate(1.05);
593 assert!(i_m > 0.01, "i_m at 1.05 pu should be > 0.01, got {i_m}");
594 assert!(i_m < 0.03, "i_m at 1.05 pu should be < 0.03, got {i_m}");
595 let i_at_1_0 = c.evaluate(1.0);
597 let i_at_1_1 = c.evaluate(1.1);
598 assert!(i_m > i_at_1_0 && i_m < i_at_1_1);
599 }
600
601 #[test]
602 fn pwl_interpolation_extrapolation() {
603 let c = typical_curve();
604 let i_at_1_3 = c.evaluate(1.30);
606 let i_at_1_5 = c.evaluate(1.50);
607 assert!(i_at_1_5 > i_at_1_3, "extrapolation should be increasing");
608 let last = c.points.last().unwrap();
610 let prev = &c.points[c.points.len() - 2];
611 let expected_slope = (last.i_m_pu - prev.i_m_pu) / (last.phi_pu - prev.phi_pu);
612 let actual_slope = (i_at_1_5 - i_at_1_3) / 0.2;
613 assert!(
614 (actual_slope - expected_slope).abs() < 1e-10,
615 "extrapolation slope {actual_slope:.6} != expected {expected_slope:.6}"
616 );
617 }
618
619 #[test]
620 fn pwl_odd_symmetry() {
621 let c = typical_curve();
622 for phi in [0.5, 1.0, 1.1, 1.2, 1.3] {
623 let pos = c.evaluate(phi);
624 let neg = c.evaluate(-phi);
625 assert!(
626 (pos + neg).abs() < 1e-15,
627 "odd symmetry violated at phi={phi}: f({phi})={pos}, f(-{phi})={neg}"
628 );
629 }
630 }
631
632 #[test]
633 fn two_slope_to_pwl() {
634 let ts = TwoSlopeSaturation {
635 phi_knee_pu: 1.15,
636 b_mag_unsat: 0.01,
637 x_air_pu: 0.3,
638 };
639 let pwl = ts.to_piecewise_linear();
640 assert!(pwl.validate().is_ok());
641 let i_at_knee = pwl.evaluate(ts.phi_knee_pu);
643 let expected = ts.phi_knee_pu * ts.b_mag_unsat;
644 assert!(
645 (i_at_knee - expected).abs() < 1e-6,
646 "at knee: i_m={i_at_knee:.6}, expected {expected:.6}"
647 );
648 let i_above = pwl.evaluate(1.25);
650 assert!(i_above > i_at_knee);
651 }
652
653 #[test]
654 fn polynomial_to_pwl() {
655 let poly = PolynomialSaturation {
656 coefficients: vec![1.0, 0.5], };
658 let pwl = poly.to_piecewise_linear(20);
659 assert!(pwl.points.len() == 20);
660 let i_m = poly.evaluate(1.0);
662 assert!((i_m - 1.5).abs() < 1e-10);
663 }
664
665 #[test]
666 fn validate_rejects_nonmonotonic() {
667 let c = SaturationCurve {
668 points: vec![
669 SaturationPoint {
670 i_m_pu: 0.0,
671 phi_pu: 0.0,
672 },
673 SaturationPoint {
674 i_m_pu: 0.1,
675 phi_pu: 1.0,
676 },
677 SaturationPoint {
678 i_m_pu: 0.05,
679 phi_pu: 1.1,
680 }, ],
682 };
683 assert!(c.validate().is_err());
684 }
685
686 #[test]
687 fn validate_rejects_single_point() {
688 let c = SaturationCurve {
689 points: vec![SaturationPoint {
690 i_m_pu: 0.01,
691 phi_pu: 1.0,
692 }],
693 };
694 assert!(c.validate().is_err());
695 }
696
697 #[test]
698 fn core_loss_scale_at_h1() {
699 let m = CoreLossModel::default();
700 let s = m.scale(1.0);
701 assert!(
702 (s - 1.0).abs() < 1e-10,
703 "scale at h=1 should be 1.0, got {s}"
704 );
705 }
706
707 #[test]
708 fn core_loss_scale_at_h5() {
709 let m = CoreLossModel::default(); let s = m.scale(5.0);
711 let expected = 0.5 * 25.0 + 0.5 * 5.0_f64.powf(1.6);
713 assert!(
714 (s - expected).abs() < 1e-10,
715 "scale at h=5: got {s:.6}, expected {expected:.6}"
716 );
717 assert!(
718 s > 10.0,
719 "core loss at 5th harmonic should be >10x fundamental"
720 );
721 }
722
723 #[test]
724 fn core_loss_all_eddy() {
725 let m = CoreLossModel {
726 f_eddy: 1.0,
727 f_hyst: 0.0,
728 f_excess: 0.0,
729 };
730 assert!(m.validate().is_ok());
731 let s = m.scale(7.0);
732 assert!((s - 49.0).abs() < 1e-10, "all-eddy scale at h=7 = h^2 = 49");
733 }
734
735 #[test]
736 fn core_loss_validate_rejects_bad_sum() {
737 let m = CoreLossModel {
738 f_eddy: 0.5,
739 f_hyst: 0.3,
740 f_excess: 0.0,
741 };
742 assert!(m.validate().is_err());
743 }
744
745 #[test]
746 fn core_type_k_factor() {
747 assert!((CoreType::ThreeLimbCore.k_factor() - 0.33).abs() < 1e-10);
748 assert!((CoreType::FiveLimbCore.k_factor() - 1.18).abs() < 1e-10);
749 assert!((CoreType::Custom(2.5).k_factor() - 2.5).abs() < 1e-10);
750 }
751
752 #[test]
753 fn core_type_from_str() {
754 assert_eq!(
755 "3-limb".parse::<CoreType>().unwrap(),
756 CoreType::ThreeLimbCore
757 );
758 assert_eq!(
759 "5-limb-shell".parse::<CoreType>().unwrap(),
760 CoreType::FiveLimbShell
761 );
762 assert!("invalid".parse::<CoreType>().is_err());
763 }
764
765 #[test]
766 fn inverse_lookup() {
767 let c = typical_curve();
768 for phi in [0.5, 1.0, 1.1, 1.15, 1.2] {
770 let i_m = c.evaluate(phi);
771 let phi_back = c.evaluate_inverse(i_m);
772 assert!(
773 (phi_back - phi).abs() < 1e-10,
774 "round-trip failed at phi={phi}: i_m={i_m}, phi_back={phi_back}"
775 );
776 }
777 }
778}