use cobre_core::{
EntityId,
entities::{
DiversionChannel, EfficiencyModel, FillingConfig, HydraulicLossesModel, Hydro,
HydroGenerationModel, TailraceModel, TailracePoint,
},
penalty::{GlobalPenaltyDefaults, HydroPenaltyOverrides, resolve_hydro_penalties},
};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
use crate::LoadError;
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawHydroFile {
#[serde(rename = "$schema")]
_schema: Option<String>,
hydros: Vec<RawHydro>,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawHydro {
id: i32,
name: String,
bus_id: i32,
downstream_id: Option<i32>,
#[serde(default)]
entry_stage_id: Option<i32>,
#[serde(default)]
exit_stage_id: Option<i32>,
reservoir: RawReservoir,
outflow: RawOutflow,
generation: RawGeneration,
#[serde(default)]
tailrace: Option<RawTailrace>,
#[serde(default)]
hydraulic_losses: Option<RawHydraulicLosses>,
#[serde(default)]
efficiency: Option<RawEfficiency>,
#[serde(default)]
evaporation: Option<RawEvaporation>,
#[serde(default)]
diversion: Option<RawDiversionChannel>,
#[serde(default)]
filling: Option<RawFillingConfig>,
#[serde(default)]
penalties: Option<RawHydroPenaltyOverrides>,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawReservoir {
min_storage_hm3: f64,
max_storage_hm3: f64,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawOutflow {
min_outflow_m3s: f64,
max_outflow_m3s: Option<f64>,
}
#[derive(Deserialize)]
#[serde(tag = "model", rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) enum RawGeneration {
ConstantProductivity {
productivity_mw_per_m3s: f64,
min_turbined_m3s: f64,
max_turbined_m3s: f64,
min_generation_mw: f64,
max_generation_mw: f64,
},
LinearizedHead {
productivity_mw_per_m3s: f64,
min_turbined_m3s: f64,
max_turbined_m3s: f64,
min_generation_mw: f64,
max_generation_mw: f64,
},
Fpha {
min_turbined_m3s: f64,
max_turbined_m3s: f64,
min_generation_mw: f64,
max_generation_mw: f64,
},
}
impl RawGeneration {
fn bounds(&self) -> (f64, f64, f64, f64) {
match self {
Self::ConstantProductivity {
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
..
}
| Self::LinearizedHead {
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
..
}
| Self::Fpha {
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
} => (
*min_turbined_m3s,
*max_turbined_m3s,
*min_generation_mw,
*max_generation_mw,
),
}
}
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) enum RawTailrace {
Polynomial {
coefficients: Vec<f64>,
},
Piecewise {
points: Vec<RawTailracePoint>,
},
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawTailracePoint {
outflow_m3s: f64,
height_m: f64,
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) enum RawHydraulicLosses {
Factor {
value: f64,
},
Constant {
value_m: f64,
},
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) enum RawEfficiency {
Constant {
value: f64,
},
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawEvaporation {
coefficients_mm: Vec<f64>,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawDiversionChannel {
downstream_id: i32,
max_flow_m3s: f64,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawFillingConfig {
start_stage_id: i32,
#[serde(default)]
filling_inflow_m3s: f64,
}
#[allow(clippy::struct_field_names)]
#[derive(Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawHydroPenaltyOverrides {
#[serde(default)]
spillage_cost: Option<f64>,
#[serde(default)]
diversion_cost: Option<f64>,
#[serde(default)]
fpha_turbined_cost: Option<f64>,
#[serde(default)]
storage_violation_below_cost: Option<f64>,
#[serde(default)]
filling_target_violation_cost: Option<f64>,
#[serde(default)]
turbined_violation_below_cost: Option<f64>,
#[serde(default)]
outflow_violation_below_cost: Option<f64>,
#[serde(default)]
outflow_violation_above_cost: Option<f64>,
#[serde(default)]
generation_violation_below_cost: Option<f64>,
#[serde(default)]
evaporation_violation_cost: Option<f64>,
#[serde(default)]
water_withdrawal_violation_cost: Option<f64>,
}
pub fn parse_hydros(
path: &Path,
global_penalties: &GlobalPenaltyDefaults,
) -> Result<Vec<Hydro>, LoadError> {
let raw_text = std::fs::read_to_string(path).map_err(|e| LoadError::io(path, e))?;
let raw: RawHydroFile = serde_json::from_str(&raw_text).map_err(|e| {
let msg = e.to_string();
if msg.contains("unknown variant") || msg.contains("missing field") {
LoadError::SchemaError {
path: path.to_path_buf(),
field: extract_field_from_serde_msg(&msg),
message: msg,
}
} else {
LoadError::parse(path, msg)
}
})?;
validate_raw_hydros(&raw, path)?;
Ok(convert_hydros(raw, global_penalties))
}
fn validate_raw_hydros(raw: &RawHydroFile, path: &Path) -> Result<(), LoadError> {
validate_no_duplicate_hydro_ids(&raw.hydros, path)?;
for (i, hydro) in raw.hydros.iter().enumerate() {
validate_reservoir(&hydro.reservoir, i, path)?;
validate_outflow(&hydro.outflow, i, path)?;
validate_generation(&hydro.generation, i, path)?;
if let Some(evap) = &hydro.evaporation {
validate_evaporation(evap, i, path)?;
}
}
Ok(())
}
fn validate_no_duplicate_hydro_ids(hydros: &[RawHydro], path: &Path) -> Result<(), LoadError> {
let mut seen: HashSet<i32> = HashSet::new();
for (i, hydro) in hydros.iter().enumerate() {
if !seen.insert(hydro.id) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{i}].id"),
message: format!("duplicate id {} in hydros array", hydro.id),
});
}
}
Ok(())
}
fn validate_reservoir(
reservoir: &RawReservoir,
hydro_index: usize,
path: &Path,
) -> Result<(), LoadError> {
if reservoir.min_storage_hm3 < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].reservoir.min_storage_hm3"),
message: format!(
"min_storage_hm3 must be >= 0, got {}",
reservoir.min_storage_hm3
),
});
}
if reservoir.max_storage_hm3 < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].reservoir.max_storage_hm3"),
message: format!(
"max_storage_hm3 must be >= 0, got {}",
reservoir.max_storage_hm3
),
});
}
if reservoir.min_storage_hm3 > reservoir.max_storage_hm3 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].reservoir"),
message: format!(
"min_storage_hm3 ({}) must be <= max_storage_hm3 ({})",
reservoir.min_storage_hm3, reservoir.max_storage_hm3
),
});
}
Ok(())
}
fn validate_outflow(
outflow: &RawOutflow,
hydro_index: usize,
path: &Path,
) -> Result<(), LoadError> {
if outflow.min_outflow_m3s < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].outflow.min_outflow_m3s"),
message: format!(
"min_outflow_m3s must be >= 0, got {}",
outflow.min_outflow_m3s
),
});
}
Ok(())
}
fn validate_generation(
generation: &RawGeneration,
hydro_index: usize,
path: &Path,
) -> Result<(), LoadError> {
let (min_turbined, max_turbined, min_gen, max_gen) = generation.bounds();
if min_turbined < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].generation.min_turbined_m3s"),
message: format!("min_turbined_m3s must be >= 0, got {min_turbined}"),
});
}
if max_turbined < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].generation.max_turbined_m3s"),
message: format!("max_turbined_m3s must be >= 0, got {max_turbined}"),
});
}
if max_turbined < min_turbined {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].generation.max_turbined_m3s"),
message: format!(
"max_turbined_m3s ({max_turbined}) must be >= min_turbined_m3s ({min_turbined})"
),
});
}
if max_gen < min_gen {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].generation.max_generation_mw"),
message: format!(
"max_generation_mw ({max_gen}) must be >= min_generation_mw ({min_gen})"
),
});
}
Ok(())
}
fn validate_evaporation(
evaporation: &RawEvaporation,
hydro_index: usize,
path: &Path,
) -> Result<(), LoadError> {
let len = evaporation.coefficients_mm.len();
if len != 12 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].evaporation.coefficients_mm"),
message: format!(
"evaporation coefficients_mm must have exactly 12 elements (one per calendar month), got {len}"
),
});
}
Ok(())
}
fn convert_hydros(raw: RawHydroFile, global: &GlobalPenaltyDefaults) -> Vec<Hydro> {
let mut hydros: Vec<Hydro> = raw
.hydros
.into_iter()
.map(|raw_hydro| {
let (
generation_model,
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
) = convert_generation(raw_hydro.generation);
let tailrace = raw_hydro.tailrace.map(convert_tailrace);
let hydraulic_losses = raw_hydro.hydraulic_losses.map(convert_hydraulic_losses);
let efficiency = raw_hydro.efficiency.map(convert_efficiency);
let evaporation_coefficients_mm = raw_hydro.evaporation.map(|evap| {
let v = evap.coefficients_mm;
v.try_into()
.unwrap_or_else(|_| unreachable!("evaporation length validated to be 12"))
});
let diversion = raw_hydro.diversion.map(|d| DiversionChannel {
downstream_id: EntityId(d.downstream_id),
max_flow_m3s: d.max_flow_m3s,
});
let filling = raw_hydro.filling.map(|f| FillingConfig {
start_stage_id: f.start_stage_id,
filling_inflow_m3s: f.filling_inflow_m3s,
});
let entity_overrides: Option<HydroPenaltyOverrides> =
raw_hydro.penalties.map(convert_penalty_overrides);
let penalties = resolve_hydro_penalties(&entity_overrides, global);
Hydro {
id: EntityId(raw_hydro.id),
name: raw_hydro.name,
bus_id: EntityId(raw_hydro.bus_id),
downstream_id: raw_hydro.downstream_id.map(EntityId),
entry_stage_id: raw_hydro.entry_stage_id,
exit_stage_id: raw_hydro.exit_stage_id,
min_storage_hm3: raw_hydro.reservoir.min_storage_hm3,
max_storage_hm3: raw_hydro.reservoir.max_storage_hm3,
min_outflow_m3s: raw_hydro.outflow.min_outflow_m3s,
max_outflow_m3s: raw_hydro.outflow.max_outflow_m3s,
generation_model,
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
tailrace,
hydraulic_losses,
efficiency,
evaporation_coefficients_mm,
diversion,
filling,
penalties,
}
})
.collect();
hydros.sort_by_key(|h| h.id.0);
hydros
}
#[allow(clippy::needless_pass_by_value)]
fn convert_generation(raw: RawGeneration) -> (HydroGenerationModel, f64, f64, f64, f64) {
match raw {
RawGeneration::ConstantProductivity {
productivity_mw_per_m3s,
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
} => (
HydroGenerationModel::ConstantProductivity {
productivity_mw_per_m3s,
},
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
),
RawGeneration::LinearizedHead {
productivity_mw_per_m3s,
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
} => (
HydroGenerationModel::LinearizedHead {
productivity_mw_per_m3s,
},
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
),
RawGeneration::Fpha {
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
} => (
HydroGenerationModel::Fpha,
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
),
}
}
fn convert_tailrace(raw: RawTailrace) -> TailraceModel {
match raw {
RawTailrace::Polynomial { coefficients } => TailraceModel::Polynomial { coefficients },
RawTailrace::Piecewise { points } => TailraceModel::Piecewise {
points: points
.into_iter()
.map(|p| TailracePoint {
outflow_m3s: p.outflow_m3s,
height_m: p.height_m,
})
.collect(),
},
}
}
#[allow(clippy::needless_pass_by_value)]
fn convert_hydraulic_losses(raw: RawHydraulicLosses) -> HydraulicLossesModel {
match raw {
RawHydraulicLosses::Factor { value } => HydraulicLossesModel::Factor { value },
RawHydraulicLosses::Constant { value_m } => HydraulicLossesModel::Constant { value_m },
}
}
#[allow(clippy::needless_pass_by_value)]
fn convert_efficiency(raw: RawEfficiency) -> EfficiencyModel {
match raw {
RawEfficiency::Constant { value } => EfficiencyModel::Constant { value },
}
}
#[allow(clippy::needless_pass_by_value)]
fn convert_penalty_overrides(raw: RawHydroPenaltyOverrides) -> HydroPenaltyOverrides {
HydroPenaltyOverrides {
spillage_cost: raw.spillage_cost,
diversion_cost: raw.diversion_cost,
fpha_turbined_cost: raw.fpha_turbined_cost,
storage_violation_below_cost: raw.storage_violation_below_cost,
filling_target_violation_cost: raw.filling_target_violation_cost,
turbined_violation_below_cost: raw.turbined_violation_below_cost,
outflow_violation_below_cost: raw.outflow_violation_below_cost,
outflow_violation_above_cost: raw.outflow_violation_above_cost,
generation_violation_below_cost: raw.generation_violation_below_cost,
evaporation_violation_cost: raw.evaporation_violation_cost,
water_withdrawal_violation_cost: raw.water_withdrawal_violation_cost,
}
}
fn extract_field_from_serde_msg(msg: &str) -> String {
if let Some(start) = msg.find('`') {
if let Some(end) = msg[start + 1..].find('`') {
return msg[start + 1..start + 1 + end].to_string();
}
}
"<unknown>".to_string()
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::panic,
clippy::too_many_lines,
clippy::expect_used
)]
mod tests {
use super::*;
use cobre_core::entities::{DeficitSegment, HydroPenalties};
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
}
fn make_global() -> GlobalPenaltyDefaults {
GlobalPenaltyDefaults {
bus_deficit_segments: vec![
DeficitSegment {
depth_mw: Some(500.0),
cost_per_mwh: 1000.0,
},
DeficitSegment {
depth_mw: None,
cost_per_mwh: 5000.0,
},
],
bus_excess_cost: 100.0,
line_exchange_cost: 2.0,
hydro: HydroPenalties {
spillage_cost: 0.01,
fpha_turbined_cost: 0.05,
diversion_cost: 0.1,
storage_violation_below_cost: 10_000.0,
filling_target_violation_cost: 50_000.0,
turbined_violation_below_cost: 500.0,
outflow_violation_below_cost: 500.0,
outflow_violation_above_cost: 500.0,
generation_violation_below_cost: 1_000.0,
evaporation_violation_cost: 5_000.0,
water_withdrawal_violation_cost: 1_000.0,
},
ncs_curtailment_cost: 0.005,
}
}
const MINIMAL_HYDRO_JSON: &str = r#"{
"id": 1,
"name": "Minimal",
"bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 100.0, "max_storage_hm3": 2000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.75,
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 1000.0,
"min_generation_mw": 0.0,
"max_generation_mw": 750.0
}
}"#;
const FULL_HYDRO_JSON: &str = r#"{
"id": 0,
"name": "FURNAS",
"bus_id": 0,
"downstream_id": 2,
"entry_stage_id": 1,
"exit_stage_id": 600,
"reservoir": { "min_storage_hm3": 5733.0, "max_storage_hm3": 22950.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": 4000.0 },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.8765,
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 1692.0,
"min_generation_mw": 0.0,
"max_generation_mw": 1312.0
},
"tailrace": { "type": "polynomial", "coefficients": [326.0, 0.0032, -1.2e-7] },
"hydraulic_losses": { "type": "factor", "value": 0.03 },
"efficiency": { "type": "constant", "value": 0.92 },
"evaporation": { "coefficients_mm": [150, 130, 120, 90, 60, 40, 30, 40, 70, 100, 130, 150] },
"diversion": { "downstream_id": 3, "max_flow_m3s": 200.0 },
"filling": { "start_stage_id": 48, "filling_inflow_m3s": 100.0 },
"penalties": { "spillage_cost": 0.05 }
}"#;
#[test]
fn test_parse_valid_full_and_minimal() {
let json = format!(r#"{{ "hydros": [{FULL_HYDRO_JSON}, {MINIMAL_HYDRO_JSON}] }}"#);
let f = write_json(&json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
assert_eq!(hydros.len(), 2);
let h0 = &hydros[0];
assert_eq!(h0.id, EntityId(0));
assert_eq!(h0.name, "FURNAS");
assert_eq!(h0.bus_id, EntityId(0));
assert_eq!(h0.downstream_id, Some(EntityId(2)));
assert_eq!(h0.entry_stage_id, Some(1));
assert_eq!(h0.exit_stage_id, Some(600));
assert!((h0.min_storage_hm3 - 5733.0).abs() < f64::EPSILON);
assert!((h0.max_storage_hm3 - 22950.0).abs() < f64::EPSILON);
assert!((h0.min_outflow_m3s - 0.0).abs() < f64::EPSILON);
assert_eq!(h0.max_outflow_m3s, Some(4000.0));
assert!(
matches!(
h0.generation_model,
HydroGenerationModel::ConstantProductivity {
productivity_mw_per_m3s
} if (productivity_mw_per_m3s - 0.8765).abs() < f64::EPSILON
),
"expected ConstantProductivity with correct productivity"
);
assert!((h0.min_turbined_m3s - 0.0).abs() < f64::EPSILON);
assert!((h0.max_turbined_m3s - 1692.0).abs() < f64::EPSILON);
assert!((h0.min_generation_mw - 0.0).abs() < f64::EPSILON);
assert!((h0.max_generation_mw - 1312.0).abs() < f64::EPSILON);
assert!(
matches!(&h0.tailrace, Some(TailraceModel::Polynomial { coefficients }) if coefficients.len() == 3),
"expected Polynomial tailrace with 3 coefficients"
);
assert!(matches!(
h0.hydraulic_losses,
Some(HydraulicLossesModel::Factor { value }) if (value - 0.03).abs() < f64::EPSILON
));
assert!(matches!(
h0.efficiency,
Some(EfficiencyModel::Constant { value }) if (value - 0.92).abs() < f64::EPSILON
));
assert!(h0.evaporation_coefficients_mm.is_some());
assert_eq!(h0.evaporation_coefficients_mm.map(|a| a.len()), Some(12));
assert!(matches!(
&h0.diversion,
Some(DiversionChannel { downstream_id, max_flow_m3s })
if *downstream_id == EntityId(3) && (max_flow_m3s - 200.0).abs() < f64::EPSILON
));
assert!(matches!(
&h0.filling,
Some(FillingConfig { start_stage_id: 48, filling_inflow_m3s })
if (filling_inflow_m3s - 100.0).abs() < f64::EPSILON
));
assert!((h0.penalties.spillage_cost - 0.05).abs() < f64::EPSILON);
assert!((h0.penalties.diversion_cost - 0.1).abs() < f64::EPSILON);
let h1 = &hydros[1];
assert_eq!(h1.id, EntityId(1));
assert_eq!(h1.name, "Minimal");
assert_eq!(h1.downstream_id, None);
assert_eq!(h1.entry_stage_id, None);
assert_eq!(h1.exit_stage_id, None);
assert_eq!(h1.max_outflow_m3s, None);
assert!(h1.tailrace.is_none());
assert!(h1.hydraulic_losses.is_none());
assert!(h1.efficiency.is_none());
assert!(h1.evaporation_coefficients_mm.is_none());
assert!(h1.diversion.is_none());
assert!(h1.filling.is_none());
assert!((h1.penalties.spillage_cost - 0.01).abs() < f64::EPSILON);
}
#[test]
fn test_parse_fpha_generation_model() {
let json = r#"{
"hydros": [{
"id": 0, "name": "FPHA Plant", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 100.0, "max_storage_hm3": 5000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "fpha",
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 2000.0,
"min_generation_mw": 0.0,
"max_generation_mw": 8000.0
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
assert_eq!(hydros.len(), 1);
assert_eq!(hydros[0].generation_model, HydroGenerationModel::Fpha);
assert!((hydros[0].min_turbined_m3s - 0.0).abs() < f64::EPSILON);
assert!((hydros[0].max_turbined_m3s - 2000.0).abs() < f64::EPSILON);
}
#[test]
fn test_parse_linearized_head_generation_model() {
let json = r#"{
"hydros": [{
"id": 0, "name": "LH Plant", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "linearized_head",
"productivity_mw_per_m3s": 0.65,
"min_turbined_m3s": 100.0,
"max_turbined_m3s": 3000.0,
"min_generation_mw": 0.0,
"max_generation_mw": 1950.0
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
assert_eq!(hydros.len(), 1);
assert!(
matches!(
&hydros[0].generation_model,
HydroGenerationModel::LinearizedHead { productivity_mw_per_m3s }
if (productivity_mw_per_m3s - 0.65).abs() < f64::EPSILON
),
"expected LinearizedHead with productivity 0.65"
);
}
#[test]
fn test_parse_tailrace_piecewise() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Piecewise", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 500.0,
"min_generation_mw": 0.0,
"max_generation_mw": 250.0
},
"tailrace": {
"type": "piecewise",
"points": [
{ "outflow_m3s": 0.0, "height_m": 3.0 },
{ "outflow_m3s": 5000.0, "height_m": 4.5 }
]
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
assert!(
matches!(
&hydros[0].tailrace,
Some(TailraceModel::Piecewise { points }) if points.len() == 2
),
"expected Piecewise tailrace with 2 points"
);
}
#[test]
fn test_parse_hydraulic_losses_constant() {
let json = r#"{
"hydros": [{
"id": 0, "name": "ConstantLoss", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 500.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 500.0,
"min_generation_mw": 0.0,
"max_generation_mw": 250.0
},
"hydraulic_losses": { "type": "constant", "value_m": 2.5 }
}]
}"#;
let f = write_json(json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
assert!(
matches!(
hydros[0].hydraulic_losses,
Some(HydraulicLossesModel::Constant { value_m }) if (value_m - 2.5).abs() < f64::EPSILON
),
"expected Constant hydraulic losses with value_m = 2.5"
);
}
#[test]
fn test_entity_level_penalty_partial_override() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Override", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 500.0,
"min_generation_mw": 0.0,
"max_generation_mw": 250.0
},
"penalties": { "spillage_cost": 0.05 }
}]
}"#;
let f = write_json(json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
assert!(
(hydros[0].penalties.spillage_cost - 0.05).abs() < f64::EPSILON,
"spillage_cost should be 0.05 (entity override)"
);
assert!(
(hydros[0].penalties.diversion_cost - 0.1).abs() < f64::EPSILON,
"diversion_cost should be 0.1 (global default)"
);
assert!(
(hydros[0].penalties.storage_violation_below_cost - 10_000.0).abs() < f64::EPSILON,
"storage_violation_below_cost should be 10_000.0 (global default)"
);
}
#[test]
fn test_entity_level_penalty_all_global_defaults() {
let json = r#"{
"hydros": [{
"id": 0, "name": "NoOverride", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 500.0,
"min_generation_mw": 0.0,
"max_generation_mw": 250.0
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
let g = &global.hydro;
let p = &hydros[0].penalties;
assert!((p.spillage_cost - g.spillage_cost).abs() < f64::EPSILON);
assert!((p.diversion_cost - g.diversion_cost).abs() < f64::EPSILON);
assert!((p.fpha_turbined_cost - g.fpha_turbined_cost).abs() < f64::EPSILON);
assert!(
(p.storage_violation_below_cost - g.storage_violation_below_cost).abs() < f64::EPSILON
);
assert!(
(p.filling_target_violation_cost - g.filling_target_violation_cost).abs()
< f64::EPSILON
);
assert!(
(p.turbined_violation_below_cost - g.turbined_violation_below_cost).abs()
< f64::EPSILON
);
assert!(
(p.outflow_violation_below_cost - g.outflow_violation_below_cost).abs() < f64::EPSILON
);
assert!(
(p.outflow_violation_above_cost - g.outflow_violation_above_cost).abs() < f64::EPSILON
);
assert!(
(p.generation_violation_below_cost - g.generation_violation_below_cost).abs()
< f64::EPSILON
);
assert!((p.evaporation_violation_cost - g.evaporation_violation_cost).abs() < f64::EPSILON);
assert!(
(p.water_withdrawal_violation_cost - g.water_withdrawal_violation_cost).abs()
< f64::EPSILON
);
}
#[test]
fn test_filling_inflow_defaults_to_zero() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Fill", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 500.0,
"min_generation_mw": 0.0,
"max_generation_mw": 250.0
},
"filling": { "start_stage_id": 10 }
}]
}"#;
let f = write_json(json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
assert!(matches!(
&hydros[0].filling,
Some(FillingConfig {
start_stage_id: 10,
filling_inflow_m3s,
}) if (*filling_inflow_m3s - 0.0).abs() < f64::EPSILON
));
}
#[test]
fn test_duplicate_hydro_id() {
let entry = r#"{
"id": 5, "name": "Alpha", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
}
}"#;
let json = format!(r#"{{ "hydros": [{entry}, {entry}] }}"#);
let f = write_json(&json);
let global = make_global();
let err = parse_hydros(f.path(), &global).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("hydros[1].id"),
"field should contain 'hydros[1].id', got: {field}"
);
assert!(
message.contains("duplicate"),
"message should contain 'duplicate', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_invalid_reservoir_bounds_min_gt_max() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Bad", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 5000.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_hydros(f.path(), &global).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("reservoir"),
"field should contain 'reservoir', got: {field}"
);
assert!(
message.contains("min_storage_hm3"),
"message should contain 'min_storage_hm3', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_invalid_reservoir_negative_min() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Bad", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": -1.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_hydros(f.path(), &global).unwrap_err();
assert!(
matches!(&err, LoadError::SchemaError { field, .. } if field.contains("min_storage_hm3")),
"expected SchemaError for negative min_storage_hm3, got: {err:?}"
);
}
#[test]
fn test_invalid_reservoir_negative_max() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Bad", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": -100.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_hydros(f.path(), &global).unwrap_err();
assert!(
matches!(&err, LoadError::SchemaError { field, .. } if field.contains("max_storage_hm3")),
"expected SchemaError for negative max_storage_hm3, got: {err:?}"
);
}
#[test]
fn test_invalid_generation_bounds_max_lt_min() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Bad", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 500.0, "max_generation_mw": 100.0
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_hydros(f.path(), &global).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("max_generation_mw"),
"field should contain 'max_generation_mw', got: {field}"
);
assert!(
message.contains("min_generation_mw"),
"message should reference min_generation_mw, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_invalid_turbined_bounds_max_lt_min() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Bad", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 600.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_hydros(f.path(), &global).unwrap_err();
assert!(
matches!(&err, LoadError::SchemaError { field, .. } if field.contains("max_turbined_m3s")),
"expected SchemaError for max_turbined < min_turbined, got: {err:?}"
);
}
#[test]
fn test_invalid_outflow_negative_min() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Bad", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": -10.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_hydros(f.path(), &global).unwrap_err();
assert!(
matches!(&err, LoadError::SchemaError { field, .. } if field.contains("min_outflow_m3s")),
"expected SchemaError for negative min_outflow_m3s, got: {err:?}"
);
}
#[test]
fn test_invalid_evaporation_wrong_length() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Bad", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
},
"evaporation": { "coefficients_mm": [10.0, 20.0] }
}]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_hydros(f.path(), &global).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("coefficients_mm"),
"field should contain 'coefficients_mm', got: {field}"
);
assert!(
message.contains("12"),
"message should mention 12 elements, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_unknown_generation_model() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Bad", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "unknown_model_xyz",
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_hydros(f.path(), &global).unwrap_err();
assert!(
matches!(err, LoadError::SchemaError { .. }),
"unknown generation model should produce SchemaError, got: {err:?}"
);
}
#[test]
fn test_declaration_order_invariance() {
let entry_a = r#"{
"id": 0, "name": "Alpha", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 500.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
}
}"#;
let entry_b = r#"{
"id": 1, "name": "Beta", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 1000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.8,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 1000.0,
"min_generation_mw": 0.0, "max_generation_mw": 800.0
}
}"#;
let json_forward = format!(r#"{{ "hydros": [{entry_a}, {entry_b}] }}"#);
let json_reversed = format!(r#"{{ "hydros": [{entry_b}, {entry_a}] }}"#);
let global = make_global();
let f1 = write_json(&json_forward);
let f2 = write_json(&json_reversed);
let hydros1 = parse_hydros(f1.path(), &global).unwrap();
let hydros2 = parse_hydros(f2.path(), &global).unwrap();
assert_eq!(
hydros1, hydros2,
"results must be identical regardless of input ordering"
);
assert_eq!(hydros1[0].id, EntityId(0));
assert_eq!(hydros1[1].id, EntityId(1));
}
#[test]
fn test_file_not_found() {
let path = Path::new("/nonexistent/system/hydros.json");
let global = make_global();
let err = parse_hydros(path, &global).unwrap_err();
match &err {
LoadError::IoError { path: p, .. } => {
assert_eq!(p, path);
}
other => panic!("expected IoError, got: {other:?}"),
}
}
#[test]
fn test_invalid_json() {
let f = write_json(r#"{"hydros": [not valid json}}"#);
let global = make_global();
let err = parse_hydros(f.path(), &global).unwrap_err();
assert!(
matches!(err, LoadError::ParseError { .. }),
"expected ParseError for invalid JSON, got: {err:?}"
);
}
#[test]
fn test_empty_hydros_array() {
let json = r#"{ "hydros": [] }"#;
let f = write_json(json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
assert!(hydros.is_empty());
}
#[test]
fn test_reservoir_min_equals_max_is_valid() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Deg", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 500.0, "max_storage_hm3": 500.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let result = parse_hydros(f.path(), &global);
assert!(
result.is_ok(),
"min_storage == max_storage should be valid, got: {result:?}"
);
}
#[test]
fn test_schema_field_is_ignored() {
let json = r#"{
"$schema": "https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/book/src/schemas/hydros.schema.json",
"hydros": [{
"id": 0, "name": "H", "bus_id": 0,
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 100.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.5,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 100.0,
"min_generation_mw": 0.0, "max_generation_mw": 50.0
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let result = parse_hydros(f.path(), &global);
assert!(
result.is_ok(),
"$schema field should be ignored, got: {result:?}"
);
}
}