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}