use chrono::NaiveDate;
use cobre_core::{
AnticipatedCommitmentHistory, EntityId, HydroPastDefluence, HydroPastInflows, HydroStorage,
InitialConditions, RecentObservation,
};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
use crate::LoadError;
#[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)]
past_inflows: Vec<RawHydroPastInflows>,
#[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 RawHydroPastInflows {
hydro_id: i32,
values_m3s: Vec<f64>,
#[serde(default)]
season_ids: Option<Vec<u32>>,
}
#[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,
values_mw: Vec<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)?;
validate_past_inflows_no_duplicates(&raw.past_inflows, path)?;
validate_past_inflows_values(&raw.past_inflows, path)?;
validate_past_inflows_season_ids(&raw.past_inflows, path)?;
validate_recent_observations_dates(&raw.recent_observations, path)?;
validate_recent_observations_values(&raw.recent_observations, path)?;
validate_recent_observations_no_overlap(&raw.recent_observations, path)?;
validate_anticipated_commitment_histories(&raw.past_anticipated_commitments, path)?;
validate_past_defluences_dates(&raw.past_defluences, path)?;
validate_past_defluences_values(&raw.past_defluences, path)?;
validate_past_defluences_no_overlap(&raw.past_defluences, path)?;
Ok(())
}
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_past_inflows_no_duplicates(
entries: &[RawHydroPastInflows],
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!("past_inflows[{i}].hydro_id"),
message: format!("duplicate hydro_id {} in past_inflows", entry.hydro_id),
});
}
}
Ok(())
}
fn validate_past_inflows_values(
entries: &[RawHydroPastInflows],
path: &Path,
) -> Result<(), LoadError> {
for (i, entry) in entries.iter().enumerate() {
for (j, &v) in entry.values_m3s.iter().enumerate() {
if !v.is_finite() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("past_inflows[{i}].values_m3s[{j}]"),
message: format!(
"past_inflows[{i}].values_m3s[{j}] is not finite (got {v}); \
all inflow values must be finite numbers"
),
});
}
}
}
Ok(())
}
fn validate_past_inflows_season_ids(
entries: &[RawHydroPastInflows],
path: &Path,
) -> Result<(), LoadError> {
for (i, entry) in entries.iter().enumerate() {
if let Some(season_ids) = &entry.season_ids
&& season_ids.len() != entry.values_m3s.len()
{
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("past_inflows[{i}].season_ids"),
message: format!(
"past_inflows[{i}].season_ids has {} element(s) but \
past_inflows[{i}].values_m3s has {} element(s); \
season_ids length must equal values_m3s length",
season_ids.len(),
entry.values_m3s.len()
),
});
}
}
Ok(())
}
fn validate_recent_observations_dates(
entries: &[RawRecentObservation],
path: &Path,
) -> Result<(), LoadError> {
for (i, entry) in entries.iter().enumerate() {
let start = NaiveDate::parse_from_str(&entry.start_date, "%Y-%m-%d").map_err(|_| {
LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("recent_observations[{i}].start_date"),
message: format!(
"recent_observations[{i}].start_date '{}' is not a valid ISO 8601 date \
(expected YYYY-MM-DD)",
entry.start_date
),
}
})?;
let end = NaiveDate::parse_from_str(&entry.end_date, "%Y-%m-%d").map_err(|_| {
LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("recent_observations[{i}].end_date"),
message: format!(
"recent_observations[{i}].end_date '{}' is not a valid ISO 8601 date \
(expected YYYY-MM-DD)",
entry.end_date
),
}
})?;
if end <= start {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("recent_observations[{i}].end_date"),
message: format!(
"recent_observations[{i}]: end_date must be after start_date \
(start_date={}, end_date={})",
entry.start_date, entry.end_date
),
});
}
}
Ok(())
}
fn validate_recent_observations_values(
entries: &[RawRecentObservation],
path: &Path,
) -> Result<(), LoadError> {
for (i, entry) in entries.iter().enumerate() {
if !entry.value_m3s.is_finite() || entry.value_m3s < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("recent_observations[{i}].value_m3s"),
message: format!(
"recent_observations[{i}].value_m3s must be a finite non-negative number, \
got {}",
entry.value_m3s
),
});
}
}
Ok(())
}
fn validate_recent_observations_no_overlap(
entries: &[RawRecentObservation],
path: &Path,
) -> Result<(), LoadError> {
use std::collections::HashMap;
let mut by_hydro: HashMap<i32, Vec<usize>> = HashMap::new();
for (i, entry) in entries.iter().enumerate() {
by_hydro.entry(entry.hydro_id).or_default().push(i);
}
for (hydro_id, mut indices) in by_hydro {
indices.sort_by_key(|&i| {
NaiveDate::parse_from_str(&entries[i].start_date, "%Y-%m-%d")
.unwrap_or_else(|_| unreachable!("start_date already validated"))
});
for window in indices.windows(2) {
let (i_prev, i_curr) = (window[0], window[1]);
let prev_end = NaiveDate::parse_from_str(&entries[i_prev].end_date, "%Y-%m-%d")
.unwrap_or_else(|_| unreachable!("end_date already validated"));
let curr_start = NaiveDate::parse_from_str(&entries[i_curr].start_date, "%Y-%m-%d")
.unwrap_or_else(|_| unreachable!("start_date already validated"));
if curr_start < prev_end {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("recent_observations[{i_curr}].start_date"),
message: format!(
"recent_observations: overlapping date ranges for hydro_id {hydro_id}: \
entry [{i_prev}] ends on {prev_end} but entry [{i_curr}] starts on \
{curr_start}"
),
});
}
}
}
Ok(())
}
fn validate_anticipated_commitment_histories(
histories: &[RawAnticipatedCommitmentHistory],
path: &Path,
) -> Result<(), LoadError> {
let mut seen: HashSet<i32> = HashSet::new();
for (i, entry) in histories.iter().enumerate() {
if !seen.insert(entry.thermal_id) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("past_anticipated_commitments[{i}].thermal_id"),
message: format!(
"duplicate thermal_id {} in past_anticipated_commitments",
entry.thermal_id
),
});
}
if entry.values_mw.is_empty() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("past_anticipated_commitments[{i}].values_mw"),
message: format!(
"past_anticipated_commitments[{i}].values_mw must not be empty; \
anticipated plants always require at least one committed value"
),
});
}
for (j, &v) in entry.values_mw.iter().enumerate() {
if !v.is_finite() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("past_anticipated_commitments[{i}].values_mw[{j}]"),
message: format!(
"past_anticipated_commitments[{i}].values_mw[{j}] is not finite \
(got {v}); all committed MW values must be finite numbers"
),
});
}
if v < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("past_anticipated_commitments[{i}].values_mw[{j}]"),
message: format!(
"past_anticipated_commitments[{i}].values_mw[{j}] must be >= 0 \
(got {v}); anticipated commitments are physical generation amounts"
),
});
}
}
}
Ok(())
}
fn validate_past_defluences_dates(
entries: &[RawHydroPastDefluence],
path: &Path,
) -> Result<(), LoadError> {
for (i, entry) in entries.iter().enumerate() {
let start = NaiveDate::parse_from_str(&entry.start_date, "%Y-%m-%d").map_err(|_| {
LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("past_defluences[{i}].start_date"),
message: format!(
"past_defluences[{i}].start_date '{}' is not a valid ISO 8601 date \
(expected YYYY-MM-DD)",
entry.start_date
),
}
})?;
let end = NaiveDate::parse_from_str(&entry.end_date, "%Y-%m-%d").map_err(|_| {
LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("past_defluences[{i}].end_date"),
message: format!(
"past_defluences[{i}].end_date '{}' is not a valid ISO 8601 date \
(expected YYYY-MM-DD)",
entry.end_date
),
}
})?;
if end <= start {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("past_defluences[{i}].end_date"),
message: format!(
"past_defluences[{i}]: end_date must be after start_date \
(start_date={}, end_date={})",
entry.start_date, entry.end_date
),
});
}
}
Ok(())
}
fn validate_past_defluences_values(
entries: &[RawHydroPastDefluence],
path: &Path,
) -> Result<(), LoadError> {
for (i, entry) in entries.iter().enumerate() {
if !entry.value_m3s.is_finite() || 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 a finite non-negative number, \
got {}",
entry.value_m3s
),
});
}
}
Ok(())
}
fn validate_past_defluences_no_overlap(
entries: &[RawHydroPastDefluence],
path: &Path,
) -> Result<(), LoadError> {
use std::collections::HashMap;
let mut by_hydro: HashMap<i32, Vec<usize>> = HashMap::new();
for (i, entry) in entries.iter().enumerate() {
by_hydro.entry(entry.hydro_id).or_default().push(i);
}
for (hydro_id, mut indices) in by_hydro {
indices.sort_by_key(|&i| {
NaiveDate::parse_from_str(&entries[i].start_date, "%Y-%m-%d")
.unwrap_or_else(|_| unreachable!("start_date already validated"))
});
for window in indices.windows(2) {
let (i_prev, i_curr) = (window[0], window[1]);
let prev_end = NaiveDate::parse_from_str(&entries[i_prev].end_date, "%Y-%m-%d")
.unwrap_or_else(|_| unreachable!("end_date already validated"));
let curr_start = NaiveDate::parse_from_str(&entries[i_curr].start_date, "%Y-%m-%d")
.unwrap_or_else(|_| unreachable!("start_date already validated"));
if curr_start < prev_end {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("past_defluences[{i_curr}].start_date"),
message: format!(
"past_defluences: overlapping date ranges for hydro_id {hydro_id}: \
entry [{i_prev}] ends on {prev_end} but entry [{i_curr}] starts on \
{curr_start}"
),
});
}
}
}
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 past_inflows: Vec<HydroPastInflows> = raw
.past_inflows
.into_iter()
.map(|e| HydroPastInflows {
hydro_id: EntityId(e.hydro_id),
values_m3s: e.values_m3s,
season_ids: e.season_ids,
})
.collect();
past_inflows.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),
values_mw: e.values_mw,
})
.collect();
past_anticipated_commitments.sort_by_key(|e| e.thermal_id.0);
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_inflows,
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!(
ic.past_inflows.is_empty(),
"past_inflows absent defaults to empty"
);
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_valid_past_inflows() {
let json = r#"{
"storage": [
{ "hydro_id": 0, "value_hm3": 1000.0 },
{ "hydro_id": 1, "value_hm3": 2000.0 }
],
"filling_storage": [],
"past_inflows": [
{ "hydro_id": 1, "values_m3s": [200.0, 100.0] },
{ "hydro_id": 0, "values_m3s": [600.0, 500.0] }
]
}"#;
let f = write_json(json);
let ic = parse_initial_conditions(f.path()).unwrap();
assert_eq!(ic.past_inflows.len(), 2);
assert_eq!(ic.past_inflows[0].hydro_id, EntityId(0));
assert_eq!(ic.past_inflows[0].values_m3s, vec![600.0, 500.0]);
assert_eq!(ic.past_inflows[1].hydro_id, EntityId(1));
assert_eq!(ic.past_inflows[1].values_m3s, vec![200.0, 100.0]);
}
#[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());
assert!(ic.past_inflows.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_duplicate_hydro_id_in_past_inflows() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_inflows": [
{ "hydro_id": 3, "values_m3s": [100.0] },
{ "hydro_id": 3, "values_m3s": [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_inflows"),
"field should mention 'past_inflows', got: {field}"
);
assert!(
message.contains("duplicate"),
"message should mention 'duplicate', 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": [],
"past_inflows": [
{ "hydro_id": 0, "values_m3s": [600.0, 500.0] },
{ "hydro_id": 1, "values_m3s": [200.0, 100.0] }
]
}"#;
let json_reversed = r#"{
"storage": [
{ "hydro_id": 1, "value_hm3": 2000.0 },
{ "hydro_id": 0, "value_hm3": 1000.0 }
],
"filling_storage": [],
"past_inflows": [
{ "hydro_id": 1, "values_m3s": [200.0, 100.0] },
{ "hydro_id": 0, "values_m3s": [600.0, 500.0] }
]
}"#;
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));
assert_eq!(ic1.past_inflows[0].hydro_id, EntityId(0));
assert_eq!(ic1.past_inflows[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_zero_past_inflow_value_is_valid() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_inflows": [
{ "hydro_id": 1, "values_m3s": [0.0, 50.0] }
]
}"#;
let f = write_json(json);
let result = parse_initial_conditions(f.path());
assert!(
result.is_ok(),
"0.0 in past_inflows is valid (dry season), got: {result:?}"
);
}
#[test]
fn test_empty_values_m3s_is_valid() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_inflows": [
{ "hydro_id": 1, "values_m3s": [] }
]
}"#;
let f = write_json(json);
let result = parse_initial_conditions(f.path());
assert!(
result.is_ok(),
"empty values_m3s should be accepted, got: {result:?}"
);
}
#[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}"
);
}
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}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_recent_observations_negative_value() {
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 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_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_inflows_with_valid_season_ids() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_inflows": [
{ "hydro_id": 0, "values_m3s": [600.0, 500.0], "season_ids": [3, 2] }
]
}"#;
let f = write_json(json);
let ic = parse_initial_conditions(f.path()).unwrap();
assert_eq!(ic.past_inflows.len(), 1);
assert_eq!(ic.past_inflows[0].hydro_id, EntityId(0));
assert_eq!(ic.past_inflows[0].values_m3s, vec![600.0, 500.0]);
assert_eq!(ic.past_inflows[0].season_ids, Some(vec![3, 2]));
}
#[test]
fn test_parse_past_inflows_season_ids_length_mismatch() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_inflows": [
{ "hydro_id": 0, "values_m3s": [600.0, 500.0], "season_ids": [3, 2, 1] }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("season_ids"),
"field should contain 'season_ids', got: {field}"
);
assert!(
field.contains("past_inflows[0]"),
"field should reference 'past_inflows[0]', got: {field}"
);
assert!(
message.contains("season_ids length must equal values_m3s length")
|| message.contains('3')
|| message.contains('2'),
"message should describe the mismatch, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_parse_past_inflows_without_season_ids_backward_compat() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_inflows": [
{ "hydro_id": 0, "values_m3s": [600.0, 500.0] }
]
}"#;
let f = write_json(json);
let ic = parse_initial_conditions(f.path()).unwrap();
assert_eq!(ic.past_inflows.len(), 1);
assert_eq!(
ic.past_inflows[0].season_ids, None,
"absent season_ids must deserialize as None"
);
}
#[test]
fn test_parse_past_anticipated_commitments_present() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_anticipated_commitments": [
{ "thermal_id": 1, "values_mw": [120.0, 180.0] }
]
}"#;
let f = write_json(json);
let ic = parse_initial_conditions(f.path()).unwrap();
assert_eq!(ic.past_anticipated_commitments.len(), 1);
assert_eq!(ic.past_anticipated_commitments[0].thermal_id, EntityId(1));
assert_eq!(
ic.past_anticipated_commitments[0].values_mw,
vec![120.0, 180.0]
);
}
#[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, "values_mw": [120.0, 180.0] },
{ "thermal_id": 2, "values_mw": [50.0] }
]
}"#;
let json_reversed = r#"{
"storage": [],
"filling_storage": [],
"past_anticipated_commitments": [
{ "thermal_id": 2, "values_mw": [50.0] },
{ "thermal_id": 1, "values_mw": [120.0, 180.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[1].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, "values_mw": [100.0] },
{ "thermal_id": 5, "values_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_empty_values_mw_rejected() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_anticipated_commitments": [
{ "thermal_id": 3, "values_mw": [] }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("values_mw must not be empty"),
"message should contain 'values_mw must not be empty', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_negative_values_mw_rejected() {
let json = r#"{
"storage": [],
"filling_storage": [],
"past_anticipated_commitments": [
{ "thermal_id": 7, "values_mw": [120.0, -50.0] }
]
}"#;
let f = write_json(json);
let err = parse_initial_conditions(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
message.contains("must be >= 0"),
"message should contain 'must be >= 0', got: {message}"
);
assert!(
field.contains("values_mw[1]"),
"field should contain 'values_mw[1]', got: {field}"
);
}
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}"
);
}
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:?}"),
}
}
}