Skip to main content

cobre_core/
scenario.rs

1//! Scenario pipeline raw data types — PAR model parameters, load statistics,
2//! and correlation model.
3//!
4//! This module defines the clarity-first data containers for the raw scenario
5//! pipeline parameters loaded from input files. These are the data types stored
6//! in [`System`](crate::System) and passed to downstream crates for processing.
7//!
8//! ## Dual-nature design
9//!
10//! Following the dual-nature design principle (internal-structures.md §1.1),
11//! this module holds only the **raw input-facing types**:
12//!
13//! - [`InflowModel`] — PAR(p) parameters per (hydro, stage). AR coefficients
14//!   are stored **standardized by seasonal std** (dimensionless ψ\*), and
15//!   `residual_std_ratio` (`σ_m` / `s_m`) captures the remaining variance not
16//!   explained by the AR model. Downstream crates recover the runtime residual
17//!   std as `std_m3s * residual_std_ratio`.
18//! - [`LoadModel`] — seasonal load statistics per (bus, stage)
19//! - [`CorrelationModel`] — named correlation profiles with entity groups
20//!   and correlation matrices
21//!
22//! Performance-adapted views (`PrecomputedPar`, 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}