Skip to main content

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).
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}
81
82/// Horizon type tag for the policy graph.
83///
84/// Determines whether the study horizon is finite (acyclic linear chain or DAG)
85/// or cyclic (infinite periodic horizon with at least one back-edge). The
86/// solver-level `HorizonMode` enum in downstream solver crates is built from a
87/// [`PolicyGraph`] that carries this tag — it precomputes transition maps,
88/// cycle detection, and discount factors for efficient runtime dispatch.
89///
90/// Cross-reference: [Horizon Mode Trait SS3.1](../architecture/horizon-mode-trait.md).
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93pub enum PolicyGraphType {
94    /// Acyclic stage chain: the study has a definite end stage.
95    /// Terminal value is zero (no future-cost approximation beyond the horizon).
96    FiniteHorizon,
97    /// Infinite periodic horizon: at least one transition has
98    /// `source_id >= target_id` (a back-edge). Requires a positive
99    /// `annual_discount_rate` for convergence.
100    Cyclic,
101}
102
103// ---------------------------------------------------------------------------
104// Block (SS12.2)
105// ---------------------------------------------------------------------------
106
107/// A load block within a stage, representing a sub-period with uniform
108/// demand and generation characteristics.
109///
110/// Blocks partition the stage duration into sub-periods (e.g., peak,
111/// off-peak, shoulder). Block IDs are contiguous within each stage,
112/// starting at 0. The block weight (fraction of stage duration) is
113/// derived from `duration_hours` and is not stored — it is computed
114/// on demand as `duration_hours / sum(all block hours in stage)`.
115///
116/// Source: `stages.json` `stages[].blocks[]`.
117/// See [Input Scenarios §1.5](input-scenarios.md).
118#[derive(Debug, Clone, PartialEq)]
119#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
120pub struct Block {
121    /// 0-based index within the parent stage.
122    /// Matches the `id` field from `stages.json`, validated to be
123    /// contiguous (0, 1, 2, ..., n-1) during loading.
124    pub index: usize,
125
126    /// Human-readable block label (e.g., "LEVE", "MEDIA", "PESADA").
127    pub name: String,
128
129    /// Duration of this block in hours. Must be positive.
130    /// Validation: the sum of all block hours within a stage must
131    /// equal the total stage duration in hours.
132    /// See [Input Scenarios §1.10](input-scenarios.md), rule 3.
133    pub duration_hours: f64,
134}
135
136// ---------------------------------------------------------------------------
137// StageStateConfig (SS12.3)
138// ---------------------------------------------------------------------------
139
140/// State variable flags controlling which variables carry state
141/// between stages for a given stage.
142///
143/// Source: `stages.json` `stages[].state_variables`.
144/// See [Input Scenarios §1.6](input-scenarios.md).
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
147pub struct StageStateConfig {
148    /// Whether reservoir storage volumes are state variables.
149    /// Default: true. Mandatory in most applications but kept as an
150    /// explicit flag for transparency.
151    pub storage: bool,
152
153    /// Whether past inflow realizations (AR model lags) are state
154    /// variables. Default: false. Required when PAR model order `p > 0`
155    /// and inflow lag cuts are enabled.
156    pub inflow_lags: bool,
157}
158
159// ---------------------------------------------------------------------------
160// StageRiskConfig (SS12.4)
161// ---------------------------------------------------------------------------
162
163/// Per-stage risk measure configuration, representing the parsed and
164/// validated risk parameters for a single stage.
165///
166/// This is the clarity-first representation stored in the [`Stage`] struct.
167/// The solver-level `RiskMeasure` enum in
168/// [Risk Measure Trait](../architecture/risk-measure-trait.md) is the
169/// dispatch type built FROM this configuration during the variant
170/// selection pipeline.
171///
172/// Source: `stages.json` `stages[].risk_measure`.
173/// See [Input Scenarios §1.7](input-scenarios.md).
174#[derive(Debug, Clone, Copy, PartialEq)]
175#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
176pub enum StageRiskConfig {
177    /// Risk-neutral expected value. No additional parameters.
178    Expectation,
179
180    /// Convex combination of expectation and `CVaR`.
181    /// See [Risk Measures](../math/risk-measures.md) for the
182    /// mathematical formulation.
183    CVaR {
184        /// Confidence level `alpha` in (0, 1].
185        /// `alpha = 0.95` means 5% worst-case scenarios are considered.
186        alpha: f64,
187
188        /// Risk aversion weight `lambda` in \[0, 1\].
189        /// `lambda = 0` reduces to Expectation; `lambda = 1` is pure `CVaR`.
190        lambda: f64,
191    },
192}
193
194// ---------------------------------------------------------------------------
195// ScenarioSourceConfig (SS12.5)
196// ---------------------------------------------------------------------------
197
198/// Scenario source configuration for one stage.
199///
200/// Groups the scenario-related settings that were formerly separate
201/// `num_scenarios` and `sampling_method` fields. Sourced from
202/// `stages.json` `scenario_source` and per-stage overrides.
203///
204/// See [Input Scenarios §1.4, §1.8](input-scenarios.md).
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
207pub struct ScenarioSourceConfig {
208    /// Number of noise realizations per stage for both the opening
209    /// tree and forward pass. Formerly `num_scenarios`.
210    /// Must be positive. Controls the per-stage branching factor.
211    pub branching_factor: usize,
212
213    /// Algorithm for generating noise vectors in the opening tree.
214    /// Orthogonal to `SamplingScheme`, which selects the noise
215    /// source (in-sample, external, historical).
216    /// Can vary per stage, allowing adaptive strategies (e.g., LHS
217    /// for near-term, SAA for distant stages).
218    pub noise_method: NoiseMethod,
219}
220
221// ---------------------------------------------------------------------------
222// Stage (SS12.6)
223// ---------------------------------------------------------------------------
224
225/// A single stage in the multi-stage stochastic optimization problem.
226///
227/// Stages partition the study horizon into decision periods. Each stage
228/// has a temporal extent, block structure, scenario configuration, risk
229/// parameters, and state variable flags. Stages are sorted by `id` in
230/// canonical order after loading (see Design Principles §3).
231///
232/// Study stages have non-negative IDs; pre-study stages (used only for
233/// PAR model lag initialization) have negative IDs. Pre-study stages
234/// carry only `id`, `start_date`, `end_date`, and `season_id` — their
235/// blocks, risk, and sampling fields are unused.
236///
237/// This struct does NOT contain LP-related fields (variable indices,
238/// constraint counts, coefficient arrays). Those belong to the
239/// downstream solver crate performance layer — see Solver Abstraction SS11.
240///
241/// Source: `stages.json` `stages[]` and `pre_study_stages[]`.
242/// See [Input Scenarios §1.4](input-scenarios.md).
243#[derive(Debug, Clone, PartialEq)]
244#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
245pub struct Stage {
246    // -- Identity and temporal extent --
247    /// 0-based index of this stage in the canonical-ordered stages
248    /// vector. Used for array indexing into per-stage data structures
249    /// (cuts, results, penalty arrays). Assigned during loading after
250    /// sorting by `id`.
251    pub index: usize,
252
253    /// Unique stage identifier from `stages.json`.
254    /// Non-negative for study stages, negative for pre-study stages.
255    /// The `id` is the domain-level identifier; `index` is the
256    /// internal array position.
257    pub id: i32,
258
259    /// Stage start date (inclusive). Parsed from ISO 8601 string.
260    /// Uses `chrono::NaiveDate` — timezone-free calendar date, which
261    /// is appropriate because stage boundaries are calendar concepts,
262    /// not instants in time.
263    pub start_date: NaiveDate,
264
265    /// Stage end date (exclusive). Parsed from ISO 8601 string.
266    /// The stage duration is `end_date - start_date`.
267    pub end_date: NaiveDate,
268
269    /// Season index linking to [`SeasonDefinition`]. Maps this stage to
270    /// a position in the seasonal cycle (e.g., month 0-11 for monthly).
271    /// Required for PAR model coefficient lookup and inflow history
272    /// aggregation. `None` for stages without seasonal structure.
273    pub season_id: Option<usize>,
274
275    // -- Block structure --
276    /// Ordered list of load blocks within this stage.
277    /// Sorted by block index (0, 1, ..., n-1). The sum of all block
278    /// `duration_hours` must equal the total stage duration in hours.
279    pub blocks: Vec<Block>,
280
281    /// Block formulation mode for this stage.
282    /// Can vary per stage (e.g., chronological for near-term,
283    /// parallel for distant stages).
284    /// See [Block Formulations](../math/block-formulations.md).
285    pub block_mode: BlockMode,
286
287    // -- State, risk, and sampling --
288    /// State variable flags controlling which variables carry state
289    /// from this stage to the next.
290    pub state_config: StageStateConfig,
291
292    /// Risk measure configuration for this stage.
293    /// Can vary per stage (e.g., `CVaR` for near-term, Expectation
294    /// for distant stages).
295    pub risk_config: StageRiskConfig,
296
297    /// Scenario source configuration (branching factor and noise method).
298    pub scenario_config: ScenarioSourceConfig,
299}
300
301// ---------------------------------------------------------------------------
302// SeasonDefinition (SS12.7)
303// ---------------------------------------------------------------------------
304
305/// A single season entry mapping a season ID to a calendar period.
306///
307/// Season definitions are required when deriving AR parameters from
308/// inflow history — the season determines how history values are
309/// aggregated into seasonal means and standard deviations.
310///
311/// Source: `stages.json` `season_definitions.seasons[]`.
312/// See [Input Scenarios §1.1](input-scenarios.md).
313#[derive(Debug, Clone, PartialEq)]
314#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
315pub struct SeasonDefinition {
316    /// Season index (0-based). For monthly cycles: 0 = January, ...,
317    /// 11 = December. For weekly cycles: 0-51 (ISO week numbers).
318    pub id: usize,
319
320    /// Human-readable label (e.g., "January", "Q1", "Wet Season").
321    pub label: String,
322
323    /// Calendar month where this season starts (1-12).
324    /// For monthly `cycle_type`, this uniquely identifies the month.
325    pub month_start: u32,
326
327    /// Calendar day where this season starts (1-31).
328    /// Only used when `cycle_type` is `Custom`. Default: 1.
329    pub day_start: Option<u32>,
330
331    /// Calendar month where this season ends (1-12).
332    /// Only used when `cycle_type` is `Custom`.
333    pub month_end: Option<u32>,
334
335    /// Calendar day where this season ends (1-31).
336    /// Only used when `cycle_type` is `Custom`.
337    pub day_end: Option<u32>,
338}
339
340// ---------------------------------------------------------------------------
341// SeasonMap (SS12.8)
342// ---------------------------------------------------------------------------
343
344/// Complete season definitions including cycle type and all season entries.
345///
346/// The `SeasonMap` is the resolved representation of the `season_definitions`
347/// section in `stages.json`. It provides the season-to-calendar mapping
348/// consumed by the PAR model and inflow history aggregation.
349///
350/// Source: `stages.json` `season_definitions`.
351/// See [Input Scenarios §1.1](input-scenarios.md).
352#[derive(Debug, Clone, PartialEq)]
353#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
354pub struct SeasonMap {
355    /// Cycle type controlling how season IDs map to calendar periods.
356    pub cycle_type: SeasonCycleType,
357
358    /// Season entries sorted by `id`. Length depends on `cycle_type`:
359    /// 12 for `Monthly`, 52 for `Weekly`, user-defined for `Custom`.
360    pub seasons: Vec<SeasonDefinition>,
361}
362
363impl SeasonMap {
364    /// Resolve a calendar date to a season ID using the cycle definition.
365    ///
366    /// This mapping is purely calendar-based and does not depend on the study
367    /// horizon — a date from 1931 maps to the same season as a date from 2026
368    /// if they share the same calendar position. This is essential for PAR
369    /// model estimation from historical inflow data that predates the study.
370    ///
371    /// Returns `None` only for `Custom` cycle types where the date does not
372    /// fall within any defined season range.
373    #[must_use]
374    pub fn season_for_date(&self, date: NaiveDate) -> Option<usize> {
375        match self.cycle_type {
376            SeasonCycleType::Monthly => {
377                let month = date.month();
378                self.seasons
379                    .iter()
380                    .find(|s| s.month_start == month)
381                    .map(|s| s.id)
382            }
383            SeasonCycleType::Weekly => {
384                let iso_week = date.iso_week().week();
385                let week_idx = (iso_week.saturating_sub(1)).min(51) as usize;
386                self.seasons.iter().find(|s| s.id == week_idx).map(|s| s.id)
387            }
388            SeasonCycleType::Custom => {
389                let (m, d) = (date.month(), date.day());
390                self.seasons
391                    .iter()
392                    .find(|s| {
393                        let ms = s.month_start;
394                        let ds = s.day_start.unwrap_or(1);
395                        let me = s.month_end.unwrap_or(ms);
396                        let de = s.day_end.unwrap_or(31);
397                        let start = (ms, ds);
398                        let end = (me, de);
399                        let cur = (m, d);
400                        if start <= end {
401                            cur >= start && cur <= end
402                        } else {
403                            cur >= start || cur <= end
404                        }
405                    })
406                    .map(|s| s.id)
407            }
408        }
409    }
410}
411
412// ---------------------------------------------------------------------------
413// Transition (SS12.9)
414// ---------------------------------------------------------------------------
415
416/// A single transition in the policy graph, representing a directed
417/// edge from one stage to another with an associated probability and
418/// optional discount rate override.
419///
420/// Transitions define the stage traversal order for both the forward
421/// and backward passes. In finite horizon mode, transitions form a
422/// linear chain. In cyclic mode, at least one transition creates a
423/// back-edge (`source_id >= target_id`).
424///
425/// Source: `stages.json` `policy_graph.transitions[]`.
426/// See [Input Scenarios §1.2](input-scenarios.md).
427#[derive(Debug, Clone, Copy, PartialEq)]
428#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
429pub struct Transition {
430    /// Source stage ID. Must exist in the stage set.
431    pub source_id: i32,
432
433    /// Target stage ID. Must exist in the stage set.
434    pub target_id: i32,
435
436    /// Transition probability. Outgoing probabilities from each source
437    /// must sum to 1.0 (within tolerance).
438    pub probability: f64,
439
440    /// Per-transition annual discount rate override.
441    /// When `None`, the global `annual_discount_rate` from the
442    /// [`PolicyGraph`] is used. When `Some(r)`, this rate is converted to
443    /// a per-transition factor using the source stage duration:
444    /// `d = 1 / (1 + r)^dt`.
445    /// See [Discount Rate §3](../math/discount-rate.md).
446    pub annual_discount_rate_override: Option<f64>,
447}
448
449// ---------------------------------------------------------------------------
450// PolicyGraph (SS12.10)
451// ---------------------------------------------------------------------------
452
453/// Parsed and validated policy graph defining stage transitions,
454/// horizon type, and global discount rate.
455///
456/// This is the `cobre-core` clarity-first representation loaded from
457/// `stages.json`. It stores the graph topology as specified by the
458/// user. The solver-level `HorizonMode` enum (see Horizon Mode Trait
459/// SS1) is built from this struct during initialization — it
460/// precomputes transition maps, cycle detection, and discount factors
461/// for efficient runtime dispatch.
462///
463/// Cross-reference: [Horizon Mode Trait](../architecture/horizon-mode-trait.md)
464/// defines the `HorizonMode` enum that interprets this graph structure.
465///
466/// Source: `stages.json` `policy_graph`.
467/// See [Input Scenarios §1.2](input-scenarios.md).
468#[derive(Debug, Clone, PartialEq)]
469#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
470pub struct PolicyGraph {
471    /// Horizon type: finite (acyclic chain) or cyclic (infinite periodic).
472    /// Determines which `HorizonMode` variant will be constructed at
473    /// solver initialization.
474    pub graph_type: PolicyGraphType,
475
476    /// Global annual discount rate.
477    /// Converted to per-transition factors using source stage durations:
478    /// `d = 1 / (1 + annual_discount_rate)^dt`.
479    /// A value of 0.0 means no discounting (`d = 1.0` for all transitions).
480    /// For cyclic graphs, must be > 0 for convergence (validation rule 7).
481    /// See [Discount Rate §3](../math/discount-rate.md).
482    pub annual_discount_rate: f64,
483
484    /// Stage transitions with probabilities and optional per-transition
485    /// discount rate overrides. For finite horizon, these form a linear
486    /// chain or DAG. For cyclic horizon, at least one transition has
487    /// `source_id >= target_id` (the back-edge).
488    pub transitions: Vec<Transition>,
489
490    /// Season definitions loaded from `season_definitions` in
491    /// `stages.json`. Required when PAR models or inflow history
492    /// aggregation are used. `None` when no season definitions are
493    /// provided and none are required.
494    pub season_map: Option<SeasonMap>,
495}
496
497impl Default for PolicyGraph {
498    /// Returns a finite-horizon policy graph with no transitions and no discounting.
499    ///
500    /// This is the minimal-viable-solver default: a finite study horizon with
501    /// zero terminal value and no discount factor. `cobre-io` replaces this
502    /// with the fully specified graph loaded from `stages.json`.
503    fn default() -> Self {
504        Self {
505            graph_type: PolicyGraphType::FiniteHorizon,
506            annual_discount_rate: 0.0,
507            transitions: Vec::new(),
508            season_map: None,
509        }
510    }
511}
512
513// ---------------------------------------------------------------------------
514// Tests
515// ---------------------------------------------------------------------------
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn test_block_mode_copy() {
523        let original = BlockMode::Parallel;
524        let copied = original;
525        assert_eq!(original, BlockMode::Parallel);
526        assert_eq!(copied, BlockMode::Parallel);
527
528        let chrono = BlockMode::Chronological;
529        let copied_chrono = chrono;
530        assert_eq!(chrono, BlockMode::Chronological);
531        assert_eq!(copied_chrono, BlockMode::Chronological);
532    }
533
534    #[test]
535    fn test_stage_duration() {
536        let stage = Stage {
537            index: 0,
538            id: 1,
539            start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
540            end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
541            season_id: Some(0),
542            blocks: vec![Block {
543                index: 0,
544                name: "SINGLE".to_string(),
545                duration_hours: 744.0,
546            }],
547            block_mode: BlockMode::Parallel,
548            state_config: StageStateConfig {
549                storage: true,
550                inflow_lags: false,
551            },
552            risk_config: StageRiskConfig::Expectation,
553            scenario_config: ScenarioSourceConfig {
554                branching_factor: 50,
555                noise_method: NoiseMethod::Saa,
556            },
557        };
558
559        assert_eq!(
560            stage.end_date - stage.start_date,
561            chrono::TimeDelta::days(31)
562        );
563    }
564
565    #[test]
566    fn test_policy_graph_construction() {
567        let transitions = vec![
568            Transition {
569                source_id: 1,
570                target_id: 2,
571                probability: 1.0,
572                annual_discount_rate_override: None,
573            },
574            Transition {
575                source_id: 2,
576                target_id: 3,
577                probability: 1.0,
578                annual_discount_rate_override: Some(0.08),
579            },
580            Transition {
581                source_id: 3,
582                target_id: 4,
583                probability: 1.0,
584                annual_discount_rate_override: None,
585            },
586        ];
587
588        let graph = PolicyGraph {
589            graph_type: PolicyGraphType::FiniteHorizon,
590            annual_discount_rate: 0.06,
591            transitions,
592            season_map: None,
593        };
594
595        assert_eq!(graph.graph_type, PolicyGraphType::FiniteHorizon);
596        assert!((graph.annual_discount_rate - 0.06).abs() < f64::EPSILON);
597        assert_eq!(graph.transitions.len(), 3);
598        assert_eq!(
599            graph.transitions[1].annual_discount_rate_override,
600            Some(0.08)
601        );
602        assert!(graph.season_map.is_none());
603    }
604
605    #[test]
606    fn test_season_map_construction() {
607        let months = [
608            "January",
609            "February",
610            "March",
611            "April",
612            "May",
613            "June",
614            "July",
615            "August",
616            "September",
617            "October",
618            "November",
619            "December",
620        ];
621
622        let seasons: Vec<SeasonDefinition> = months
623            .iter()
624            .enumerate()
625            .map(|(i, &label)| SeasonDefinition {
626                id: i,
627                label: label.to_string(),
628                month_start: u32::try_from(i + 1).unwrap(),
629                day_start: None,
630                month_end: None,
631                day_end: None,
632            })
633            .collect();
634
635        let season_map = SeasonMap {
636            cycle_type: SeasonCycleType::Monthly,
637            seasons,
638        };
639
640        assert_eq!(season_map.cycle_type, SeasonCycleType::Monthly);
641        assert_eq!(season_map.seasons.len(), 12);
642        assert_eq!(season_map.seasons[0].label, "January");
643        assert_eq!(season_map.seasons[11].label, "December");
644        assert_eq!(season_map.seasons[0].month_start, 1);
645        assert_eq!(season_map.seasons[11].month_start, 12);
646    }
647
648    #[cfg(feature = "serde")]
649    #[test]
650    fn test_policy_graph_serde_roundtrip() {
651        let graph = PolicyGraph {
652            graph_type: PolicyGraphType::FiniteHorizon,
653            annual_discount_rate: 0.06,
654            transitions: vec![
655                Transition {
656                    source_id: 1,
657                    target_id: 2,
658                    probability: 1.0,
659                    annual_discount_rate_override: None,
660                },
661                Transition {
662                    source_id: 2,
663                    target_id: 3,
664                    probability: 1.0,
665                    annual_discount_rate_override: None,
666                },
667            ],
668            season_map: None,
669        };
670
671        let json = serde_json::to_string(&graph).unwrap();
672
673        // Acceptance criterion: JSON must contain both key-value pairs.
674        assert!(
675            json.contains("\"graph_type\":\"FiniteHorizon\""),
676            "JSON did not contain expected graph_type: {json}"
677        );
678        assert!(
679            json.contains("\"annual_discount_rate\":0.06"),
680            "JSON did not contain expected annual_discount_rate: {json}"
681        );
682
683        // Round-trip must produce an equal value.
684        let deserialized: PolicyGraph = serde_json::from_str(&json).unwrap();
685        assert_eq!(graph, deserialized);
686    }
687}