Skip to main content

surge_io/cgmes/
dynamics.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! CGMES DY (Dynamics) profile parser.
3//!
4//! Parses CGMES DY XML files and produces a [`DynamicModel`] that the surge
5//! dynamics engine can use directly.
6//!
7//! ## CGMES DY reference chain
8//!
9//! A dynamics object (e.g. `ExcIEEEST1A`) links to a generator via:
10//!
11//! ```text
12//! ExcIEEEST1A.SynchronousMachineDynamics  →  SynchronousMachineDynamics
13//! SynchronousMachineDynamics.SynchronousMachine  →  SynchronousMachine MRID
14//! ```
15//!
16//! Some files use a shorter direct reference:
17//! ```text
18//! ExcIEEEST1A.SynchronousMachine  →  SynchronousMachine MRID
19//! ```
20//!
21//! Some PSS objects use an indirect 3-hop reference via the exciter's SMD:
22//! ```text
23//! PssIEEE2B.ExcitationSystemDynamics  →  SynchronousMachineDynamics MRID
24//! SynchronousMachineDynamics.SynchronousMachine  →  SynchronousMachine MRID
25//! ```
26//!
27//! The [`parse_cgmes_dy`] function accepts a pre-built `sm_bus_map` that maps
28//! SynchronousMachine MRID → `(bus_number, machine_id)`.  Build this map using
29//! `build_sm_bus_map` or by parsing the EQ/SSH profiles first.
30
31use std::collections::HashMap;
32
33use surge_network::dynamics::{
34    DynamicModel, Esdc1aParams, Esst1aParams, ExciterDyn, ExciterModel, GastParams, GenclsParams,
35    GeneratorDyn, GeneratorModel, GenrouParams, GensalParams, GovernorDyn, GovernorModel,
36    HygovParams, Oel1bParams, OelDyn, OelModel, Pss1aParams, Pss2bParams, PssDyn, PssModel,
37    ScrxParams, SexsParams, Tgov1Params, Uel1Params, UelDyn, UelModel,
38};
39use thiserror::Error;
40
41use super::{CgmesError, CimObj, ObjMap, collect_objects};
42
43// ---------------------------------------------------------------------------
44// Error type
45// ---------------------------------------------------------------------------
46
47/// Errors returned by the CGMES DY profile parser.
48#[derive(Error, Debug)]
49pub enum CgmesDyError {
50    /// A required parameter was not present in the CIM object.
51    #[error("missing required parameter '{0}' on {1}")]
52    MissingParam(String, String),
53    /// Underlying CGMES XML parse error.
54    #[error("CGMES parse error: {0}")]
55    Cgmes(#[from] CgmesError),
56    /// No SynchronousMachine could be resolved for this dynamics object.
57    #[error("could not resolve SynchronousMachine for dynamics object '{0}'")]
58    UnresolvedMachine(String),
59}
60
61// ---------------------------------------------------------------------------
62// Public entry point
63// ---------------------------------------------------------------------------
64
65/// Parse one or more CGMES DY (Dynamics) profile XML strings and produce a
66/// [`DynamicModel`].
67///
68/// # Arguments
69/// * `dy_xml` — slice of DY XML strings (each a complete RDF/XML document).
70/// * `sm_bus_map` — mapping from SynchronousMachine mRID → `(bus_number, machine_id)`.
71///   Build this with `build_sm_bus_map` after parsing the EQ/SSH profiles.
72///
73/// # Behaviour for unknown models
74/// Unknown CGMES class names are silently logged with [`tracing::warn!`] and
75/// skipped.  Missing required parameters return [`CgmesDyError::MissingParam`].
76pub fn parse_cgmes_dy(
77    dy_xml: &[&str],
78    sm_bus_map: &HashMap<String, (u32, String)>,
79) -> Result<DynamicModel, CgmesDyError> {
80    // Stage 1: collect all DY objects into a unified map.
81    let mut objects: ObjMap = ObjMap::new();
82    for xml in dy_xml {
83        collect_objects(xml, &mut objects)?;
84    }
85
86    // Stage 2: build a lookup from SynchronousMachineDynamics mRID → SM mRID.
87    // CGMES typically links:  ExcXxx.SynchronousMachineDynamics → SmdXxx
88    //                         SmdXxx.SynchronousMachine → SM mRID
89    let smd_to_sm: HashMap<String, String> = objects
90        .iter()
91        .filter(|(_, o)| {
92            // Any of the concrete SM dynamics classes
93            matches!(
94                o.class.as_str(),
95                "SynchronousMachineTimeConstantReactance"
96                    | "SynchronousMachineSimplified"
97                    | "SynchronousMachineEquivalentCircuit"
98                    | "SynchronousMachineDetailedFDX"
99                    | "SynchronousMachineDetailed"
100            )
101        })
102        .filter_map(|(id, o)| {
103            let sm_ref = o.get_ref("SynchronousMachine")?;
104            Some((id.clone(), sm_ref.to_string()))
105        })
106        .collect();
107
108    // Stage 3: for every dynamics object, resolve the SM mRID and emit a record.
109    let mut dm = DynamicModel::default();
110
111    for (obj_id, obj) in &objects {
112        let cls = obj.class.as_str();
113
114        // Dispatch by class name
115        match cls {
116            // ----------------------------------------------------------------
117            // Generator models
118            // ----------------------------------------------------------------
119            "SynchronousMachineTimeConstantReactance" => {
120                // This IS the SMDynamics object — it references the SM directly.
121                let sm_mrid_direct = obj.get_ref("SynchronousMachine").map(|s| s.to_string());
122                let effective_sm = sm_mrid_direct.as_deref().unwrap_or("");
123
124                let (bus, machine_id) = match sm_bus_map.get(effective_sm) {
125                    Some(pair) => pair.clone(),
126                    None => {
127                        tracing::warn!(
128                            obj_id,
129                            sm_mrid = effective_sm,
130                            "SynchronousMachineTimeConstantReactance: cannot resolve SM to bus — skipping"
131                        );
132                        continue;
133                    }
134                };
135
136                let rotor_type = obj.get_text("rotorType").unwrap_or("roundRotor");
137                let is_salient = rotor_type.contains("salientPole")
138                    || rotor_type.ends_with("salient")
139                    || rotor_type.contains("Salient");
140
141                let h = require_f64(obj, "inertia", obj_id)?;
142                let d = obj.parse_f64("damping").unwrap_or(0.0);
143                let xd = require_f64(obj, "xDirectSync", obj_id)?;
144                let xq = require_f64(obj, "xQuadSync", obj_id)?;
145                let xd_prime = require_f64(obj, "xDirectTrans", obj_id)?;
146                let xd_pprime = require_f64(obj, "xDirectSubtrans", obj_id)?;
147                let xl = obj.parse_f64("statorLeakageReactance").unwrap_or(0.0);
148                let td0_prime = require_f64(obj, "tpdo", obj_id)?;
149                let td0_pprime = require_f64(obj, "tppdo", obj_id)?;
150                let tq0_pprime = require_f64(obj, "tppqo", obj_id)?;
151                let s1 = obj.parse_f64("saturationFactor").unwrap_or(0.0);
152                let s12 = obj.parse_f64("saturationFactor120").unwrap_or(0.0);
153
154                if is_salient {
155                    // GENSAL: no tq0_prime, no xq_prime
156                    let xtran = obj.parse_f64("xQuadTrans").unwrap_or(xd_prime);
157                    dm.generators.push(GeneratorDyn {
158                        bus,
159                        machine_id,
160                        model: GeneratorModel::Gensal(GensalParams {
161                            td0_prime,
162                            td0_pprime,
163                            tq0_pprime,
164                            h,
165                            d,
166                            xd,
167                            xq,
168                            xd_prime,
169                            xd_pprime,
170                            xl,
171                            s1,
172                            s12,
173                            xtran,
174                        }),
175                    });
176                } else {
177                    // GENROU: round rotor — needs tq0_prime and xq_prime
178                    let tq0_prime = obj.parse_f64("tpqo").unwrap_or(0.4);
179                    let xq_prime = obj.parse_f64("xQuadTrans").unwrap_or(xq * 0.6);
180                    dm.generators.push(GeneratorDyn {
181                        bus,
182                        machine_id,
183                        model: GeneratorModel::Genrou(GenrouParams {
184                            td0_prime,
185                            td0_pprime,
186                            tq0_prime,
187                            tq0_pprime,
188                            h,
189                            d,
190                            xd,
191                            xq,
192                            xd_prime,
193                            xq_prime,
194                            xd_pprime,
195                            xl,
196                            s1,
197                            s12,
198                            ra: obj.parse_f64("statorResistance"),
199                        }),
200                    });
201                }
202            }
203
204            "SynchronousMachineSimplified" => {
205                let sm_mrid_direct = obj.get_ref("SynchronousMachine").map(|s| s.to_string());
206                let effective_sm = sm_mrid_direct.as_deref().unwrap_or("");
207
208                let (bus, machine_id) = match sm_bus_map.get(effective_sm) {
209                    Some(pair) => pair.clone(),
210                    None => {
211                        tracing::warn!(
212                            obj_id,
213                            sm_mrid = effective_sm,
214                            "SynchronousMachineSimplified: cannot resolve SM to bus — skipping"
215                        );
216                        continue;
217                    }
218                };
219
220                let h = require_f64(obj, "inertia", obj_id)?;
221                let d = obj.parse_f64("damping").unwrap_or(0.0);
222                dm.generators.push(GeneratorDyn {
223                    bus,
224                    machine_id,
225                    model: GeneratorModel::Gencls(GenclsParams { h, d }),
226                });
227            }
228
229            // ----------------------------------------------------------------
230            // Exciter models
231            // ----------------------------------------------------------------
232            "ExcIEEEST1A" => {
233                let (bus, machine_id) = match resolve_sm(obj, &smd_to_sm, sm_bus_map, obj_id) {
234                    Some(pair) => pair,
235                    None => continue,
236                };
237                let tr = obj.parse_f64("tr").unwrap_or(0.0);
238                let vimax = obj.parse_f64("vimax").unwrap_or(999.0);
239                let vimin = obj.parse_f64("vimin").unwrap_or(-999.0);
240                let tc = require_f64(obj, "tc", obj_id)?;
241                let tb = require_f64(obj, "tb", obj_id)?;
242                let tc1 = obj.parse_f64("tc1").unwrap_or(0.0);
243                let tb1 = obj.parse_f64("tb1").unwrap_or(0.0);
244                let ka = require_f64(obj, "ka", obj_id)?;
245                let ta = obj.parse_f64("ta").unwrap_or(0.0);
246                let vamax = obj.parse_f64("vamax").unwrap_or(14.5);
247                let vamin = obj.parse_f64("vamin").unwrap_or(-14.5);
248                let vrmax = require_f64(obj, "vrmax", obj_id)?;
249                let vrmin = require_f64(obj, "vrmin", obj_id)?;
250                let kc = obj.parse_f64("kc").unwrap_or(0.0);
251                let kf = obj.parse_f64("kf").unwrap_or(0.0);
252                let tf = obj.parse_f64("tf").unwrap_or(1.0);
253                let klr = obj.parse_f64("klr").unwrap_or(0.0);
254                let ilr = obj.parse_f64("ilr").unwrap_or(0.0);
255                dm.exciters.push(ExciterDyn {
256                    bus,
257                    machine_id,
258                    model: ExciterModel::Esst1a(Esst1aParams {
259                        tr,
260                        vimax,
261                        vimin,
262                        tc,
263                        tb,
264                        tc1,
265                        tb1,
266                        ka,
267                        ta,
268                        vamax,
269                        vamin,
270                        vrmax,
271                        vrmin,
272                        kc,
273                        kf,
274                        tf,
275                        klr,
276                        ilr,
277                    }),
278                });
279            }
280
281            "ExcDC1A" | "ExcIEEEDC1A" => {
282                let (bus, machine_id) = match resolve_sm(obj, &smd_to_sm, sm_bus_map, obj_id) {
283                    Some(pair) => pair,
284                    None => continue,
285                };
286                let tr = obj.parse_f64("tr").unwrap_or(0.0);
287                let ka = require_f64(obj, "ka", obj_id)?;
288                let ta = require_f64(obj, "ta", obj_id)?;
289                let vrmax = require_f64(obj, "vrmax", obj_id)?;
290                let vrmin = require_f64(obj, "vrmin", obj_id)?;
291                let ke = obj.parse_f64("ke").unwrap_or(1.0);
292                let te = require_f64(obj, "te", obj_id)?;
293                let kf = require_f64(obj, "kf", obj_id)?;
294                let tf = obj
295                    .parse_f64("tf1")
296                    .or_else(|| obj.parse_f64("tf"))
297                    .unwrap_or(1.0);
298                let e1 = obj.parse_f64("e1").unwrap_or(0.0);
299                let se1 = obj.parse_f64("se1").unwrap_or(0.0);
300                let e2 = obj.parse_f64("e2").unwrap_or(0.0);
301                let se2 = obj.parse_f64("se2").unwrap_or(0.0);
302                dm.exciters.push(ExciterDyn {
303                    bus,
304                    machine_id,
305                    model: ExciterModel::Esdc1a(Esdc1aParams {
306                        tr,
307                        ka,
308                        ta,
309                        kf,
310                        tf,
311                        ke,
312                        te,
313                        e1,
314                        se1,
315                        e2,
316                        se2,
317                        vrmax,
318                        vrmin,
319                    }),
320                });
321            }
322
323            "ExcSEXS" => {
324                let (bus, machine_id) = match resolve_sm(obj, &smd_to_sm, sm_bus_map, obj_id) {
325                    Some(pair) => pair,
326                    None => continue,
327                };
328                let tb = require_f64(obj, "tb", obj_id)?;
329                let tc = require_f64(obj, "tc", obj_id)?;
330                let k = require_f64(obj, "k", obj_id)?;
331                let te = require_f64(obj, "te", obj_id)?;
332                let emin = require_f64(obj, "emin", obj_id)?;
333                let emax = require_f64(obj, "emax", obj_id)?;
334                dm.exciters.push(ExciterDyn {
335                    bus,
336                    machine_id,
337                    model: ExciterModel::Sexs(SexsParams {
338                        tb,
339                        tc,
340                        k,
341                        te,
342                        emin,
343                        emax,
344                    }),
345                });
346            }
347
348            "ExcSCRX" => {
349                let (bus, machine_id) = match resolve_sm(obj, &smd_to_sm, sm_bus_map, obj_id) {
350                    Some(pair) => pair,
351                    None => continue,
352                };
353                let tr = obj.parse_f64("tr").unwrap_or(0.0);
354                let k = require_f64(obj, "k", obj_id)?;
355                let te = require_f64(obj, "te", obj_id)?;
356                let emin = require_f64(obj, "emin", obj_id)?;
357                let emax = require_f64(obj, "emax", obj_id)?;
358                let rcrfd = obj.parse_f64("rcrfd");
359                dm.exciters.push(ExciterDyn {
360                    bus,
361                    machine_id,
362                    model: ExciterModel::Scrx(ScrxParams {
363                        tr,
364                        k,
365                        te,
366                        emin,
367                        emax,
368                        rcrfd,
369                    }),
370                });
371            }
372
373            // ----------------------------------------------------------------
374            // Governor models
375            // ----------------------------------------------------------------
376            "GovGAST" => {
377                let (bus, machine_id) = match resolve_sm(obj, &smd_to_sm, sm_bus_map, obj_id) {
378                    Some(pair) => pair,
379                    None => continue,
380                };
381                let r = require_f64(obj, "r", obj_id)?;
382                let t1 = require_f64(obj, "t1", obj_id)?;
383                let t2 = require_f64(obj, "t2", obj_id)?;
384                let t3 = require_f64(obj, "t3", obj_id)?;
385                let at = obj.parse_f64("at").unwrap_or(1.0);
386                let kt = obj.parse_f64("kt").unwrap_or(2.0);
387                let vmin = obj.parse_f64("vmin").unwrap_or(0.0);
388                let vmax = obj.parse_f64("vmax").unwrap_or(1.0);
389                dm.governors.push(GovernorDyn {
390                    bus,
391                    machine_id,
392                    model: GovernorModel::Gast(GastParams {
393                        r,
394                        t1,
395                        t2,
396                        t3,
397                        at,
398                        kt,
399                        vmin,
400                        vmax,
401                    }),
402                });
403            }
404
405            "GovSteamIEEE1" | "GovSteam1" => {
406                let (bus, machine_id) = match resolve_sm(obj, &smd_to_sm, sm_bus_map, obj_id) {
407                    Some(pair) => pair,
408                    None => continue,
409                };
410                let r = require_f64(obj, "r", obj_id)?;
411                let t1 = require_f64(obj, "t1", obj_id)?;
412                let vmax = require_f64(obj, "vmax", obj_id)?;
413                let vmin = require_f64(obj, "vmin", obj_id)?;
414                let t2 = obj.parse_f64("t2").unwrap_or(0.0);
415                let t3 = require_f64(obj, "t3", obj_id)?;
416                let dt = obj.parse_f64("dt");
417                dm.governors.push(GovernorDyn {
418                    bus,
419                    machine_id,
420                    model: GovernorModel::Tgov1(Tgov1Params {
421                        r,
422                        t1,
423                        vmax,
424                        vmin,
425                        t2,
426                        t3,
427                        dt,
428                    }),
429                });
430            }
431
432            "GovHydro1" => {
433                let (bus, machine_id) = match resolve_sm(obj, &smd_to_sm, sm_bus_map, obj_id) {
434                    Some(pair) => pair,
435                    None => continue,
436                };
437                let r = require_f64(obj, "r", obj_id)?;
438                let tp = require_f64(obj, "tp", obj_id)?;
439                let velm = obj.parse_f64("velm").unwrap_or(0.2);
440                let tg = require_f64(obj, "tg", obj_id)?;
441                let gmax = obj.parse_f64("gmax").unwrap_or(1.0);
442                let gmin = obj.parse_f64("gmin").unwrap_or(0.0);
443                let tw = require_f64(obj, "tw", obj_id)?;
444                let at = obj.parse_f64("at").unwrap_or(1.2);
445                let dturb = obj.parse_f64("dturb").unwrap_or(0.5);
446                let qnl = obj.parse_f64("qnl").unwrap_or(0.08);
447                dm.governors.push(GovernorDyn {
448                    bus,
449                    machine_id,
450                    model: GovernorModel::Hygov(HygovParams {
451                        r,
452                        tp,
453                        velm,
454                        tg,
455                        gmax,
456                        gmin,
457                        tw,
458                        at,
459                        dturb,
460                        qnl,
461                    }),
462                });
463            }
464
465            // ----------------------------------------------------------------
466            // PSS models
467            // ----------------------------------------------------------------
468            "PssIEEE1A" => {
469                let (bus, machine_id) = match resolve_sm(obj, &smd_to_sm, sm_bus_map, obj_id) {
470                    Some(pair) => pair,
471                    None => continue,
472                };
473                let ks = require_f64(obj, "ks", obj_id)?;
474                let t1 = require_f64(obj, "t1", obj_id)?;
475                let t2 = require_f64(obj, "t2", obj_id)?;
476                let t3 = require_f64(obj, "t3", obj_id)?;
477                let t4 = require_f64(obj, "t4", obj_id)?;
478                let vstmax = require_f64(obj, "vstmax", obj_id)?;
479                let vstmin = require_f64(obj, "vstmin", obj_id)?;
480                dm.pss.push(PssDyn {
481                    bus,
482                    machine_id,
483                    model: PssModel::Pss1a(Pss1aParams {
484                        ks,
485                        t1,
486                        t2,
487                        t3,
488                        t4,
489                        vstmax,
490                        vstmin,
491                    }),
492                });
493            }
494
495            "PssIEEE2B" | "Pss2B" => {
496                let (bus, machine_id) = match resolve_sm(obj, &smd_to_sm, sm_bus_map, obj_id) {
497                    Some(pair) => pair,
498                    None => continue,
499                };
500                let m1 = obj.parse_f64("m").unwrap_or(5.0);
501                let t6 = obj.parse_f64("t6").unwrap_or(0.0);
502                let t7 = require_f64(obj, "t7", obj_id)?;
503                let ks2 = obj.parse_f64("ks2").unwrap_or(0.99);
504                let t8 = require_f64(obj, "t8", obj_id)?;
505                let t9 = require_f64(obj, "t9", obj_id)?;
506                let m2 = obj.parse_f64("n").unwrap_or(1.0);
507                let tw1 = require_f64(obj, "tw1", obj_id)?;
508                let tw2 = require_f64(obj, "tw2", obj_id)?;
509                let tw3 = require_f64(obj, "tw3", obj_id)?;
510                let tw4 = obj.parse_f64("tw4").unwrap_or(0.0);
511                let t1 = require_f64(obj, "t1", obj_id)?;
512                let t2 = require_f64(obj, "t2", obj_id)?;
513                let t3 = require_f64(obj, "t3", obj_id)?;
514                let t4 = require_f64(obj, "t4", obj_id)?;
515                let ks1 = require_f64(obj, "ks1", obj_id)?;
516                let ks3 = obj.parse_f64("ks3").unwrap_or(1.0);
517                let vstmax = require_f64(obj, "vstmax", obj_id)?;
518                let vstmin = require_f64(obj, "vstmin", obj_id)?;
519                let t10 = obj.parse_f64("t10").unwrap_or(0.0);
520                let t11 = obj.parse_f64("t11").unwrap_or(0.0);
521                dm.pss.push(PssDyn {
522                    bus,
523                    machine_id,
524                    model: PssModel::Pss2b(Pss2bParams {
525                        m1,
526                        t6,
527                        t7,
528                        ks2,
529                        t8,
530                        t9,
531                        m2,
532                        tw1,
533                        tw2,
534                        tw3,
535                        tw4,
536                        t1,
537                        t2,
538                        t3,
539                        t4,
540                        ks1,
541                        ks3,
542                        vstmax,
543                        vstmin,
544                        t10,
545                        t11,
546                    }),
547                });
548            }
549
550            // ----------------------------------------------------------------
551            // OEL / UEL models
552            // ----------------------------------------------------------------
553            "OverexcLimIEEE" | "OverexcLimX1" | "OverexcLimX2" => {
554                let (bus, machine_id) = match resolve_sm(obj, &smd_to_sm, sm_bus_map, obj_id) {
555                    Some(pair) => pair,
556                    None => continue,
557                };
558                let ifdmax = require_f64(obj, "ifdmax", obj_id)?;
559                let ifdlim = obj.parse_f64("ifdlim").unwrap_or(ifdmax * 1.05);
560                let vrmax = obj.parse_f64("vrmax").unwrap_or(5.0);
561                let vamin = obj.parse_f64("vamin").unwrap_or(-5.0);
562                let kramp = obj.parse_f64("kramp").unwrap_or(10.0);
563                let tff = obj.parse_f64("tff").unwrap_or(0.05);
564                dm.oels.push(OelDyn {
565                    bus,
566                    machine_id,
567                    model: OelModel::Oel1b(Oel1bParams {
568                        ifdmax,
569                        ifdlim,
570                        vrmax,
571                        vamin,
572                        kramp,
573                        tff,
574                    }),
575                });
576            }
577
578            "UnderexcLimIEEE1" | "UnderexcLim2Simplified" => {
579                let (bus, machine_id) = match resolve_sm(obj, &smd_to_sm, sm_bus_map, obj_id) {
580                    Some(pair) => pair,
581                    None => continue,
582                };
583                let kul = require_f64(obj, "kul", obj_id)?;
584                let tu1 = obj.parse_f64("tu1").unwrap_or(0.0);
585                let vucmax = obj.parse_f64("vucmax").unwrap_or(5.0);
586                let vucmin = obj.parse_f64("vucmin").unwrap_or(-5.0);
587                let kur = obj.parse_f64("kur").unwrap_or(0.0);
588                dm.uels.push(UelDyn {
589                    bus,
590                    machine_id,
591                    model: UelModel::Uel1(Uel1Params {
592                        kul,
593                        tu1,
594                        vucmax,
595                        vucmin,
596                        kur,
597                    }),
598                });
599            }
600
601            // ----------------------------------------------------------------
602            // Known-irrelevant classes in DY profile — skip silently
603            // ----------------------------------------------------------------
604            "SynchronousMachineDynamics"
605            | "SynchronousMachineEquivalentCircuit"
606            | "SynchronousMachineDetailedFDX"
607            | "SynchronousMachineDetailed"
608            | "FullModel"
609            | "Model"
610            | "Analog"
611            | "Control"
612            | "Terminal"
613            | "TopologicalNode" => {
614                // Skip — these are structural/reference objects in the DY profile.
615            }
616
617            // ----------------------------------------------------------------
618            // Unknown class — warn and continue
619            // ----------------------------------------------------------------
620            _ => {
621                tracing::warn!(
622                    class = cls,
623                    obj_id,
624                    "CGMES DY: unrecognised dynamics class — skipping"
625                );
626            }
627        }
628    }
629
630    Ok(dm)
631}
632
633// ---------------------------------------------------------------------------
634// Helpers
635// ---------------------------------------------------------------------------
636
637/// Require a numeric parameter from a CIM object, returning `MissingParam` on failure.
638fn require_f64(obj: &CimObj, key: &str, obj_id: &str) -> Result<f64, CgmesDyError> {
639    obj.parse_f64(key).ok_or_else(|| {
640        CgmesDyError::MissingParam(key.to_string(), format!("{}({})", obj.class, obj_id))
641    })
642}
643
644/// Resolve a dynamics object's SM mRID and look it up in `sm_bus_map`.
645///
646/// Tries three reference chains:
647/// 1. `obj.SynchronousMachineDynamics` → `smd_to_sm` lookup
648/// 2. `obj.SynchronousMachine` directly
649/// 3. `obj.ExcitationSystemDynamics` → `smd_to_sm` lookup (PSS 3-hop linkage)
650///
651/// Returns `None` (after logging a warning) when no SM can be resolved or
652/// when the SM mRID is not in `sm_bus_map`.
653fn resolve_sm(
654    obj: &CimObj,
655    smd_to_sm: &HashMap<String, String>,
656    sm_bus_map: &HashMap<String, (u32, String)>,
657    obj_id: &str,
658) -> Option<(u32, String)> {
659    let sm_mrid: Option<String> = obj
660        .get_ref("SynchronousMachineDynamics")
661        .and_then(|smd_id| smd_to_sm.get(smd_id))
662        .map(|s| s.to_string())
663        .or_else(|| obj.get_ref("SynchronousMachine").map(|s| s.to_string()))
664        // Path 3: PSS models reference ExcitationSystemDynamics which points
665        // to the SMD object (not an exciter). Same smd_to_sm lookup.
666        .or_else(|| {
667            obj.get_ref("ExcitationSystemDynamics")
668                .and_then(|smd_id| smd_to_sm.get(smd_id))
669                .map(|s| s.to_string())
670        });
671
672    let sm_mrid = match sm_mrid {
673        Some(m) => m,
674        None => {
675            tracing::warn!(
676                class = obj.class.as_str(),
677                obj_id,
678                "CGMES DY: cannot resolve SynchronousMachine reference — skipping"
679            );
680            return None;
681        }
682    };
683
684    match sm_bus_map.get(&sm_mrid) {
685        Some(pair) => Some(pair.clone()),
686        None => {
687            tracing::warn!(
688                class = obj.class.as_str(),
689                obj_id,
690                sm_mrid,
691                "CGMES DY: SM mRID not found in bus map (EQ profile may not include this SM) — skipping"
692            );
693            None
694        }
695    }
696}
697
698// ---------------------------------------------------------------------------
699// Tests
700// ---------------------------------------------------------------------------
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    // Helper: build sm_bus_map with a single entry
707    fn single_sm_map(mrid: &str, bus: u32, id: &str) -> HashMap<String, (u32, String)> {
708        let mut m = HashMap::new();
709        m.insert(mrid.to_string(), (bus, id.to_string()));
710        m
711    }
712
713    // -----------------------------------------------------------------------
714    // Test 1: round-rotor generator (GENROU)
715    // -----------------------------------------------------------------------
716
717    #[test]
718    fn test_cgmes_dy_genrou() {
719        let dy_xml = r##"<?xml version="1.0"?>
720<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
721         xmlns:cim="http://iec.ch/TC57/CIM100#">
722  <cim:SynchronousMachineTimeConstantReactance rdf:ID="gen-dyn-001">
723    <cim:SynchronousMachineDynamics.SynchronousMachine rdf:resource="#sm-001"/>
724    <cim:SynchronousMachineTimeConstantReactance.rotorType>roundRotor</cim:SynchronousMachineTimeConstantReactance.rotorType>
725    <cim:RotatingMachineDynamics.inertia>6.5</cim:RotatingMachineDynamics.inertia>
726    <cim:RotatingMachineDynamics.damping>0.0</cim:RotatingMachineDynamics.damping>
727    <cim:SynchronousMachineTimeConstantReactance.tpdo>8.0</cim:SynchronousMachineTimeConstantReactance.tpdo>
728    <cim:SynchronousMachineTimeConstantReactance.tppdo>0.03</cim:SynchronousMachineTimeConstantReactance.tppdo>
729    <cim:SynchronousMachineTimeConstantReactance.tpqo>0.4</cim:SynchronousMachineTimeConstantReactance.tpqo>
730    <cim:SynchronousMachineTimeConstantReactance.tppqo>0.05</cim:SynchronousMachineTimeConstantReactance.tppqo>
731    <cim:SynchronousMachineTimeConstantReactance.xDirectSync>1.8</cim:SynchronousMachineTimeConstantReactance.xDirectSync>
732    <cim:SynchronousMachineTimeConstantReactance.xQuadSync>1.7</cim:SynchronousMachineTimeConstantReactance.xQuadSync>
733    <cim:SynchronousMachineTimeConstantReactance.xDirectTrans>0.3</cim:SynchronousMachineTimeConstantReactance.xDirectTrans>
734    <cim:SynchronousMachineTimeConstantReactance.xDirectSubtrans>0.25</cim:SynchronousMachineTimeConstantReactance.xDirectSubtrans>
735    <cim:SynchronousMachineTimeConstantReactance.xQuadTrans>0.55</cim:SynchronousMachineTimeConstantReactance.xQuadTrans>
736    <cim:SynchronousMachineTimeConstantReactance.statorLeakageReactance>0.2</cim:SynchronousMachineTimeConstantReactance.statorLeakageReactance>
737  </cim:SynchronousMachineTimeConstantReactance>
738</rdf:RDF>"##;
739
740        let sm_bus_map = single_sm_map("sm-001", 1, "1");
741        let dm = parse_cgmes_dy(&[dy_xml], &sm_bus_map).unwrap();
742        assert_eq!(dm.generators.len(), 1, "should have 1 generator");
743        let gdyn = &dm.generators[0];
744        assert_eq!(gdyn.bus, 1);
745        match &gdyn.model {
746            GeneratorModel::Genrou(p) => {
747                assert!((p.h - 6.5).abs() < 1e-9, "inertia H");
748                assert!((p.xd - 1.8).abs() < 1e-9, "xd");
749                assert!((p.xq - 1.7).abs() < 1e-9, "xq");
750                assert!((p.td0_prime - 8.0).abs() < 1e-9, "td0'");
751                assert!((p.xl - 0.2).abs() < 1e-9, "xl");
752            }
753            other => panic!("expected Genrou, got {other:?}"),
754        }
755    }
756
757    // -----------------------------------------------------------------------
758    // Test 2: salient-pole generator (GENSAL)
759    // -----------------------------------------------------------------------
760
761    #[test]
762    fn test_cgmes_dy_gensal() {
763        let dy_xml = r##"<?xml version="1.0"?>
764<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
765         xmlns:cim="http://iec.ch/TC57/CIM100#">
766  <cim:SynchronousMachineTimeConstantReactance rdf:ID="gen-dyn-002">
767    <cim:SynchronousMachineDynamics.SynchronousMachine rdf:resource="#sm-002"/>
768    <cim:SynchronousMachineTimeConstantReactance.rotorType>salientPole</cim:SynchronousMachineTimeConstantReactance.rotorType>
769    <cim:RotatingMachineDynamics.inertia>4.0</cim:RotatingMachineDynamics.inertia>
770    <cim:RotatingMachineDynamics.damping>2.0</cim:RotatingMachineDynamics.damping>
771    <cim:SynchronousMachineTimeConstantReactance.tpdo>5.9</cim:SynchronousMachineTimeConstantReactance.tpdo>
772    <cim:SynchronousMachineTimeConstantReactance.tppdo>0.033</cim:SynchronousMachineTimeConstantReactance.tppdo>
773    <cim:SynchronousMachineTimeConstantReactance.tppqo>0.078</cim:SynchronousMachineTimeConstantReactance.tppqo>
774    <cim:SynchronousMachineTimeConstantReactance.xDirectSync>1.05</cim:SynchronousMachineTimeConstantReactance.xDirectSync>
775    <cim:SynchronousMachineTimeConstantReactance.xQuadSync>0.66</cim:SynchronousMachineTimeConstantReactance.xQuadSync>
776    <cim:SynchronousMachineTimeConstantReactance.xDirectTrans>0.32</cim:SynchronousMachineTimeConstantReactance.xDirectTrans>
777    <cim:SynchronousMachineTimeConstantReactance.xDirectSubtrans>0.25</cim:SynchronousMachineTimeConstantReactance.xDirectSubtrans>
778    <cim:SynchronousMachineTimeConstantReactance.statorLeakageReactance>0.15</cim:SynchronousMachineTimeConstantReactance.statorLeakageReactance>
779  </cim:SynchronousMachineTimeConstantReactance>
780</rdf:RDF>"##;
781
782        let sm_bus_map = single_sm_map("sm-002", 2, "1");
783        let dm = parse_cgmes_dy(&[dy_xml], &sm_bus_map).unwrap();
784        assert_eq!(dm.generators.len(), 1);
785        match &dm.generators[0].model {
786            GeneratorModel::Gensal(p) => {
787                assert!((p.h - 4.0).abs() < 1e-9, "H");
788                assert!((p.xd - 1.05).abs() < 1e-9, "xd");
789                assert!((p.td0_prime - 5.9).abs() < 1e-9, "td0'");
790            }
791            other => panic!("expected Gensal, got {other:?}"),
792        }
793    }
794
795    // -----------------------------------------------------------------------
796    // Test 3: ExcIEEEST1A exciter
797    // -----------------------------------------------------------------------
798
799    #[test]
800    fn test_cgmes_dy_exciter_st1a() {
801        let dy_xml = r##"<?xml version="1.0"?>
802<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
803         xmlns:cim="http://iec.ch/TC57/CIM100#">
804  <cim:SynchronousMachineTimeConstantReactance rdf:ID="smd-003">
805    <cim:SynchronousMachineDynamics.SynchronousMachine rdf:resource="#sm-003"/>
806    <cim:SynchronousMachineTimeConstantReactance.rotorType>roundRotor</cim:SynchronousMachineTimeConstantReactance.rotorType>
807    <cim:RotatingMachineDynamics.inertia>5.0</cim:RotatingMachineDynamics.inertia>
808    <cim:RotatingMachineDynamics.damping>0.0</cim:RotatingMachineDynamics.damping>
809    <cim:SynchronousMachineTimeConstantReactance.tpdo>6.0</cim:SynchronousMachineTimeConstantReactance.tpdo>
810    <cim:SynchronousMachineTimeConstantReactance.tppdo>0.04</cim:SynchronousMachineTimeConstantReactance.tppdo>
811    <cim:SynchronousMachineTimeConstantReactance.tpqo>0.5</cim:SynchronousMachineTimeConstantReactance.tpqo>
812    <cim:SynchronousMachineTimeConstantReactance.tppqo>0.05</cim:SynchronousMachineTimeConstantReactance.tppqo>
813    <cim:SynchronousMachineTimeConstantReactance.xDirectSync>1.79</cim:SynchronousMachineTimeConstantReactance.xDirectSync>
814    <cim:SynchronousMachineTimeConstantReactance.xQuadSync>1.71</cim:SynchronousMachineTimeConstantReactance.xQuadSync>
815    <cim:SynchronousMachineTimeConstantReactance.xDirectTrans>0.169</cim:SynchronousMachineTimeConstantReactance.xDirectTrans>
816    <cim:SynchronousMachineTimeConstantReactance.xDirectSubtrans>0.135</cim:SynchronousMachineTimeConstantReactance.xDirectSubtrans>
817    <cim:SynchronousMachineTimeConstantReactance.statorLeakageReactance>0.13</cim:SynchronousMachineTimeConstantReactance.statorLeakageReactance>
818  </cim:SynchronousMachineTimeConstantReactance>
819  <cim:ExcIEEEST1A rdf:ID="exc-003">
820    <cim:ExcitationSystemDynamics.SynchronousMachineDynamics rdf:resource="#smd-003"/>
821    <cim:ExcIEEEST1A.tc>10.0</cim:ExcIEEEST1A.tc>
822    <cim:ExcIEEEST1A.tb>10.0</cim:ExcIEEEST1A.tb>
823    <cim:ExcIEEEST1A.ka>200.0</cim:ExcIEEEST1A.ka>
824    <cim:ExcIEEEST1A.vrmax>6.43</cim:ExcIEEEST1A.vrmax>
825    <cim:ExcIEEEST1A.vrmin>-6.43</cim:ExcIEEEST1A.vrmin>
826  </cim:ExcIEEEST1A>
827</rdf:RDF>"##;
828
829        let sm_bus_map = single_sm_map("sm-003", 3, "1");
830        let dm = parse_cgmes_dy(&[dy_xml], &sm_bus_map).unwrap();
831        assert_eq!(dm.generators.len(), 1, "generator");
832        assert_eq!(dm.exciters.len(), 1, "exciter");
833        let exc = &dm.exciters[0];
834        assert_eq!(exc.bus, 3);
835        match &exc.model {
836            ExciterModel::Esst1a(p) => {
837                assert!((p.ka - 200.0).abs() < 1e-9, "ka");
838                assert!((p.vrmax - 6.43).abs() < 1e-9, "vrmax");
839            }
840            other => panic!("expected Esst1a, got {other:?}"),
841        }
842    }
843
844    // -----------------------------------------------------------------------
845    // Test 4: GovGAST governor
846    // -----------------------------------------------------------------------
847
848    #[test]
849    fn test_cgmes_dy_governor_gast() {
850        let dy_xml = r##"<?xml version="1.0"?>
851<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
852         xmlns:cim="http://iec.ch/TC57/CIM100#">
853  <cim:SynchronousMachineTimeConstantReactance rdf:ID="smd-004">
854    <cim:SynchronousMachineDynamics.SynchronousMachine rdf:resource="#sm-004"/>
855    <cim:SynchronousMachineTimeConstantReactance.rotorType>roundRotor</cim:SynchronousMachineTimeConstantReactance.rotorType>
856    <cim:RotatingMachineDynamics.inertia>7.0</cim:RotatingMachineDynamics.inertia>
857    <cim:RotatingMachineDynamics.damping>0.0</cim:RotatingMachineDynamics.damping>
858    <cim:SynchronousMachineTimeConstantReactance.tpdo>7.0</cim:SynchronousMachineTimeConstantReactance.tpdo>
859    <cim:SynchronousMachineTimeConstantReactance.tppdo>0.04</cim:SynchronousMachineTimeConstantReactance.tppdo>
860    <cim:SynchronousMachineTimeConstantReactance.tpqo>0.5</cim:SynchronousMachineTimeConstantReactance.tpqo>
861    <cim:SynchronousMachineTimeConstantReactance.tppqo>0.05</cim:SynchronousMachineTimeConstantReactance.tppqo>
862    <cim:SynchronousMachineTimeConstantReactance.xDirectSync>1.8</cim:SynchronousMachineTimeConstantReactance.xDirectSync>
863    <cim:SynchronousMachineTimeConstantReactance.xQuadSync>1.7</cim:SynchronousMachineTimeConstantReactance.xQuadSync>
864    <cim:SynchronousMachineTimeConstantReactance.xDirectTrans>0.3</cim:SynchronousMachineTimeConstantReactance.xDirectTrans>
865    <cim:SynchronousMachineTimeConstantReactance.xDirectSubtrans>0.25</cim:SynchronousMachineTimeConstantReactance.xDirectSubtrans>
866    <cim:SynchronousMachineTimeConstantReactance.statorLeakageReactance>0.2</cim:SynchronousMachineTimeConstantReactance.statorLeakageReactance>
867  </cim:SynchronousMachineTimeConstantReactance>
868  <cim:GovGAST rdf:ID="gov-004">
869    <cim:TurbineGovernorDynamics.SynchronousMachineDynamics rdf:resource="#smd-004"/>
870    <cim:GovGAST.r>0.05</cim:GovGAST.r>
871    <cim:GovGAST.t1>0.5</cim:GovGAST.t1>
872    <cim:GovGAST.t2>3.0</cim:GovGAST.t2>
873    <cim:GovGAST.t3>10.0</cim:GovGAST.t3>
874    <cim:GovGAST.at>1.0</cim:GovGAST.at>
875    <cim:GovGAST.kt>2.0</cim:GovGAST.kt>
876    <cim:GovGAST.voltage_min_pu>0.0</cim:GovGAST.voltage_min_pu>
877    <cim:GovGAST.voltage_max_pu>1.0</cim:GovGAST.voltage_max_pu>
878  </cim:GovGAST>
879</rdf:RDF>"##;
880
881        let sm_bus_map = single_sm_map("sm-004", 4, "G4");
882        let dm = parse_cgmes_dy(&[dy_xml], &sm_bus_map).unwrap();
883        assert_eq!(dm.governors.len(), 1, "governor");
884        let gov = &dm.governors[0];
885        assert_eq!(gov.bus, 4);
886        assert_eq!(gov.machine_id, "G4");
887        match &gov.model {
888            GovernorModel::Gast(p) => {
889                assert!((p.r - 0.05).abs() < 1e-9, "r");
890                assert!((p.t1 - 0.5).abs() < 1e-9, "t1");
891                assert!((p.t3 - 10.0).abs() < 1e-9, "t3");
892            }
893            other => panic!("expected Gast, got {other:?}"),
894        }
895    }
896
897    // -----------------------------------------------------------------------
898    // Test 5: PssIEEE2B
899    // -----------------------------------------------------------------------
900
901    #[test]
902    fn test_cgmes_dy_pss_ieee2b() {
903        let dy_xml = r##"<?xml version="1.0"?>
904<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
905         xmlns:cim="http://iec.ch/TC57/CIM100#">
906  <cim:SynchronousMachineTimeConstantReactance rdf:ID="smd-005">
907    <cim:SynchronousMachineDynamics.SynchronousMachine rdf:resource="#sm-005"/>
908    <cim:SynchronousMachineTimeConstantReactance.rotorType>roundRotor</cim:SynchronousMachineTimeConstantReactance.rotorType>
909    <cim:RotatingMachineDynamics.inertia>3.0</cim:RotatingMachineDynamics.inertia>
910    <cim:RotatingMachineDynamics.damping>0.0</cim:RotatingMachineDynamics.damping>
911    <cim:SynchronousMachineTimeConstantReactance.tpdo>6.0</cim:SynchronousMachineTimeConstantReactance.tpdo>
912    <cim:SynchronousMachineTimeConstantReactance.tppdo>0.04</cim:SynchronousMachineTimeConstantReactance.tppdo>
913    <cim:SynchronousMachineTimeConstantReactance.tpqo>0.5</cim:SynchronousMachineTimeConstantReactance.tpqo>
914    <cim:SynchronousMachineTimeConstantReactance.tppqo>0.05</cim:SynchronousMachineTimeConstantReactance.tppqo>
915    <cim:SynchronousMachineTimeConstantReactance.xDirectSync>1.8</cim:SynchronousMachineTimeConstantReactance.xDirectSync>
916    <cim:SynchronousMachineTimeConstantReactance.xQuadSync>1.7</cim:SynchronousMachineTimeConstantReactance.xQuadSync>
917    <cim:SynchronousMachineTimeConstantReactance.xDirectTrans>0.3</cim:SynchronousMachineTimeConstantReactance.xDirectTrans>
918    <cim:SynchronousMachineTimeConstantReactance.xDirectSubtrans>0.25</cim:SynchronousMachineTimeConstantReactance.xDirectSubtrans>
919    <cim:SynchronousMachineTimeConstantReactance.statorLeakageReactance>0.2</cim:SynchronousMachineTimeConstantReactance.statorLeakageReactance>
920  </cim:SynchronousMachineTimeConstantReactance>
921  <cim:PssIEEE2B rdf:ID="pss-005">
922    <cim:PowerSystemStabilizerDynamics.ExcitationSystemDynamics rdf:resource="#smd-005"/>
923    <cim:PssIEEE2B.tw1>10.0</cim:PssIEEE2B.tw1>
924    <cim:PssIEEE2B.tw2>10.0</cim:PssIEEE2B.tw2>
925    <cim:PssIEEE2B.tw3>2.0</cim:PssIEEE2B.tw3>
926    <cim:PssIEEE2B.t1>0.12</cim:PssIEEE2B.t1>
927    <cim:PssIEEE2B.t2>0.02</cim:PssIEEE2B.t2>
928    <cim:PssIEEE2B.t3>0.3</cim:PssIEEE2B.t3>
929    <cim:PssIEEE2B.t4>0.15</cim:PssIEEE2B.t4>
930    <cim:PssIEEE2B.t7>2.0</cim:PssIEEE2B.t7>
931    <cim:PssIEEE2B.t8>0.5</cim:PssIEEE2B.t8>
932    <cim:PssIEEE2B.t9>0.1</cim:PssIEEE2B.t9>
933    <cim:PssIEEE2B.ks1>12.0</cim:PssIEEE2B.ks1>
934    <cim:PssIEEE2B.vstmax>0.1</cim:PssIEEE2B.vstmax>
935    <cim:PssIEEE2B.vstmin>-0.1</cim:PssIEEE2B.vstmin>
936  </cim:PssIEEE2B>
937</rdf:RDF>"##;
938
939        let sm_bus_map = single_sm_map("sm-005", 5, "1");
940        let dm = parse_cgmes_dy(&[dy_xml], &sm_bus_map).unwrap();
941        // PSS links via ExcitationSystemDynamics → SMD → SM (3-hop chain).
942        // resolve_sm() now handles the ExcitationSystemDynamics path.
943        assert_eq!(
944            dm.generators.len(),
945            1,
946            "generator from SynchronousMachineTimeConstantReactance"
947        );
948        assert_eq!(
949            dm.pss.len(),
950            1,
951            "PSS resolved via ExcitationSystemDynamics 3-hop"
952        );
953        assert_eq!(dm.pss[0].bus, 5);
954        assert_eq!(dm.pss[0].machine_id, "1");
955    }
956
957    // -----------------------------------------------------------------------
958    // Test 6: unknown class doesn't error
959    // -----------------------------------------------------------------------
960
961    #[test]
962    fn test_cgmes_dy_unsupported_warns() {
963        let dy_xml = r##"<?xml version="1.0"?>
964<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
965         xmlns:cim="http://iec.ch/TC57/CIM100#">
966  <cim:SomeFutureModel2035 rdf:ID="future-001">
967    <cim:SomeFutureModel2035.SynchronousMachine rdf:resource="#sm-006"/>
968    <cim:SomeFutureModel2035.param1>42.0</cim:SomeFutureModel2035.param1>
969  </cim:SomeFutureModel2035>
970</rdf:RDF>"##;
971
972        let sm_bus_map = single_sm_map("sm-006", 6, "1");
973        // Must not return an error — unknown classes are silently warned and skipped.
974        let dm = parse_cgmes_dy(&[dy_xml], &sm_bus_map).unwrap();
975        assert_eq!(dm.generators.len(), 0);
976        assert_eq!(dm.exciters.len(), 0);
977        assert_eq!(dm.governors.len(), 0);
978    }
979
980    // -----------------------------------------------------------------------
981    // Test 7: full machine — generator + exciter + governor linked to same SM
982    // -----------------------------------------------------------------------
983
984    #[test]
985    fn test_cgmes_dy_full_machine() {
986        let dy_xml = r##"<?xml version="1.0"?>
987<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
988         xmlns:cim="http://iec.ch/TC57/CIM100#">
989  <!-- Generator dynamics -->
990  <cim:SynchronousMachineTimeConstantReactance rdf:ID="smd-007">
991    <cim:SynchronousMachineDynamics.SynchronousMachine rdf:resource="#sm-007"/>
992    <cim:SynchronousMachineTimeConstantReactance.rotorType>roundRotor</cim:SynchronousMachineTimeConstantReactance.rotorType>
993    <cim:RotatingMachineDynamics.inertia>6.0</cim:RotatingMachineDynamics.inertia>
994    <cim:RotatingMachineDynamics.damping>0.0</cim:RotatingMachineDynamics.damping>
995    <cim:SynchronousMachineTimeConstantReactance.tpdo>8.0</cim:SynchronousMachineTimeConstantReactance.tpdo>
996    <cim:SynchronousMachineTimeConstantReactance.tppdo>0.03</cim:SynchronousMachineTimeConstantReactance.tppdo>
997    <cim:SynchronousMachineTimeConstantReactance.tpqo>0.4</cim:SynchronousMachineTimeConstantReactance.tpqo>
998    <cim:SynchronousMachineTimeConstantReactance.tppqo>0.05</cim:SynchronousMachineTimeConstantReactance.tppqo>
999    <cim:SynchronousMachineTimeConstantReactance.xDirectSync>1.8</cim:SynchronousMachineTimeConstantReactance.xDirectSync>
1000    <cim:SynchronousMachineTimeConstantReactance.xQuadSync>1.7</cim:SynchronousMachineTimeConstantReactance.xQuadSync>
1001    <cim:SynchronousMachineTimeConstantReactance.xDirectTrans>0.3</cim:SynchronousMachineTimeConstantReactance.xDirectTrans>
1002    <cim:SynchronousMachineTimeConstantReactance.xDirectSubtrans>0.25</cim:SynchronousMachineTimeConstantReactance.xDirectSubtrans>
1003    <cim:SynchronousMachineTimeConstantReactance.statorLeakageReactance>0.2</cim:SynchronousMachineTimeConstantReactance.statorLeakageReactance>
1004  </cim:SynchronousMachineTimeConstantReactance>
1005  <!-- Exciter directly referencing the SM dynamics via SynchronousMachineDynamics -->
1006  <cim:ExcIEEEST1A rdf:ID="exc-007">
1007    <cim:ExcitationSystemDynamics.SynchronousMachineDynamics rdf:resource="#smd-007"/>
1008    <cim:ExcIEEEST1A.tc>10.0</cim:ExcIEEEST1A.tc>
1009    <cim:ExcIEEEST1A.tb>10.0</cim:ExcIEEEST1A.tb>
1010    <cim:ExcIEEEST1A.ka>200.0</cim:ExcIEEEST1A.ka>
1011    <cim:ExcIEEEST1A.vrmax>6.43</cim:ExcIEEEST1A.vrmax>
1012    <cim:ExcIEEEST1A.vrmin>-6.43</cim:ExcIEEEST1A.vrmin>
1013  </cim:ExcIEEEST1A>
1014  <!-- Governor directly referencing the SM dynamics -->
1015  <cim:GovGAST rdf:ID="gov-007">
1016    <cim:TurbineGovernorDynamics.SynchronousMachineDynamics rdf:resource="#smd-007"/>
1017    <cim:GovGAST.r>0.05</cim:GovGAST.r>
1018    <cim:GovGAST.t1>0.5</cim:GovGAST.t1>
1019    <cim:GovGAST.t2>3.0</cim:GovGAST.t2>
1020    <cim:GovGAST.t3>10.0</cim:GovGAST.t3>
1021  </cim:GovGAST>
1022</rdf:RDF>"##;
1023
1024        let sm_bus_map = single_sm_map("sm-007", 7, "G7");
1025        let dm = parse_cgmes_dy(&[dy_xml], &sm_bus_map).unwrap();
1026        assert_eq!(dm.generators.len(), 1, "generator");
1027        assert_eq!(dm.exciters.len(), 1, "exciter");
1028        assert_eq!(dm.governors.len(), 1, "governor");
1029
1030        // All must reference the same bus + machine_id
1031        assert_eq!(dm.generators[0].bus, 7);
1032        assert_eq!(dm.generators[0].machine_id, "G7");
1033        assert_eq!(dm.exciters[0].bus, 7);
1034        assert_eq!(dm.exciters[0].machine_id, "G7");
1035        assert_eq!(dm.governors[0].bus, 7);
1036        assert_eq!(dm.governors[0].machine_id, "G7");
1037    }
1038
1039    // -----------------------------------------------------------------------
1040    // Test 8: SynchronousMachineSimplified → GENCLS
1041    // -----------------------------------------------------------------------
1042
1043    #[test]
1044    fn test_cgmes_dy_gencls() {
1045        let dy_xml = r##"<?xml version="1.0"?>
1046<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
1047         xmlns:cim="http://iec.ch/TC57/CIM100#">
1048  <cim:SynchronousMachineSimplified rdf:ID="sms-008">
1049    <cim:SynchronousMachineDynamics.SynchronousMachine rdf:resource="#sm-008"/>
1050    <cim:RotatingMachineDynamics.inertia>3.0</cim:RotatingMachineDynamics.inertia>
1051    <cim:RotatingMachineDynamics.damping>1.0</cim:RotatingMachineDynamics.damping>
1052  </cim:SynchronousMachineSimplified>
1053</rdf:RDF>"##;
1054
1055        let sm_bus_map = single_sm_map("sm-008", 8, "CLK");
1056        let dm = parse_cgmes_dy(&[dy_xml], &sm_bus_map).unwrap();
1057        assert_eq!(dm.generators.len(), 1);
1058        match &dm.generators[0].model {
1059            GeneratorModel::Gencls(p) => {
1060                assert!((p.h - 3.0).abs() < 1e-9);
1061                assert!((p.d - 1.0).abs() < 1e-9);
1062            }
1063            other => panic!("expected Gencls, got {other:?}"),
1064        }
1065    }
1066}