Skip to main content

powerio_tx/
dynamic.rs

1//! Loss-preserving support for PSS/E transient-stability DYR records.
2//!
3//! A DYR file is a model library paired with a steady-state case, not itself a
4//! power-flow network. This module therefore keeps dynamic records separate
5//! from BalancedNetwork. Model-specific parameter schemas are intentionally
6//! not guessed: unknown/user-defined model names and their ordered parameters
7//! remain available to callers.
8//!
9//! Parsing is bounded and quote-aware. The original source is retained, so
10//! DynamicCase::to_dyr_string reproduces comments, whitespace, line endings,
11//! token spelling, and record order exactly. No model is silently discarded.
12
13use std::collections::BTreeSet;
14use std::fmt;
15
16const MAX_DYR_BYTES: usize = 16 * 1024 * 1024;
17const MAX_RECORDS: usize = 100_000;
18const MAX_FIELDS_PER_RECORD: usize = 4_096;
19const MAX_FIELD_BYTES: usize = 64 * 1024;
20
21/// Parsed PSS/E dynamic-model data, kept separate from a steady-state network.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct DynamicCase {
24    source: String,
25    records: Vec<DynamicRecord>,
26}
27
28impl DynamicCase {
29    /// Dynamic model records in source order.
30    #[must_use]
31    pub fn records(&self) -> &[DynamicRecord] {
32        &self.records
33    }
34
35    /// Return the exact source text used to construct this case.
36    ///
37    /// The records are read-only, so returning the retained source cannot
38    /// accidentally emit stale data after an edit.
39    #[must_use]
40    pub fn to_dyr_string(&self) -> &str {
41        &self.source
42    }
43
44    /// Find references to buses absent from the supplied static-case bus IDs.
45    ///
46    /// The check deliberately validates only the generic bus reference. It
47    /// does not assume that every model's second identifier is a generator ID:
48    /// DYR model families use different device keys, and custom models are
49    /// retained without pretending to understand their schemas.
50    #[must_use]
51    pub fn unresolved_buses(&self, bus_ids: &BTreeSet<u32>) -> Vec<UnresolvedBusReference> {
52        self.records
53            .iter()
54            .filter_map(|record| {
55                // A record keyed by a device name references no bus here.
56                let bus_id = record.bus_id?;
57                (!bus_ids.contains(&bus_id)).then(|| UnresolvedBusReference {
58                    line: record.line,
59                    model_name: record.model_name.clone(),
60                    bus_id,
61                })
62            })
63            .collect()
64    }
65
66    /// Check generic DYR bus references against a parsed balanced network.
67    ///
68    /// This deliberately does not validate `device_id`: there is no
69    /// model-independent rule that the second DYR key names a generator.
70    #[must_use]
71    pub fn unresolved_buses_in(
72        &self,
73        network: &crate::BalancedNetwork,
74    ) -> Vec<UnresolvedBusReference> {
75        let bus_ids: BTreeSet<u32> = network
76            .buses()
77            .iter()
78            .filter_map(|bus| u32::try_from(bus.id.0).ok())
79            .collect();
80        self.unresolved_buses(&bus_ids)
81    }
82}
83
84/// A steady-state case explicitly paired with its transient-stability data.
85///
86/// This is a study-level association, not a mutation of [`crate::BalancedNetwork`].
87/// The current linking guarantee validates generic bus references only; it does
88/// not resolve model-specific device keys or claim that the named device exists.
89#[derive(Clone, Debug)]
90pub struct DynamicStudy {
91    network: crate::BalancedNetwork,
92    dynamics: DynamicCase,
93}
94
95impl DynamicStudy {
96    /// Pair a parsed static network with a parsed DYR case.
97    #[must_use]
98    pub fn new(network: crate::BalancedNetwork, dynamics: DynamicCase) -> Self {
99        Self { network, dynamics }
100    }
101
102    /// The steady-state network, unchanged by the pairing.
103    #[must_use]
104    pub fn network(&self) -> &crate::BalancedNetwork {
105        &self.network
106    }
107
108    /// The loss-preserving dynamic-model records.
109    #[must_use]
110    pub fn dynamics(&self) -> &DynamicCase {
111        &self.dynamics
112    }
113
114    /// DYR records whose bus reference is absent from the static case.
115    #[must_use]
116    pub fn unresolved_buses(&self) -> Vec<UnresolvedBusReference> {
117        self.dynamics.unresolved_buses_in(&self.network)
118    }
119
120    /// Consume the study and recover the separately represented components.
121    #[must_use]
122    pub fn into_parts(self) -> (crate::BalancedNetwork, DynamicCase) {
123        (self.network, self.dynamics)
124    }
125}
126
127/// One DYR model record. Parameters remain ordered and lexically decoded;
128/// model-specific interpretation belongs in a separate, schema-backed layer.
129#[derive(Clone, Debug, PartialEq, Eq)]
130pub struct DynamicRecord {
131    model_name: String,
132    key: String,
133    bus_id: Option<u32>,
134    device_id: Option<String>,
135    parameters: Vec<String>,
136    line: usize,
137    raw_record: String,
138}
139
140impl DynamicRecord {
141    /// Raw model name as represented by the record, without surrounding quotes.
142    #[must_use]
143    pub fn model_name(&self) -> &str {
144        &self.model_name
145    }
146
147    /// The first DYR field, without surrounding quotes. Most models key a
148    /// record by an unquoted bus number; dc line and similar models name
149    /// their device here in quotes instead.
150    #[must_use]
151    pub fn key(&self) -> &str {
152        &self.key
153    }
154
155    /// Bus number in an unquoted first field, or `None` for a record keyed
156    /// by a quoted device name such as a dc line. Load models keyed by area,
157    /// zone or owner state that number here too; it is not a bus.
158    #[must_use]
159    pub fn bus_id(&self) -> Option<u32> {
160        self.bus_id
161    }
162
163    /// Optional second device-key field, commonly a generator/load identifier.
164    #[must_use]
165    pub fn device_id(&self) -> Option<&str> {
166        self.device_id.as_deref()
167    }
168
169    /// Ordered parameter tokens after the generic bus/model/device fields.
170    #[must_use]
171    pub fn parameters(&self) -> &[String] {
172        &self.parameters
173    }
174
175    /// One-based source line where the record starts.
176    #[must_use]
177    pub fn line(&self) -> usize {
178        self.line
179    }
180
181    /// The record's source spelling, excluding the terminating slash.
182    #[must_use]
183    pub fn raw_record(&self) -> &str {
184        &self.raw_record
185    }
186}
187
188/// A DYR record whose bus reference is not present in a static case.
189#[derive(Clone, Debug, PartialEq, Eq)]
190#[non_exhaustive]
191pub struct UnresolvedBusReference {
192    pub line: usize,
193    pub model_name: String,
194    pub bus_id: u32,
195}
196
197/// Errors detected while reading a DYR source.
198#[derive(Clone, Debug, PartialEq, Eq)]
199#[non_exhaustive]
200pub enum DyrError {
201    InputTooLarge { actual: usize, maximum: usize },
202    InvalidUtf8,
203    UnterminatedQuote { line: usize },
204    UnterminatedRecord { line: usize },
205    MissingModelName { line: usize },
206    InvalidBusId { line: usize, token: String },
207    TooManyRecords { maximum: usize },
208    TooManyFields { line: usize, maximum: usize },
209    FieldTooLong { line: usize, maximum: usize },
210}
211
212impl fmt::Display for DyrError {
213    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214        match self {
215            Self::InputTooLarge { actual, maximum } => {
216                write!(f, "DYR input is {actual} bytes; maximum is {maximum}")
217            }
218            Self::InvalidUtf8 => f.write_str("DYR input is not valid UTF-8"),
219            Self::UnterminatedQuote { line } => {
220                write!(f, "unterminated quoted field starting at line {line}")
221            }
222            Self::UnterminatedRecord { line } => {
223                write!(
224                    f,
225                    "DYR record starting at line {line} has no terminating slash"
226                )
227            }
228            Self::MissingModelName { line } => {
229                write!(f, "DYR record at line {line} has no model name")
230            }
231            Self::InvalidBusId { line, token } => {
232                write!(f, "invalid DYR bus identifier {token:?} at line {line}")
233            }
234            Self::TooManyRecords { maximum } => {
235                write!(f, "DYR input exceeds the maximum of {maximum} records")
236            }
237            Self::TooManyFields { line, maximum } => {
238                write!(f, "DYR record at line {line} exceeds {maximum} fields")
239            }
240            Self::FieldTooLong { line, maximum } => {
241                write!(f, "DYR field at line {line} exceeds {maximum} bytes")
242            }
243        }
244    }
245}
246
247impl std::error::Error for DyrError {}
248
249/// Parse PSS/E DYR text without assuming a closed list of model names.
250///
251/// Records are delimited by slash outside quoted strings. Exclamation mark
252/// starts a line-comment outside quotes. Fields are separated by commas or ASCII whitespace; single-
253/// quoted values may contain commas or slashes, and doubled single quotes
254/// represent one literal quote. Parameter tokens remain strings because their
255/// types and meanings depend on the model schema.
256pub fn parse_dyr(text: &str) -> Result<DynamicCase, DyrError> {
257    if text.len() > MAX_DYR_BYTES {
258        return Err(DyrError::InputTooLarge {
259            actual: text.len(),
260            maximum: MAX_DYR_BYTES,
261        });
262    }
263
264    let uncommented = strip_comments(text)?;
265    let mut records = Vec::new();
266    let mut start = 0;
267    let mut start_line = 1;
268    let mut line = 1;
269    let mut in_quote = false;
270    let mut record_started = false;
271    let bytes = uncommented.as_bytes();
272    let mut index = 0;
273
274    while index < bytes.len() {
275        match bytes[index] {
276            b'\n' => {
277                line += 1;
278                if !record_started {
279                    start_line = line;
280                }
281                index += 1;
282            }
283            b'/' if !in_quote => {
284                if record_started {
285                    if records.len() >= MAX_RECORDS {
286                        return Err(DyrError::TooManyRecords {
287                            maximum: MAX_RECORDS,
288                        });
289                    }
290                    records.push(parse_record(uncommented[start..index].trim(), start_line)?);
291                }
292                start = index + 1;
293                start_line = line;
294                record_started = false;
295                index += 1;
296            }
297            b'\'' => {
298                if !record_started {
299                    record_started = true;
300                    start_line = line;
301                }
302                if in_quote && index + 1 < bytes.len() && bytes[index + 1] == b'\'' {
303                    index += 2;
304                } else {
305                    in_quote = !in_quote;
306                    index += 1;
307                }
308            }
309            byte => {
310                if !record_started && !byte.is_ascii_whitespace() {
311                    record_started = true;
312                    start_line = line;
313                }
314                index += 1;
315            }
316        }
317    }
318
319    if record_started {
320        return Err(DyrError::UnterminatedRecord { line: start_line });
321    }
322
323    Ok(DynamicCase {
324        source: text.to_owned(),
325        records,
326    })
327}
328
329/// Parse DYR bytes, rejecting invalid UTF-8 rather than replacing bytes.
330pub fn parse_dyr_bytes(bytes: &[u8]) -> Result<DynamicCase, DyrError> {
331    if bytes.len() > MAX_DYR_BYTES {
332        return Err(DyrError::InputTooLarge {
333            actual: bytes.len(),
334            maximum: MAX_DYR_BYTES,
335        });
336    }
337    let text = std::str::from_utf8(bytes).map_err(|_| DyrError::InvalidUtf8)?;
338    parse_dyr(text)
339}
340
341fn strip_comments(text: &str) -> Result<String, DyrError> {
342    let mut output = String::with_capacity(text.len());
343    let mut in_quote = false;
344    let mut in_comment = false;
345    let mut line = 1;
346    let mut quote_start_line = 1;
347    let mut chars = text.chars().peekable();
348
349    while let Some(character) = chars.next() {
350        if character == '\n' {
351            line += 1;
352            in_comment = false;
353            output.push('\n');
354            continue;
355        }
356        if in_comment {
357            output.push(' ');
358            continue;
359        }
360        if character == '\'' {
361            output.push(character);
362            if in_quote && chars.peek() == Some(&'\'') {
363                output.push(chars.next().unwrap_or('\''));
364                continue;
365            }
366            if !in_quote {
367                quote_start_line = line;
368            }
369            in_quote = !in_quote;
370            continue;
371        }
372        if character == '!' && !in_quote {
373            in_comment = true;
374            output.push(' ');
375            continue;
376        }
377        output.push(character);
378    }
379
380    if in_quote {
381        return Err(DyrError::UnterminatedQuote {
382            line: quote_start_line,
383        });
384    }
385    Ok(output)
386}
387
388fn parse_record(raw: &str, line: usize) -> Result<DynamicRecord, DyrError> {
389    let fields = split_fields(raw, line)?;
390    if fields.len() < 2 || fields[1].is_empty() {
391        return Err(DyrError::MissingModelName { line });
392    }
393    // An unquoted key is a bus number; a quoted key names a device, as dc
394    // line models do (`'DC1' 'CDC4T' ... /`).
395    let bus_id = if raw.trim_start().starts_with('\'') {
396        None
397    } else {
398        Some(
399            fields[0]
400                .parse::<u32>()
401                .map_err(|_| DyrError::InvalidBusId {
402                    line,
403                    token: fields[0].clone(),
404                })?,
405        )
406    };
407    let key = fields[0].clone();
408    let model_name = fields[1].clone();
409    let device_id = fields.get(2).cloned().filter(|value| !value.is_empty());
410    let parameters = fields.into_iter().skip(3).collect();
411
412    Ok(DynamicRecord {
413        model_name,
414        key,
415        bus_id,
416        device_id,
417        parameters,
418        line,
419        raw_record: raw.to_owned(),
420    })
421}
422
423fn split_fields(raw: &str, line: usize) -> Result<Vec<String>, DyrError> {
424    let mut fields = Vec::new();
425    let mut token_start = None;
426    let mut in_quote = false;
427    let mut pending_comma = false;
428    let bytes = raw.as_bytes();
429    let mut index = 0;
430
431    while index < bytes.len() {
432        match bytes[index] {
433            b'\'' => {
434                if token_start.is_none() {
435                    token_start = Some(index);
436                    pending_comma = false;
437                }
438                if in_quote && index + 1 < bytes.len() && bytes[index + 1] == b'\'' {
439                    index += 2;
440                    continue;
441                }
442                in_quote = !in_quote;
443                index += 1;
444            }
445            b',' if !in_quote => {
446                if let Some(start) = token_start.take() {
447                    push_decoded_field(&mut fields, &raw[start..index], line)?;
448                } else if pending_comma || fields.is_empty() {
449                    push_decoded_field(&mut fields, "", line)?;
450                }
451                pending_comma = true;
452                index += 1;
453            }
454            byte if byte.is_ascii_whitespace() && !in_quote => {
455                if let Some(start) = token_start.take() {
456                    push_decoded_field(&mut fields, &raw[start..index], line)?;
457                    pending_comma = false;
458                }
459                index += 1;
460            }
461            _ => {
462                if token_start.is_none() {
463                    token_start = Some(index);
464                    pending_comma = false;
465                }
466                index += 1;
467            }
468        }
469    }
470
471    if in_quote {
472        return Err(DyrError::UnterminatedQuote { line });
473    }
474    if let Some(start) = token_start {
475        push_decoded_field(&mut fields, &raw[start..], line)?;
476    } else if pending_comma {
477        push_decoded_field(&mut fields, "", line)?;
478    }
479    Ok(fields)
480}
481
482fn push_decoded_field(fields: &mut Vec<String>, raw: &str, line: usize) -> Result<(), DyrError> {
483    fields.push(decode_field(raw, line)?);
484    if fields.len() > MAX_FIELDS_PER_RECORD {
485        return Err(DyrError::TooManyFields {
486            line,
487            maximum: MAX_FIELDS_PER_RECORD,
488        });
489    }
490    Ok(())
491}
492
493fn decode_field(raw: &str, line: usize) -> Result<String, DyrError> {
494    let token = raw.trim();
495    if token.len() > MAX_FIELD_BYTES {
496        return Err(DyrError::FieldTooLong {
497            line,
498            maximum: MAX_FIELD_BYTES,
499        });
500    }
501    if token.len() >= 2 && token.starts_with('\'') && token.ends_with('\'') {
502        Ok(token[1..token.len() - 1].replace("''", "'"))
503    } else {
504        Ok(token.to_owned())
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn parses_known_and_unknown_models_and_multiline_records() {
514        let source = "! machine models\n\
515            1, 'GENCLS', '1', 3.5, 0.2 /\n\
516            1, 'USERMODEL', '2', 'alpha, beta', 0.4 /\n\
517            2, 'EXDC1', '1', 0.1,\n\
518            0.2, 5.0 /\n";
519        let case = parse_dyr(source).expect("valid DYR");
520        assert_eq!(case.records().len(), 3);
521        assert_eq!(case.records()[0].model_name(), "GENCLS");
522        assert_eq!(case.records()[0].bus_id(), Some(1));
523        assert_eq!(case.records()[0].device_id(), Some("1"));
524        assert_eq!(case.records()[0].parameters(), ["3.5", "0.2"]);
525        assert_eq!(case.records()[1].model_name(), "USERMODEL");
526        assert_eq!(case.records()[1].parameters()[0], "alpha, beta");
527        assert_eq!(case.records()[2].parameters(), ["0.1", "0.2", "5.0"]);
528        assert_eq!(case.to_dyr_string(), source);
529    }
530
531    #[test]
532    fn quoted_slash_and_comment_markers_do_not_end_a_record() {
533        let source = "1, 'USER/EXC', '1', 'not ! a comment', 1.0 / ! tail\n";
534        let case = parse_dyr(source).expect("quoted delimiters");
535        assert_eq!(case.records()[0].model_name(), "USER/EXC");
536        assert_eq!(case.records()[0].parameters()[0], "not ! a comment");
537        assert_eq!(case.to_dyr_string(), source);
538    }
539
540    #[test]
541    fn preserves_empty_parameter_slots() {
542        let case = parse_dyr("1, 'MODEL', '1', 1.0, , 3.0 /\n").unwrap();
543        assert_eq!(case.records()[0].parameters(), ["1.0", "", "3.0"]);
544    }
545
546    #[test]
547    fn reports_record_start_lines_after_blank_lines() {
548        let case = parse_dyr("\n1, 'GENCLS', '1', 3.5 /\n\n2, 'EXDC1', '1', 1.0 /\n").unwrap();
549        assert_eq!(case.records()[0].line(), 2);
550        assert_eq!(case.records()[1].line(), 4);
551    }
552
553    #[test]
554    fn reports_unresolved_bus_references() {
555        let case = parse_dyr("1, 'GENCLS', '1', 3.5, 0.2 /\n2, 'EXDC1', '1', 1.0 /\n").unwrap();
556        let ids = BTreeSet::from([1]);
557        let unresolved = case.unresolved_buses(&ids);
558        assert_eq!(unresolved.len(), 1);
559        assert_eq!(unresolved[0].bus_id, 2);
560        assert_eq!(unresolved[0].model_name, "EXDC1");
561    }
562
563    #[test]
564    fn rejects_truncated_records_and_bad_bus_ids() {
565        assert!(matches!(
566            parse_dyr("1, 'GENCLS', '1', 3.5"),
567            Err(DyrError::UnterminatedRecord { .. })
568        ));
569        assert!(matches!(
570            parse_dyr("bus, 'GENCLS', '1', 3.5 /"),
571            Err(DyrError::InvalidBusId { .. })
572        ));
573    }
574
575    #[test]
576    fn quoted_device_keys_are_names_not_buses() {
577        // PSS/E dc line models key a record by its quoted line name, as in
578        // the Model Library's `'DC Line Name', 'USRDCL', 'CHVDC2U1' ...` record.
579        let source = "'DC1' 'CDC4T' 0.1 0.2 /\n\
580            '1', 'USRDCL', 'CHVDC2U1', 18, 0.5 /\n\
581            3, 'GENCLS', '1', 3.5 /\n";
582        let case = parse_dyr(source).expect("named dc line records parse");
583        let records = case.records();
584        assert_eq!((records[0].key(), records[0].bus_id()), ("DC1", None));
585        assert_eq!(records[0].model_name(), "CDC4T");
586        assert_eq!((records[1].key(), records[1].bus_id()), ("1", None));
587        assert_eq!((records[2].key(), records[2].bus_id()), ("3", Some(3)));
588        // A dc line named '1' is not bus 1, and named records reference no bus.
589        let unresolved = case.unresolved_buses(&BTreeSet::from([1]));
590        assert_eq!(unresolved.len(), 1);
591        assert_eq!(unresolved[0].bus_id, 3);
592        assert_eq!(case.to_dyr_string(), source);
593    }
594
595    #[test]
596    fn rejects_unterminated_quotes_and_invalid_utf8() {
597        assert!(matches!(
598            parse_dyr("1, 'GENCLS, '1', 3.5 /"),
599            Err(DyrError::UnterminatedQuote { .. })
600        ));
601        assert_eq!(parse_dyr_bytes(&[0xff]), Err(DyrError::InvalidUtf8));
602    }
603
604    #[test]
605    fn enforces_input_size_limit_before_parsing() {
606        let text = " ".repeat(MAX_DYR_BYTES + 1);
607        assert!(matches!(
608            parse_dyr(&text),
609            Err(DyrError::InputTooLarge { .. })
610        ));
611    }
612    #[test]
613    fn accepts_comma_whitespace_mixed_tab_and_multiline_dyr_records() {
614        let cases = [
615            "1, 'GENCLS', '1', 13.0000, 0.000000 /",
616            "1 'GENCLS' '1' 13.0000 0.000000 /",
617            "1, 'GENCLS', '1', 13.0000 0.000000 /",
618            "1\t'GENCLS'\t'1'\t13.0000\t0.000000 /",
619            "1, 'GENCLS',\n  '1', 13.0000\n 0.000000 /",
620        ];
621        for source in cases {
622            let case = parse_dyr(source).unwrap_or_else(|error| panic!("{source:?}: {error}"));
623            assert_eq!(case.records().len(), 1, "{source:?}");
624            let record = &case.records()[0];
625            assert_eq!(record.bus_id(), Some(1), "{source:?}");
626            assert_eq!(record.model_name(), "GENCLS", "{source:?}");
627            assert_eq!(record.device_id(), Some("1"), "{source:?}");
628            assert_eq!(record.parameters(), ["13.0000", "0.000000"], "{source:?}");
629            assert_eq!(case.to_dyr_string(), source);
630        }
631    }
632
633    #[test]
634    fn quoted_whitespace_commas_and_explicit_empty_fields_are_preserved() {
635        let source = "1, 'USERMODEL', '1', 'alpha, beta', 1.0, , 3.0 /";
636        let case = parse_dyr(source).unwrap();
637        assert_eq!(case.records()[0].model_name(), "USERMODEL");
638        assert_eq!(
639            case.records()[0].parameters(),
640            ["alpha, beta", "1.0", "", "3.0"]
641        );
642        assert_eq!(case.to_dyr_string(), source);
643
644        let quoted = "1, 'USERMODEL', '1', 'O''Brien, alpha', 1.0 /";
645        let case = parse_dyr(quoted).unwrap();
646        assert_eq!(case.records()[0].parameters(), ["O'Brien, alpha", "1.0"]);
647        assert_eq!(case.to_dyr_string(), quoted);
648    }
649
650    #[test]
651    fn long_blank_and_comment_prefixes_are_scanned_once() {
652        let source = format!(
653            "{}{}1 'GENCLS' '1' 13.0 0.0 /\n",
654            "\n".repeat(100_000),
655            "! comment\n".repeat(100_000)
656        );
657        let case = parse_dyr(&source).expect("long blank/comment prefix");
658        assert_eq!(case.records().len(), 1);
659        assert_eq!(case.records()[0].line(), 200_001);
660    }
661
662    #[test]
663    fn paired_dynamic_study_checks_bus_ids_but_keeps_device_keys_opaque() {
664        let network = crate::BalancedNetwork::in_memory(
665            "static",
666            100.0,
667            vec![crate::Bus::new(crate::BusId(1), crate::BusType::Ref, 230.0)],
668            Vec::new(),
669        );
670        let dynamics =
671            parse_dyr("1, 'GENCLS', '1', 13.0, 0.0 /\n2, 'USERMODEL', 'CUSTOM-A', 1.0 /\n")
672                .expect("DYR parses");
673        let study = DynamicStudy::new(network, dynamics);
674        let unresolved = study.unresolved_buses();
675        assert_eq!(unresolved.len(), 1);
676        assert_eq!(unresolved[0].bus_id, 2);
677        assert_eq!(unresolved[0].model_name, "USERMODEL");
678        assert_eq!(study.dynamics().records()[1].device_id(), Some("CUSTOM-A"));
679    }
680}