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}