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}