Skip to main content

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