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 (`PrecomputedPar`, 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 (`PrecomputedPar`) 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// NcsModel (per NCS entity, per stage)
261// ---------------------------------------------------------------------------
262
263/// Per-stage normal noise model parameters for a non-controllable source.
264///
265/// Loaded from `scenarios/non_controllable_stats.parquet`. Each row provides
266/// the mean and standard deviation of the stochastic availability factor for
267/// one NCS entity at one stage. The scenario pipeline uses these parameters
268/// to generate per-scenario availability realisations.
269///
270/// The noise model is: `A_r = max_gen * clamp(mean + std * epsilon, 0, 1)`,
271/// where `epsilon ~ N(0,1)` and `mean`, `std` are dimensionless availability
272/// factors in `[0, 1]`.
273///
274/// The `System` holds a `Vec<NcsModel>` sorted by `(ncs_id, stage_id)`.
275///
276/// # Examples
277///
278/// ```
279/// use cobre_core::{EntityId, scenario::NcsModel};
280///
281/// let model = NcsModel {
282///     ncs_id: EntityId(3),
283///     stage_id: 0,
284///     mean: 0.5,
285///     std: 0.1,
286/// };
287/// assert_eq!(model.mean, 0.5);
288/// ```
289#[derive(Debug, Clone, PartialEq)]
290#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
291pub struct NcsModel {
292    /// NCS entity identifier matching `NonControllableSource.id`.
293    pub ncs_id: EntityId,
294
295    /// Stage (0-based index within `System::stages`) this model applies to.
296    pub stage_id: i32,
297
298    /// Mean availability factor [dimensionless, in `[0, 1]`].
299    pub mean: f64,
300
301    /// Standard deviation of the availability factor [dimensionless, >= 0].
302    pub std: f64,
303}
304
305// ---------------------------------------------------------------------------
306// CorrelationEntity
307// ---------------------------------------------------------------------------
308
309/// A single entity reference within a correlation group.
310///
311/// `entity_type` is a string tag that identifies the kind of stochastic
312/// variable. Valid values are:
313///
314/// - `"inflow"` — hydro inflow series (entity ID matches `Hydro.id`)
315/// - `"load"` — stochastic load demand (entity ID matches `Bus.id`)
316/// - `"ncs"` — non-controllable source availability (entity ID matches
317///   `NonControllableSource.id`)
318///
319/// Using `String` rather than an enum preserves forward compatibility when
320/// additional entity types are added without a breaking schema change.
321///
322/// See [Input Scenarios §5](input-scenarios.md).
323#[derive(Debug, Clone, PartialEq, Eq)]
324#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
325pub struct CorrelationEntity {
326    /// Entity type tag: `"inflow"`, `"load"`, or `"ncs"`.
327    pub entity_type: String,
328
329    /// Entity identifier matching the corresponding entity's `id` field.
330    pub id: EntityId,
331}
332
333// ---------------------------------------------------------------------------
334// CorrelationGroup
335// ---------------------------------------------------------------------------
336
337/// A named group of correlated entities and their correlation matrix.
338///
339/// `matrix` is a symmetric positive-semi-definite matrix stored in
340/// row-major order as `Vec<Vec<f64>>`. `matrix[i][j]` is the correlation
341/// coefficient between `entities[i]` and `entities[j]`.
342/// `matrix.len()` must equal `entities.len()`.
343///
344/// Cholesky decomposition of the matrix is NOT performed here; that
345/// belongs to `cobre-stochastic`.
346///
347/// See [Input Scenarios §5](input-scenarios.md).
348///
349/// # Examples
350///
351/// ```
352/// use cobre_core::{EntityId, scenario::{CorrelationEntity, CorrelationGroup}};
353///
354/// let group = CorrelationGroup {
355///     name: "Southeast".to_string(),
356///     entities: vec![
357///         CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(1) },
358///         CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(2) },
359///     ],
360///     matrix: vec![
361///         vec![1.0, 0.8],
362///         vec![0.8, 1.0],
363///     ],
364/// };
365/// assert_eq!(group.matrix.len(), 2);
366/// ```
367#[derive(Debug, Clone, PartialEq)]
368#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
369pub struct CorrelationGroup {
370    /// Human-readable group label (e.g., `"Southeast"`, `"North"`).
371    pub name: String,
372
373    /// Ordered list of entities whose correlation is captured by `matrix`.
374    pub entities: Vec<CorrelationEntity>,
375
376    /// Symmetric correlation matrix in row-major order.
377    /// `matrix[i][j]` = correlation between `entities[i]` and `entities[j]`.
378    /// Diagonal entries must be 1.0. Shape: `entities.len() × entities.len()`.
379    pub matrix: Vec<Vec<f64>>,
380}
381
382// ---------------------------------------------------------------------------
383// CorrelationProfile
384// ---------------------------------------------------------------------------
385
386/// A named correlation profile containing one or more correlation groups.
387///
388/// A profile groups correlated entities into disjoint [`CorrelationGroup`]s.
389/// Entities in different groups are treated as uncorrelated. Profiles are
390/// stored in [`CorrelationModel::profiles`] keyed by profile name.
391///
392/// See [Input Scenarios §5](input-scenarios.md).
393///
394/// # Examples
395///
396/// ```
397/// use cobre_core::{EntityId, scenario::{CorrelationEntity, CorrelationGroup, CorrelationProfile}};
398///
399/// let profile = CorrelationProfile {
400///     groups: vec![CorrelationGroup {
401///         name: "All".to_string(),
402///         entities: vec![
403///             CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(1) },
404///             CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(2) },
405///             CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(3) },
406///         ],
407///         matrix: vec![
408///             vec![1.0, 0.0, 0.0],
409///             vec![0.0, 1.0, 0.0],
410///             vec![0.0, 0.0, 1.0],
411///         ],
412///     }],
413/// };
414/// assert_eq!(profile.groups[0].matrix.len(), 3);
415/// ```
416#[derive(Debug, Clone, PartialEq)]
417#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
418pub struct CorrelationProfile {
419    /// Disjoint groups of correlated entities within this profile.
420    pub groups: Vec<CorrelationGroup>,
421}
422
423// ---------------------------------------------------------------------------
424// CorrelationScheduleEntry
425// ---------------------------------------------------------------------------
426
427/// Maps a stage to its active correlation profile name.
428///
429/// When [`CorrelationModel::schedule`] is non-empty, each stage that
430/// requires a non-default correlation profile has an entry here. Stages
431/// without an entry use the profile named `"default"` if present, or the
432/// sole profile if only one profile exists.
433///
434/// See [Input Scenarios §5](input-scenarios.md).
435#[derive(Debug, Clone, PartialEq, Eq)]
436#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
437pub struct CorrelationScheduleEntry {
438    /// Stage index (0-based within `System::stages`) this entry applies to.
439    pub stage_id: i32,
440
441    /// Name of the correlation profile active for this stage.
442    /// Must match a key in [`CorrelationModel::profiles`].
443    pub profile_name: String,
444}
445
446// ---------------------------------------------------------------------------
447// CorrelationModel
448// ---------------------------------------------------------------------------
449
450/// Top-level correlation configuration for the scenario pipeline.
451///
452/// Holds all named correlation profiles and the optional stage-to-profile
453/// schedule. When `schedule` is empty, the solver uses a single profile
454/// (typically named `"default"`) for all stages.
455///
456/// `profiles` uses [`BTreeMap`] rather than [`HashMap`](std::collections::HashMap) to preserve
457/// deterministic iteration order, satisfying the declaration-order
458/// invariance requirement (design-principles.md §3).
459///
460/// `method` is always `"cholesky"` for the minimal viable solver but stored
461/// as a `String` for forward compatibility with future decomposition methods.
462///
463/// Source: `correlation.json`.
464/// See [Input Scenarios §5](input-scenarios.md) and
465/// [internal-structures.md §14](../specs/data-model/internal-structures.md).
466///
467/// # Examples
468///
469/// ```
470/// use std::collections::BTreeMap;
471/// use cobre_core::{EntityId, scenario::{
472///     CorrelationEntity, CorrelationGroup, CorrelationModel, CorrelationProfile,
473///     CorrelationScheduleEntry,
474/// }};
475///
476/// let mut profiles = BTreeMap::new();
477/// profiles.insert("default".to_string(), CorrelationProfile {
478///     groups: vec![CorrelationGroup {
479///         name: "All".to_string(),
480///         entities: vec![
481///             CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(1) },
482///         ],
483///         matrix: vec![vec![1.0]],
484///     }],
485/// });
486///
487/// let model = CorrelationModel {
488///     method: "cholesky".to_string(),
489///     profiles,
490///     schedule: vec![],
491/// };
492/// assert!(model.profiles.contains_key("default"));
493/// ```
494#[derive(Debug, Clone, PartialEq)]
495#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
496pub struct CorrelationModel {
497    /// Decomposition method. Always `"cholesky"` for the minimal viable solver.
498    /// Stored as `String` for forward compatibility.
499    pub method: String,
500
501    /// Named correlation profiles keyed by profile name.
502    /// `BTreeMap` for deterministic ordering (declaration-order invariance).
503    pub profiles: BTreeMap<String, CorrelationProfile>,
504
505    /// Stage-to-profile schedule. Empty when a single profile applies to
506    /// all stages.
507    pub schedule: Vec<CorrelationScheduleEntry>,
508}
509
510impl Default for ScenarioSource {
511    fn default() -> Self {
512        Self {
513            sampling_scheme: SamplingScheme::InSample,
514            seed: None,
515            selection_mode: None,
516        }
517    }
518}
519
520impl Default for CorrelationModel {
521    fn default() -> Self {
522        Self {
523            method: "cholesky".to_string(),
524            profiles: BTreeMap::new(),
525            schedule: Vec::new(),
526        }
527    }
528}
529
530// ---------------------------------------------------------------------------
531// Tests
532// ---------------------------------------------------------------------------
533
534#[cfg(test)]
535mod tests {
536    use std::collections::BTreeMap;
537
538    use super::{
539        CorrelationEntity, CorrelationGroup, CorrelationModel, CorrelationProfile,
540        CorrelationScheduleEntry, InflowModel, NcsModel, SamplingScheme,
541    };
542    #[cfg(feature = "serde")]
543    use super::{ExternalSelectionMode, ScenarioSource};
544    use crate::EntityId;
545
546    #[test]
547    fn test_inflow_model_construction() {
548        let model = InflowModel {
549            hydro_id: EntityId(7),
550            stage_id: 11,
551            mean_m3s: 250.0,
552            std_m3s: 55.0,
553            ar_coefficients: vec![0.5, 0.2, 0.1],
554            residual_std_ratio: 0.85,
555        };
556
557        assert_eq!(model.hydro_id, EntityId(7));
558        assert_eq!(model.stage_id, 11);
559        assert_eq!(model.mean_m3s, 250.0);
560        assert_eq!(model.std_m3s, 55.0);
561        assert_eq!(model.ar_order(), 3);
562        assert_eq!(model.ar_coefficients, vec![0.5, 0.2, 0.1]);
563        assert_eq!(model.ar_coefficients.len(), model.ar_order());
564        assert!((model.residual_std_ratio - 0.85).abs() < f64::EPSILON);
565    }
566
567    #[test]
568    fn test_inflow_model_ar_order_method() {
569        // Empty coefficients: ar_order() == 0 (white noise)
570        let white_noise = InflowModel {
571            hydro_id: EntityId(1),
572            stage_id: 0,
573            mean_m3s: 100.0,
574            std_m3s: 10.0,
575            ar_coefficients: vec![],
576            residual_std_ratio: 1.0,
577        };
578        assert_eq!(white_noise.ar_order(), 0);
579
580        // Two coefficients: ar_order() == 2
581        let par2 = InflowModel {
582            hydro_id: EntityId(2),
583            stage_id: 1,
584            mean_m3s: 200.0,
585            std_m3s: 20.0,
586            ar_coefficients: vec![0.45, 0.22],
587            residual_std_ratio: 0.85,
588        };
589        assert_eq!(par2.ar_order(), 2);
590    }
591
592    #[test]
593    fn test_correlation_model_construction() {
594        let make_profile = |entity_ids: &[i32]| {
595            let entities: Vec<CorrelationEntity> = entity_ids
596                .iter()
597                .map(|&id| CorrelationEntity {
598                    entity_type: "inflow".to_string(),
599                    id: EntityId(id),
600                })
601                .collect();
602            let n = entities.len();
603            let matrix: Vec<Vec<f64>> = (0..n)
604                .map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
605                .collect();
606            CorrelationProfile {
607                groups: vec![CorrelationGroup {
608                    name: "group_a".to_string(),
609                    entities,
610                    matrix,
611                }],
612            }
613        };
614
615        let mut profiles = BTreeMap::new();
616        profiles.insert("wet".to_string(), make_profile(&[1, 2, 3]));
617        profiles.insert("dry".to_string(), make_profile(&[1, 2]));
618
619        let model = CorrelationModel {
620            method: "cholesky".to_string(),
621            profiles,
622            schedule: vec![
623                CorrelationScheduleEntry {
624                    stage_id: 0,
625                    profile_name: "wet".to_string(),
626                },
627                CorrelationScheduleEntry {
628                    stage_id: 6,
629                    profile_name: "dry".to_string(),
630                },
631            ],
632        };
633
634        // Two profiles present
635        assert_eq!(model.profiles.len(), 2);
636
637        // BTreeMap ordering is alphabetical: "dry" before "wet"
638        let mut profile_iter = model.profiles.keys();
639        assert_eq!(profile_iter.next().unwrap(), "dry");
640        assert_eq!(profile_iter.next().unwrap(), "wet");
641
642        // Profile lookup by name
643        assert!(model.profiles.contains_key("wet"));
644        assert!(model.profiles.contains_key("dry"));
645
646        // Matrix dimensions match entity count
647        let wet = &model.profiles["wet"];
648        assert_eq!(wet.groups[0].matrix.len(), 3);
649
650        let dry = &model.profiles["dry"];
651        assert_eq!(dry.groups[0].matrix.len(), 2);
652
653        // Schedule entries
654        assert_eq!(model.schedule.len(), 2);
655        assert_eq!(model.schedule[0].profile_name, "wet");
656        assert_eq!(model.schedule[1].profile_name, "dry");
657    }
658
659    #[test]
660    fn test_sampling_scheme_copy() {
661        let original = SamplingScheme::InSample;
662        let copied = original;
663        assert_eq!(original, copied);
664
665        let original_ext = SamplingScheme::External;
666        let copied_ext = original_ext;
667        assert_eq!(original_ext, copied_ext);
668
669        let original_hist = SamplingScheme::Historical;
670        let copied_hist = original_hist;
671        assert_eq!(original_hist, copied_hist);
672    }
673
674    #[cfg(feature = "serde")]
675    #[test]
676    fn test_scenario_source_serde_roundtrip() {
677        // InSample with seed
678        let source = ScenarioSource {
679            sampling_scheme: SamplingScheme::InSample,
680            seed: Some(12345),
681            selection_mode: None,
682        };
683        let json = serde_json::to_string(&source).unwrap();
684        let deserialized: ScenarioSource = serde_json::from_str(&json).unwrap();
685        assert_eq!(source, deserialized);
686
687        // External with selection mode
688        let source_ext = ScenarioSource {
689            sampling_scheme: SamplingScheme::External,
690            seed: Some(99),
691            selection_mode: Some(ExternalSelectionMode::Sequential),
692        };
693        let json_ext = serde_json::to_string(&source_ext).unwrap();
694        let deserialized_ext: ScenarioSource = serde_json::from_str(&json_ext).unwrap();
695        assert_eq!(source_ext, deserialized_ext);
696
697        // Historical without seed
698        let source_hist = ScenarioSource {
699            sampling_scheme: SamplingScheme::Historical,
700            seed: None,
701            selection_mode: None,
702        };
703        let json_hist = serde_json::to_string(&source_hist).unwrap();
704        let deserialized_hist: ScenarioSource = serde_json::from_str(&json_hist).unwrap();
705        assert_eq!(source_hist, deserialized_hist);
706    }
707
708    #[cfg(feature = "serde")]
709    #[test]
710    fn test_inflow_model_serde_roundtrip() {
711        let model = InflowModel {
712            hydro_id: EntityId(3),
713            stage_id: 0,
714            mean_m3s: 150.0,
715            std_m3s: 30.0,
716            ar_coefficients: vec![0.45, 0.22],
717            residual_std_ratio: 0.85,
718        };
719        let json = serde_json::to_string(&model).unwrap();
720        let deserialized: InflowModel = serde_json::from_str(&json).unwrap();
721        assert_eq!(model, deserialized);
722        assert!((deserialized.residual_std_ratio - 0.85).abs() < f64::EPSILON);
723    }
724
725    #[test]
726    fn test_ncs_model_construction() {
727        let model = NcsModel {
728            ncs_id: EntityId(3),
729            stage_id: 0,
730            mean: 0.5,
731            std: 0.1,
732        };
733
734        assert_eq!(model.ncs_id, EntityId(3));
735        assert_eq!(model.stage_id, 0);
736        assert_eq!(model.mean, 0.5);
737        assert_eq!(model.std, 0.1);
738    }
739
740    #[cfg(feature = "serde")]
741    #[test]
742    fn test_ncs_model_serde_roundtrip() {
743        let model = NcsModel {
744            ncs_id: EntityId(5),
745            stage_id: 2,
746            mean: 0.75,
747            std: 0.15,
748        };
749        let json = serde_json::to_string(&model).unwrap();
750        let deserialized: NcsModel = serde_json::from_str(&json).unwrap();
751        assert_eq!(model, deserialized);
752    }
753
754    #[test]
755    fn test_correlation_model_identity_matrix_access() {
756        let identity = vec![
757            vec![1.0, 0.0, 0.0],
758            vec![0.0, 1.0, 0.0],
759            vec![0.0, 0.0, 1.0],
760        ];
761        let mut profiles = BTreeMap::new();
762        profiles.insert(
763            "default".to_string(),
764            CorrelationProfile {
765                groups: vec![CorrelationGroup {
766                    name: "all_hydros".to_string(),
767                    entities: vec![
768                        CorrelationEntity {
769                            entity_type: "inflow".to_string(),
770                            id: EntityId(1),
771                        },
772                        CorrelationEntity {
773                            entity_type: "inflow".to_string(),
774                            id: EntityId(2),
775                        },
776                        CorrelationEntity {
777                            entity_type: "inflow".to_string(),
778                            id: EntityId(3),
779                        },
780                    ],
781                    matrix: identity,
782                }],
783            },
784        );
785        let model = CorrelationModel {
786            method: "cholesky".to_string(),
787            profiles,
788            schedule: vec![],
789        };
790
791        // AC: model.profiles["default"].groups[0].matrix.len() == 3
792        assert_eq!(model.profiles["default"].groups[0].matrix.len(), 3);
793    }
794}