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`, spectrally 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/// Forward-pass noise source for multi-stage optimization solvers.
41///
42/// Determines where the forward-pass scenario realisations come from.
43/// This is orthogonal to [`NoiseMethod`](crate::temporal::NoiseMethod),
44/// which controls how the opening tree is generated during the backward
45/// pass. `SamplingScheme` selects the *source* of forward-pass noise;
46/// `NoiseMethod` selects the *algorithm* used to produce backward-pass
47/// openings.
48///
49/// See [Input Scenarios §1.8](input-scenarios.md) for the full catalog.
50///
51/// # Examples
52///
53/// ```
54/// use cobre_core::scenario::SamplingScheme;
55///
56/// let scheme = SamplingScheme::InSample;
57/// // SamplingScheme is Copy
58/// let copy = scheme;
59/// assert_eq!(scheme, copy);
60/// ```
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63pub enum SamplingScheme {
64 /// Forward pass uses the same opening tree generated for the backward pass.
65 /// This is the default for the minimal viable solver.
66 InSample,
67 /// Forward pass generates fresh noise on-the-fly from the same distribution
68 /// as the opening tree, using an independent seed.
69 OutOfSample,
70 /// Forward pass draws from an externally supplied scenario file.
71 External,
72 /// Forward pass replays historical inflow realisations in sequence or at random.
73 Historical,
74}
75
76// ScenarioSource (SS14 top-level config)
77
78/// Top-level scenario source configuration, parsed from `stages.json`.
79///
80/// Groups the sampling scheme and random seed that govern how forward-pass
81/// scenarios are produced. Populated during case loading by `cobre-io` from
82/// the `scenario_source` field in `stages.json`. Distinct from
83/// [`ScenarioSourceConfig`](crate::temporal::ScenarioSourceConfig),
84/// which also holds the branching factor (`num_scenarios`).
85///
86/// Each entity class (inflow, load, NCS) independently specifies its
87/// forward-pass noise source via a dedicated `SamplingScheme` field.
88/// The `seed` and `historical_years` fields are shared across all classes.
89///
90/// See [Input Scenarios §1.4, §1.8](input-scenarios.md).
91///
92/// # Examples
93///
94/// ```
95/// use cobre_core::scenario::{SamplingScheme, ScenarioSource};
96///
97/// let source = ScenarioSource {
98/// inflow_scheme: SamplingScheme::InSample,
99/// load_scheme: SamplingScheme::OutOfSample,
100/// ncs_scheme: SamplingScheme::InSample,
101/// seed: Some(42),
102/// historical_years: None,
103/// };
104/// assert_eq!(source.inflow_scheme, SamplingScheme::InSample);
105/// assert_eq!(source.load_scheme, SamplingScheme::OutOfSample);
106/// ```
107#[derive(Debug, Clone, PartialEq, Eq)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
109pub struct ScenarioSource {
110 /// Noise source used during the inflow forward pass.
111 pub inflow_scheme: SamplingScheme,
112
113 /// Noise source used during the load forward pass.
114 pub load_scheme: SamplingScheme,
115
116 /// Noise source used during the NCS (non-controllable source) forward pass.
117 pub ncs_scheme: SamplingScheme,
118
119 /// Random seed for reproducible opening tree generation.
120 /// `None` means non-deterministic (OS entropy).
121 pub seed: Option<i64>,
122
123 /// Historical year pool for [`SamplingScheme::Historical`] inflow sampling.
124 /// When `None`, all valid windows are auto-discovered at validation time.
125 pub historical_years: Option<HistoricalYears>,
126}
127
128// HistoricalYears (SS14 — year pool for Historical sampling)
129
130/// Specifies which historical years to draw from when using
131/// [`SamplingScheme::Historical`] sampling.
132///
133/// Preserves user intent (list vs range) so that validation and error messages
134/// can reference the original specification form. Expansion into a concrete
135/// year list is deferred to `cobre-io` validation (Tier 1) and scenario library
136/// construction.
137///
138/// When absent (represented as `Option<HistoricalYears>::None` at the
139/// `ScenarioSource` level), all valid windows are auto-discovered at
140/// validation time.
141///
142/// # Examples
143///
144/// ```
145/// use cobre_core::scenario::HistoricalYears;
146///
147/// // Explicit list of years
148/// let list = HistoricalYears::List(vec![1940, 1953, 1971]);
149/// assert!(matches!(list, HistoricalYears::List(_)));
150///
151/// // Inclusive range shorthand
152/// let range = HistoricalYears::Range { from: 1940, to: 2010 };
153/// assert!(matches!(range, HistoricalYears::Range { from: 1940, to: 2010 }));
154/// ```
155#[derive(Debug, Clone, PartialEq, Eq)]
156#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
157pub enum HistoricalYears {
158 /// Explicit list of historical years (e.g., `[1940, 1953, 1971]`).
159 List(Vec<i32>),
160
161 /// Inclusive range shorthand (e.g., years 1940 through 2010).
162 /// `from` and `to` are both inclusive. Validation of `from <= to`
163 /// is performed by `cobre-io`.
164 Range {
165 /// First year of the range (inclusive).
166 from: i32,
167 /// Last year of the range (inclusive).
168 to: i32,
169 },
170}
171
172impl HistoricalYears {
173 /// Expand the year specification into a concrete sorted list.
174 ///
175 /// - `List` — returns the years as-is (caller order is preserved).
176 /// - `Range` — expands the inclusive range `[from, to]` into a full list.
177 ///
178 /// # Examples
179 ///
180 /// ```
181 /// use cobre_core::scenario::HistoricalYears;
182 ///
183 /// let list = HistoricalYears::List(vec![1995, 2000, 2005]);
184 /// assert_eq!(list.to_years(), vec![1995, 2000, 2005]);
185 ///
186 /// let range = HistoricalYears::Range { from: 2000, to: 2003 };
187 /// assert_eq!(range.to_years(), vec![2000, 2001, 2002, 2003]);
188 /// ```
189 #[must_use]
190 pub fn to_years(&self) -> Vec<i32> {
191 match self {
192 HistoricalYears::List(years) => years.clone(),
193 HistoricalYears::Range { from, to } => (*from..=*to).collect(),
194 }
195 }
196}
197
198// InflowModel (SS14 — per hydro, per stage)
199
200/// Annual component of a PAR(p)-A inflow model for one (hydro, stage) pair.
201///
202/// Augments the classical PAR(p) with an annual term capturing long-range persistence.
203/// All three sub-fields are required together for the runtime unit conversion
204/// `ψ̂ = ψ · σ_m / σ^A_m`:
205///
206/// - the standardized annual coefficient `ψ` (Yule-Walker output)
207/// - the sample mean `μ^A_m` of the rolling 12-month average
208/// - the sample std `σ^A_m` of the rolling 12-month average
209///
210/// When `InflowModel::annual` is `None`, the classical PAR(p) model is in effect.
211///
212/// Source: `inflow_annual_component.parquet` (one row per (hydro, stage)
213/// carrying coefficient, mean, and std).
214#[derive(Debug, Clone, PartialEq)]
215#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
216pub struct AnnualComponent {
217 /// Standardized annual coefficient ψ (dimensionless, direct Yule-Walker output).
218 /// Relates the current-season inflow to the rolling 12-month average.
219 pub coefficient: f64,
220
221 /// Sample mean μ^A of the rolling 12-month average for the season, in m³/s.
222 /// Used to de-standardize the annual term at runtime.
223 pub mean_m3s: f64,
224
225 /// Sample std σ^A of the rolling 12-month average for the season, in m³/s.
226 /// Must be positive. Used together with `coefficient` and the seasonal std
227 /// to form the runtime annual coefficient: `ψ̂ = ψ · σ_m / σ^A_m`.
228 pub std_m3s: f64,
229}
230
231/// Raw PAR(p) model parameters for a single (hydro, stage) pair.
232///
233/// Stores the seasonal mean, standard deviation, and standardized AR lag
234/// coefficients loaded from `inflow_seasonal_stats.parquet` and
235/// `inflow_ar_coefficients.parquet`. These are the raw input-facing values.
236///
237/// AR coefficients are stored **standardized by seasonal std** (dimensionless ψ\*,
238/// direct Yule-Walker output). The `residual_std_ratio` field carries the ratio
239/// `σ_m` / `s_m` so that downstream crates can recover the runtime residual std as
240/// `std_m3s * residual_std_ratio` without re-deriving it from the coefficients.
241///
242/// The optional `annual` field activates the PAR(p)-A extension; when `None`,
243/// the classical PAR(p) model is in effect.
244///
245/// The performance-adapted view (`PrecomputedPar`) is built from these
246/// parameters once at solver initialisation and belongs to downstream solver crates.
247///
248/// ## Declaration-order invariance
249///
250/// The `System` holds a `Vec<InflowModel>` sorted by `(hydro_id, stage_id)`.
251/// All processing must iterate in that canonical order.
252///
253/// See [internal-structures.md §14](../specs/data-model/internal-structures.md)
254/// and [PAR Inflow Model §7](../math/par-inflow-model.md).
255///
256/// # Examples
257///
258/// Classical PAR(p) model (no annual component):
259///
260/// ```
261/// use cobre_core::{EntityId, scenario::InflowModel};
262///
263/// let model = InflowModel {
264/// hydro_id: EntityId(1),
265/// stage_id: 3,
266/// mean_m3s: 150.0,
267/// std_m3s: 30.0,
268/// ar_coefficients: vec![0.45, 0.22],
269/// residual_std_ratio: 0.85,
270/// annual: None,
271/// };
272/// assert_eq!(model.ar_order(), 2);
273/// assert_eq!(model.ar_coefficients.len(), 2);
274/// assert!((model.residual_std_ratio - 0.85).abs() < f64::EPSILON);
275/// assert!(model.annual.is_none());
276/// ```
277///
278/// PAR(p)-A model with annual component:
279///
280/// ```
281/// use cobre_core::{EntityId, scenario::{AnnualComponent, InflowModel}};
282///
283/// let model = InflowModel {
284/// hydro_id: EntityId(1),
285/// stage_id: 3,
286/// mean_m3s: 150.0,
287/// std_m3s: 30.0,
288/// ar_coefficients: vec![0.45, 0.22],
289/// residual_std_ratio: 0.85,
290/// annual: Some(AnnualComponent {
291/// coefficient: 0.15,
292/// mean_m3s: 90.0,
293/// std_m3s: 12.0,
294/// }),
295/// };
296/// assert_eq!(model.ar_order(), 2);
297/// let ann = model.annual.as_ref().expect("annual present");
298/// assert!((ann.coefficient - 0.15).abs() < f64::EPSILON);
299/// ```
300#[derive(Debug, Clone, PartialEq)]
301#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
302pub struct InflowModel {
303 /// Hydro plant this model belongs to.
304 pub hydro_id: EntityId,
305
306 /// Stage (0-based index within `System::stages`) this model applies to.
307 pub stage_id: i32,
308
309 /// Seasonal mean inflow μ in m³/s.
310 pub mean_m3s: f64,
311
312 /// Seasonal standard deviation `s_m` in m³/s (seasonal sample std).
313 pub std_m3s: f64,
314
315 /// AR lag coefficients [ψ\*₁, ψ\*₂, …, ψ\*ₚ] standardized by seasonal std
316 /// (dimensionless). These are the direct Yule-Walker output. Length is the
317 /// AR order p. Empty when p == 0 (white noise).
318 pub ar_coefficients: Vec<f64>,
319
320 /// Ratio of residual standard deviation to seasonal standard deviation
321 /// (`σ_m` / `s_m`). Dimensionless, in (0, 1]. The runtime residual std is
322 /// `std_m3s * residual_std_ratio`. When `ar_coefficients` is empty
323 /// (white noise), this is 1.0 (the AR model explains nothing).
324 pub residual_std_ratio: f64,
325
326 /// Optional annual component for the PAR(p)-A extension.
327 ///
328 /// `None` — classical PAR(p) model; no annual term is applied.
329 /// `Some(AnnualComponent { ... })` — the PAR(p)-A extension is active for
330 /// this (hydro, stage); all three sub-fields (`coefficient`, `mean_m3s`,
331 /// `std_m3s`) are guaranteed to be present. See [`AnnualComponent`] for
332 /// field details and the mathematical role of each value.
333 pub annual: Option<AnnualComponent>,
334}
335
336impl InflowModel {
337 /// AR model order p (number of lags). Zero means white-noise inflow.
338 #[must_use]
339 pub fn ar_order(&self) -> usize {
340 self.ar_coefficients.len()
341 }
342}
343
344// LoadModel (SS14 — per bus, per stage)
345
346/// Raw load seasonal statistics for a single (bus, stage) pair.
347///
348/// Stores the mean and standard deviation of load demand loaded from
349/// `load_seasonal_stats.parquet`. Load typically has no AR structure,
350/// so no lag coefficients are stored here.
351///
352/// The `System` holds a `Vec<LoadModel>` sorted by `(bus_id, stage_id)`.
353///
354/// See [internal-structures.md §14](../specs/data-model/internal-structures.md).
355///
356/// # Examples
357///
358/// ```
359/// use cobre_core::{EntityId, scenario::LoadModel};
360///
361/// let model = LoadModel {
362/// bus_id: EntityId(5),
363/// stage_id: 0,
364/// mean_mw: 320.5,
365/// std_mw: 45.0,
366/// };
367/// assert_eq!(model.mean_mw, 320.5);
368/// ```
369#[derive(Debug, Clone, PartialEq)]
370#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
371pub struct LoadModel {
372 /// Bus this load model belongs to.
373 pub bus_id: EntityId,
374
375 /// Stage (0-based index within `System::stages`) this model applies to.
376 pub stage_id: i32,
377
378 /// Seasonal mean load demand in MW.
379 pub mean_mw: f64,
380
381 /// Seasonal standard deviation of load demand in MW.
382 pub std_mw: f64,
383}
384
385// NcsModel (per NCS entity, per stage)
386
387/// Per-stage normal noise model parameters for a non-controllable source.
388///
389/// Loaded from `scenarios/non_controllable_stats.parquet`. Each row provides
390/// the mean and standard deviation of the stochastic availability factor for
391/// one NCS entity at one stage. The scenario pipeline uses these parameters
392/// to generate per-scenario availability realisations.
393///
394/// The noise model is: `A_r = max_gen * clamp(mean + std * epsilon, 0, 1)`,
395/// where `epsilon ~ N(0,1)` and `mean`, `std` are dimensionless availability
396/// factors in `[0, 1]`.
397///
398/// The `System` holds a `Vec<NcsModel>` sorted by `(ncs_id, stage_id)`.
399///
400/// # Examples
401///
402/// ```
403/// use cobre_core::{EntityId, scenario::NcsModel};
404///
405/// let model = NcsModel {
406/// ncs_id: EntityId(3),
407/// stage_id: 0,
408/// mean: 0.5,
409/// std: 0.1,
410/// };
411/// assert_eq!(model.mean, 0.5);
412/// ```
413#[derive(Debug, Clone, PartialEq)]
414#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
415pub struct NcsModel {
416 /// NCS entity identifier matching `NonControllableSource.id`.
417 pub ncs_id: EntityId,
418
419 /// Stage (0-based index within `System::stages`) this model applies to.
420 pub stage_id: i32,
421
422 /// Mean availability factor [dimensionless, in `[0, 1]`].
423 pub mean: f64,
424
425 /// Standard deviation of the availability factor [dimensionless, >= 0].
426 pub std: f64,
427}
428
429// InflowHistoryRow (SS2.4 — raw historical observation)
430
431/// A single row from `scenarios/inflow_history.parquet`.
432///
433/// Carries one historical inflow observation for a (hydro, date) pair.
434/// These rows constitute the raw historical record used by PAR(p) fitting
435/// routines in `cobre-stochastic` and by the historical scenario library
436/// constructed during solver setup.
437///
438/// # Examples
439///
440/// ```
441/// use cobre_core::{EntityId, scenario::InflowHistoryRow};
442/// use chrono::NaiveDate;
443///
444/// let row = InflowHistoryRow {
445/// hydro_id: EntityId::from(1),
446/// date: NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(),
447/// value_m3s: 500.0,
448/// };
449/// assert_eq!(row.hydro_id, EntityId::from(1));
450/// assert_eq!(row.value_m3s, 500.0);
451/// ```
452#[derive(Debug, Clone, PartialEq)]
453#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
454pub struct InflowHistoryRow {
455 /// Hydro plant this observation belongs to.
456 pub hydro_id: EntityId,
457 /// Date of the observation (timezone-free calendar date).
458 pub date: chrono::NaiveDate,
459 /// Mean inflow for this observation period in m³/s. Must be finite.
460 pub value_m3s: f64,
461}
462
463// ExternalScenarioRow (SS2.5 — pre-computed external scenario value)
464
465/// A single row from `scenarios/external_inflow_scenarios.parquet`.
466///
467/// Each row defines the pre-computed inflow value for one (stage, scenario, hydro)
468/// triple. Used when [`SamplingScheme::External`] is active.
469///
470/// # Examples
471///
472/// ```
473/// use cobre_core::{EntityId, scenario::ExternalScenarioRow};
474///
475/// let row = ExternalScenarioRow {
476/// stage_id: 0,
477/// scenario_id: 2,
478/// hydro_id: EntityId::from(5),
479/// value_m3s: 320.5,
480/// };
481/// assert_eq!(row.scenario_id, 2);
482/// assert!((row.value_m3s - 320.5).abs() < 1e-10);
483/// ```
484#[derive(Debug, Clone, PartialEq)]
485#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
486pub struct ExternalScenarioRow {
487 /// Stage index (0-based within `System::stages`).
488 pub stage_id: i32,
489
490 /// Scenario index (0-based). Must be >= 0.
491 pub scenario_id: i32,
492
493 /// Hydro plant this inflow value belongs to.
494 pub hydro_id: EntityId,
495
496 /// Pre-computed inflow value in m³/s. Must be finite.
497 pub value_m3s: f64,
498}
499
500// ExternalLoadRow (E2 — pre-computed external load scenario value)
501
502/// A single row from `scenarios/external_load_scenarios.parquet`.
503///
504/// Each row defines the pre-computed load value for one (stage, scenario, bus)
505/// triple. Used when [`SamplingScheme::External`] is active for load variables.
506///
507/// # Examples
508///
509/// ```
510/// use cobre_core::{EntityId, scenario::ExternalLoadRow};
511///
512/// let row = ExternalLoadRow {
513/// stage_id: 0,
514/// scenario_id: 2,
515/// bus_id: EntityId::from(3),
516/// value_mw: 150.0,
517/// };
518/// assert_eq!(row.scenario_id, 2);
519/// assert!((row.value_mw - 150.0).abs() < 1e-10);
520/// ```
521#[derive(Debug, Clone, PartialEq)]
522#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
523pub struct ExternalLoadRow {
524 /// Stage index (0-based within `System::stages`).
525 pub stage_id: i32,
526
527 /// Scenario index (0-based). Must be >= 0.
528 pub scenario_id: i32,
529
530 /// Bus this load value belongs to.
531 pub bus_id: EntityId,
532
533 /// Pre-computed load value in MW. Must be finite.
534 pub value_mw: f64,
535}
536
537// ExternalNcsRow (E2 — pre-computed external NCS scenario value)
538
539/// A single row from `scenarios/external_ncs_scenarios.parquet`.
540///
541/// Each row defines the pre-computed dimensionless availability factor for one
542/// (stage, scenario, ncs) triple. Used when [`SamplingScheme::External`] is
543/// active for NCS availability variables.
544///
545/// # Examples
546///
547/// ```
548/// use cobre_core::{EntityId, scenario::ExternalNcsRow};
549///
550/// let row = ExternalNcsRow {
551/// stage_id: 1,
552/// scenario_id: 0,
553/// ncs_id: EntityId::from(7),
554/// value: 0.85,
555/// };
556/// assert_eq!(row.ncs_id, EntityId::from(7));
557/// assert!((row.value - 0.85).abs() < 1e-10);
558/// ```
559#[derive(Debug, Clone, PartialEq)]
560#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
561pub struct ExternalNcsRow {
562 /// Stage index (0-based within `System::stages`).
563 pub stage_id: i32,
564
565 /// Scenario index (0-based). Must be >= 0.
566 pub scenario_id: i32,
567
568 /// NCS source this availability factor belongs to.
569 pub ncs_id: EntityId,
570
571 /// Pre-computed dimensionless availability factor. Must be finite.
572 pub value: f64,
573}
574
575// CorrelationEntity
576
577/// A single entity reference within a correlation group.
578///
579/// `entity_type` is a string tag that identifies the kind of stochastic
580/// variable. Valid values are:
581///
582/// - `"inflow"` — hydro inflow series (entity ID matches `Hydro.id`)
583/// - `"load"` — stochastic load demand (entity ID matches `Bus.id`)
584/// - `"ncs"` — non-controllable source availability (entity ID matches
585/// `NonControllableSource.id`)
586///
587/// Using `String` rather than an enum preserves forward compatibility when
588/// additional entity types are added without a breaking schema change.
589///
590/// See [Input Scenarios §5](input-scenarios.md).
591#[derive(Debug, Clone, PartialEq, Eq)]
592#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
593pub struct CorrelationEntity {
594 /// Entity type tag: `"inflow"`, `"load"`, or `"ncs"`.
595 pub entity_type: String,
596
597 /// Entity identifier matching the corresponding entity's `id` field.
598 pub id: EntityId,
599}
600
601// CorrelationGroup
602
603/// A named group of correlated entities and their correlation matrix.
604///
605/// `matrix` is a symmetric positive-semi-definite matrix stored in
606/// row-major order as `Vec<Vec<f64>>`. `matrix[i][j]` is the correlation
607/// coefficient between `entities[i]` and `entities[j]`.
608/// `matrix.len()` must equal `entities.len()`.
609///
610/// Spectral decomposition of the matrix is NOT performed here; that
611/// belongs to `cobre-stochastic`.
612///
613/// See [Input Scenarios §5](input-scenarios.md).
614///
615/// # Examples
616///
617/// ```
618/// use cobre_core::{EntityId, scenario::{CorrelationEntity, CorrelationGroup}};
619///
620/// let group = CorrelationGroup {
621/// name: "Southeast".to_string(),
622/// entities: vec![
623/// CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(1) },
624/// CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(2) },
625/// ],
626/// matrix: vec![
627/// vec![1.0, 0.8],
628/// vec![0.8, 1.0],
629/// ],
630/// };
631/// assert_eq!(group.matrix.len(), 2);
632/// ```
633#[derive(Debug, Clone, PartialEq)]
634#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
635pub struct CorrelationGroup {
636 /// Human-readable group label (e.g., `"Southeast"`, `"North"`).
637 pub name: String,
638
639 /// Ordered list of entities whose correlation is captured by `matrix`.
640 pub entities: Vec<CorrelationEntity>,
641
642 /// Symmetric correlation matrix in row-major order.
643 /// `matrix[i][j]` = correlation between `entities[i]` and `entities[j]`.
644 /// Diagonal entries must be 1.0. Shape: `entities.len() × entities.len()`.
645 pub matrix: Vec<Vec<f64>>,
646}
647
648// CorrelationProfile
649
650/// A named correlation profile containing one or more correlation groups.
651///
652/// A profile groups correlated entities into disjoint [`CorrelationGroup`]s.
653/// Entities in different groups are treated as uncorrelated. Profiles are
654/// stored in [`CorrelationModel::profiles`] keyed by profile name.
655///
656/// See [Input Scenarios §5](input-scenarios.md).
657///
658/// # Examples
659///
660/// ```
661/// use cobre_core::{EntityId, scenario::{CorrelationEntity, CorrelationGroup, CorrelationProfile}};
662///
663/// let profile = CorrelationProfile {
664/// groups: vec![CorrelationGroup {
665/// name: "All".to_string(),
666/// entities: vec![
667/// CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(1) },
668/// CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(2) },
669/// CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(3) },
670/// ],
671/// matrix: vec![
672/// vec![1.0, 0.0, 0.0],
673/// vec![0.0, 1.0, 0.0],
674/// vec![0.0, 0.0, 1.0],
675/// ],
676/// }],
677/// };
678/// assert_eq!(profile.groups[0].matrix.len(), 3);
679/// ```
680#[derive(Debug, Clone, PartialEq)]
681#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
682pub struct CorrelationProfile {
683 /// Disjoint groups of correlated entities within this profile.
684 pub groups: Vec<CorrelationGroup>,
685}
686
687// CorrelationScheduleEntry
688
689/// Maps a stage to its active correlation profile name.
690///
691/// When [`CorrelationModel::schedule`] is non-empty, each stage that
692/// requires a non-default correlation profile has an entry here. Stages
693/// without an entry use the profile named `"default"` if present, or the
694/// sole profile if only one profile exists.
695///
696/// See [Input Scenarios §5](input-scenarios.md).
697#[derive(Debug, Clone, PartialEq, Eq)]
698#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
699pub struct CorrelationScheduleEntry {
700 /// Stage index (0-based within `System::stages`) this entry applies to.
701 pub stage_id: i32,
702
703 /// Name of the correlation profile active for this stage.
704 /// Must match a key in [`CorrelationModel::profiles`].
705 pub profile_name: String,
706}
707
708// CorrelationModel
709
710/// Top-level correlation configuration for the scenario pipeline.
711///
712/// Holds all named correlation profiles and the optional stage-to-profile
713/// schedule. When `schedule` is empty, the solver uses a single profile
714/// (typically named `"default"`) for all stages.
715///
716/// `profiles` uses [`BTreeMap`] rather than [`HashMap`](std::collections::HashMap) to preserve
717/// deterministic iteration order, satisfying the declaration-order
718/// invariance requirement (design-principles.md §3).
719///
720/// `method` defaults to `"spectral"`. The value `"cholesky"` is accepted for
721/// backward compatibility with existing case files. Stored as a `String` for
722/// forward compatibility with future decomposition methods.
723///
724/// Source: `correlation.json`.
725/// See [Input Scenarios §5](input-scenarios.md) and
726/// [internal-structures.md §14](../specs/data-model/internal-structures.md).
727///
728/// # Examples
729///
730/// ```
731/// use std::collections::BTreeMap;
732/// use cobre_core::{EntityId, scenario::{
733/// CorrelationEntity, CorrelationGroup, CorrelationModel, CorrelationProfile,
734/// CorrelationScheduleEntry,
735/// }};
736///
737/// let mut profiles = BTreeMap::new();
738/// profiles.insert("default".to_string(), CorrelationProfile {
739/// groups: vec![CorrelationGroup {
740/// name: "All".to_string(),
741/// entities: vec![
742/// CorrelationEntity { entity_type: "inflow".to_string(), id: EntityId(1) },
743/// ],
744/// matrix: vec![vec![1.0]],
745/// }],
746/// });
747///
748/// let model = CorrelationModel {
749/// method: "spectral".to_string(),
750/// profiles,
751/// schedule: vec![],
752/// };
753/// assert!(model.profiles.contains_key("default"));
754/// ```
755#[derive(Debug, Clone, PartialEq)]
756#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
757pub struct CorrelationModel {
758 /// Decomposition method. Defaults to `"spectral"`. `"cholesky"` is also
759 /// accepted for backward compatibility with existing case files.
760 /// Stored as `String` for forward compatibility with future methods.
761 pub method: String,
762
763 /// Named correlation profiles keyed by profile name.
764 /// `BTreeMap` for deterministic ordering (declaration-order invariance).
765 pub profiles: BTreeMap<String, CorrelationProfile>,
766
767 /// Stage-to-profile schedule. Empty when a single profile applies to
768 /// all stages.
769 pub schedule: Vec<CorrelationScheduleEntry>,
770}
771
772impl Default for ScenarioSource {
773 fn default() -> Self {
774 Self {
775 inflow_scheme: SamplingScheme::InSample,
776 load_scheme: SamplingScheme::InSample,
777 ncs_scheme: SamplingScheme::InSample,
778 seed: None,
779 historical_years: None,
780 }
781 }
782}
783
784impl Default for CorrelationModel {
785 fn default() -> Self {
786 Self {
787 method: "spectral".to_string(),
788 profiles: BTreeMap::new(),
789 schedule: Vec::new(),
790 }
791 }
792}
793
794// Tests
795
796#[cfg(test)]
797mod tests {
798 use std::collections::BTreeMap;
799
800 #[cfg(feature = "serde")]
801 use super::ScenarioSource;
802 use super::{
803 AnnualComponent, CorrelationEntity, CorrelationGroup, CorrelationModel, CorrelationProfile,
804 CorrelationScheduleEntry, InflowModel, NcsModel, SamplingScheme,
805 };
806 use crate::EntityId;
807
808 #[test]
809 fn test_inflow_model_construction() {
810 let model = InflowModel {
811 hydro_id: EntityId(7),
812 stage_id: 11,
813 mean_m3s: 250.0,
814 std_m3s: 55.0,
815 ar_coefficients: vec![0.5, 0.2, 0.1],
816 residual_std_ratio: 0.85,
817 annual: None,
818 };
819
820 assert_eq!(model.hydro_id, EntityId(7));
821 assert_eq!(model.stage_id, 11);
822 assert_eq!(model.mean_m3s, 250.0);
823 assert_eq!(model.std_m3s, 55.0);
824 assert_eq!(model.ar_order(), 3);
825 assert_eq!(model.ar_coefficients, vec![0.5, 0.2, 0.1]);
826 assert_eq!(model.ar_coefficients.len(), model.ar_order());
827 assert!((model.residual_std_ratio - 0.85).abs() < f64::EPSILON);
828 }
829
830 #[test]
831 fn test_inflow_model_ar_order_method() {
832 // Empty coefficients: ar_order() == 0 (white noise)
833 let white_noise = InflowModel {
834 hydro_id: EntityId(1),
835 stage_id: 0,
836 mean_m3s: 100.0,
837 std_m3s: 10.0,
838 ar_coefficients: vec![],
839 residual_std_ratio: 1.0,
840 annual: None,
841 };
842 assert_eq!(white_noise.ar_order(), 0);
843
844 // Two coefficients: ar_order() == 2
845 let par2 = InflowModel {
846 hydro_id: EntityId(2),
847 stage_id: 1,
848 mean_m3s: 200.0,
849 std_m3s: 20.0,
850 ar_coefficients: vec![0.45, 0.22],
851 residual_std_ratio: 0.85,
852 annual: None,
853 };
854 assert_eq!(par2.ar_order(), 2);
855 }
856
857 #[test]
858 fn test_correlation_model_construction() {
859 let make_profile = |entity_ids: &[i32]| {
860 let entities: Vec<CorrelationEntity> = entity_ids
861 .iter()
862 .map(|&id| CorrelationEntity {
863 entity_type: "inflow".to_string(),
864 id: EntityId(id),
865 })
866 .collect();
867 let n = entities.len();
868 let matrix: Vec<Vec<f64>> = (0..n)
869 .map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
870 .collect();
871 CorrelationProfile {
872 groups: vec![CorrelationGroup {
873 name: "group_a".to_string(),
874 entities,
875 matrix,
876 }],
877 }
878 };
879
880 let mut profiles = BTreeMap::new();
881 profiles.insert("wet".to_string(), make_profile(&[1, 2, 3]));
882 profiles.insert("dry".to_string(), make_profile(&[1, 2]));
883
884 let model = CorrelationModel {
885 method: "spectral".to_string(),
886 profiles,
887 schedule: vec![
888 CorrelationScheduleEntry {
889 stage_id: 0,
890 profile_name: "wet".to_string(),
891 },
892 CorrelationScheduleEntry {
893 stage_id: 6,
894 profile_name: "dry".to_string(),
895 },
896 ],
897 };
898
899 // Two profiles present
900 assert_eq!(model.profiles.len(), 2);
901
902 // BTreeMap ordering is alphabetical: "dry" before "wet"
903 let mut profile_iter = model.profiles.keys();
904 assert_eq!(profile_iter.next().unwrap(), "dry");
905 assert_eq!(profile_iter.next().unwrap(), "wet");
906
907 // Profile lookup by name
908 assert!(model.profiles.contains_key("wet"));
909 assert!(model.profiles.contains_key("dry"));
910
911 // Matrix dimensions match entity count
912 let wet = &model.profiles["wet"];
913 assert_eq!(wet.groups[0].matrix.len(), 3);
914
915 let dry = &model.profiles["dry"];
916 assert_eq!(dry.groups[0].matrix.len(), 2);
917
918 // Schedule entries
919 assert_eq!(model.schedule.len(), 2);
920 assert_eq!(model.schedule[0].profile_name, "wet");
921 assert_eq!(model.schedule[1].profile_name, "dry");
922 }
923
924 #[test]
925 fn test_sampling_scheme_copy() {
926 let original = SamplingScheme::InSample;
927 let copied = original;
928 assert_eq!(original, copied);
929
930 let original_oos = SamplingScheme::OutOfSample;
931 let copied_oos = original_oos;
932 assert_eq!(original_oos, copied_oos);
933
934 let original_ext = SamplingScheme::External;
935 let copied_ext = original_ext;
936 assert_eq!(original_ext, copied_ext);
937
938 let original_hist = SamplingScheme::Historical;
939 let copied_hist = original_hist;
940 assert_eq!(original_hist, copied_hist);
941 }
942
943 #[cfg(feature = "serde")]
944 #[test]
945 fn test_scenario_source_serde_roundtrip() {
946 use super::HistoricalYears;
947
948 // All three schemes set to different values with seed and no historical_years
949 let source = ScenarioSource {
950 inflow_scheme: SamplingScheme::InSample,
951 load_scheme: SamplingScheme::OutOfSample,
952 ncs_scheme: SamplingScheme::External,
953 seed: Some(12345),
954 historical_years: None,
955 };
956 let json = serde_json::to_string(&source).unwrap();
957 let deserialized: ScenarioSource = serde_json::from_str(&json).unwrap();
958 assert_eq!(source, deserialized);
959
960 // Historical inflow with historical_years list
961 let source_hist = ScenarioSource {
962 inflow_scheme: SamplingScheme::Historical,
963 load_scheme: SamplingScheme::InSample,
964 ncs_scheme: SamplingScheme::InSample,
965 seed: Some(7),
966 historical_years: Some(HistoricalYears::List(vec![1990, 2000, 2010])),
967 };
968 let json_hist = serde_json::to_string(&source_hist).unwrap();
969 let deserialized_hist: ScenarioSource = serde_json::from_str(&json_hist).unwrap();
970 assert_eq!(source_hist, deserialized_hist);
971
972 // Historical inflow with historical_years range and no seed
973 let source_range = ScenarioSource {
974 inflow_scheme: SamplingScheme::Historical,
975 load_scheme: SamplingScheme::InSample,
976 ncs_scheme: SamplingScheme::InSample,
977 seed: None,
978 historical_years: Some(HistoricalYears::Range {
979 from: 1940,
980 to: 2010,
981 }),
982 };
983 let json_range = serde_json::to_string(&source_range).unwrap();
984 let deserialized_range: ScenarioSource = serde_json::from_str(&json_range).unwrap();
985 assert_eq!(source_range, deserialized_range);
986
987 // All InSample, no seed, no historical_years (default-like)
988 let source_default = ScenarioSource {
989 inflow_scheme: SamplingScheme::InSample,
990 load_scheme: SamplingScheme::InSample,
991 ncs_scheme: SamplingScheme::InSample,
992 seed: None,
993 historical_years: None,
994 };
995 let json_default = serde_json::to_string(&source_default).unwrap();
996 let deserialized_default: ScenarioSource = serde_json::from_str(&json_default).unwrap();
997 assert_eq!(source_default, deserialized_default);
998 }
999
1000 #[test]
1001 fn test_scenario_source_default() {
1002 let source = ScenarioSource::default();
1003 assert_eq!(source.inflow_scheme, SamplingScheme::InSample);
1004 assert_eq!(source.load_scheme, SamplingScheme::InSample);
1005 assert_eq!(source.ncs_scheme, SamplingScheme::InSample);
1006 assert!(source.seed.is_none());
1007 assert!(source.historical_years.is_none());
1008 }
1009
1010 #[test]
1011 fn test_historical_years_list_construction() {
1012 use super::HistoricalYears;
1013 let years = HistoricalYears::List(vec![1940, 1953, 1971]);
1014 match &years {
1015 HistoricalYears::List(v) => {
1016 assert_eq!(v.len(), 3);
1017 assert_eq!(v[0], 1940);
1018 assert_eq!(v[1], 1953);
1019 assert_eq!(v[2], 1971);
1020 }
1021 HistoricalYears::Range { .. } => panic!("expected List variant"),
1022 }
1023 }
1024
1025 #[test]
1026 fn test_historical_years_range_construction() {
1027 use super::HistoricalYears;
1028 let years = HistoricalYears::Range {
1029 from: 1940,
1030 to: 2010,
1031 };
1032 match years {
1033 HistoricalYears::Range { from, to } => {
1034 assert_eq!(from, 1940);
1035 assert_eq!(to, 2010);
1036 }
1037 HistoricalYears::List(_) => panic!("expected Range variant"),
1038 }
1039 }
1040
1041 #[cfg(feature = "serde")]
1042 #[test]
1043 fn test_historical_years_list_serde_roundtrip() {
1044 use super::HistoricalYears;
1045 let years = HistoricalYears::List(vec![1940, 1953, 1971]);
1046 let json = serde_json::to_string(&years).unwrap();
1047 let deserialized: HistoricalYears = serde_json::from_str(&json).unwrap();
1048 assert_eq!(years, deserialized);
1049 }
1050
1051 #[cfg(feature = "serde")]
1052 #[test]
1053 fn test_historical_years_range_serde_roundtrip() {
1054 use super::HistoricalYears;
1055 let years = HistoricalYears::Range {
1056 from: 1940,
1057 to: 2010,
1058 };
1059 let json = serde_json::to_string(&years).unwrap();
1060 let deserialized: HistoricalYears = serde_json::from_str(&json).unwrap();
1061 assert_eq!(years, deserialized);
1062 }
1063
1064 #[cfg(feature = "serde")]
1065 #[test]
1066 fn test_inflow_model_serde_roundtrip() {
1067 let model = InflowModel {
1068 hydro_id: EntityId(3),
1069 stage_id: 0,
1070 mean_m3s: 150.0,
1071 std_m3s: 30.0,
1072 ar_coefficients: vec![0.45, 0.22],
1073 residual_std_ratio: 0.85,
1074 annual: None,
1075 };
1076 let json = serde_json::to_string(&model).unwrap();
1077 let deserialized: InflowModel = serde_json::from_str(&json).unwrap();
1078 assert_eq!(model, deserialized);
1079 assert!((deserialized.residual_std_ratio - 0.85).abs() < f64::EPSILON);
1080 }
1081
1082 #[test]
1083 fn test_ncs_model_construction() {
1084 let model = NcsModel {
1085 ncs_id: EntityId(3),
1086 stage_id: 0,
1087 mean: 0.5,
1088 std: 0.1,
1089 };
1090
1091 assert_eq!(model.ncs_id, EntityId(3));
1092 assert_eq!(model.stage_id, 0);
1093 assert_eq!(model.mean, 0.5);
1094 assert_eq!(model.std, 0.1);
1095 }
1096
1097 #[cfg(feature = "serde")]
1098 #[test]
1099 fn test_ncs_model_serde_roundtrip() {
1100 let model = NcsModel {
1101 ncs_id: EntityId(5),
1102 stage_id: 2,
1103 mean: 0.75,
1104 std: 0.15,
1105 };
1106 let json = serde_json::to_string(&model).unwrap();
1107 let deserialized: NcsModel = serde_json::from_str(&json).unwrap();
1108 assert_eq!(model, deserialized);
1109 }
1110
1111 #[test]
1112 fn test_correlation_model_identity_matrix_access() {
1113 let identity = vec![
1114 vec![1.0, 0.0, 0.0],
1115 vec![0.0, 1.0, 0.0],
1116 vec![0.0, 0.0, 1.0],
1117 ];
1118 let mut profiles = BTreeMap::new();
1119 profiles.insert(
1120 "default".to_string(),
1121 CorrelationProfile {
1122 groups: vec![CorrelationGroup {
1123 name: "all_hydros".to_string(),
1124 entities: vec![
1125 CorrelationEntity {
1126 entity_type: "inflow".to_string(),
1127 id: EntityId(1),
1128 },
1129 CorrelationEntity {
1130 entity_type: "inflow".to_string(),
1131 id: EntityId(2),
1132 },
1133 CorrelationEntity {
1134 entity_type: "inflow".to_string(),
1135 id: EntityId(3),
1136 },
1137 ],
1138 matrix: identity,
1139 }],
1140 },
1141 );
1142 let model = CorrelationModel {
1143 method: "spectral".to_string(),
1144 profiles,
1145 schedule: vec![],
1146 };
1147
1148 // AC: model.profiles["default"].groups[0].matrix.len() == 3
1149 assert_eq!(model.profiles["default"].groups[0].matrix.len(), 3);
1150 }
1151
1152 #[test]
1153 fn inflow_model_annual_default_none() {
1154 let m = InflowModel {
1155 hydro_id: EntityId(1),
1156 stage_id: 0,
1157 mean_m3s: 100.0,
1158 std_m3s: 10.0,
1159 ar_coefficients: vec![0.5],
1160 residual_std_ratio: 0.85,
1161 annual: None,
1162 };
1163 assert!(m.annual.is_none());
1164 }
1165
1166 #[test]
1167 fn inflow_model_annual_some_round_trip() {
1168 let ann = AnnualComponent {
1169 coefficient: 0.15,
1170 mean_m3s: 90.0,
1171 std_m3s: 12.0,
1172 };
1173 let m = InflowModel {
1174 hydro_id: EntityId(1),
1175 stage_id: 0,
1176 mean_m3s: 100.0,
1177 std_m3s: 10.0,
1178 ar_coefficients: vec![0.5],
1179 residual_std_ratio: 0.85,
1180 annual: Some(ann.clone()),
1181 };
1182 assert_eq!(m.annual.as_ref().expect("annual present"), &ann);
1183 assert_eq!(m.ar_order(), 1);
1184 }
1185
1186 #[test]
1187 fn annual_component_partial_eq_clone() {
1188 let a = AnnualComponent {
1189 coefficient: 0.15,
1190 mean_m3s: 90.0,
1191 std_m3s: 12.0,
1192 };
1193 let b = a.clone();
1194 assert_eq!(a, b);
1195 }
1196}