Skip to main content

surge_network/dynamics/
shaft.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Multi-mass torsional shaft model for time-domain SSR simulation.
3//!
4//! These types define the N-mass shaft topology (segments, couplings, torque
5//! sources) used by `surge-dyn` for torsional time-domain integration and by
6//! `surge-ssr` for eigenvalue-based frequency screening.
7
8use serde::{Deserialize, Serialize};
9
10/// A single mass segment of a turbine-generator shaft.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ShaftSegment {
13    /// Segment label (e.g., "HP", "IP", "LPA", "LPB", "GEN", "EXC").
14    pub name: String,
15    /// Inertia constant H (seconds, pu on machine MVA base).
16    pub h_pu: f64,
17    /// Self-damping coefficient D_self (pu torque per pu speed deviation).
18    /// Represents bearing friction, windage losses for this segment.
19    pub d_self_pu: f64,
20}
21
22/// Torsional spring + viscous coupling between two adjacent shaft segments.
23///
24/// `couplings[i]` connects `segments[i]` to `segments[i+1]`.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ShaftCoupling {
27    /// Spring stiffness K (pu torque / rad).
28    pub k_pu: f64,
29    /// Mutual (inter-segment) viscous damping D_mutual (pu torque per pu speed difference).
30    /// Represents oil film damping in the coupling between adjacent masses.
31    /// Set to 0.0 when unknown (standard for most SSR studies including IEEE FBM).
32    pub d_mutual_pu: f64,
33}
34
35/// Source of mechanical torque for a shaft segment.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub enum SegmentTorqueSource {
38    /// This segment receives electrical torque Te from the network solution.
39    /// Exactly one segment per shaft must be Electrical (the GEN segment).
40    Electrical,
41
42    /// This segment receives mechanical torque from a specific governor stage output.
43    /// For IEEEG1: stage 0 = K2·x_gate, 1 = (K1+K4)·x1, 2 = (K3+K6)·x2, 3 = (K5+K7+K8)·x3.
44    GovernorStage(usize),
45
46    /// This segment receives a fixed fraction of the governor's total Pm output.
47    /// Used for single-stage governors (TGOV1, GAST, HYGOV) that don't model
48    /// per-stage turbine dynamics internally.
49    Fraction(f64),
50
51    /// No torque input (e.g., exciter segment).
52    None,
53}
54
55/// N-mass torsional shaft model for time-domain SSR simulation.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ShaftModel {
58    /// Ordered HP → EXC along the shaft.
59    pub segments: Vec<ShaftSegment>,
60    /// Spring + viscous couplings; couplings\[i\] connects segments\[i\] to segments\[i+1\].
61    pub couplings: Vec<ShaftCoupling>,
62    /// Torque source for each segment. Length must equal segments.len().
63    /// Exactly one entry must be SegmentTorqueSource::Electrical (the GEN segment).
64    pub torque_sources: Vec<SegmentTorqueSource>,
65}
66
67impl ShaftModel {
68    /// Index of the GEN (electrical torque) segment.
69    ///
70    /// Panics if no segment has `SegmentTorqueSource::Electrical` — this is
71    /// prevented by `validate()`.
72    pub fn gen_segment_idx(&self) -> usize {
73        self.torque_sources
74            .iter()
75            .position(|s| matches!(s, SegmentTorqueSource::Electrical))
76            .expect("ShaftModel has no Electrical segment — call validate() first")
77    }
78
79    /// Validate the shaft model topology and torque source assignments.
80    pub fn validate(&self) -> Result<(), String> {
81        let n = self.segments.len();
82        if n == 0 {
83            return Err("shaft must have at least one segment".into());
84        }
85        if self.couplings.len() != n - 1 {
86            return Err(format!(
87                "expected {} couplings for {} segments, got {}",
88                n - 1,
89                n,
90                self.couplings.len()
91            ));
92        }
93        if self.torque_sources.len() != n {
94            return Err(format!(
95                "torque_sources length {} != segments length {}",
96                self.torque_sources.len(),
97                n
98            ));
99        }
100
101        // Exactly one Electrical segment
102        let n_elec = self
103            .torque_sources
104            .iter()
105            .filter(|s| matches!(s, SegmentTorqueSource::Electrical))
106            .count();
107        if n_elec != 1 {
108            return Err(format!(
109                "exactly one Electrical torque source required, found {}",
110                n_elec
111            ));
112        }
113
114        // All H > 0
115        for (i, seg) in self.segments.iter().enumerate() {
116            if seg.h_pu <= 0.0 {
117                return Err(format!(
118                    "segment {} ({}) has H={} <= 0",
119                    i, seg.name, seg.h_pu
120                ));
121            }
122        }
123
124        // All K > 0
125        for (i, c) in self.couplings.iter().enumerate() {
126            if c.k_pu <= 0.0 {
127                return Err(format!("coupling {} has K={} <= 0", i, c.k_pu));
128            }
129        }
130
131        // Sum of Fraction values for non-Electrical segments = 1.0
132        let mut frac_sum = 0.0_f64;
133        let mut has_fractions = false;
134        for ts in &self.torque_sources {
135            if let SegmentTorqueSource::Fraction(f) = ts {
136                frac_sum += f;
137                has_fractions = true;
138            }
139        }
140        if has_fractions && (frac_sum - 1.0).abs() > 1e-6 {
141            return Err(format!(
142                "torque fractions sum to {}, expected 1.0",
143                frac_sum
144            ));
145        }
146
147        Ok(())
148    }
149}
150
151/// Build the IEEE First Benchmark Model shaft.
152///
153/// 6-mass shaft (HP/IP/LPA/LPB/GEN/EXC) with published inertias, stiffnesses,
154/// and torque fractions. Zero damping (standard for FBM validation).
155///
156/// Reference: IEEE Trans. PAS-96, No.5, Sep/Oct 1977.
157/// Data matches `surge-ssr/src/ieee_fbm.rs` canonical values.
158pub fn ieee_fbm_shaft_model() -> ShaftModel {
159    let names = ["HP", "IP", "LPA", "LPB", "GEN", "EXC"];
160    let h_vals = [0.092595, 0.155589, 0.858670, 0.884215, 0.868495, 0.034216];
161    let k_vals = [19.652, 34.929, 52.038, 70.858, 2.822];
162
163    let segments = names
164        .iter()
165        .zip(h_vals.iter())
166        .map(|(&name, &h)| ShaftSegment {
167            name: name.to_string(),
168            h_pu: h,
169            d_self_pu: 0.0,
170        })
171        .collect();
172
173    let couplings = k_vals
174        .iter()
175        .map(|&k| ShaftCoupling {
176            k_pu: k,
177            d_mutual_pu: 0.0,
178        })
179        .collect();
180
181    let torque_sources = vec![
182        SegmentTorqueSource::Fraction(0.30), // HP
183        SegmentTorqueSource::Fraction(0.26), // IP
184        SegmentTorqueSource::Fraction(0.22), // LPA
185        SegmentTorqueSource::Fraction(0.22), // LPB
186        SegmentTorqueSource::Electrical,     // GEN
187        SegmentTorqueSource::None,           // EXC
188    ];
189
190    ShaftModel {
191        segments,
192        couplings,
193        torque_sources,
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_ieee_fbm_validates() {
203        let model = ieee_fbm_shaft_model();
204        model.validate().unwrap();
205        assert_eq!(model.gen_segment_idx(), 4);
206    }
207
208    #[test]
209    fn test_validate_no_electrical() {
210        let model = ShaftModel {
211            segments: vec![
212                ShaftSegment {
213                    name: "A".into(),
214                    h_pu: 1.0,
215                    d_self_pu: 0.0,
216                },
217                ShaftSegment {
218                    name: "B".into(),
219                    h_pu: 1.0,
220                    d_self_pu: 0.0,
221                },
222            ],
223            couplings: vec![ShaftCoupling {
224                k_pu: 10.0,
225                d_mutual_pu: 0.0,
226            }],
227            torque_sources: vec![
228                SegmentTorqueSource::Fraction(1.0),
229                SegmentTorqueSource::None,
230            ],
231        };
232        assert!(model.validate().unwrap_err().contains("Electrical"));
233    }
234
235    #[test]
236    fn test_validate_bad_fraction_sum() {
237        let model = ShaftModel {
238            segments: vec![
239                ShaftSegment {
240                    name: "HP".into(),
241                    h_pu: 1.0,
242                    d_self_pu: 0.0,
243                },
244                ShaftSegment {
245                    name: "GEN".into(),
246                    h_pu: 1.0,
247                    d_self_pu: 0.0,
248                },
249            ],
250            couplings: vec![ShaftCoupling {
251                k_pu: 10.0,
252                d_mutual_pu: 0.0,
253            }],
254            torque_sources: vec![
255                SegmentTorqueSource::Fraction(0.5),
256                SegmentTorqueSource::Electrical,
257            ],
258        };
259        assert!(model.validate().unwrap_err().contains("fractions sum"));
260    }
261
262    #[test]
263    fn test_validate_coupling_count() {
264        let model = ShaftModel {
265            segments: vec![
266                ShaftSegment {
267                    name: "A".into(),
268                    h_pu: 1.0,
269                    d_self_pu: 0.0,
270                },
271                ShaftSegment {
272                    name: "B".into(),
273                    h_pu: 1.0,
274                    d_self_pu: 0.0,
275                },
276            ],
277            couplings: vec![], // should be 1
278            torque_sources: vec![
279                SegmentTorqueSource::Fraction(1.0),
280                SegmentTorqueSource::Electrical,
281            ],
282        };
283        assert!(model.validate().unwrap_err().contains("couplings"));
284    }
285
286    #[test]
287    fn test_validate_zero_inertia() {
288        let model = ShaftModel {
289            segments: vec![
290                ShaftSegment {
291                    name: "A".into(),
292                    h_pu: 0.0,
293                    d_self_pu: 0.0,
294                },
295                ShaftSegment {
296                    name: "B".into(),
297                    h_pu: 1.0,
298                    d_self_pu: 0.0,
299                },
300            ],
301            couplings: vec![ShaftCoupling {
302                k_pu: 10.0,
303                d_mutual_pu: 0.0,
304            }],
305            torque_sources: vec![
306                SegmentTorqueSource::Fraction(1.0),
307                SegmentTorqueSource::Electrical,
308            ],
309        };
310        assert!(model.validate().unwrap_err().contains("H=0"));
311    }
312}