use chrono::NaiveDate;
use cobre_core::{
AnticipatedCommitmentHistory, EntityId, HydroPastDefluence, HydroStorage, InitialConditions,
RecentObservation,
};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
use crate::LoadError;
use crate::windowed_history::{WindowedRecord, parse_iso_date, validate_windowed_records};
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawInitialConditions {
#[serde(rename = "$schema")]
_schema: Option<String>,
storage: Vec<RawHydroStorage>,
filling_storage: Vec<RawHydroStorage>,
#[serde(default)]
recent_observations: Vec<RawRecentObservation>,
#[serde(default)]
past_anticipated_commitments: Vec<RawAnticipatedCommitmentHistory>,
#[serde(default)]
past_defluences: Vec<RawHydroPastDefluence>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawHydroStorage {
hydro_id: i32,
value_hm3: f64,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawHydroPastDefluence {
hydro_id: i32,
start_date: String,
end_date: String,
value_m3s: f64,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawRecentObservation {
hydro_id: i32,
start_date: String,
end_date: String,
value_m3s: f64,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawAnticipatedCommitmentHistory {
thermal_id: i32,
start_date: String,
end_date: String,
value_mw: f64,
}
pub fn parse_initial_conditions(path: &Path) -> Result<InitialConditions, LoadError> {
let raw_text = std::fs::read_to_string(path).map_err(|e| LoadError::io(path, e))?;
let raw: RawInitialConditions =
serde_json::from_str(&raw_text).map_err(|e| LoadError::parse(path, e.to_string()))?;
validate_raw(&raw, path)?;
Ok(convert(raw))
}
fn validate_raw(raw: &RawInitialConditions, path: &Path) -> Result<(), LoadError> {
validate_non_negative(&raw.storage, "storage", path)?;
validate_non_negative(&raw.filling_storage, "filling_storage", path)?;
validate_no_duplicates(&raw.storage, "storage", path)?;
validate_no_duplicates(&raw.filling_storage, "filling_storage", path)?;
validate_mutual_exclusion(raw, path)?;
let recent_observation_windows =
parse_recent_observation_windows(&raw.recent_observations, path)?;
validate_windowed_records(
&recent_observation_windows,
"recent_observations",
"hydro_id",
"value_m3s",
path,
)?;
let anticipated_commitment_windows =
parse_anticipated_commitment_windows(&raw.past_anticipated_commitments, path)?;
validate_no_duplicate_commitment_windows(&anticipated_commitment_windows, path)?;
validate_windowed_records(
&anticipated_commitment_windows,
"past_anticipated_commitments",
"thermal_id",
"value_mw",
path,
)?;
let past_defluence_windows = parse_past_defluence_windows(&raw.past_defluences, path)?;
validate_windowed_records(
&past_defluence_windows,
"past_defluences",
"hydro_id",
"value_m3s",
path,
)?;
validate_past_defluences_non_negative(&raw.past_defluences, path)?;
Ok(())
}
fn parse_recent_observation_windows(
entries: &[RawRecentObservation],
path: &Path,
) -> Result<Vec<WindowedRecord>, LoadError> {
entries
.iter()
.enumerate()
.map(|(i, entry)| {
let start_date = parse_iso_date(
&format!("recent_observations[{i}].start_date"),
&entry.start_date,
path,
)?;
let end_date = parse_iso_date(
&format!("recent_observations[{i}].end_date"),
&entry.end_date,
path,
)?;
Ok(WindowedRecord {
entity_id: entry.hydro_id,
start_date,
end_date,
value: entry.value_m3s,
})
})
.collect()
}
fn parse_anticipated_commitment_windows(
entries: &[RawAnticipatedCommitmentHistory],
path: &Path,
) -> Result<Vec<WindowedRecord>, LoadError> {
entries
.iter()
.enumerate()
.map(|(i, entry)| {
let start_date = parse_iso_date(
&format!("past_anticipated_commitments[{i}].start_date"),
&entry.start_date,
path,
)?;
let end_date = parse_iso_date(
&format!("past_anticipated_commitments[{i}].end_date"),
&entry.end_date,
path,
)?;
Ok(WindowedRecord {
entity_id: entry.thermal_id,
start_date,
end_date,
value: entry.value_mw,
})
})
.collect()
}
fn parse_past_defluence_windows(
entries: &[RawHydroPastDefluence],
path: &Path,
) -> Result<Vec<WindowedRecord>, LoadError> {
entries
.iter()
.enumerate()
.map(|(i, entry)| {
let start_date = parse_iso_date(
&format!("past_defluences[{i}].start_date"),
&entry.start_date,
path,
)?;
let end_date = parse_iso_date(
&format!("past_defluences[{i}].end_date"),
&entry.end_date,
path,
)?;
Ok(WindowedRecord {
entity_id: entry.hydro_id,
start_date,
end_date,
value: entry.value_m3s,
})
})
.collect()
}
fn validate_non_negative(
entries: &[RawHydroStorage],
array_name: &str,
path: &Path,
) -> Result<(), LoadError> {
for (i, entry) in entries.iter().enumerate() {
if entry.value_hm3 < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("{array_name}[{i}].value_hm3"),
message: format!("value_hm3 must be >= 0.0, got {}", entry.value_hm3),
});
}
}
Ok(())
}
fn validate_no_duplicates(
entries: &[RawHydroStorage],
array_name: &str,
path: &Path,
) -> Result<(), LoadError> {
let mut seen: HashSet<i32> = HashSet::new();
for (i, entry) in entries.iter().enumerate() {
if !seen.insert(entry.hydro_id) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("{array_name}[{i}].hydro_id"),
message: format!("duplicate hydro_id {} in {array_name}", entry.hydro_id),
});
}
}
Ok(())
}
fn validate_mutual_exclusion(raw: &RawInitialConditions, path: &Path) -> Result<(), LoadError> {
let storage_ids: HashSet<i32> = raw.storage.iter().map(|e| e.hydro_id).collect();
for (i, entry) in raw.filling_storage.iter().enumerate() {
if storage_ids.contains(&entry.hydro_id) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("filling_storage[{i}].hydro_id"),
message: format!(
"hydro_id {} appears in both storage and filling_storage; \
a hydro must appear in exactly one of the two arrays",
entry.hydro_id
),
});
}
}
Ok(())
}
fn validate_no_duplicate_commitment_windows(
windows: &[WindowedRecord],
path: &Path,
) -> Result<(), LoadError> {
let mut seen: HashSet<(i32, NaiveDate)> = HashSet::new();
for (i, window) in windows.iter().enumerate() {
if !seen.insert((window.entity_id, window.start_date)) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("past_anticipated_commitments[{i}].start_date"),
message: format!(
"duplicate (thermal_id {}, start_date {}) in past_anticipated_commitments",
window.entity_id, window.start_date
),
});
}
}
Ok(())
}
fn validate_past_defluences_non_negative(
entries: &[RawHydroPastDefluence],
path: &Path,
) -> Result<(), LoadError> {
for (i, entry) in entries.iter().enumerate() {
if entry.value_m3s < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("past_defluences[{i}].value_m3s"),
message: format!(
"past_defluences[{i}].value_m3s must be >= 0.0, got {}",
entry.value_m3s
),
});
}
}
Ok(())
}
fn convert(raw: RawInitialConditions) -> InitialConditions {
let mut storage: Vec<HydroStorage> = raw
.storage
.into_iter()
.map(|e| HydroStorage {
hydro_id: EntityId(e.hydro_id),
value_hm3: e.value_hm3,
})
.collect();
storage.sort_by_key(|e| e.hydro_id.0);
let mut filling_storage: Vec<HydroStorage> = raw
.filling_storage
.into_iter()
.map(|e| HydroStorage {
hydro_id: EntityId(e.hydro_id),
value_hm3: e.value_hm3,
})
.collect();
filling_storage.sort_by_key(|e| e.hydro_id.0);
let mut recent_observations: Vec<RecentObservation> = raw
.recent_observations
.into_iter()
.map(|e| RecentObservation {
hydro_id: EntityId(e.hydro_id),
start_date: NaiveDate::parse_from_str(&e.start_date, "%Y-%m-%d")
.unwrap_or_else(|_| unreachable!("start_date already validated")),
end_date: NaiveDate::parse_from_str(&e.end_date, "%Y-%m-%d")
.unwrap_or_else(|_| unreachable!("end_date already validated")),
value_m3s: e.value_m3s,
})
.collect();
recent_observations.sort_by_key(|e| (e.hydro_id.0, e.start_date));
let mut past_anticipated_commitments: Vec<AnticipatedCommitmentHistory> = raw
.past_anticipated_commitments
.into_iter()
.map(|e| AnticipatedCommitmentHistory {
thermal_id: EntityId(e.thermal_id),
start_date: NaiveDate::parse_from_str(&e.start_date, "%Y-%m-%d")
.unwrap_or_else(|_| unreachable!("start_date already validated")),
end_date: NaiveDate::parse_from_str(&e.end_date, "%Y-%m-%d")
.unwrap_or_else(|_| unreachable!("end_date already validated")),
value_mw: e.value_mw,
})
.collect();
past_anticipated_commitments.sort_by_key(|e| (e.thermal_id.0, e.start_date));
let mut past_defluences: Vec<HydroPastDefluence> = raw
.past_defluences
.into_iter()
.map(|e| HydroPastDefluence {
hydro_id: EntityId(e.hydro_id),
start_date: NaiveDate::parse_from_str(&e.start_date, "%Y-%m-%d")
.unwrap_or_else(|_| unreachable!("start_date already validated")),
end_date: NaiveDate::parse_from_str(&e.end_date, "%Y-%m-%d")
.unwrap_or_else(|_| unreachable!("end_date already validated")),
value_m3s: e.value_m3s,
})
.collect();
past_defluences.sort_by_key(|e| (e.hydro_id.0, e.start_date));
InitialConditions {
storage,
filling_storage,
past_anticipated_commitments,
recent_observations,
past_defluences,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic, clippy::too_many_lines)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_json(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
const VALID_JSON: &str = r#"{
"$schema": "https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/schemas/initial_conditions.schema.json",
"storage": [
{ "hydro_id": 0, "value_hm3": 15000.0 },
{ "hydro_id": 1, "value_hm3": 8500.0 }
],
"filling_storage": [
{ "hydro_id": 10, "value_hm3": 200.0 }
]
}"#;
#[test]
fn test_parse_valid_initial_conditions() {
let f = write_json(VALID_JSON);
let ic = parse_initial_conditions(f.path()).unwrap();
assert_eq!(ic.storage.len(), 2);
assert_eq!(ic.filling_storage.len(), 1);
assert_eq!(ic.storage[0].hydro_id, EntityId(0));
assert!(
(ic.storage[0].value_hm3 - 15_000.0).abs() < f64::EPSILON,
"expected 15000.0, got {}",
ic.storage[0].value_hm3
);
assert_eq!(ic.storage[1].hydro_id, EntityId(1));
assert!(
(ic.storage[1].value_hm3 - 8_500.0).abs() < f64::EPSILON,
"expected 8500.0, got {}",
ic.storage[1].value_hm3
);
assert_eq!(ic.filling_storage[0].hydro_id, EntityId(10));
assert!(
(ic.filling_storage[0].value_hm3 - 200.0).abs() < f64::EPSILON,
"expected 200.0, got {}",
ic.filling_storage[0].value_hm3
);
}
#[test]
fn test_parse_empty_arrays() {
let json = r#"{ "storage": [], "filling_storage": [] }"#;
let f = write_json(json);
let ic = parse_initial_conditions(f.path()).unwrap();
assert!(ic.storage.is_empty());
assert!(ic.filling_storage.is_empty());
}
#[test]
fn test_negative_storage_value() {
let json = r#"{
"storage": [
{ "hydro_id": 0, "value_hm3": -1.0 }
],
"filling_storage": []
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("value_hm3"),
"field should contain 'value_hm3', got: {field}"
);
assert!(
message.contains("value_hm3"),
"message should mention value_hm3, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_negative_filling_storage_value() {
let json = r#"{
"storage": [],
"filling_storage": [
{ "hydro_id": 10, "value_hm3": -100.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("value_hm3"),
"field should contain 'value_hm3', got: {field}"
);
assert!(
message.contains("value_hm3"),
"message should mention value_hm3, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_duplicate_hydro_id_in_storage() {
let json = r#"{
"storage": [
{ "hydro_id": 5, "value_hm3": 1000.0 },
{ "hydro_id": 5, "value_hm3": 2000.0 }
],
"filling_storage": []
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("storage"),
"field should mention 'storage', got: {field}"
);
assert!(
message.contains("duplicate"),
"message should mention 'duplicate', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_duplicate_hydro_id_in_filling_storage() {
let json = r#"{
"storage": [],
"filling_storage": [
{ "hydro_id": 10, "value_hm3": 100.0 },
{ "hydro_id": 10, "value_hm3": 200.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("filling_storage"),
"field should mention 'filling_storage', got: {field}"
);
assert!(
message.contains("duplicate"),
"message should mention 'duplicate', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_hydro_id_in_both_lists() {
let json = r#"{
"storage": [
{ "hydro_id": 5, "value_hm3": 1000.0 }
],
"filling_storage": [
{ "hydro_id": 5, "value_hm3": 100.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("filling_storage"),
"field should mention 'filling_storage', got: {field}"
);
assert!(
message.contains("storage") && message.contains("filling_storage"),
"message should mention both arrays for mutual exclusion, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_file_not_found() {
let path = Path::new("/nonexistent/initial_conditions.json");
let err = parse_initial_conditions(path).unwrap_err();
match &err {
LoadError::IoError { path: p, .. } => {
assert_eq!(p, path);
}
other => panic!("expected IoError, got: {other:?}"),
}
}
#[test]
fn test_zero_storage_value_is_valid() {
let json = r#"{
"storage": [
{ "hydro_id": 0, "value_hm3": 0.0 }
],
"filling_storage": []
}"#;
let f = write_json(json);
let result = parse_initial_conditions(f.path());
assert!(
result.is_ok(),
"0.0 is non-negative and must be accepted, got: {result:?}"
);
}
#[test]
fn test_filling_storage_below_dead_volume_is_valid() {
let json = r#"{
"storage": [],
"filling_storage": [
{ "hydro_id": 10, "value_hm3": 1.0 }
]
}"#;
let f = write_json(json);
let result = parse_initial_conditions(f.path());
assert!(
result.is_ok(),
"filling storage values below dead volume are valid at this layer, got: {result:?}"
);
}
#[test]
fn test_declaration_order_invariance() {
let json_ordered = r#"{
"storage": [
{ "hydro_id": 0, "value_hm3": 1000.0 },
{ "hydro_id": 1, "value_hm3": 2000.0 }
],
"filling_storage": []
}"#;
let json_reversed = r#"{
"storage": [
{ "hydro_id": 1, "value_hm3": 2000.0 },
{ "hydro_id": 0, "value_hm3": 1000.0 }
],
"filling_storage": []
}"#;
let f1 = write_json(json_ordered);
let f2 = write_json(json_reversed);
let ic1 = parse_initial_conditions(f1.path()).unwrap();
let ic2 = parse_initial_conditions(f2.path()).unwrap();
assert_eq!(
ic1, ic2,
"results must be identical regardless of input ordering"
);
assert_eq!(ic1.storage[0].hydro_id, EntityId(0));
assert_eq!(ic1.storage[1].hydro_id, EntityId(1));
}
#[test]
fn test_invalid_json_syntax() {
let f = write_json(r#"{"storage": [not valid json}}"#);
let err = parse_initial_conditions(f.path()).unwrap_err();
assert!(
matches!(err, LoadError::ParseError { .. }),
"expected ParseError for invalid JSON, got: {err:?}"
);
}
#[test]
fn test_missing_required_field() {
let json = r#"{ "filling_storage": [] }"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
assert!(
matches!(err, LoadError::ParseError { .. }),
"expected ParseError for missing storage field, got: {err:?}"
);
}
#[test]
fn test_recent_observations_absent_defaults_to_empty() {
let f = write_json(VALID_JSON);
let ic = parse_initial_conditions(f.path()).unwrap();
assert!(
ic.recent_observations.is_empty(),
"absent recent_observations must default to empty vec"
);
}
#[test]
fn test_recent_observations_empty_array() {
let json = r#"{
"storage": [],
"filling_storage": [],
"recent_observations": []
}"#;
let f = write_json(json);
let ic = parse_initial_conditions(f.path()).unwrap();
assert!(ic.recent_observations.is_empty());
}
#[test]
fn test_recent_observations_valid_two_entries() {
let json = r#"{
"storage": [],
"filling_storage": [],
"recent_observations": [
{ "hydro_id": 0, "start_date": "2026-04-01", "end_date": "2026-04-04", "value_m3s": 500.0 },
{ "hydro_id": 0, "start_date": "2026-04-04", "end_date": "2026-04-11", "value_m3s": 480.0 }
]
}"#;
let f = write_json(json);
let ic = parse_initial_conditions(f.path()).unwrap();
assert_eq!(ic.recent_observations.len(), 2);
assert_eq!(ic.recent_observations[0].hydro_id, EntityId(0));
assert_eq!(
ic.recent_observations[0].start_date,
chrono::NaiveDate::from_ymd_opt(2026, 4, 1).unwrap()
);
assert_eq!(
ic.recent_observations[0].end_date,
chrono::NaiveDate::from_ymd_opt(2026, 4, 4).unwrap()
);
assert!((ic.recent_observations[0].value_m3s - 500.0).abs() < f64::EPSILON);
assert!((ic.recent_observations[1].value_m3s - 480.0).abs() < f64::EPSILON);
}
#[test]
fn test_recent_observations_invalid_start_date_format() {
let json = r#"{
"storage": [],
"filling_storage": [],
"recent_observations": [
{ "hydro_id": 0, "start_date": "2026/04/01", "end_date": "2026-04-04", "value_m3s": 500.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, .. } => {
assert!(
field.contains("start_date"),
"field should mention 'start_date', got: {field}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_recent_observations_invalid_end_date_format() {
let json = r#"{
"storage": [],
"filling_storage": [],
"recent_observations": [
{ "hydro_id": 0, "start_date": "2026-04-01", "end_date": "not-a-date", "value_m3s": 500.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, .. } => {
assert!(
field.contains("end_date"),
"field should mention 'end_date', got: {field}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_recent_observations_end_date_equals_start_date() {
let json = r#"{
"storage": [],
"filling_storage": [],
"recent_observations": [
{ "hydro_id": 0, "start_date": "2026-04-01", "end_date": "2026-04-01", "value_m3s": 500.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("end_date must be after start_date"),
"message should contain 'end_date must be after start_date', got: {message}"
);
assert!(
message.contains("hydro_id 0"),
"message should name the entity, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_recent_observations_end_date_before_start_date() {
let json = r#"{
"storage": [],
"filling_storage": [],
"recent_observations": [
{ "hydro_id": 0, "start_date": "2026-04-05", "end_date": "2026-04-01", "value_m3s": 500.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("end_date must be after start_date"),
"message should contain 'end_date must be after start_date', got: {message}"
);
assert!(
message.contains("hydro_id 0"),
"message should name the entity, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_recent_observations_negative_value_accepted() {
let json = r#"{
"storage": [],
"filling_storage": [],
"recent_observations": [
{ "hydro_id": 0, "start_date": "2026-04-01", "end_date": "2026-04-04", "value_m3s": -1.0 }
]
}"#;
let f = write_json(json);
let ic = parse_initial_conditions(f.path()).unwrap();
assert_eq!(ic.recent_observations.len(), 1);
assert!((ic.recent_observations[0].value_m3s - (-1.0)).abs() < f64::EPSILON);
}
#[test]
fn test_recent_observations_overlapping_ranges() {
let json = r#"{
"storage": [],
"filling_storage": [],
"recent_observations": [
{ "hydro_id": 0, "start_date": "2026-04-01", "end_date": "2026-04-05", "value_m3s": 500.0 },
{ "hydro_id": 0, "start_date": "2026-04-03", "end_date": "2026-04-10", "value_m3s": 480.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("overlapping"),
"message should contain 'overlapping', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_recent_observations_adjacent_ranges_are_valid() {
let json = r#"{
"storage": [],
"filling_storage": [],
"recent_observations": [
{ "hydro_id": 0, "start_date": "2026-04-01", "end_date": "2026-04-04", "value_m3s": 500.0 },
{ "hydro_id": 0, "start_date": "2026-04-04", "end_date": "2026-04-11", "value_m3s": 480.0 }
]
}"#;
let f = write_json(json);
let result = parse_initial_conditions(f.path());
assert!(
result.is_ok(),
"adjacent ranges (start == prev_end) must be accepted, got: {result:?}"
);
}
#[test]
fn test_recent_observations_sorted_by_hydro_id_then_start_date() {
let json = r#"{
"storage": [],
"filling_storage": [],
"recent_observations": [
{ "hydro_id": 1, "start_date": "2026-04-01", "end_date": "2026-04-07", "value_m3s": 300.0 },
{ "hydro_id": 0, "start_date": "2026-04-04", "end_date": "2026-04-11", "value_m3s": 480.0 },
{ "hydro_id": 0, "start_date": "2026-04-01", "end_date": "2026-04-04", "value_m3s": 500.0 }
]
}"#;
let f = write_json(json);
let ic = parse_initial_conditions(f.path()).unwrap();
assert_eq!(ic.recent_observations.len(), 3);
assert_eq!(ic.recent_observations[0].hydro_id, EntityId(0));
assert_eq!(
ic.recent_observations[0].start_date,
chrono::NaiveDate::from_ymd_opt(2026, 4, 1).unwrap()
);
assert_eq!(ic.recent_observations[1].hydro_id, EntityId(0));
assert_eq!(
ic.recent_observations[1].start_date,
chrono::NaiveDate::from_ymd_opt(2026, 4, 4).unwrap()
);
assert_eq!(ic.recent_observations[2].hydro_id, EntityId(1));
}
#[test]
fn test_recent_observations_declaration_order_invariance() {
let json_forward = r#"{
"storage": [],
"filling_storage": [],
"recent_observations": [
{ "hydro_id": 0, "start_date": "2026-04-01", "end_date": "2026-04-04", "value_m3s": 500.0 },
{ "hydro_id": 0, "start_date": "2026-04-04", "end_date": "2026-04-11", "value_m3s": 480.0 }
]
}"#;
let json_reversed = r#"{
"storage": [],
"filling_storage": [],
"recent_observations": [
{ "hydro_id": 0, "start_date": "2026-04-04", "end_date": "2026-04-11", "value_m3s": 480.0 },
{ "hydro_id": 0, "start_date": "2026-04-01", "end_date": "2026-04-04", "value_m3s": 500.0 }
]
}"#;
let f1 = write_json(json_forward);
let f2 = write_json(json_reversed);
let ic1 = parse_initial_conditions(f1.path()).unwrap();
let ic2 = parse_initial_conditions(f2.path()).unwrap();
assert_eq!(
ic1, ic2,
"results must be identical regardless of input ordering"
);
assert_eq!(
ic1.recent_observations[0].start_date,
chrono::NaiveDate::from_ymd_opt(2026, 4, 1).unwrap()
);
}
#[test]
fn test_parse_past_anticipated_commitments_present() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_anticipated_commitments": [
{ "thermal_id": 1, "start_date": "2026-01-01", "end_date": "2026-02-01", "value_mw": 120.0 },
{ "thermal_id": 1, "start_date": "2026-02-01", "end_date": "2026-03-01", "value_mw": 180.0 }
]
}"#;
let f = write_json(json);
let ic = parse_initial_conditions(f.path()).unwrap();
assert_eq!(ic.past_anticipated_commitments.len(), 2);
assert_eq!(ic.past_anticipated_commitments[0].thermal_id, EntityId(1));
assert_eq!(
ic.past_anticipated_commitments[0].start_date,
chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()
);
assert!((ic.past_anticipated_commitments[0].value_mw - 120.0).abs() < f64::EPSILON);
assert!((ic.past_anticipated_commitments[1].value_mw - 180.0).abs() < f64::EPSILON);
}
#[test]
fn test_parse_past_anticipated_commitments_absent_defaults_empty() {
let f = write_json(VALID_JSON);
let ic = parse_initial_conditions(f.path()).unwrap();
assert!(
ic.past_anticipated_commitments.is_empty(),
"absent past_anticipated_commitments must default to empty vec"
);
}
#[test]
fn test_past_anticipated_commitments_declaration_order_invariance() {
let json_forward = r#"{
"storage": [],
"filling_storage": [],
"past_anticipated_commitments": [
{ "thermal_id": 1, "start_date": "2026-01-01", "end_date": "2026-02-01", "value_mw": 120.0 },
{ "thermal_id": 1, "start_date": "2026-02-01", "end_date": "2026-03-01", "value_mw": 180.0 },
{ "thermal_id": 2, "start_date": "2026-01-01", "end_date": "2026-02-01", "value_mw": 50.0 }
]
}"#;
let json_reversed = r#"{
"storage": [],
"filling_storage": [],
"past_anticipated_commitments": [
{ "thermal_id": 2, "start_date": "2026-01-01", "end_date": "2026-02-01", "value_mw": 50.0 },
{ "thermal_id": 1, "start_date": "2026-02-01", "end_date": "2026-03-01", "value_mw": 180.0 },
{ "thermal_id": 1, "start_date": "2026-01-01", "end_date": "2026-02-01", "value_mw": 120.0 }
]
}"#;
let f1 = write_json(json_forward);
let f2 = write_json(json_reversed);
let ic1 = parse_initial_conditions(f1.path()).unwrap();
let ic2 = parse_initial_conditions(f2.path()).unwrap();
assert_eq!(
ic1.past_anticipated_commitments, ic2.past_anticipated_commitments,
"results must be identical regardless of input ordering"
);
assert_eq!(ic1.past_anticipated_commitments[0].thermal_id, EntityId(1));
assert_eq!(
ic1.past_anticipated_commitments[0].start_date,
chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()
);
assert_eq!(ic1.past_anticipated_commitments[1].thermal_id, EntityId(1));
assert_eq!(
ic1.past_anticipated_commitments[1].start_date,
chrono::NaiveDate::from_ymd_opt(2026, 2, 1).unwrap()
);
assert_eq!(ic1.past_anticipated_commitments[2].thermal_id, EntityId(2));
}
#[test]
fn test_duplicate_thermal_id_in_past_anticipated_commitments_rejected() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_anticipated_commitments": [
{ "thermal_id": 5, "start_date": "2026-01-01", "end_date": "2026-02-01", "value_mw": 100.0 },
{ "thermal_id": 5, "start_date": "2026-01-01", "end_date": "2026-02-01", "value_mw": 200.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("past_anticipated_commitments["),
"field should contain 'past_anticipated_commitments[', got: {field}"
);
assert!(
message.contains("duplicate"),
"message should contain 'duplicate', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_multiple_windows_same_thermal_distinct_starts_accepted() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_anticipated_commitments": [
{ "thermal_id": 3, "start_date": "2026-01-01", "end_date": "2026-02-01", "value_mw": 100.0 },
{ "thermal_id": 3, "start_date": "2026-02-01", "end_date": "2026-03-01", "value_mw": 200.0 }
]
}"#;
let f = write_json(json);
let ic = parse_initial_conditions(f.path()).unwrap();
assert_eq!(ic.past_anticipated_commitments.len(), 2);
}
#[test]
fn test_commitment_window_end_before_start_rejected() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_anticipated_commitments": [
{ "thermal_id": 7, "start_date": "2026-02-01", "end_date": "2026-01-01", "value_mw": 120.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("end_date must be after start_date"),
"message should contain 'end_date must be after start_date', got: {message}"
);
assert!(
message.contains("thermal_id 7"),
"message should name the entity, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_commitment_windows_overlapping_rejected() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_anticipated_commitments": [
{ "thermal_id": 7, "start_date": "2026-01-01", "end_date": "2026-02-15", "value_mw": 120.0 },
{ "thermal_id": 7, "start_date": "2026-02-01", "end_date": "2026-03-01", "value_mw": 130.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("overlapping"),
"message should contain 'overlapping', got: {message}"
);
assert!(
message.contains("thermal_id 7"),
"message should name thermal_id (the commitment surface's own entity \
label), got: {message}"
);
assert!(
!message.contains("hydro_id"),
"message must not borrow the hydro surfaces' entity label, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_past_defluences_absent_defaults_to_empty() {
let f = write_json(VALID_JSON);
let ic = parse_initial_conditions(f.path()).unwrap();
assert!(
ic.past_defluences.is_empty(),
"absent past_defluences must default to empty vec"
);
}
#[test]
fn test_parse_valid_past_defluences_windows_sorted() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_defluences": [
{ "hydro_id": 1, "start_date": "2023-12-30", "end_date": "2024-01-01", "value_m3s": 200.0 },
{ "hydro_id": 0, "start_date": "2023-12-25", "end_date": "2023-12-28", "value_m3s": 600.0 },
{ "hydro_id": 0, "start_date": "2023-12-28", "end_date": "2024-01-01", "value_m3s": 500.0 }
]
}"#;
let f = write_json(json);
let ic = parse_initial_conditions(f.path()).unwrap();
assert_eq!(ic.past_defluences.len(), 3);
assert_eq!(ic.past_defluences[0].hydro_id, EntityId(0));
assert_eq!(
ic.past_defluences[0].start_date,
chrono::NaiveDate::from_ymd_opt(2023, 12, 25).unwrap()
);
assert_eq!(ic.past_defluences[1].hydro_id, EntityId(0));
assert_eq!(
ic.past_defluences[1].start_date,
chrono::NaiveDate::from_ymd_opt(2023, 12, 28).unwrap()
);
assert_eq!(ic.past_defluences[2].hydro_id, EntityId(1));
assert!((ic.past_defluences[2].value_m3s - 200.0).abs() < f64::EPSILON);
}
#[test]
fn test_past_defluences_overlapping_windows_rejected() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_defluences": [
{ "hydro_id": 0, "start_date": "2023-12-25", "end_date": "2023-12-30", "value_m3s": 500.0 },
{ "hydro_id": 0, "start_date": "2023-12-28", "end_date": "2024-01-01", "value_m3s": 480.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("overlapping"),
"message should contain 'overlapping', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_past_defluences_end_before_start_rejected() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_defluences": [
{ "hydro_id": 0, "start_date": "2024-01-01", "end_date": "2023-12-30", "value_m3s": 500.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("end_date must be after start_date"),
"message should contain 'end_date must be after start_date', got: {message}"
);
assert!(
message.contains("hydro_id 0"),
"message should name the entity, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_past_defluences_negative_value_rejected() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_defluences": [
{ "hydro_id": 0, "start_date": "2023-12-30", "end_date": "2024-01-01", "value_m3s": -1.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, .. } => {
assert!(
field.contains("value_m3s"),
"field should contain 'value_m3s', got: {field}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_legacy_past_inflows_field_is_rejected() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_inflows": [
{ "hydro_id": 0, "values_m3s": [600.0, 500.0] }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::ParseError { message, .. } => {
assert!(
message.contains("past_inflows"),
"message should name the unknown 'past_inflows' field, got: {message}"
);
}
other => panic!("expected ParseError, got: {other:?}"),
}
}
#[test]
fn test_retired_future_anticipated_deliveries_field_is_rejected() {
let json = r#"{
"storage": [],
"filling_storage": [],
"future_anticipated_deliveries": [
{ "thermal_id": 86, "delivery_start": "2026-09-05", "delivery_end": "2026-09-12", "min_mw": 0.0, "max_mw": 350.0 }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::ParseError { message, .. } => {
assert!(
message.contains("future_anticipated_deliveries"),
"message should name the unknown 'future_anticipated_deliveries' field, \
got: {message}"
);
}
other => panic!("expected ParseError, got: {other:?}"),
}
}
}