1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ShaftSegment {
13 pub name: String,
15 pub h_pu: f64,
17 pub d_self_pu: f64,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ShaftCoupling {
27 pub k_pu: f64,
29 pub d_mutual_pu: f64,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub enum SegmentTorqueSource {
38 Electrical,
41
42 GovernorStage(usize),
45
46 Fraction(f64),
50
51 None,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ShaftModel {
58 pub segments: Vec<ShaftSegment>,
60 pub couplings: Vec<ShaftCoupling>,
62 pub torque_sources: Vec<SegmentTorqueSource>,
65}
66
67impl ShaftModel {
68 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 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 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 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 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 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
151pub 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), SegmentTorqueSource::Fraction(0.26), SegmentTorqueSource::Fraction(0.22), SegmentTorqueSource::Fraction(0.22), SegmentTorqueSource::Electrical, SegmentTorqueSource::None, ];
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![], 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}