Skip to main content

surge_network/dynamics/
saturation.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Transformer core saturation, core loss, and core type models.
3//!
4//! Provides data structures for nonlinear harmonic power flow:
5//!
6//! - [`SaturationCurve`]: Piecewise-linear Phi-I_m characteristic
7//! - [`CoreLossModel`]: Frequency-dependent Steinmetz core loss decomposition
8//! - [`CoreType`]: Transformer core construction type (affects GIC K-factor)
9//! - [`TransformerSaturation`]: Unified saturation model (PWL, two-slope, polynomial)
10//! - [`ConverterCommutationModel`]: 6/12/18/24-pulse voltage-dependent overlap
11
12use std::fmt;
13use std::str::FromStr;
14
15use serde::{Deserialize, Serialize};
16
17// ---------------------------------------------------------------------------
18// CoreType (moved from surge-gic/src/types.rs)
19// ---------------------------------------------------------------------------
20
21/// Transformer core construction type.
22///
23/// Determines the K-factor for GIC reactive power absorption and affects
24/// harmonic saturation behaviour.
25///
26/// K-factor values from EPRI 3002002985 and Overbye et al. (2012).
27#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
28pub enum CoreType {
29    /// 3-limb core-form: most resistant to GIC (K = 0.33).
30    ThreeLimbCore,
31    /// 5-limb core-form: moderate vulnerability (K = 1.18).
32    #[default]
33    FiveLimbCore,
34    /// 5-limb shell-form: most vulnerable to GIC (K = 2.0).
35    FiveLimbShell,
36    /// Bank of single-phase core-form units (K = 1.18).
37    SinglePhaseBank,
38    /// Single-phase shell-form (K = 0.80).
39    SinglePhaseShell,
40    /// User-specified custom K-factor.
41    Custom(f64),
42}
43
44impl CoreType {
45    /// Return the K-factor for this core type (dimensionless).
46    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// ---------------------------------------------------------------------------
104// SaturationCurve
105// ---------------------------------------------------------------------------
106
107/// A single point on the Phi-I_m saturation curve.
108#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
109pub struct SaturationPoint {
110    /// Magnetizing current in pu of rated transformer current.
111    pub i_m_pu: f64,
112    /// Flux linkage in pu of rated flux.
113    pub phi_pu: f64,
114}
115
116/// Piecewise-linear transformer core saturation characteristic.
117///
118/// Represents the single-valued (anhysteretic) Phi-I_m curve of the transformer
119/// core material. Points must be monotonically increasing in both Phi and I_m.
120///
121/// Points are stored for Phi >= 0 only. [`evaluate()`](Self::evaluate) applies
122/// odd symmetry for negative flux: `f_BH(-Phi) = -f_BH(Phi)`.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct SaturationCurve {
125    /// Ordered (I_m, Phi) points. Sorted ascending by i_m_pu (and phi_pu).
126    /// Minimum 2 points required. First point should be (0, 0).
127    pub points: Vec<SaturationPoint>,
128}
129
130impl SaturationCurve {
131    /// Evaluate magnetizing current I_m at a given flux Phi using PWL interpolation.
132    ///
133    /// Applies odd symmetry: `f(-Phi) = -f(Phi)`. For Phi beyond the last point,
134    /// linear extrapolation from the last two points is used.
135    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    /// Evaluate for phi >= 0 (no symmetry applied).
143    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            // Single point: assume linear from origin
150            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        // Below first point: interpolate from origin (0,0) to first point
157        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        // Interior: find the segment
165        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        // Beyond last point: linear extrapolation from last two points
177        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    /// Inverse lookup: find Phi for a given I_m (for initialization).
187    ///
188    /// Applies odd symmetry for negative I_m.
189    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        // Extrapolate
227        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    /// Validate the saturation curve data.
237    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    /// Typical 500 MVA power transformer saturation curve.
269    ///
270    /// Knee point at ~1.15 pu flux, air-core slope above 1.25 pu.
271    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    /// Typical 25 MVA distribution transformer saturation curve.
315    ///
316    /// Lower knee point (~1.10 pu) and steeper saturation than power transformers.
317    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// ---------------------------------------------------------------------------
358// TwoSlopeSaturation
359// ---------------------------------------------------------------------------
360
361/// Two-slope saturation model (PSS/E convention).
362///
363/// Below the knee: linear with slope 1/b_mag_unsat (unsaturated magnetizing
364/// admittance). Above the knee: air-core reactance (much steeper I vs Phi).
365#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
366pub struct TwoSlopeSaturation {
367    /// Knee-point flux in pu (typically 1.1-1.2 pu).
368    pub phi_knee_pu: f64,
369    /// Unsaturated magnetizing susceptance (pu, = Branch.b_mag).
370    pub b_mag_unsat: f64,
371    /// Air-core reactance above knee (pu). Typical: 0.2-0.4 pu.
372    pub x_air_pu: f64,
373}
374
375impl TwoSlopeSaturation {
376    /// Convert to a piecewise-linear curve with 10 points.
377    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        // Unsaturated region: I_m = Phi * b_mag_unsat
385        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        // Saturated region: I_m = I_knee + (Phi - Phi_knee) / x_air
397        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; // 0.05, 0.10, 0.15, 0.20, 0.25 above knee
402            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// ---------------------------------------------------------------------------
415// PolynomialSaturation
416// ---------------------------------------------------------------------------
417
418/// Polynomial saturation model (EMTP/Dommel-type).
419///
420/// `I_m = a_1 * Phi + a_3 * Phi^3 + a_5 * Phi^5 + ...`
421/// (odd powers only for symmetric saturation)
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct PolynomialSaturation {
424    /// Coefficients [a_1, a_3, a_5, ...] for odd-power terms.
425    pub coefficients: Vec<f64>,
426}
427
428impl PolynomialSaturation {
429    /// Evaluate I_m from Phi using the polynomial.
430    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    /// Sample the polynomial to produce a piecewise-linear curve.
441    pub fn to_piecewise_linear(&self, n_points: usize) -> SaturationCurve {
442        let n = n_points.max(3);
443        let phi_max = 1.5; // sample up to 1.5 pu flux
444        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// ---------------------------------------------------------------------------
458// TransformerSaturation (unified enum)
459// ---------------------------------------------------------------------------
460
461/// Unified transformer saturation model supporting multiple representations.
462///
463/// All variants can be converted to the canonical [`SaturationCurve`] (PWL)
464/// for computation via [`as_pwl()`](Self::as_pwl).
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub enum TransformerSaturation {
467    /// Full piecewise-linear Phi-I_m curve.
468    PiecewiseLinear(SaturationCurve),
469    /// Two-slope model (knee point + air-core reactance).
470    TwoSlope(TwoSlopeSaturation),
471    /// Polynomial (Dommel-type) model.
472    Polynomial(PolynomialSaturation),
473}
474
475impl TransformerSaturation {
476    /// Convert any variant to the canonical PWL representation.
477    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// ---------------------------------------------------------------------------
487// CoreLossModel
488// ---------------------------------------------------------------------------
489
490/// Frequency-dependent core loss decomposition (Steinmetz model).
491///
492/// At harmonic order h, the core loss conductance is:
493///
494/// ```text
495/// g_core(h) = g_mag * (f_eddy * h^2 + f_hyst * h^1.6 + f_excess * h^1.5)
496/// ```
497///
498/// where `f_eddy + f_hyst + f_excess = 1.0` and `g_mag` is the
499/// fundamental-frequency core loss conductance from `Branch::g_mag`.
500///
501/// Default decomposition (IEEE C57.110): f_eddy = 0.5, f_hyst = 0.5.
502#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
503pub struct CoreLossModel {
504    /// Fraction of fundamental core loss due to eddy currents (scales as h^2).
505    pub f_eddy: f64,
506    /// Fraction due to hysteresis (scales as h^1.6).
507    pub f_hyst: f64,
508    /// Fraction due to excess/anomalous losses (scales as h^1.5).
509    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    /// Validate that fractions sum to 1.0 and are non-negative.
524    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    /// Compute the core loss conductance scaling factor at harmonic order h.
536    ///
537    /// Returns the multiplier to apply to `g_mag`:
538    /// `g_core(h) = g_mag * self.scale(h)`
539    ///
540    /// At h=1, returns 1.0 (by construction, since fractions sum to 1.0).
541    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// ---------------------------------------------------------------------------
547// ConverterCommutationModel
548// ---------------------------------------------------------------------------
549
550/// 6-pulse converter with voltage-dependent commutation overlap.
551///
552/// During commutation, two thyristors conduct simultaneously, shorting two
553/// phases through the commutation reactance. The overlap angle mu depends on
554/// the AC terminal voltage — lower voltage -> larger mu -> more harmonic
555/// distortion.
556///
557/// Reference: Arrillaga & Watson (2003), Chapter 4; Kimbark (1971).
558#[derive(Debug, Clone, Serialize, Deserialize)]
559pub struct ConverterCommutationModel {
560    /// Bus number where the converter is connected.
561    pub bus: u32,
562    /// Number of pulses (6, 12, 18, 24). Default: 6.
563    pub pulse_number: u32,
564    /// Firing angle alpha in degrees. Default: 15 (rectifier).
565    pub firing_angle_deg: f64,
566    /// Commutation reactance X_c in per-unit (on converter MVA base).
567    pub x_commutation_pu: f64,
568    /// DC load current in per-unit (on converter MVA base).
569    pub i_dc_pu: f64,
570    /// Converter transformer turns ratio (AC:DC side).
571    pub transformer_ratio: f64,
572    /// Rated power (MVA) for per-unit base conversion to system base.
573    pub rated_mva: f64,
574}
575
576// ---------------------------------------------------------------------------
577// Tests
578// ---------------------------------------------------------------------------
579
580#[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        // At phi = 1.05 (between 1.0 and 1.1), should interpolate
592        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        // Check it's between the two bounding values
596        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        // Beyond last point (phi=1.30), should extrapolate linearly
605        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        // Check the slope matches the last segment
609        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        // At the knee, i_m should be phi_knee * b_mag
642        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        // Above knee, slope should be steeper (1/x_air)
649        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], // I = Phi + 0.5*Phi^3
657        };
658        let pwl = poly.to_piecewise_linear(20);
659        assert!(pwl.points.len() == 20);
660        // Spot-check at phi=1.0: I = 1.0 + 0.5 = 1.5
661        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                }, // non-monotonic i_m
681            ],
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(); // 50/50 eddy/hysteresis
710        let s = m.scale(5.0);
711        // 0.5 * 25 + 0.5 * 5^1.6
712        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        // Round-trip: evaluate at some phi, then inverse should give back phi
769        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}