Skip to main content

cobre_core/
scenario.rs

1//! Scenario pipeline raw data types — PAR model parameters, load statistics,
2//! and correlation model.
3//!
4//! This module defines the clarity-first data containers for the raw scenario
5//! pipeline parameters loaded from input files. These are the data types stored
6//! in [`System`](crate::System) and passed to downstream crates for processing.
7//!
8//! ## Dual-nature design
9//!
10//! Following the dual-nature design principle (internal-structures.md §1.1),
11//! this module holds only the **raw input-facing types**:
12//!
13//! - [`InflowModel`] — PAR(p) parameters per (hydro, stage). AR coefficients
14//!   are stored **standardized by seasonal std** (dimensionless ψ\*), and
15//!   `residual_std_ratio` (`σ_m` / `s_m`) captures the remaining variance not
16//!   explained by the AR model. Downstream crates recover the runtime residual
17//!   std as `std_m3s * residual_std_ratio`.
18//! - [`LoadModel`] — seasonal load statistics per (bus, stage)
19//! - [`CorrelationModel`] — named correlation profiles with entity groups
20//!   and correlation matrices
21//!
22//! Performance-adapted views (`PrecomputedParLp`, Cholesky-decomposed matrices)
23//! belong in downstream solver crates (`cobre-stochastic`).
24//!
25//! ## Declaration-order invariance
26//!
27//! [`CorrelationModel::profiles`] uses [`BTreeMap`] to preserve deterministic
28//! ordering of named profiles, ensuring bit-for-bit identical behaviour
29//! regardless of the order in which profiles appear in `correlation.json`.
30//!
31//! Source: `inflow_seasonal_stats.parquet`, `inflow_ar_coefficients.parquet`,
32//! `load_seasonal_stats.parquet`, `correlation.json`.
33//! See [internal-structures.md §14](../specs/data-model/internal-structures.md)
34//! and [Input Scenarios §2–5](../specs/data-model/input-scenarios.md).
35
36use std::collections::BTreeMap;
37
38use crate::EntityId;
39
40// ---------------------------------------------------------------------------
41// SamplingScheme (SS14 scenario source)
42// ---------------------------------------------------------------------------
43
44/// Forward-pass noise source for multi-stage optimization solvers.
45///
46/// Determines where the forward-pass scenario realisations come from.
47/// This is orthogonal to [`NoiseMethod`](crate::temporal::NoiseMethod),
48/// which controls how the opening tree is generated during the backward
49/// pass. `SamplingScheme` selects the *source* of forward-pass noise;
50/// `NoiseMethod` selects the *algorithm* used to produce backward-pass
51/// openings.
52///
53/// See [Input Scenarios §1.8](input-scenarios.md) for the full catalog.
54///
55/// # Examples
56///
57/// ```
58/// use cobre_core::scenario::SamplingScheme;
59///
60/// let scheme = SamplingScheme::InSample;
61/// // SamplingScheme is Copy
62/// let copy = scheme;
63/// assert_eq!(scheme, copy);
64/// ```
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
67pub enum SamplingScheme {
68    /// Forward pass uses the same opening tree generated for the backward pass.
69    /// This is the default for the minimal viable solver.
70    InSample,
71    /// Forward pass draws from an externally supplied scenario file.
72    External,
73    /// Forward pass replays historical inflow realisations in sequence or at random.
74    Historical,
75}
76
77// ---------------------------------------------------------------------------
78// ExternalSelectionMode
79// ---------------------------------------------------------------------------
80
81/// Scenario selection mode when [`SamplingScheme::External`] is active.
82///
83/// Controls whether external scenarios are replayed sequentially (useful for
84/// deterministic replay of a fixed test set) or drawn at random (useful for
85/// Monte Carlo evaluation with a large external library).
86///
87/// See [Input Scenarios §1.8](input-scenarios.md).
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
90pub enum ExternalSelectionMode {
91    /// Scenarios are drawn uniformly at random from the external library.
92    Random,
93    /// Scenarios are replayed in file order, cycling when the end is reached.
94    Sequential,
95}
96
97// ---------------------------------------------------------------------------
98// ScenarioSource (SS14 top-level config)
99// ---------------------------------------------------------------------------
100
101/// Top-level scenario source configuration, parsed from `stages.json`.
102///
103/// Groups the sampling scheme, random seed, and external selection mode
104/// that govern how forward-pass scenarios are produced. Populated during
105/// case loading by `cobre-io` from the `scenario_source` field in
106/// `stages.json`. Distinct from [`ScenarioSourceConfig`](crate::temporal::ScenarioSourceConfig),
107/// which also holds the branching factor (`num_scenarios`).
108///
109/// See [Input Scenarios §1.4, §1.8](input-scenarios.md).
110///
111/// # Examples
112///
113/// ```
114/// use cobre_core::scenario::{SamplingScheme, ScenarioSource};
115///
116/// let source = ScenarioSource {
117///     sampling_scheme: SamplingScheme::InSample,
118///     seed: Some(42),
119///     selection_mode: None,
120/// };
121/// assert_eq!(source.sampling_scheme, SamplingScheme::InSample);
122/// ```
123#[derive(Debug, Clone, PartialEq)]
124#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
125pub struct ScenarioSource {
126    /// Noise source used during the forward pass.
127    pub sampling_scheme: SamplingScheme,
128
129    /// Random seed for reproducible opening tree generation.
130    /// `None` means non-deterministic (OS entropy).
131    pub seed: Option<i64>,
132
133    /// Selection mode when `sampling_scheme` is [`SamplingScheme::External`].
134    /// `None` for `InSample` and `Historical` schemes.
135    pub selection_mode: Option<ExternalSelectionMode>,
136}
137
138// ---------------------------------------------------------------------------
139// InflowModel (SS14 — per hydro, per stage)
140// ---------------------------------------------------------------------------
141
142/// Raw PAR(p) model parameters for a single (hydro, stage) pair.
143///
144/// Stores the seasonal mean, standard deviation, and standardized AR lag
145/// coefficients loaded from `inflow_seasonal_stats.parquet` and
146/// `inflow_ar_coefficients.parquet`. These are the raw input-facing values.
147///
148/// AR coefficients are stored **standardized by seasonal std** (dimensionless ψ\*,
149/// direct Yule-Walker output). The `residual_std_ratio` field carries the ratio
150/// `σ_m` / `s_m` so that downstream crates can recover the runtime residual std as
151/// `std_m3s * residual_std_ratio` without re-deriving it from the coefficients.
152///
153/// The performance-adapted view (`PrecomputedParLp`) is built from these
154/// parameters once at solver initialisation and belongs to downstream solver crates.
155///
156/// ## Declaration-order invariance
157///
158/// The `System` holds a `Vec<InflowModel>` sorted by `(hydro_id, stage_id)`.
159/// All processing must iterate in that canonical order.
160///
161/// See [internal-structures.md §14](../specs/data-model/internal-structures.md)
162/// and [PAR Inflow Model §7](../math/par-inflow-model.md).
163///
164/// # Examples
165///
166/// ```
167/// use cobre_core::{EntityId, scenario::InflowModel};
168///
169/// let model = InflowModel {
170///     hydro_id: EntityId(1),
171///     stage_id: 3,
172///     mean_m3s: 150.0,
173///     std_m3s: 30.0,
174///     ar_coefficients: vec![0.45, 0.22],
175///     residual_std_ratio: 0.85,
176/// };
177/// assert_eq!(model.ar_order(), 2);
178/// assert_eq!(model.ar_coefficients.len(), 2);
179/// assert!((model.residual_std_ratio - 0.85).abs() < f64::EPSILON);
180/// ```
181#[derive(Debug, Clone, PartialEq)]
182#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
183pub struct InflowModel {
184    /// Hydro plant this model belongs to.
185    pub hydro_id: EntityId,
186
187    /// Stage (0-based index within `System::stages`) this model applies to.
188    pub stage_id: i32,
189
190    /// Seasonal mean inflow μ in m³/s.
191    pub mean_m3s: f64,
192
193    /// Seasonal standard deviation `s_m` in m³/s (seasonal sample std).
194    pub std_m3s: f64,
195
196    /// AR lag coefficients [ψ\*₁, ψ\*₂, …, ψ\*ₚ] standardized by seasonal std
197    /// (dimensionless). These are the direct Yule-Walker output. Length is the
198    /// AR order p. Empty when p == 0 (white noise).
199    pub ar_coefficients: Vec<f64>,
200
201    /// Ratio of residual standard deviation to seasonal standard deviation
202    /// (`σ_m` / `s_m`). Dimensionless, in (0, 1]. The runtime residual std is
203    /// `std_m3s * residual_std_ratio`. When `ar_coefficients` is empty
204    /// (white noise), this is 1.0 (the AR model explains nothing).
205    pub residual_std_ratio: f64,
206}
207
208impl InflowModel {
209    /// AR model order p (number of lags). Zero means white-noise inflow.
210    #[must_use]
211    pub fn ar_order(&self) -> usize {
212        self.ar_coefficients.len()
213    }
214}
215
216// ---------------------------------------------------------------------------
217// LoadModel (SS14 — per bus, per stage)
218// ---------------------------------------------------------------------------
219
220/// Raw load seasonal statistics for a single (bus, stage) pair.
221///
222/// Stores the mean and standard deviation of load demand loaded from
223/// `load_seasonal_stats.parquet`. Load typically has no AR structure,
224/// so no lag coefficients are stored here.
225///
226/// The `System` holds a `Vec<LoadModel>` sorted by `(bus_id, stage_id)`.
227///
228/// See [internal-structures.md §14](../specs/data-model/internal-structures.md).
229///
230/// # Examples
231///
232/// ```
233/// use cobre_core::{EntityId, scenario::LoadModel};
234///
235/// let model = LoadModel {
236///     bus_id: EntityId(5),
237///     stage_id: 0,
238///     mean_mw: 320.5,
239///     std_mw: 45.0,
240/// };
241/// assert_eq!(model.mean_mw, 320.5);
242/// ```
243#[derive(Debug, Clone, PartialEq)]
244#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
245pub struct LoadModel {
246    /// Bus this load model belongs to.
247    pub bus_id: EntityId,
248
249    /// Stage (0-based index within `System::stages`) this model applies to.
250    pub stage_id: i32,
251
252    /// Seasonal mean load demand in MW.
253    pub mean_mw: f64,
254
255    /// Seasonal standard deviation of load demand in MW.
256    pub std_mw: f64,
257}
258
259// ---------------------------------------------------------------------------
260// CorrelationEntity
261// ---------------------------------------------------------------------------
262
263/// A single entity reference within a correlation group.
264///
265/// `entity_type` is a string tag (currently always `"inflow"`) that
266/// identifies the kind of stochastic variable. Using `String` rather than
267/// an enum preserves forward compatibility when additional entity types
268/// (e.g., load, wind) are added without a breaking schema change.
269///
270/// See [Input Scenarios §5](input-scenarios.md).
271#[derive(Debug, Clone, PartialEq, Eq)]
272#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
273pub struct CorrelationEntity {
274    /// Entity type tag. Currently `"inflow"` for hydro inflow series.
275    pub entity_type: String,
276
277    /// Entity identifier matching the corresponding entity's `id` field.
278    pub id: EntityId,
279}
280
281// ---------------------------------------------------------------------------
282// CorrelationGroup
283// ---------------------------------------------------------------------------
284
285/// A named group of correlated entities and their correlation matrix.
286///
287/// `matrix` is a symmetric positive-semi-definite matrix stored in
288/// row-major order as `Vec<Vec<f64>>`. `matrix[i][j]` is the correlation
289/// coefficient between `entities[i]` and `entities[j]`.
290/// `matrix.len()` must equal `entities.len()`.
291///
292/// Cholesky decomposition of the matrix is NOT performed here; that
293/// belongs to `cobre-stochastic`.
294///
295/// See [Input Scenarios §5](input-scenarios.md).
296///
297/// # Examples
298///
299/// ```
300/// use cobre_core::{EntityId, scenario::{CorrelationEntity, CorrelationGroup}};
301///
302/// let group = CorrelationGroup {
303///     name: "Southeast".to_string(),
304///     entities: vec![
305///         CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(1) },
306///         CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(2) },
307///     ],
308///     matrix: vec![
309///         vec![1.0, 0.8],
310///         vec![0.8, 1.0],
311///     ],
312/// };
313/// assert_eq!(group.matrix.len(), 2);
314/// ```
315#[derive(Debug, Clone, PartialEq)]
316#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
317pub struct CorrelationGroup {
318    /// Human-readable group label (e.g., `"Southeast"`, `"North"`).
319    pub name: String,
320
321    /// Ordered list of entities whose correlation is captured by `matrix`.
322    pub entities: Vec<CorrelationEntity>,
323
324    /// Symmetric correlation matrix in row-major order.
325    /// `matrix[i][j]` = correlation between `entities[i]` and `entities[j]`.
326    /// Diagonal entries must be 1.0. Shape: `entities.len() × entities.len()`.
327    pub matrix: Vec<Vec<f64>>,
328}
329
330// ---------------------------------------------------------------------------
331// CorrelationProfile
332// ---------------------------------------------------------------------------
333
334/// A named correlation profile containing one or more correlation groups.
335///
336/// A profile groups correlated entities into disjoint [`CorrelationGroup`]s.
337/// Entities in different groups are treated as uncorrelated. Profiles are
338/// stored in [`CorrelationModel::profiles`] keyed by profile name.
339///
340/// See [Input Scenarios §5](input-scenarios.md).
341///
342/// # Examples
343///
344/// ```
345/// use cobre_core::{EntityId, scenario::{CorrelationEntity, CorrelationGroup, CorrelationProfile}};
346///
347/// let profile = CorrelationProfile {
348///     groups: vec![CorrelationGroup {
349///         name: "All".to_string(),
350///         entities: vec![
351///             CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(1) },
352///             CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(2) },
353///             CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(3) },
354///         ],
355///         matrix: vec![
356///             vec![1.0, 0.0, 0.0],
357///             vec![0.0, 1.0, 0.0],
358///             vec![0.0, 0.0, 1.0],
359///         ],
360///     }],
361/// };
362/// assert_eq!(profile.groups[0].matrix.len(), 3);
363/// ```
364#[derive(Debug, Clone, PartialEq)]
365#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
366pub struct CorrelationProfile {
367    /// Disjoint groups of correlated entities within this profile.
368    pub groups: Vec<CorrelationGroup>,
369}
370
371// ---------------------------------------------------------------------------
372// CorrelationScheduleEntry
373// ---------------------------------------------------------------------------
374
375/// Maps a stage to its active correlation profile name.
376///
377/// When [`CorrelationModel::schedule`] is non-empty, each stage that
378/// requires a non-default correlation profile has an entry here. Stages
379/// without an entry use the profile named `"default"` if present, or the
380/// sole profile if only one profile exists.
381///
382/// See [Input Scenarios §5](input-scenarios.md).
383#[derive(Debug, Clone, PartialEq, Eq)]
384#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
385pub struct CorrelationScheduleEntry {
386    /// Stage index (0-based within `System::stages`) this entry applies to.
387    pub stage_id: i32,
388
389    /// Name of the correlation profile active for this stage.
390    /// Must match a key in [`CorrelationModel::profiles`].
391    pub profile_name: String,
392}
393
394// ---------------------------------------------------------------------------
395// CorrelationModel
396// ---------------------------------------------------------------------------
397
398/// Top-level correlation configuration for the scenario pipeline.
399///
400/// Holds all named correlation profiles and the optional stage-to-profile
401/// schedule. When `schedule` is empty, the solver uses a single profile
402/// (typically named `"default"`) for all stages.
403///
404/// `profiles` uses [`BTreeMap`] rather than [`HashMap`](std::collections::HashMap) to preserve
405/// deterministic iteration order, satisfying the declaration-order
406/// invariance requirement (design-principles.md §3).
407///
408/// `method` is always `"cholesky"` for the minimal viable solver but stored
409/// as a `String` for forward compatibility with future decomposition methods.
410///
411/// Source: `correlation.json`.
412/// See [Input Scenarios §5](input-scenarios.md) and
413/// [internal-structures.md §14](../specs/data-model/internal-structures.md).
414///
415/// # Examples
416///
417/// ```
418/// use std::collections::BTreeMap;
419/// use cobre_core::{EntityId, scenario::{
420///     CorrelationEntity, CorrelationGroup, CorrelationModel, CorrelationProfile,
421///     CorrelationScheduleEntry,
422/// }};
423///
424/// let mut profiles = BTreeMap::new();
425/// profiles.insert("default".to_string(), CorrelationProfile {
426///     groups: vec![CorrelationGroup {
427///         name: "All".to_string(),
428///         entities: vec![
429///             CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(1) },
430///         ],
431///         matrix: vec![vec![1.0]],
432///     }],
433/// });
434///
435/// let model = CorrelationModel {
436///     method: "cholesky".to_string(),
437///     profiles,
438///     schedule: vec![],
439/// };
440/// assert!(model.profiles.contains_key("default"));
441/// ```
442#[derive(Debug, Clone, PartialEq)]
443#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
444pub struct CorrelationModel {
445    /// Decomposition method. Always `"cholesky"` for the minimal viable solver.
446    /// Stored as `String` for forward compatibility.
447    pub method: String,
448
449    /// Named correlation profiles keyed by profile name.
450    /// `BTreeMap` for deterministic ordering (declaration-order invariance).
451    pub profiles: BTreeMap<String, CorrelationProfile>,
452
453    /// Stage-to-profile schedule. Empty when a single profile applies to
454    /// all stages.
455    pub schedule: Vec<CorrelationScheduleEntry>,
456}
457
458impl Default for ScenarioSource {
459    fn default() -> Self {
460        Self {
461            sampling_scheme: SamplingScheme::InSample,
462            seed: None,
463            selection_mode: None,
464        }
465    }
466}
467
468impl Default for CorrelationModel {
469    fn default() -> Self {
470        Self {
471            method: "cholesky".to_string(),
472            profiles: BTreeMap::new(),
473            schedule: Vec::new(),
474        }
475    }
476}
477
478// ---------------------------------------------------------------------------
479// Tests
480// ---------------------------------------------------------------------------
481
482#[cfg(test)]
483mod tests {
484    use std::collections::BTreeMap;
485
486    use super::{
487        CorrelationEntity, CorrelationGroup, CorrelationModel, CorrelationProfile,
488        CorrelationScheduleEntry, InflowModel, SamplingScheme,
489    };
490    #[cfg(feature = "serde")]
491    use super::{ExternalSelectionMode, ScenarioSource};
492    use crate::EntityId;
493
494    #[test]
495    fn test_inflow_model_construction() {
496        let model = InflowModel {
497            hydro_id: EntityId(7),
498            stage_id: 11,
499            mean_m3s: 250.0,
500            std_m3s: 55.0,
501            ar_coefficients: vec![0.5, 0.2, 0.1],
502            residual_std_ratio: 0.85,
503        };
504
505        assert_eq!(model.hydro_id, EntityId(7));
506        assert_eq!(model.stage_id, 11);
507        assert_eq!(model.mean_m3s, 250.0);
508        assert_eq!(model.std_m3s, 55.0);
509        assert_eq!(model.ar_order(), 3);
510        assert_eq!(model.ar_coefficients, vec![0.5, 0.2, 0.1]);
511        assert_eq!(model.ar_coefficients.len(), model.ar_order());
512        assert!((model.residual_std_ratio - 0.85).abs() < f64::EPSILON);
513    }
514
515    #[test]
516    fn test_inflow_model_ar_order_method() {
517        // Empty coefficients: ar_order() == 0 (white noise)
518        let white_noise = InflowModel {
519            hydro_id: EntityId(1),
520            stage_id: 0,
521            mean_m3s: 100.0,
522            std_m3s: 10.0,
523            ar_coefficients: vec![],
524            residual_std_ratio: 1.0,
525        };
526        assert_eq!(white_noise.ar_order(), 0);
527
528        // Two coefficients: ar_order() == 2
529        let par2 = InflowModel {
530            hydro_id: EntityId(2),
531            stage_id: 1,
532            mean_m3s: 200.0,
533            std_m3s: 20.0,
534            ar_coefficients: vec![0.45, 0.22],
535            residual_std_ratio: 0.85,
536        };
537        assert_eq!(par2.ar_order(), 2);
538    }
539
540    #[test]
541    fn test_correlation_model_construction() {
542        let make_profile = |entity_ids: &[i32]| {
543            let entities: Vec<CorrelationEntity> = entity_ids
544                .iter()
545                .map(|&id| CorrelationEntity {
546                    entity_type: "inflow".to_string(),
547                    id: EntityId(id),
548                })
549                .collect();
550            let n = entities.len();
551            let matrix: Vec<Vec<f64>> = (0..n)
552                .map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
553                .collect();
554            CorrelationProfile {
555                groups: vec![CorrelationGroup {
556                    name: "group_a".to_string(),
557                    entities,
558                    matrix,
559                }],
560            }
561        };
562
563        let mut profiles = BTreeMap::new();
564        profiles.insert("wet".to_string(), make_profile(&[1, 2, 3]));
565        profiles.insert("dry".to_string(), make_profile(&[1, 2]));
566
567        let model = CorrelationModel {
568            method: "cholesky".to_string(),
569            profiles,
570            schedule: vec![
571                CorrelationScheduleEntry {
572                    stage_id: 0,
573                    profile_name: "wet".to_string(),
574                },
575                CorrelationScheduleEntry {
576                    stage_id: 6,
577                    profile_name: "dry".to_string(),
578                },
579            ],
580        };
581
582        // Two profiles present
583        assert_eq!(model.profiles.len(), 2);
584
585        // BTreeMap ordering is alphabetical: "dry" before "wet"
586        let mut profile_iter = model.profiles.keys();
587        assert_eq!(profile_iter.next().unwrap(), "dry");
588        assert_eq!(profile_iter.next().unwrap(), "wet");
589
590        // Profile lookup by name
591        assert!(model.profiles.contains_key("wet"));
592        assert!(model.profiles.contains_key("dry"));
593
594        // Matrix dimensions match entity count
595        let wet = &model.profiles["wet"];
596        assert_eq!(wet.groups[0].matrix.len(), 3);
597
598        let dry = &model.profiles["dry"];
599        assert_eq!(dry.groups[0].matrix.len(), 2);
600
601        // Schedule entries
602        assert_eq!(model.schedule.len(), 2);
603        assert_eq!(model.schedule[0].profile_name, "wet");
604        assert_eq!(model.schedule[1].profile_name, "dry");
605    }
606
607    #[test]
608    fn test_sampling_scheme_copy() {
609        let original = SamplingScheme::InSample;
610        let copied = original;
611        assert_eq!(original, copied);
612
613        let original_ext = SamplingScheme::External;
614        let copied_ext = original_ext;
615        assert_eq!(original_ext, copied_ext);
616
617        let original_hist = SamplingScheme::Historical;
618        let copied_hist = original_hist;
619        assert_eq!(original_hist, copied_hist);
620    }
621
622    #[cfg(feature = "serde")]
623    #[test]
624    fn test_scenario_source_serde_roundtrip() {
625        // InSample with seed
626        let source = ScenarioSource {
627            sampling_scheme: SamplingScheme::InSample,
628            seed: Some(12345),
629            selection_mode: None,
630        };
631        let json = serde_json::to_string(&source).unwrap();
632        let deserialized: ScenarioSource = serde_json::from_str(&json).unwrap();
633        assert_eq!(source, deserialized);
634
635        // External with selection mode
636        let source_ext = ScenarioSource {
637            sampling_scheme: SamplingScheme::External,
638            seed: Some(99),
639            selection_mode: Some(ExternalSelectionMode::Sequential),
640        };
641        let json_ext = serde_json::to_string(&source_ext).unwrap();
642        let deserialized_ext: ScenarioSource = serde_json::from_str(&json_ext).unwrap();
643        assert_eq!(source_ext, deserialized_ext);
644
645        // Historical without seed
646        let source_hist = ScenarioSource {
647            sampling_scheme: SamplingScheme::Historical,
648            seed: None,
649            selection_mode: None,
650        };
651        let json_hist = serde_json::to_string(&source_hist).unwrap();
652        let deserialized_hist: ScenarioSource = serde_json::from_str(&json_hist).unwrap();
653        assert_eq!(source_hist, deserialized_hist);
654    }
655
656    #[cfg(feature = "serde")]
657    #[test]
658    fn test_inflow_model_serde_roundtrip() {
659        let model = InflowModel {
660            hydro_id: EntityId(3),
661            stage_id: 0,
662            mean_m3s: 150.0,
663            std_m3s: 30.0,
664            ar_coefficients: vec![0.45, 0.22],
665            residual_std_ratio: 0.85,
666        };
667        let json = serde_json::to_string(&model).unwrap();
668        let deserialized: InflowModel = serde_json::from_str(&json).unwrap();
669        assert_eq!(model, deserialized);
670        assert!((deserialized.residual_std_ratio - 0.85).abs() < f64::EPSILON);
671    }
672
673    #[test]
674    fn test_correlation_model_identity_matrix_access() {
675        let identity = vec![
676            vec![1.0, 0.0, 0.0],
677            vec![0.0, 1.0, 0.0],
678            vec![0.0, 0.0, 1.0],
679        ];
680        let mut profiles = BTreeMap::new();
681        profiles.insert(
682            "default".to_string(),
683            CorrelationProfile {
684                groups: vec![CorrelationGroup {
685                    name: "all_hydros".to_string(),
686                    entities: vec![
687                        CorrelationEntity {
688                            entity_type: "inflow".to_string(),
689                            id: EntityId(1),
690                        },
691                        CorrelationEntity {
692                            entity_type: "inflow".to_string(),
693                            id: EntityId(2),
694                        },
695                        CorrelationEntity {
696                            entity_type: "inflow".to_string(),
697                            id: EntityId(3),
698                        },
699                    ],
700                    matrix: identity,
701                }],
702            },
703        );
704        let model = CorrelationModel {
705            method: "cholesky".to_string(),
706            profiles,
707            schedule: vec![],
708        };
709
710        // AC: model.profiles["default"].groups[0].matrix.len() == 3
711        assert_eq!(model.profiles["default"].groups[0].matrix.len(), 3);
712    }
713}