use chrono::NaiveDate;
use crate::EntityId;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct HydroStorage {
pub hydro_id: EntityId,
pub value_hm3: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct HydroPastDefluence {
pub hydro_id: EntityId,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub value_m3s: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AnticipatedCommitmentHistory {
pub thermal_id: EntityId,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub value_mw: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FutureAnticipatedDelivery {
pub thermal_id: EntityId,
pub delivery_start: NaiveDate,
pub delivery_end: NaiveDate,
pub min_mw: f64,
pub max_mw: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RecentObservation {
pub hydro_id: EntityId,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub value_m3s: f64,
}
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct InitialConditions {
pub storage: Vec<HydroStorage>,
pub filling_storage: Vec<HydroStorage>,
#[cfg_attr(feature = "serde", serde(default))]
pub past_anticipated_commitments: Vec<AnticipatedCommitmentHistory>,
#[cfg_attr(feature = "serde", serde(default))]
pub recent_observations: Vec<RecentObservation>,
#[cfg_attr(feature = "serde", serde(default))]
pub past_defluences: Vec<HydroPastDefluence>,
#[cfg_attr(feature = "serde", serde(default))]
pub future_anticipated_deliveries: Vec<FutureAnticipatedDelivery>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_initial_conditions_construction() {
let ic = InitialConditions {
storage: vec![
HydroStorage {
hydro_id: EntityId(0),
value_hm3: 15_000.0,
},
HydroStorage {
hydro_id: EntityId(1),
value_hm3: 8_500.0,
},
],
filling_storage: vec![HydroStorage {
hydro_id: EntityId(10),
value_hm3: 200.0,
}],
past_anticipated_commitments: vec![],
recent_observations: vec![],
past_defluences: vec![],
future_anticipated_deliveries: vec![],
};
assert_eq!(ic.storage.len(), 2);
assert_eq!(ic.filling_storage.len(), 1);
assert_eq!(ic.storage[0].hydro_id, EntityId(0));
assert_eq!(ic.storage[0].value_hm3, 15_000.0);
assert_eq!(ic.storage[1].hydro_id, EntityId(1));
assert_eq!(ic.filling_storage[0].hydro_id, EntityId(10));
assert_eq!(ic.filling_storage[0].value_hm3, 200.0);
assert!(ic.recent_observations.is_empty());
}
#[test]
fn test_initial_conditions_default_is_empty() {
let ic = InitialConditions::default();
assert!(ic.storage.is_empty());
assert!(ic.filling_storage.is_empty());
assert!(ic.recent_observations.is_empty());
}
#[test]
fn test_hydro_storage_clone() {
let hs = HydroStorage {
hydro_id: EntityId(5),
value_hm3: 1_234.5,
};
let cloned = hs.clone();
assert_eq!(hs, cloned);
}
#[cfg(feature = "serde")]
#[test]
fn test_initial_conditions_serde_roundtrip() {
let ic = InitialConditions {
storage: vec![
HydroStorage {
hydro_id: EntityId(0),
value_hm3: 15_000.0,
},
HydroStorage {
hydro_id: EntityId(1),
value_hm3: 8_500.0,
},
],
filling_storage: vec![HydroStorage {
hydro_id: EntityId(10),
value_hm3: 200.0,
}],
past_anticipated_commitments: vec![],
recent_observations: vec![],
past_defluences: vec![],
future_anticipated_deliveries: vec![],
};
let json = serde_json::to_string(&ic).unwrap();
let deserialized: InitialConditions = serde_json::from_str(&json).unwrap();
assert_eq!(ic, deserialized);
}
#[test]
fn test_recent_observation_construction_and_clone() {
let obs = RecentObservation {
hydro_id: EntityId(2),
start_date: NaiveDate::from_ymd_opt(2026, 4, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 4, 4)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_m3s: 500.0,
};
let cloned = obs.clone();
assert_eq!(obs, cloned);
}
#[test]
fn test_initial_conditions_construction_with_recent_observations() {
let ic = InitialConditions {
storage: vec![HydroStorage {
hydro_id: EntityId(0),
value_hm3: 1_000.0,
}],
filling_storage: vec![],
past_anticipated_commitments: vec![],
recent_observations: vec![RecentObservation {
hydro_id: EntityId(0),
start_date: NaiveDate::from_ymd_opt(2026, 4, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 4, 4)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_m3s: 500.0,
}],
past_defluences: vec![],
future_anticipated_deliveries: vec![],
};
assert_eq!(ic.recent_observations.len(), 1);
assert_eq!(ic.recent_observations[0].hydro_id, EntityId(0));
assert_eq!(ic.recent_observations[0].value_m3s, 500.0);
}
#[cfg(feature = "serde")]
#[test]
fn test_initial_conditions_serde_roundtrip_with_recent_observations() {
let ic = InitialConditions {
storage: vec![HydroStorage {
hydro_id: EntityId(0),
value_hm3: 1_000.0,
}],
filling_storage: vec![],
past_anticipated_commitments: vec![],
recent_observations: vec![
RecentObservation {
hydro_id: EntityId(0),
start_date: NaiveDate::from_ymd_opt(2026, 4, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 4, 4)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_m3s: 500.0,
},
RecentObservation {
hydro_id: EntityId(0),
start_date: NaiveDate::from_ymd_opt(2026, 4, 4)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 4, 11)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_m3s: 480.0,
},
],
past_defluences: vec![],
future_anticipated_deliveries: vec![],
};
let json = serde_json::to_string(&ic).unwrap();
let deserialized: InitialConditions = serde_json::from_str(&json).unwrap();
assert_eq!(ic, deserialized);
assert_eq!(deserialized.recent_observations.len(), 2);
}
#[cfg(feature = "serde")]
#[test]
fn test_initial_conditions_serde_default_recent_observations_absent() {
let json = r#"{"storage":[],"filling_storage":[]}"#;
let ic: InitialConditions = serde_json::from_str(json).unwrap();
assert!(ic.recent_observations.is_empty());
}
#[test]
fn test_past_anticipated_commitments_default_empty() {
let ic = InitialConditions::default();
assert!(ic.past_anticipated_commitments.is_empty());
}
#[cfg(feature = "serde")]
#[test]
fn test_anticipated_commitment_history_serde_roundtrip() {
let ach = AnticipatedCommitmentHistory {
thermal_id: EntityId(7),
start_date: NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 2, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_mw: 100.0,
};
let json = serde_json::to_string(&ach).unwrap();
let deserialized: AnticipatedCommitmentHistory = serde_json::from_str(&json).unwrap();
assert_eq!(ach, deserialized);
assert!(
!json.contains("season_ids"),
"JSON must not contain 'season_ids', got: {json}"
);
}
#[test]
fn test_initial_conditions_with_anticipated_commitments() {
let ic = InitialConditions {
storage: vec![],
filling_storage: vec![],
past_anticipated_commitments: vec![
AnticipatedCommitmentHistory {
thermal_id: EntityId(3),
start_date: NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 2, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_mw: 50.0,
},
AnticipatedCommitmentHistory {
thermal_id: EntityId(5),
start_date: NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 2, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_mw: 200.0,
},
],
recent_observations: vec![],
past_defluences: vec![],
future_anticipated_deliveries: vec![],
};
assert_eq!(ic.past_anticipated_commitments.len(), 2);
assert_eq!(ic.past_anticipated_commitments[0].thermal_id, EntityId(3));
assert_eq!(ic.past_anticipated_commitments[0].value_mw, 50.0);
assert_eq!(ic.past_anticipated_commitments[1].thermal_id, EntityId(5));
assert_eq!(ic.past_anticipated_commitments[1].value_mw, 200.0);
}
#[test]
fn test_anticipated_commitment_history_has_no_season_ids_field() {
let ach = AnticipatedCommitmentHistory {
thermal_id: EntityId(0),
start_date: NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 2, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_mw: 0.0,
};
assert_eq!(ach.thermal_id, EntityId(0));
assert_eq!(ach.value_mw, 0.0);
}
#[test]
fn test_hydro_past_defluence_construction_and_clone() {
let hpd = HydroPastDefluence {
hydro_id: EntityId(4),
start_date: NaiveDate::from_ymd_opt(2026, 4, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 4, 4)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_m3s: 700.0,
};
assert_eq!(hpd.hydro_id, EntityId(4));
assert_eq!(hpd.value_m3s, 700.0);
assert_eq!(hpd.clone(), hpd);
}
#[test]
fn test_past_defluences_default_empty() {
let ic = InitialConditions::default();
assert!(ic.past_defluences.is_empty());
}
#[cfg(feature = "serde")]
#[test]
fn test_initial_conditions_serde_default_past_defluences_absent() {
let json = r#"{"storage":[],"filling_storage":[]}"#;
let ic: InitialConditions = serde_json::from_str(json).unwrap();
assert!(ic.past_defluences.is_empty());
}
#[cfg(feature = "serde")]
#[test]
fn test_past_defluences_postcard_round_trip() {
let ic = InitialConditions {
storage: vec![HydroStorage {
hydro_id: EntityId(0),
value_hm3: 1_000.0,
}],
filling_storage: vec![],
past_anticipated_commitments: vec![],
recent_observations: vec![],
past_defluences: vec![
HydroPastDefluence {
hydro_id: EntityId(0),
start_date: NaiveDate::from_ymd_opt(2026, 4, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 4, 4)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_m3s: 700.0,
},
HydroPastDefluence {
hydro_id: EntityId(0),
start_date: NaiveDate::from_ymd_opt(2026, 4, 4)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 4, 11)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_m3s: 650.0,
},
],
future_anticipated_deliveries: vec![],
};
let bytes = postcard::to_allocvec(&ic).unwrap();
let restored: InitialConditions = postcard::from_bytes(&bytes).unwrap();
assert_eq!(ic, restored);
assert_eq!(bytes, postcard::to_allocvec(&restored).unwrap());
assert_eq!(restored.past_defluences.len(), 2);
}
#[cfg(feature = "serde")]
#[test]
fn test_past_defluences_postcard_field_order_precedes_future_anticipated_deliveries() {
let base = InitialConditions {
storage: vec![HydroStorage {
hydro_id: EntityId(0),
value_hm3: 1_000.0,
}],
filling_storage: vec![],
past_anticipated_commitments: vec![],
recent_observations: vec![RecentObservation {
hydro_id: EntityId(0),
start_date: NaiveDate::from_ymd_opt(2026, 4, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 4, 4)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_m3s: 500.0,
}],
past_defluences: vec![],
future_anticipated_deliveries: vec![],
};
let mut with_defl = base.clone();
with_defl.past_defluences = vec![HydroPastDefluence {
hydro_id: EntityId(0),
start_date: NaiveDate::from_ymd_opt(2026, 4, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 4, 4)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_m3s: 700.0,
}];
let prefix = postcard::to_allocvec(&base).unwrap();
let full = postcard::to_allocvec(&with_defl).unwrap();
let shared_prefix_len = prefix.len() - 2;
assert!(full.len() > prefix.len());
assert_eq!(
&full[..shared_prefix_len],
&prefix[..shared_prefix_len],
"past_defluences must serialize after recent_observations and before \
future_anticipated_deliveries (append-last wire contract)"
);
assert_eq!(
full[full.len() - 1],
prefix[prefix.len() - 1],
"future_anticipated_deliveries (trailing, empty in both) must be \
unaffected by populating past_defluences"
);
}
#[test]
fn test_future_anticipated_delivery_construction_and_clone() {
let fad = FutureAnticipatedDelivery {
thermal_id: EntityId(6),
delivery_start: NaiveDate::from_ymd_opt(2026, 9, 5)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
delivery_end: NaiveDate::from_ymd_opt(2026, 9, 12)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
min_mw: 0.0,
max_mw: 350.0,
};
assert_eq!(fad.thermal_id, EntityId(6));
assert_eq!(fad.max_mw, 350.0);
assert_eq!(fad.clone(), fad);
}
#[test]
fn test_future_anticipated_deliveries_default_empty() {
let ic = InitialConditions::default();
assert!(ic.future_anticipated_deliveries.is_empty());
}
#[cfg(feature = "serde")]
#[test]
fn test_future_anticipated_delivery_serde_roundtrip() {
let fad = FutureAnticipatedDelivery {
thermal_id: EntityId(86),
delivery_start: NaiveDate::from_ymd_opt(2026, 9, 5)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
delivery_end: NaiveDate::from_ymd_opt(2026, 9, 12)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
min_mw: 0.0,
max_mw: 350.0,
};
let json = serde_json::to_string(&fad).unwrap();
let deserialized: FutureAnticipatedDelivery = serde_json::from_str(&json).unwrap();
assert_eq!(fad, deserialized);
}
#[cfg(feature = "serde")]
#[test]
fn test_initial_conditions_serde_default_future_anticipated_deliveries_absent() {
let json = r#"{"storage":[],"filling_storage":[]}"#;
let ic: InitialConditions = serde_json::from_str(json).unwrap();
assert!(ic.future_anticipated_deliveries.is_empty());
}
#[test]
fn test_initial_conditions_with_future_anticipated_deliveries() {
let ic = InitialConditions {
storage: vec![],
filling_storage: vec![],
past_anticipated_commitments: vec![],
recent_observations: vec![],
past_defluences: vec![],
future_anticipated_deliveries: vec![
FutureAnticipatedDelivery {
thermal_id: EntityId(3),
delivery_start: NaiveDate::from_ymd_opt(2026, 9, 5)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
delivery_end: NaiveDate::from_ymd_opt(2026, 9, 12)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
min_mw: 0.0,
max_mw: 100.0,
},
FutureAnticipatedDelivery {
thermal_id: EntityId(5),
delivery_start: NaiveDate::from_ymd_opt(2026, 9, 5)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
delivery_end: NaiveDate::from_ymd_opt(2026, 9, 12)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
min_mw: 50.0,
max_mw: 50.0,
},
],
};
assert_eq!(ic.future_anticipated_deliveries.len(), 2);
assert_eq!(ic.future_anticipated_deliveries[0].thermal_id, EntityId(3));
assert_eq!(ic.future_anticipated_deliveries[0].max_mw, 100.0);
assert_eq!(ic.future_anticipated_deliveries[1].thermal_id, EntityId(5));
assert_eq!(ic.future_anticipated_deliveries[1].min_mw, 50.0);
assert_eq!(ic.future_anticipated_deliveries[1].max_mw, 50.0);
}
#[cfg(feature = "serde")]
#[test]
fn test_future_anticipated_deliveries_postcard_round_trip() {
let ic = InitialConditions {
storage: vec![HydroStorage {
hydro_id: EntityId(0),
value_hm3: 1_000.0,
}],
filling_storage: vec![],
past_anticipated_commitments: vec![],
recent_observations: vec![],
past_defluences: vec![],
future_anticipated_deliveries: vec![
FutureAnticipatedDelivery {
thermal_id: EntityId(0),
delivery_start: NaiveDate::from_ymd_opt(2026, 9, 5)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
delivery_end: NaiveDate::from_ymd_opt(2026, 9, 12)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
min_mw: 0.0,
max_mw: 350.0,
},
FutureAnticipatedDelivery {
thermal_id: EntityId(0),
delivery_start: NaiveDate::from_ymd_opt(2026, 9, 12)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
delivery_end: NaiveDate::from_ymd_opt(2026, 9, 19)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
min_mw: 100.0,
max_mw: 100.0,
},
],
};
let bytes = postcard::to_allocvec(&ic).unwrap();
let restored: InitialConditions = postcard::from_bytes(&bytes).unwrap();
assert_eq!(ic, restored);
assert_eq!(bytes, postcard::to_allocvec(&restored).unwrap());
assert_eq!(restored.future_anticipated_deliveries.len(), 2);
}
#[cfg(feature = "serde")]
#[test]
fn test_future_anticipated_deliveries_postcard_field_order_is_last() {
let base = InitialConditions {
storage: vec![HydroStorage {
hydro_id: EntityId(0),
value_hm3: 1_000.0,
}],
filling_storage: vec![],
past_anticipated_commitments: vec![],
recent_observations: vec![],
past_defluences: vec![HydroPastDefluence {
hydro_id: EntityId(0),
start_date: NaiveDate::from_ymd_opt(2026, 4, 1)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
end_date: NaiveDate::from_ymd_opt(2026, 4, 4)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
value_m3s: 700.0,
}],
future_anticipated_deliveries: vec![],
};
let mut with_future = base.clone();
with_future.future_anticipated_deliveries = vec![FutureAnticipatedDelivery {
thermal_id: EntityId(9),
delivery_start: NaiveDate::from_ymd_opt(2026, 9, 5)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
delivery_end: NaiveDate::from_ymd_opt(2026, 9, 12)
.unwrap_or_else(|| unreachable!("hardcoded date is valid")),
min_mw: 0.0,
max_mw: 350.0,
}];
let prefix = postcard::to_allocvec(&base).unwrap();
let full = postcard::to_allocvec(&with_future).unwrap();
assert!(full.len() > prefix.len());
assert_eq!(
&full[..prefix.len() - 1],
&prefix[..prefix.len() - 1],
"future_anticipated_deliveries must serialize after past_defluences \
(append-last wire contract)"
);
}
}