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