use chrono::{Datelike, NaiveDate};
pub mod overlap;
pub mod stage_key;
pub use overlap::window_period_overlaps;
pub use stage_key::{CalendarMonth, StageId, StudyPos, month_of};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum BlockMode {
#[default]
Parallel,
Chronological,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum SeasonCycleType {
Monthly,
Weekly,
Custom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum NoiseMethod {
Saa,
Lhs,
QmcSobol,
QmcHalton,
Selective,
HistoricalResiduals,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PolicyGraphType {
FiniteHorizon,
Cyclic,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Block {
pub index: usize,
pub name: String,
pub duration_hours: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct StageStateConfig {
pub storage: bool,
pub inflow_lags: bool,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct StageLagTransition {
pub accumulate_weight: f64,
pub spillover_weight: f64,
pub finalize_period: bool,
pub accumulate_downstream: bool,
pub downstream_accumulate_weight: f64,
pub downstream_spillover_weight: f64,
pub downstream_finalize: bool,
pub rebuild_from_downstream: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum StageRiskConfig {
Expectation,
CVaR {
alpha: f64,
lambda: f64,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ScenarioSourceConfig {
pub branching_factor: usize,
pub noise_method: NoiseMethod,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Stage {
pub index: usize,
pub id: i32,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub season_id: Option<usize>,
pub blocks: Vec<Block>,
pub block_mode: BlockMode,
pub state_config: StageStateConfig,
pub risk_config: StageRiskConfig,
pub scenario_config: ScenarioSourceConfig,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SeasonDefinition {
pub id: usize,
pub label: String,
pub month_start: u32,
pub day_start: Option<u32>,
pub month_end: Option<u32>,
pub day_end: Option<u32>,
}
impl SeasonDefinition {
fn covers(&self, month: u32, day: u32) -> bool {
let start = (self.month_start, self.day_start.unwrap_or(1));
let end = (
self.month_end.unwrap_or(self.month_start),
self.day_end.unwrap_or(31),
);
let cur = (month, day);
if start <= end {
cur >= start && cur <= end
} else {
cur >= start || cur <= end
}
}
#[must_use]
pub fn span_days(&self, cycle_type: SeasonCycleType) -> usize {
match cycle_type {
SeasonCycleType::Weekly => 7,
SeasonCycleType::Monthly => {
const MONTH_DAYS: [usize; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let index = usize::try_from(self.month_start.saturating_sub(1)).unwrap_or(0);
MONTH_DAYS.get(index).copied().unwrap_or(31)
}
SeasonCycleType::Custom => canonical_calendar_days()
.into_iter()
.filter(|&(month, day)| self.covers(month, day))
.count(),
}
}
}
fn canonical_calendar_days() -> Vec<(u32, u32)> {
const DAYS_IN_MONTH_LEAP: [u32; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut days = Vec::with_capacity(366);
for (month_index, &days_in_month) in DAYS_IN_MONTH_LEAP.iter().enumerate() {
let month = u32::try_from(month_index).unwrap_or(0) + 1;
for day in 1..=days_in_month {
days.push((month, day));
}
}
days
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SeasonMap {
pub cycle_type: SeasonCycleType,
pub seasons: Vec<SeasonDefinition>,
}
impl SeasonMap {
#[must_use]
pub fn season_for_date(&self, date: NaiveDate) -> Option<usize> {
match self.cycle_type {
SeasonCycleType::Monthly => {
let month = date.month();
self.seasons
.iter()
.find(|s| s.month_start == month)
.map(|s| s.id)
}
SeasonCycleType::Weekly => {
let iso_week = date.iso_week().week();
let week_idx = (iso_week.saturating_sub(1)).min(51) as usize;
self.seasons.iter().find(|s| s.id == week_idx).map(|s| s.id)
}
SeasonCycleType::Custom => {
let (m, d) = (date.month(), date.day());
self.seasons.iter().find(|s| s.covers(m, d)).map(|s| s.id)
}
}
}
#[must_use]
pub fn is_multi_resolution(&self) -> bool {
if self.cycle_type != SeasonCycleType::Custom {
return false;
}
canonical_calendar_days().into_iter().any(|(month, day)| {
self.seasons
.iter()
.filter(|def| def.covers(month, day))
.count()
>= 2
})
}
#[must_use]
pub fn resolution_level_of(&self, season_id: usize) -> Option<usize> {
self.seasons
.iter()
.find(|s| s.id == season_id)
.map(|s| s.span_days(self.cycle_type))
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Transition {
pub source_id: i32,
pub target_id: i32,
pub probability: f64,
pub annual_discount_rate_override: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PolicyGraph {
pub graph_type: PolicyGraphType,
pub annual_discount_rate: f64,
pub transitions: Vec<Transition>,
pub season_map: Option<SeasonMap>,
}
impl Default for PolicyGraph {
fn default() -> Self {
Self {
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.0,
transitions: Vec::new(),
season_map: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_block_mode_copy() {
let original = BlockMode::Parallel;
let copied = original;
assert_eq!(original, BlockMode::Parallel);
assert_eq!(copied, BlockMode::Parallel);
let chrono = BlockMode::Chronological;
let copied_chrono = chrono;
assert_eq!(chrono, BlockMode::Chronological);
assert_eq!(copied_chrono, BlockMode::Chronological);
}
#[test]
fn test_stage_duration() {
let stage = Stage {
index: 0,
id: 1,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: Some(0),
blocks: vec![Block {
index: 0,
name: "SINGLE".to_string(),
duration_hours: 744.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 50,
noise_method: NoiseMethod::Saa,
},
};
assert_eq!(
stage.end_date - stage.start_date,
chrono::TimeDelta::days(31)
);
}
#[test]
fn test_policy_graph_construction() {
let transitions = vec![
Transition {
source_id: 1,
target_id: 2,
probability: 1.0,
annual_discount_rate_override: None,
},
Transition {
source_id: 2,
target_id: 3,
probability: 1.0,
annual_discount_rate_override: Some(0.08),
},
Transition {
source_id: 3,
target_id: 4,
probability: 1.0,
annual_discount_rate_override: None,
},
];
let graph = PolicyGraph {
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.06,
transitions,
season_map: None,
};
assert_eq!(graph.graph_type, PolicyGraphType::FiniteHorizon);
assert!((graph.annual_discount_rate - 0.06).abs() < f64::EPSILON);
assert_eq!(graph.transitions.len(), 3);
assert_eq!(
graph.transitions[1].annual_discount_rate_override,
Some(0.08)
);
assert!(graph.season_map.is_none());
}
#[test]
fn test_season_map_construction() {
let months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
let seasons: Vec<SeasonDefinition> = months
.iter()
.enumerate()
.map(|(i, &label)| SeasonDefinition {
id: i,
label: label.to_string(),
month_start: u32::try_from(i + 1).unwrap(),
day_start: None,
month_end: None,
day_end: None,
})
.collect();
let season_map = SeasonMap {
cycle_type: SeasonCycleType::Monthly,
seasons,
};
assert_eq!(season_map.cycle_type, SeasonCycleType::Monthly);
assert_eq!(season_map.seasons.len(), 12);
assert_eq!(season_map.seasons[0].label, "January");
assert_eq!(season_map.seasons[11].label, "December");
assert_eq!(season_map.seasons[0].month_start, 1);
assert_eq!(season_map.seasons[11].month_start, 12);
}
#[test]
fn test_weekly_season_iso_week_53_folds_into_week_52() {
let seasons: Vec<SeasonDefinition> = (0..52)
.map(|i| SeasonDefinition {
id: i,
label: format!("W{:02}", i + 1),
month_start: 1,
day_start: None,
month_end: None,
day_end: None,
})
.collect();
let season_map = SeasonMap {
cycle_type: SeasonCycleType::Weekly,
seasons,
};
let week53_date = NaiveDate::from_ymd_opt(2020, 12, 30).unwrap();
assert_eq!(
week53_date.iso_week().week(),
53,
"precondition: ISO week 53"
);
assert_eq!(
season_map.season_for_date(week53_date),
Some(51),
"ISO week 53 must fold into week 52 (id 51), not None or week 1 (id 0)"
);
let week2_date = NaiveDate::from_ymd_opt(2021, 1, 11).unwrap();
assert_eq!(week2_date.iso_week().week(), 2, "precondition: ISO week 2");
assert_eq!(season_map.season_for_date(week2_date), Some(1));
}
fn d30_shaped_season_map() -> SeasonMap {
let mut seasons: Vec<SeasonDefinition> = (0..12u32)
.map(|i| SeasonDefinition {
id: i as usize,
label: format!("Month{}", i + 1),
month_start: i + 1,
day_start: Some(1),
month_end: Some(i + 1),
day_end: Some(if i == 1 { 28 } else { 31 }),
})
.collect();
seasons.extend([
SeasonDefinition {
id: 12,
label: "Q3".to_string(),
month_start: 7,
day_start: Some(1),
month_end: Some(9),
day_end: Some(30),
},
SeasonDefinition {
id: 13,
label: "Q4".to_string(),
month_start: 10,
day_start: Some(1),
month_end: Some(12),
day_end: Some(31),
},
SeasonDefinition {
id: 14,
label: "Q1".to_string(),
month_start: 1,
day_start: Some(1),
month_end: Some(3),
day_end: Some(31),
},
SeasonDefinition {
id: 15,
label: "Q2".to_string(),
month_start: 4,
day_start: Some(1),
month_end: Some(6),
day_end: Some(30),
},
]);
SeasonMap {
cycle_type: SeasonCycleType::Custom,
seasons,
}
}
#[test]
fn test_d30_shaped_custom_map_is_multi_resolution() {
assert!(
d30_shaped_season_map().is_multi_resolution(),
"monthly + quarterly Custom definitions overlap by design"
);
}
#[test]
fn test_all_monthly_map_is_not_multi_resolution() {
let seasons: Vec<SeasonDefinition> = (0..12u32)
.map(|i| SeasonDefinition {
id: i as usize,
label: format!("Month{}", i + 1),
month_start: i + 1,
day_start: None,
month_end: None,
day_end: None,
})
.collect();
let season_map = SeasonMap {
cycle_type: SeasonCycleType::Monthly,
seasons,
};
assert!(!season_map.is_multi_resolution());
}
#[test]
fn test_weekly_map_is_not_multi_resolution() {
let seasons: Vec<SeasonDefinition> = (0..52u32)
.map(|i| SeasonDefinition {
id: i as usize,
label: format!("W{:02}", i + 1),
month_start: 1,
day_start: None,
month_end: None,
day_end: None,
})
.collect();
let season_map = SeasonMap {
cycle_type: SeasonCycleType::Weekly,
seasons,
};
assert!(!season_map.is_multi_resolution());
}
#[test]
fn test_resolution_level_of_d30_shaped_map() {
let season_map = d30_shaped_season_map();
assert_eq!(
season_map.resolution_level_of(0),
Some(31),
"January spans 31 canonical days"
);
assert_eq!(
season_map.resolution_level_of(1),
Some(28),
"the D30 fixture caps February at day_end 28, excluding the leap Feb 29"
);
assert_eq!(
season_map.resolution_level_of(12),
Some(92),
"Q3 (Jul+Aug+Sep) spans 92 canonical days"
);
assert_eq!(
season_map.resolution_level_of(14),
Some(91),
"Q1 (Jan+Feb+Mar) spans 91 canonical days, including the leap Feb 29"
);
assert_eq!(season_map.resolution_level_of(999), None);
}
#[test]
fn test_resolution_level_of_weekly_is_always_seven() {
let seasons: Vec<SeasonDefinition> = (0..52u32)
.map(|i| SeasonDefinition {
id: i as usize,
label: format!("W{:02}", i + 1),
month_start: 1,
day_start: None,
month_end: None,
day_end: None,
})
.collect();
let season_map = SeasonMap {
cycle_type: SeasonCycleType::Weekly,
seasons,
};
assert_eq!(season_map.resolution_level_of(0), Some(7));
assert_eq!(season_map.resolution_level_of(51), Some(7));
}
#[test]
fn test_season_for_date_custom_first_match_wins_on_overlap() {
let season_map = d30_shaped_season_map();
let july_15 = NaiveDate::from_ymd_opt(2024, 7, 15).unwrap();
assert_eq!(season_map.season_for_date(july_15), Some(6));
}
#[cfg(feature = "serde")]
#[test]
fn test_policy_graph_serde_roundtrip() {
let graph = PolicyGraph {
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.06,
transitions: vec![
Transition {
source_id: 1,
target_id: 2,
probability: 1.0,
annual_discount_rate_override: None,
},
Transition {
source_id: 2,
target_id: 3,
probability: 1.0,
annual_discount_rate_override: None,
},
],
season_map: None,
};
let json = serde_json::to_string(&graph).unwrap();
assert!(
json.contains("\"graph_type\":\"FiniteHorizon\""),
"JSON did not contain expected graph_type: {json}"
);
assert!(
json.contains("\"annual_discount_rate\":0.06"),
"JSON did not contain expected annual_discount_rate: {json}"
);
let deserialized: PolicyGraph = serde_json::from_str(&json).unwrap();
assert_eq!(graph, deserialized);
}
}