use std::collections::HashSet;
use std::path::Path;
use cobre_core::{EntityId, PostStudyStage, PostStudyStages, PostStudyThermalBound};
use serde::Deserialize;
use crate::LoadError;
use crate::windowed_history::parse_iso_date;
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawPostStudyStagesFile {
#[serde(rename = "$schema")]
_schema: Option<String>,
stages: Vec<RawPostStudyStage>,
thermal_bounds: Vec<RawPostStudyThermalBound>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPostStudyStage {
start_date: String,
duration_hours: f64,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPostStudyThermalBound {
thermal_id: i32,
post_study_stage_index: usize,
cost_per_mwh: f64,
min_mw: f64,
max_mw: f64,
}
pub fn parse_post_study_stages(path: &Path) -> Result<PostStudyStages, LoadError> {
let raw_text = std::fs::read_to_string(path).map_err(|e| LoadError::io(path, e))?;
let raw: RawPostStudyStagesFile =
serde_json::from_str(&raw_text).map_err(|e| LoadError::parse(path, e.to_string()))?;
let stages = convert_stages(&raw.stages, path)?;
let thermal_bounds = convert_thermal_bounds(&raw.thermal_bounds, stages.len(), path)?;
Ok(PostStudyStages {
stages,
thermal_bounds,
})
}
fn convert_stages(
entries: &[RawPostStudyStage],
path: &Path,
) -> Result<Vec<PostStudyStage>, LoadError> {
let mut stages: Vec<PostStudyStage> = Vec::with_capacity(entries.len());
let mut seen: HashSet<chrono::NaiveDate> = HashSet::new();
for (i, entry) in entries.iter().enumerate() {
let start_date =
parse_iso_date(&format!("stages[{i}].start_date"), &entry.start_date, path)?;
if !entry.duration_hours.is_finite() || entry.duration_hours <= 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("stages[{i}].duration_hours"),
message: format!(
"duration_hours must be a finite number > 0.0, got {}",
entry.duration_hours
),
});
}
if !seen.insert(start_date) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("stages[{i}].start_date"),
message: format!("duplicate start_date {start_date} in stages"),
});
}
stages.push(PostStudyStage {
start_date,
duration_hours: entry.duration_hours,
});
}
stages.sort_by_key(|s| s.start_date);
Ok(stages)
}
fn convert_thermal_bounds(
entries: &[RawPostStudyThermalBound],
num_stages: usize,
path: &Path,
) -> Result<Vec<PostStudyThermalBound>, LoadError> {
let mut bounds: Vec<PostStudyThermalBound> = Vec::with_capacity(entries.len());
let mut seen: HashSet<(i32, usize)> = HashSet::new();
for (i, entry) in entries.iter().enumerate() {
for (value, name) in [
(entry.cost_per_mwh, "cost_per_mwh"),
(entry.min_mw, "min_mw"),
(entry.max_mw, "max_mw"),
] {
if !value.is_finite() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("thermal_bounds[{i}].{name}"),
message: format!("{name} must be a finite number, got {value}"),
});
}
}
if entry.min_mw > entry.max_mw {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("thermal_bounds[{i}].max_mw"),
message: format!(
"max_mw ({}) must be >= min_mw ({}) for thermal_id {}, \
post_study_stage_index {}",
entry.max_mw, entry.min_mw, entry.thermal_id, entry.post_study_stage_index
),
});
}
if entry.post_study_stage_index >= num_stages {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("thermal_bounds[{i}].post_study_stage_index"),
message: format!(
"post_study_stage_index {} is out of range for {num_stages} post-study \
stage(s)",
entry.post_study_stage_index
),
});
}
if !seen.insert((entry.thermal_id, entry.post_study_stage_index)) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("thermal_bounds[{i}]"),
message: format!(
"duplicate (thermal_id {}, post_study_stage_index {}) in thermal_bounds",
entry.thermal_id, entry.post_study_stage_index
),
});
}
bounds.push(PostStudyThermalBound {
thermal_id: EntityId(entry.thermal_id),
post_study_stage_index: entry.post_study_stage_index,
cost_per_mwh: entry.cost_per_mwh,
min_mw: entry.min_mw,
max_mw: entry.max_mw,
});
}
bounds.sort_by_key(|b| (b.thermal_id.0, b.post_study_stage_index));
Ok(bounds)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
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/post_study_stages.schema.json",
"stages": [
{ "start_date": "2026-11-01", "duration_hours": 720.0 },
{ "start_date": "2026-12-01", "duration_hours": 744.0 }
],
"thermal_bounds": [
{ "thermal_id": 86, "post_study_stage_index": 1, "cost_per_mwh": 220.0, "min_mw": 0.0, "max_mw": 300.0 },
{ "thermal_id": 86, "post_study_stage_index": 0, "cost_per_mwh": 210.0, "min_mw": 0.0, "max_mw": 350.0 }
]
}"#;
#[test]
fn test_parse_valid_sorts_both_collections() {
let f = write_json(VALID_JSON);
let ps = parse_post_study_stages(f.path()).unwrap();
assert_eq!(ps.stages.len(), 2);
assert_eq!(
ps.stages[0].start_date,
chrono::NaiveDate::from_ymd_opt(2026, 11, 1).unwrap()
);
assert_eq!(ps.thermal_bounds[0].post_study_stage_index, 0);
assert_eq!(ps.thermal_bounds[1].post_study_stage_index, 1);
}
#[test]
fn test_declaration_order_invariance() {
let reversed = r#"{
"stages": [
{ "start_date": "2026-12-01", "duration_hours": 744.0 },
{ "start_date": "2026-11-01", "duration_hours": 720.0 }
],
"thermal_bounds": [
{ "thermal_id": 86, "post_study_stage_index": 1, "cost_per_mwh": 220.0, "min_mw": 0.0, "max_mw": 300.0 },
{ "thermal_id": 86, "post_study_stage_index": 0, "cost_per_mwh": 210.0, "min_mw": 0.0, "max_mw": 350.0 }
]
}"#;
let f1 = write_json(VALID_JSON);
let f2 = write_json(reversed);
assert_eq!(
parse_post_study_stages(f1.path()).unwrap(),
parse_post_study_stages(f2.path()).unwrap()
);
}
#[test]
fn test_non_positive_duration_rejected() {
let json = r#"{ "stages": [{ "start_date": "2026-11-01", "duration_hours": 0.0 }], "thermal_bounds": [] }"#;
let err = parse_post_study_stages(write_json(json).path()).unwrap_err();
match err {
LoadError::SchemaError { field, .. } => assert!(field.contains("duration_hours")),
other => panic!("expected SchemaError, got {other:?}"),
}
}
#[test]
fn test_inverted_bounds_rejected() {
let json = r#"{
"stages": [{ "start_date": "2026-11-01", "duration_hours": 720.0 }],
"thermal_bounds": [{ "thermal_id": 1, "post_study_stage_index": 0, "cost_per_mwh": 1.0, "min_mw": 10.0, "max_mw": 5.0 }]
}"#;
let err = parse_post_study_stages(write_json(json).path()).unwrap_err();
match err {
LoadError::SchemaError { message, .. } => assert!(message.contains("max_mw")),
other => panic!("expected SchemaError, got {other:?}"),
}
}
#[test]
fn test_stage_index_out_of_range_rejected() {
let json = r#"{
"stages": [{ "start_date": "2026-11-01", "duration_hours": 720.0 }],
"thermal_bounds": [{ "thermal_id": 1, "post_study_stage_index": 3, "cost_per_mwh": 1.0, "min_mw": 0.0, "max_mw": 5.0 }]
}"#;
let err = parse_post_study_stages(write_json(json).path()).unwrap_err();
match err {
LoadError::SchemaError { field, .. } => {
assert!(field.contains("post_study_stage_index"));
}
other => panic!("expected SchemaError, got {other:?}"),
}
}
#[test]
fn test_duplicate_cell_rejected() {
let json = r#"{
"stages": [{ "start_date": "2026-11-01", "duration_hours": 720.0 }],
"thermal_bounds": [
{ "thermal_id": 1, "post_study_stage_index": 0, "cost_per_mwh": 1.0, "min_mw": 0.0, "max_mw": 5.0 },
{ "thermal_id": 1, "post_study_stage_index": 0, "cost_per_mwh": 2.0, "min_mw": 0.0, "max_mw": 6.0 }
]
}"#;
let err = parse_post_study_stages(write_json(json).path()).unwrap_err();
match err {
LoadError::SchemaError { message, .. } => assert!(message.contains("duplicate")),
other => panic!("expected SchemaError, got {other:?}"),
}
}
#[test]
fn test_unknown_field_rejected() {
let json = r#"{ "stages": [], "thermal_bounds": [], "extra": 1 }"#;
let err = parse_post_study_stages(write_json(json).path()).unwrap_err();
assert!(matches!(err, LoadError::ParseError { .. }));
}
}