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}