cobre_core/temporal.rs
1//! Temporal domain types — stages, blocks, seasons, and the policy graph.
2//!
3//! This module defines the types that describe the time structure of a
4//! multi-stage stochastic optimization problem: how the study horizon is
5//! partitioned into stages, how stages are subdivided into load blocks,
6//! how stages relate to seasonal patterns, and how the policy graph
7//! encodes stage-to-stage transitions.
8//!
9//! These are clarity-first data types following the dual-nature design
10//! principle: they use `Vec<T>`, `String`, and `Option` for readability
11//! and correctness. LP-related fields (variable indices, constraint counts,
12//! coefficient arrays) belong to the performance layer in downstream solver crates.
13//!
14//! Source: `stages.json`. See `internal-structures.md` SS12.
15
16use chrono::{Datelike, NaiveDate};
17
18// ---------------------------------------------------------------------------
19// Supporting enums
20// ---------------------------------------------------------------------------
21
22/// Block formulation mode controlling how blocks within a stage relate
23/// to each other in the LP.
24///
25/// See [Block Formulations](../math/block-formulations.md) for the
26/// mathematical treatment of each mode.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub enum BlockMode {
30 /// Blocks are independent sub-periods solved simultaneously.
31 /// Water balance is aggregated across all blocks in the stage.
32 /// This is the default and most common mode.
33 Parallel,
34
35 /// Blocks are sequential within the stage, with inter-block
36 /// state transitions (intra-stage storage dynamics).
37 /// Enables modeling of daily cycling patterns within monthly stages.
38 Chronological,
39}
40
41/// Season cycle type controlling how season IDs map to calendar periods.
42///
43/// See [Input Scenarios §1.1](input-scenarios.md) for the JSON schema
44/// and calendar mapping rules.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47pub enum SeasonCycleType {
48 /// Each season corresponds to one calendar month (12 seasons).
49 Monthly,
50 /// Each season corresponds to one ISO calendar week (52 seasons).
51 Weekly,
52 /// User-defined date ranges with explicit boundaries per season.
53 Custom,
54}
55
56/// Opening tree noise generation algorithm for a stage.
57///
58/// Controls which algorithm is used to generate noise vectors for
59/// the opening tree at this stage. This is orthogonal to
60/// `SamplingScheme`, which selects the forward-pass noise *source*
61/// (in-sample, external, historical). `NoiseMethod` governs *how*
62/// the noise vectors are produced (SAA, LHS, QMC-Sobol, QMC-Halton,
63/// Selective, or `HistoricalResiduals`).
64///
65/// See [Input Scenarios §1.8](input-scenarios.md) for the
66/// full method catalog and use cases.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
69pub enum NoiseMethod {
70 /// Sample Average Approximation. Pure Monte Carlo random sampling.
71 Saa,
72 /// Latin Hypercube Sampling. Stratified sampling ensuring uniform coverage.
73 Lhs,
74 /// Quasi-Monte Carlo with Sobol sequences. Low-discrepancy.
75 QmcSobol,
76 /// Quasi-Monte Carlo with Halton sequences. Low-discrepancy.
77 QmcHalton,
78 /// Selective/Representative Sampling. Clustering on historical data.
79 Selective,
80 /// Historical residuals from the `HistoricalScenarioLibrary`.
81 /// Copies pre-computed eta (residual) vectors from actual historical
82 /// observations. Skips the parametric Cholesky correlation step since
83 /// empirical cross-entity correlation is embedded in the residuals.
84 /// Year pool configuration is sourced from the system-level
85 /// `HistoricalYears` config (same as the Historical forward sampling
86 /// scheme).
87 HistoricalResiduals,
88}
89
90/// Horizon type tag for the policy graph.
91///
92/// Determines whether the study horizon is finite (acyclic linear chain or DAG)
93/// or cyclic (infinite periodic horizon with at least one back-edge). The
94/// solver-level `HorizonMode` enum in downstream solver crates is built from a
95/// [`PolicyGraph`] that carries this tag — it precomputes transition maps,
96/// cycle detection, and discount factors for efficient runtime dispatch.
97///
98/// Cross-reference: [Horizon Mode Trait SS3.1](../architecture/horizon-mode-trait.md).
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
101pub enum PolicyGraphType {
102 /// Acyclic stage chain: the study has a definite end stage.
103 /// Terminal value is zero (no future-cost approximation beyond the horizon).
104 FiniteHorizon,
105 /// Infinite periodic horizon: at least one transition has
106 /// `source_id >= target_id` (a back-edge). Requires a positive
107 /// `annual_discount_rate` for convergence.
108 Cyclic,
109}
110
111// ---------------------------------------------------------------------------
112// Block (SS12.2)
113// ---------------------------------------------------------------------------
114
115/// A load block within a stage, representing a sub-period with uniform
116/// demand and generation characteristics.
117///
118/// Blocks partition the stage duration into sub-periods (e.g., peak,
119/// off-peak, shoulder). Block IDs are contiguous within each stage,
120/// starting at 0. The block weight (fraction of stage duration) is
121/// derived from `duration_hours` and is not stored — it is computed
122/// on demand as `duration_hours / sum(all block hours in stage)`.
123///
124/// Source: `stages.json` `stages[].blocks[]`.
125/// See [Input Scenarios §1.5](input-scenarios.md).
126#[derive(Debug, Clone, PartialEq)]
127#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
128pub struct Block {
129 /// 0-based index within the parent stage.
130 /// Matches the `id` field from `stages.json`, validated to be
131 /// contiguous (0, 1, 2, ..., n-1) during loading.
132 pub index: usize,
133
134 /// Human-readable block label (e.g., "LEVE", "MEDIA", "PESADA").
135 pub name: String,
136
137 /// Duration of this block in hours. Must be positive.
138 /// Validation: the sum of all block hours within a stage must
139 /// equal the total stage duration in hours.
140 /// See [Input Scenarios §1.10](input-scenarios.md), rule 3.
141 pub duration_hours: f64,
142}
143
144// ---------------------------------------------------------------------------
145// StageStateConfig (SS12.3)
146// ---------------------------------------------------------------------------
147
148/// State variable flags controlling which variables carry state
149/// between stages for a given stage.
150///
151/// Source: `stages.json` `stages[].state_variables`.
152/// See [Input Scenarios §1.6](input-scenarios.md).
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
155pub struct StageStateConfig {
156 /// Whether reservoir storage volumes are state variables.
157 /// Default: true. Mandatory in most applications but kept as an
158 /// explicit flag for transparency.
159 pub storage: bool,
160
161 /// Whether past inflow realizations (AR model lags) are state
162 /// variables. Default: false. Required when PAR model order `p > 0`
163 /// and inflow lag state tracking is enabled.
164 pub inflow_lags: bool,
165}
166
167// ---------------------------------------------------------------------------
168// StageLagTransition (SS12.3.1)
169// ---------------------------------------------------------------------------
170
171/// Pre-encoded contribution of a single stage to the lag accumulator.
172///
173/// Each stage in a multi-resolution study may span a different calendar
174/// duration than the lag period it feeds into (for example, weekly stages
175/// contributing to a monthly lag slot). `StageLagTransition` encodes the
176/// fractional weights and finalization signal that the precomputation
177/// algorithm derives from stage date boundaries, so the hot path can apply
178/// them without recomputing calendar arithmetic at runtime.
179///
180/// A `Vec<StageLagTransition>` indexed by stage index is the canonical way
181/// to carry this information alongside the stage vector.
182///
183/// See [Design Doc — Temporal Resolution Debts §6](../docs/design/temporal-resolution-debts.md).
184// Four boolean flags encode orthogonal hot-path conditions; a state machine enum
185// would require 2^4 = 16 variants with no semantic benefit. Each flag is
186// independently set by the precomputation algorithm and tested by separate
187// if-guards in `accumulate_and_shift_lag_state`.
188#[allow(clippy::struct_excessive_bools)]
189#[derive(Debug, Clone, Copy, PartialEq)]
190#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
191pub struct StageLagTransition {
192 /// Fraction of this stage's realized value that is added to the
193 /// current lag accumulator bucket.
194 ///
195 /// A value of `1.0` means the stage contributes its full realized value
196 /// to the accumulator. A value between `0.0` and `1.0` indicates a
197 /// partial contribution (for example, a weekly stage that covers only
198 /// part of a monthly lag period). Validation that this value lies in
199 /// `[0.0, 1.0]` is the responsibility of the precomputation algorithm.
200 pub accumulate_weight: f64,
201
202 /// Fraction of this stage's realized value that carries over into the
203 /// next lag accumulator bucket when `finalize_period` is `true`.
204 ///
205 /// When a stage straddles the boundary between two lag periods, the
206 /// portion of the stage value that belongs to the next period is
207 /// captured here. For stages that fall entirely within one lag period,
208 /// this is `0.0`. Validation that `accumulate_weight + spillover_weight`
209 /// is consistent with the stage's temporal position is the
210 /// responsibility of the precomputation algorithm.
211 pub spillover_weight: f64,
212
213 /// Whether this stage marks the end of a complete lag accumulation
214 /// period.
215 ///
216 /// When `true`, the accumulator bucket for the current lag period is
217 /// considered finalized after processing this stage: the accumulated
218 /// value is committed to the lag state vector and the accumulator is
219 /// reset (possibly seeded with the `spillover_weight` contribution).
220 /// When `false`, accumulation continues into the next stage.
221 pub finalize_period: bool,
222
223 /// Whether this stage should also accumulate into a downstream
224 /// (coarser-resolution) ring buffer.
225 ///
226 /// Set to `true` for stages in the pre-transition window when the study
227 /// transitions from a finer to a coarser temporal resolution (for example,
228 /// the last `L_q * 3` monthly stages before a monthly-to-quarterly
229 /// boundary). `false` for all stages in uniform-resolution studies,
230 /// producing zero overhead on the hot path.
231 pub accumulate_downstream: bool,
232
233 /// Fraction of this stage's realized value to accumulate into the
234 /// downstream lag period bucket.
235 ///
236 /// Analogous to `accumulate_weight` but relative to the downstream
237 /// (coarser) lag period boundaries. `0.0` when `accumulate_downstream`
238 /// is `false`.
239 pub downstream_accumulate_weight: f64,
240
241 /// Fraction of this stage's realized value carrying over into the next
242 /// downstream lag period when `downstream_finalize` is `true`.
243 ///
244 /// Analogous to `spillover_weight` but for the downstream period
245 /// boundary. `0.0` when `accumulate_downstream` is `false`.
246 pub downstream_spillover_weight: f64,
247
248 /// Whether this stage marks the end of a complete downstream lag
249 /// accumulation period.
250 ///
251 /// When `true`, the downstream accumulator bucket is finalized and
252 /// pushed to the downstream ring buffer. For example, the last monthly
253 /// stage of a calendar quarter has `downstream_finalize = true` when the
254 /// study transitions to quarterly resolution. `false` when
255 /// `accumulate_downstream` is `false`.
256 pub downstream_finalize: bool,
257
258 /// Whether the lag state must be rebuilt from the downstream ring buffer
259 /// at this stage.
260 ///
261 /// Set to `true` on the first quarterly stage (the transition stage) when
262 /// `downstream_par_order > 0`. When `true`, `accumulate_and_shift_lag_state`
263 /// overwrites `state[lag_start..]` with the completed quarterly lags from the
264 /// downstream ring buffer before resuming primary accumulation at quarterly
265 /// resolution. `false` for all stages in uniform-resolution studies and all
266 /// pre-transition monthly stages, producing zero overhead on the hot path.
267 pub rebuild_from_downstream: bool,
268}
269
270// ---------------------------------------------------------------------------
271// StageRiskConfig (SS12.4)
272// ---------------------------------------------------------------------------
273
274/// Per-stage risk measure configuration, representing the parsed and
275/// validated risk parameters for a single stage.
276///
277/// This is the clarity-first representation stored in the [`Stage`] struct.
278/// The solver-level `RiskMeasure` enum in
279/// [Risk Measure Trait](../architecture/risk-measure-trait.md) is the
280/// dispatch type built FROM this configuration during the variant
281/// selection pipeline.
282///
283/// Source: `stages.json` `stages[].risk_measure`.
284/// See [Input Scenarios §1.7](input-scenarios.md).
285#[derive(Debug, Clone, Copy, PartialEq)]
286#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
287pub enum StageRiskConfig {
288 /// Risk-neutral expected value. No additional parameters.
289 Expectation,
290
291 /// Convex combination of expectation and `CVaR`.
292 /// See [Risk Measures](../math/risk-measures.md) for the
293 /// mathematical formulation.
294 CVaR {
295 /// Confidence level `alpha` in (0, 1].
296 /// `alpha = 0.95` means 5% worst-case scenarios are considered.
297 alpha: f64,
298
299 /// Risk aversion weight `lambda` in \[0, 1\].
300 /// `lambda = 0` reduces to Expectation; `lambda = 1` is pure `CVaR`.
301 lambda: f64,
302 },
303}
304
305// ---------------------------------------------------------------------------
306// ScenarioSourceConfig (SS12.5)
307// ---------------------------------------------------------------------------
308
309/// Scenario source configuration for one stage.
310///
311/// Groups the scenario-related settings that were formerly separate
312/// `num_scenarios` and `sampling_method` fields. Sourced from
313/// `stages.json` `scenario_source` and per-stage overrides.
314///
315/// See [Input Scenarios §1.4, §1.8](input-scenarios.md).
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
318pub struct ScenarioSourceConfig {
319 /// Number of noise realizations per stage for both the opening
320 /// tree and forward pass. Formerly `num_scenarios`.
321 /// Must be positive. Controls the per-stage branching factor.
322 pub branching_factor: usize,
323
324 /// Algorithm for generating noise vectors in the opening tree.
325 /// Orthogonal to `SamplingScheme`, which selects the noise
326 /// source (in-sample, external, historical).
327 /// Can vary per stage, allowing adaptive strategies (e.g., LHS
328 /// for near-term, SAA for distant stages).
329 pub noise_method: NoiseMethod,
330}
331
332// ---------------------------------------------------------------------------
333// Stage (SS12.6)
334// ---------------------------------------------------------------------------
335
336/// A single stage in the multi-stage stochastic optimization problem.
337///
338/// Stages partition the study horizon into decision periods. Each stage
339/// has a temporal extent, block structure, scenario configuration, risk
340/// parameters, and state variable flags. Stages are sorted by `id` in
341/// canonical order after loading (see Design Principles §3).
342///
343/// Study stages have non-negative IDs; pre-study stages (used only for
344/// PAR model lag initialization) have negative IDs. Pre-study stages
345/// carry only `id`, `start_date`, `end_date`, and `season_id` — their
346/// blocks, risk, and sampling fields are unused.
347///
348/// This struct does NOT contain LP-related fields (variable indices,
349/// constraint counts, coefficient arrays). Those belong to the
350/// downstream solver crate performance layer — see Solver Abstraction SS11.
351///
352/// Source: `stages.json` `stages[]` and `pre_study_stages[]`.
353/// See [Input Scenarios §1.4](input-scenarios.md).
354#[derive(Debug, Clone, PartialEq)]
355#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
356pub struct Stage {
357 // -- Identity and temporal extent --
358 /// 0-based index of this stage in the canonical-ordered stages
359 /// vector. Used for array indexing into per-stage data structures
360 /// (cuts, results, penalty arrays). Assigned during loading after
361 /// sorting by `id`.
362 pub index: usize,
363
364 /// Unique stage identifier from `stages.json`.
365 /// Non-negative for study stages, negative for pre-study stages.
366 /// The `id` is the domain-level identifier; `index` is the
367 /// internal array position.
368 pub id: i32,
369
370 /// Stage start date (inclusive). Parsed from ISO 8601 string.
371 /// Uses `chrono::NaiveDate` — timezone-free calendar date, which
372 /// is appropriate because stage boundaries are calendar concepts,
373 /// not instants in time.
374 pub start_date: NaiveDate,
375
376 /// Stage end date (exclusive). Parsed from ISO 8601 string.
377 /// The stage duration is `end_date - start_date`.
378 pub end_date: NaiveDate,
379
380 /// Season index linking to [`SeasonDefinition`]. Maps this stage to
381 /// a position in the seasonal cycle (e.g., month 0-11 for monthly).
382 /// Required for PAR model coefficient lookup and inflow history
383 /// aggregation. `None` for stages without seasonal structure.
384 pub season_id: Option<usize>,
385
386 // -- Block structure --
387 /// Ordered list of load blocks within this stage.
388 /// Sorted by block index (0, 1, ..., n-1). The sum of all block
389 /// `duration_hours` must equal the total stage duration in hours.
390 pub blocks: Vec<Block>,
391
392 /// Block formulation mode for this stage.
393 /// Can vary per stage (e.g., chronological for near-term,
394 /// parallel for distant stages).
395 /// See [Block Formulations](../math/block-formulations.md).
396 pub block_mode: BlockMode,
397
398 // -- State, risk, and sampling --
399 /// State variable flags controlling which variables carry state
400 /// from this stage to the next.
401 pub state_config: StageStateConfig,
402
403 /// Risk measure configuration for this stage.
404 /// Can vary per stage (e.g., `CVaR` for near-term, Expectation
405 /// for distant stages).
406 pub risk_config: StageRiskConfig,
407
408 /// Scenario source configuration (branching factor and noise method).
409 pub scenario_config: ScenarioSourceConfig,
410}
411
412// ---------------------------------------------------------------------------
413// SeasonDefinition (SS12.7)
414// ---------------------------------------------------------------------------
415
416/// A single season entry mapping a season ID to a calendar period.
417///
418/// Season definitions are required when deriving AR parameters from
419/// inflow history — the season determines how history values are
420/// aggregated into seasonal means and standard deviations.
421///
422/// Source: `stages.json` `season_definitions.seasons[]`.
423/// See [Input Scenarios §1.1](input-scenarios.md).
424#[derive(Debug, Clone, PartialEq)]
425#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
426pub struct SeasonDefinition {
427 /// Season index (0-based). For monthly cycles: 0 = January, ...,
428 /// 11 = December. For weekly cycles: 0-51 (ISO week numbers).
429 pub id: usize,
430
431 /// Human-readable label (e.g., "January", "Q1", "Wet Season").
432 pub label: String,
433
434 /// Calendar month where this season starts (1-12).
435 /// For monthly `cycle_type`, this uniquely identifies the month.
436 pub month_start: u32,
437
438 /// Calendar day where this season starts (1-31).
439 /// Only used when `cycle_type` is `Custom`. Default: 1.
440 pub day_start: Option<u32>,
441
442 /// Calendar month where this season ends (1-12).
443 /// Only used when `cycle_type` is `Custom`.
444 pub month_end: Option<u32>,
445
446 /// Calendar day where this season ends (1-31).
447 /// Only used when `cycle_type` is `Custom`.
448 pub day_end: Option<u32>,
449}
450
451// ---------------------------------------------------------------------------
452// SeasonMap (SS12.8)
453// ---------------------------------------------------------------------------
454
455/// Complete season definitions including cycle type and all season entries.
456///
457/// The `SeasonMap` is the resolved representation of the `season_definitions`
458/// section in `stages.json`. It provides the season-to-calendar mapping
459/// consumed by the PAR model and inflow history aggregation.
460///
461/// Source: `stages.json` `season_definitions`.
462/// See [Input Scenarios §1.1](input-scenarios.md).
463#[derive(Debug, Clone, PartialEq)]
464#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
465pub struct SeasonMap {
466 /// Cycle type controlling how season IDs map to calendar periods.
467 pub cycle_type: SeasonCycleType,
468
469 /// Season entries sorted by `id`. Length depends on `cycle_type`:
470 /// 12 for `Monthly`, 52 for `Weekly`, user-defined for `Custom`.
471 pub seasons: Vec<SeasonDefinition>,
472}
473
474impl SeasonMap {
475 /// Resolve a calendar date to a season ID using the cycle definition.
476 ///
477 /// This mapping is purely calendar-based and does not depend on the study
478 /// horizon — a date from 1931 maps to the same season as a date from 2026
479 /// if they share the same calendar position. This is essential for PAR
480 /// model estimation from historical inflow data that predates the study.
481 ///
482 /// Returns `None` only for `Custom` cycle types where the date does not
483 /// fall within any defined season range.
484 #[must_use]
485 pub fn season_for_date(&self, date: NaiveDate) -> Option<usize> {
486 match self.cycle_type {
487 SeasonCycleType::Monthly => {
488 let month = date.month();
489 self.seasons
490 .iter()
491 .find(|s| s.month_start == month)
492 .map(|s| s.id)
493 }
494 SeasonCycleType::Weekly => {
495 let iso_week = date.iso_week().week();
496 let week_idx = (iso_week.saturating_sub(1)).min(51) as usize;
497 self.seasons.iter().find(|s| s.id == week_idx).map(|s| s.id)
498 }
499 SeasonCycleType::Custom => {
500 let (m, d) = (date.month(), date.day());
501 self.seasons
502 .iter()
503 .find(|s| {
504 let ms = s.month_start;
505 let ds = s.day_start.unwrap_or(1);
506 let me = s.month_end.unwrap_or(ms);
507 let de = s.day_end.unwrap_or(31);
508 let start = (ms, ds);
509 let end = (me, de);
510 let cur = (m, d);
511 if start <= end {
512 cur >= start && cur <= end
513 } else {
514 cur >= start || cur <= end
515 }
516 })
517 .map(|s| s.id)
518 }
519 }
520 }
521}
522
523// ---------------------------------------------------------------------------
524// Transition (SS12.9)
525// ---------------------------------------------------------------------------
526
527/// A single transition in the policy graph, representing a directed
528/// edge from one stage to another with an associated probability and
529/// optional discount rate override.
530///
531/// Transitions define the stage traversal order for both the forward
532/// and backward passes. In finite horizon mode, transitions form a
533/// linear chain. In cyclic mode, at least one transition creates a
534/// back-edge (`source_id >= target_id`).
535///
536/// Source: `stages.json` `policy_graph.transitions[]`.
537/// See [Input Scenarios §1.2](input-scenarios.md).
538#[derive(Debug, Clone, Copy, PartialEq)]
539#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
540pub struct Transition {
541 /// Source stage ID. Must exist in the stage set.
542 pub source_id: i32,
543
544 /// Target stage ID. Must exist in the stage set.
545 pub target_id: i32,
546
547 /// Transition probability. Outgoing probabilities from each source
548 /// must sum to 1.0 (within tolerance).
549 pub probability: f64,
550
551 /// Per-transition annual discount rate override.
552 /// When `None`, the global `annual_discount_rate` from the
553 /// [`PolicyGraph`] is used. When `Some(r)`, this rate is converted to
554 /// a per-transition factor using the source stage duration:
555 /// `d = 1 / (1 + r)^dt`.
556 /// See [Discount Rate §3](../math/discount-rate.md).
557 pub annual_discount_rate_override: Option<f64>,
558}
559
560// ---------------------------------------------------------------------------
561// PolicyGraph (SS12.10)
562// ---------------------------------------------------------------------------
563
564/// Parsed and validated policy graph defining stage transitions,
565/// horizon type, and global discount rate.
566///
567/// This is the `cobre-core` clarity-first representation loaded from
568/// `stages.json`. It stores the graph topology as specified by the
569/// user. The solver-level `HorizonMode` enum (see Horizon Mode Trait
570/// SS1) is built from this struct during initialization — it
571/// precomputes transition maps, cycle detection, and discount factors
572/// for efficient runtime dispatch.
573///
574/// Cross-reference: [Horizon Mode Trait](../architecture/horizon-mode-trait.md)
575/// defines the `HorizonMode` enum that interprets this graph structure.
576///
577/// Source: `stages.json` `policy_graph`.
578/// See [Input Scenarios §1.2](input-scenarios.md).
579#[derive(Debug, Clone, PartialEq)]
580#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
581pub struct PolicyGraph {
582 /// Horizon type: finite (acyclic chain) or cyclic (infinite periodic).
583 /// Determines which `HorizonMode` variant will be constructed at
584 /// solver initialization.
585 pub graph_type: PolicyGraphType,
586
587 /// Global annual discount rate.
588 /// Converted to per-transition factors using source stage durations:
589 /// `d = 1 / (1 + annual_discount_rate)^dt`.
590 /// A value of 0.0 means no discounting (`d = 1.0` for all transitions).
591 /// For cyclic graphs, must be > 0 for convergence (validation rule 7).
592 /// See [Discount Rate §3](../math/discount-rate.md).
593 pub annual_discount_rate: f64,
594
595 /// Stage transitions with probabilities and optional per-transition
596 /// discount rate overrides. For finite horizon, these form a linear
597 /// chain or DAG. For cyclic horizon, at least one transition has
598 /// `source_id >= target_id` (the back-edge).
599 pub transitions: Vec<Transition>,
600
601 /// Season definitions loaded from `season_definitions` in
602 /// `stages.json`. Required when PAR models or inflow history
603 /// aggregation are used. `None` when no season definitions are
604 /// provided and none are required.
605 pub season_map: Option<SeasonMap>,
606}
607
608impl Default for PolicyGraph {
609 /// Returns a finite-horizon policy graph with no transitions and no discounting.
610 ///
611 /// This is the minimal-viable-solver default: a finite study horizon with
612 /// zero terminal value and no discount factor. `cobre-io` replaces this
613 /// with the fully specified graph loaded from `stages.json`.
614 fn default() -> Self {
615 Self {
616 graph_type: PolicyGraphType::FiniteHorizon,
617 annual_discount_rate: 0.0,
618 transitions: Vec::new(),
619 season_map: None,
620 }
621 }
622}
623
624// ---------------------------------------------------------------------------
625// Tests
626// ---------------------------------------------------------------------------
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
633 fn test_block_mode_copy() {
634 let original = BlockMode::Parallel;
635 let copied = original;
636 assert_eq!(original, BlockMode::Parallel);
637 assert_eq!(copied, BlockMode::Parallel);
638
639 let chrono = BlockMode::Chronological;
640 let copied_chrono = chrono;
641 assert_eq!(chrono, BlockMode::Chronological);
642 assert_eq!(copied_chrono, BlockMode::Chronological);
643 }
644
645 #[test]
646 fn test_stage_duration() {
647 let stage = Stage {
648 index: 0,
649 id: 1,
650 start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
651 end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
652 season_id: Some(0),
653 blocks: vec![Block {
654 index: 0,
655 name: "SINGLE".to_string(),
656 duration_hours: 744.0,
657 }],
658 block_mode: BlockMode::Parallel,
659 state_config: StageStateConfig {
660 storage: true,
661 inflow_lags: false,
662 },
663 risk_config: StageRiskConfig::Expectation,
664 scenario_config: ScenarioSourceConfig {
665 branching_factor: 50,
666 noise_method: NoiseMethod::Saa,
667 },
668 };
669
670 assert_eq!(
671 stage.end_date - stage.start_date,
672 chrono::TimeDelta::days(31)
673 );
674 }
675
676 #[test]
677 fn test_policy_graph_construction() {
678 let transitions = vec![
679 Transition {
680 source_id: 1,
681 target_id: 2,
682 probability: 1.0,
683 annual_discount_rate_override: None,
684 },
685 Transition {
686 source_id: 2,
687 target_id: 3,
688 probability: 1.0,
689 annual_discount_rate_override: Some(0.08),
690 },
691 Transition {
692 source_id: 3,
693 target_id: 4,
694 probability: 1.0,
695 annual_discount_rate_override: None,
696 },
697 ];
698
699 let graph = PolicyGraph {
700 graph_type: PolicyGraphType::FiniteHorizon,
701 annual_discount_rate: 0.06,
702 transitions,
703 season_map: None,
704 };
705
706 assert_eq!(graph.graph_type, PolicyGraphType::FiniteHorizon);
707 assert!((graph.annual_discount_rate - 0.06).abs() < f64::EPSILON);
708 assert_eq!(graph.transitions.len(), 3);
709 assert_eq!(
710 graph.transitions[1].annual_discount_rate_override,
711 Some(0.08)
712 );
713 assert!(graph.season_map.is_none());
714 }
715
716 #[test]
717 fn test_season_map_construction() {
718 let months = [
719 "January",
720 "February",
721 "March",
722 "April",
723 "May",
724 "June",
725 "July",
726 "August",
727 "September",
728 "October",
729 "November",
730 "December",
731 ];
732
733 let seasons: Vec<SeasonDefinition> = months
734 .iter()
735 .enumerate()
736 .map(|(i, &label)| SeasonDefinition {
737 id: i,
738 label: label.to_string(),
739 month_start: u32::try_from(i + 1).unwrap(),
740 day_start: None,
741 month_end: None,
742 day_end: None,
743 })
744 .collect();
745
746 let season_map = SeasonMap {
747 cycle_type: SeasonCycleType::Monthly,
748 seasons,
749 };
750
751 assert_eq!(season_map.cycle_type, SeasonCycleType::Monthly);
752 assert_eq!(season_map.seasons.len(), 12);
753 assert_eq!(season_map.seasons[0].label, "January");
754 assert_eq!(season_map.seasons[11].label, "December");
755 assert_eq!(season_map.seasons[0].month_start, 1);
756 assert_eq!(season_map.seasons[11].month_start, 12);
757 }
758
759 #[cfg(feature = "serde")]
760 #[test]
761 fn test_policy_graph_serde_roundtrip() {
762 let graph = PolicyGraph {
763 graph_type: PolicyGraphType::FiniteHorizon,
764 annual_discount_rate: 0.06,
765 transitions: vec![
766 Transition {
767 source_id: 1,
768 target_id: 2,
769 probability: 1.0,
770 annual_discount_rate_override: None,
771 },
772 Transition {
773 source_id: 2,
774 target_id: 3,
775 probability: 1.0,
776 annual_discount_rate_override: None,
777 },
778 ],
779 season_map: None,
780 };
781
782 let json = serde_json::to_string(&graph).unwrap();
783
784 // Acceptance criterion: JSON must contain both key-value pairs.
785 assert!(
786 json.contains("\"graph_type\":\"FiniteHorizon\""),
787 "JSON did not contain expected graph_type: {json}"
788 );
789 assert!(
790 json.contains("\"annual_discount_rate\":0.06"),
791 "JSON did not contain expected annual_discount_rate: {json}"
792 );
793
794 // Round-trip must produce an equal value.
795 let deserialized: PolicyGraph = serde_json::from_str(&json).unwrap();
796 assert_eq!(graph, deserialized);
797 }
798}