use cobre_core::{
EntityId,
entities::{
DiversionChannel, EfficiencyModel, FillingConfig, HydraulicLossesModel, Hydro,
HydroGenerationModel, HydroUnitGroup, TailraceModel, TailracePoint,
},
penalty::{GlobalPenaltyDefaults, HydroPenaltyOverrides, resolve_hydro_penalties},
};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
use super::parse_operational_start_date;
use crate::LoadError;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawHydroFile {
#[serde(rename = "$schema")]
_schema: Option<String>,
hydros: Vec<RawHydro>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawHydro {
id: i32,
name: String,
operational_start_date: String,
downstream_id: Option<i32>,
#[serde(default)]
travel_time_hours: Option<f64>,
#[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>,
#[cfg_attr(feature = "schema", schemars(required))]
unit_groups: Option<Vec<RawUnitGroup>>,
#[serde(default)]
specific_productivity_mw_per_m3s_per_m: Option<f64>,
#[serde(default)]
penalties: Option<RawHydroPenaltyOverrides>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawReservoir {
min_storage_hm3: f64,
max_storage_hm3: f64,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
#[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", deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) enum RawGeneration {
ConstantProductivity {
min_turbined_m3s: f64,
max_turbined_m3s: f64,
min_generation_mw: f64,
max_generation_mw: f64,
},
LinearizedHead {
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)]
#[serde(deny_unknown_fields)]
#[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)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawEvaporation {
coefficients_mm: Vec<f64>,
#[serde(default)]
reference_volumes_hm3: Option<Vec<f64>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawDiversionChannel {
downstream_id: i32,
max_flow_m3s: f64,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawFillingConfig {
start_stage_id: i32,
#[serde(default)]
filling_min_rate_m3s: f64,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawUnitGroup {
id: i32,
name: String,
bus_id: i32,
min_generation_mw: f64,
max_generation_mw: f64,
min_turbined_m3s: f64,
max_turbined_m3s: f64,
}
#[allow(clippy::struct_field_names)]
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
#[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)]
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>,
#[serde(default)]
inflow_nonnegativity_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)?;
convert_hydros(raw, global_penalties, path)
}
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,
hydro.reservoir.min_storage_hm3,
hydro.reservoir.max_storage_hm3,
)?;
}
validate_unit_groups(hydro.unit_groups.as_deref(), i, path)?;
}
Ok(())
}
fn validate_unit_groups(
unit_groups: Option<&[RawUnitGroup]>,
hydro_index: usize,
path: &Path,
) -> Result<(), LoadError> {
let Some(groups) = unit_groups.filter(|g| !g.is_empty()) else {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].unit_groups"),
message: "unit_groups is required: declare at least one group with id, name, bus_id, min/max_generation_mw and min/max_turbined_m3s".to_string(),
});
};
for (j, group) in groups.iter().enumerate() {
if group.min_turbined_m3s < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].unit_groups[{j}].min_turbined_m3s"),
message: format!(
"min_turbined_m3s must be >= 0, got {}",
group.min_turbined_m3s
),
});
}
if group.max_turbined_m3s < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].unit_groups[{j}].max_turbined_m3s"),
message: format!(
"max_turbined_m3s must be >= 0, got {}",
group.max_turbined_m3s
),
});
}
}
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,
min_storage_hm3: f64,
max_storage_hm3: f64,
) -> 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}"
),
});
}
if let Some(ref_vols) = &evaporation.reference_volumes_hm3 {
let ref_len = ref_vols.len();
if ref_len != 12 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].evaporation.reference_volumes_hm3"),
message: format!(
"evaporation reference_volumes_hm3 must have exactly 12 elements (one per calendar month), got {ref_len}"
),
});
}
for (month, &vol) in ref_vols.iter().enumerate() {
if !vol.is_finite() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].evaporation.reference_volumes_hm3"),
message: format!(
"evaporation reference_volumes_hm3[{month}] must be finite, got {vol}"
),
});
}
if vol < min_storage_hm3 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].evaporation.reference_volumes_hm3"),
message: format!(
"evaporation reference_volumes_hm3[{month}] ({vol}) must be >= min_storage_hm3 ({min_storage_hm3})"
),
});
}
if vol > max_storage_hm3 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("hydros[{hydro_index}].evaporation.reference_volumes_hm3"),
message: format!(
"evaporation reference_volumes_hm3[{month}] ({vol}) must be <= max_storage_hm3 ({max_storage_hm3})"
),
});
}
}
}
Ok(())
}
fn convert_hydros(
raw: RawHydroFile,
global: &GlobalPenaltyDefaults,
path: &Path,
) -> Result<Vec<Hydro>, LoadError> {
let mut hydros: Vec<Hydro> = raw
.hydros
.into_iter()
.enumerate()
.map(|(i, raw_hydro)| {
let operational_start_date = parse_operational_start_date(
&raw_hydro.operational_start_date,
path,
&format!("hydros[{i}].operational_start_date"),
)?;
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, evaporation_reference_volumes_hm3) =
match raw_hydro.evaporation {
None => (None, None),
Some(evap) => {
let coeffs: [f64; 12] =
evap.coefficients_mm.try_into().unwrap_or_else(|_| {
unreachable!("evaporation length validated to be 12")
});
let ref_vols: Option<[f64; 12]> = evap.reference_volumes_hm3.map(|v| {
v.try_into().unwrap_or_else(|_| {
unreachable!("reference_volumes_hm3 length validated to be 12")
})
});
(Some(coeffs), ref_vols)
}
};
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_min_rate_m3s: f.filling_min_rate_m3s,
});
let unit_groups: Vec<HydroUnitGroup> = raw_hydro
.unit_groups
.unwrap_or_default()
.into_iter()
.map(|g| HydroUnitGroup {
id: EntityId(g.id),
name: g.name,
bus_id: EntityId(g.bus_id),
min_generation_mw: g.min_generation_mw,
max_generation_mw: g.max_generation_mw,
min_turbined_m3s: g.min_turbined_m3s,
max_turbined_m3s: g.max_turbined_m3s,
})
.collect();
let entity_overrides: Option<HydroPenaltyOverrides> =
raw_hydro.penalties.map(convert_penalty_overrides);
let penalties = resolve_hydro_penalties(&entity_overrides, global);
let mut hydro = Hydro {
id: EntityId(raw_hydro.id),
name: raw_hydro.name,
operational_start_date,
downstream_id: raw_hydro.downstream_id.map(EntityId),
travel_time_hours: raw_hydro.travel_time_hours,
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,
specific_productivity_mw_per_m3s_per_m: raw_hydro
.specific_productivity_mw_per_m3s_per_m,
min_generation_mw,
max_generation_mw,
unit_groups,
tailrace,
hydraulic_losses,
efficiency,
evaporation_coefficients_mm,
evaporation_reference_volumes_hm3,
diversion,
filling,
penalties,
};
hydro.sort_unit_groups();
Ok(hydro)
})
.collect::<Result<_, LoadError>>()?;
hydros.sort_by_key(|h| h.id.0);
Ok(hydros)
}
#[allow(clippy::needless_pass_by_value)]
fn convert_generation(raw: RawGeneration) -> (HydroGenerationModel, f64, f64, f64, f64) {
match raw {
RawGeneration::ConstantProductivity {
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
} => (
HydroGenerationModel::ConstantProductivity,
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
),
RawGeneration::LinearizedHead {
min_turbined_m3s,
max_turbined_m3s,
min_generation_mw,
max_generation_mw,
} => (
HydroGenerationModel::LinearizedHead,
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,
turbined_cost: raw.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,
water_withdrawal_violation_pos_cost: raw.water_withdrawal_violation_cost,
water_withdrawal_violation_neg_cost: raw.water_withdrawal_violation_cost,
evaporation_violation_pos_cost: raw.evaporation_violation_cost,
evaporation_violation_neg_cost: raw.evaporation_violation_cost,
inflow_nonnegativity_cost: raw.inflow_nonnegativity_cost,
}
}
fn extract_field_from_serde_msg(msg: &str) -> String {
if let Some(start) = msg.find('`')
&& 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,
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,
water_withdrawal_violation_pos_cost: 1_000.0,
water_withdrawal_violation_neg_cost: 1_000.0,
evaporation_violation_pos_cost: 5_000.0,
evaporation_violation_neg_cost: 5_000.0,
inflow_nonnegativity_cost: 1000.0,
},
ncs_curtailment_cost: 0.005,
}
}
const MINIMAL_HYDRO_JSON: &str = r#"{
"id": 1,
"name": "Minimal",
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 1000.0,
"min_generation_mw": 0.0,
"max_generation_mw": 750.0
},
"unit_groups": [
{ "id": 0, "name": "Minimal", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 750.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 1000.0 }
]
}"#;
const FULL_HYDRO_JSON: &str = r#"{
"id": 0,
"name": "FURNAS",
"operational_start_date": "2024-01-01",
"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",
"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_min_rate_m3s": 100.0 },
"penalties": { "spillage_cost": 0.05 },
"unit_groups": [
{ "id": 0, "name": "FURNAS", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 1312.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 1692.0 }
]
}"#;
#[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.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
),
"expected ConstantProductivity generation model"
);
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_min_rate_m3s })
if (filling_min_rate_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",
"operational_start_date": "2024-01-01",
"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
},
"unit_groups": [
{ "id": 0, "name": "FPHA Plant", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 8000.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 2000.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",
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 100.0,
"max_turbined_m3s": 3000.0,
"min_generation_mw": 0.0,
"max_generation_mw": 1950.0
},
"unit_groups": [
{ "id": 0, "name": "LH Plant", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 1950.0,
"min_turbined_m3s": 100.0, "max_turbined_m3s": 3000.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
),
"expected LinearizedHead generation model"
);
}
#[test]
fn test_parse_tailrace_piecewise() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Piecewise",
"operational_start_date": "2024-01-01",
"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",
"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 }
]
},
"unit_groups": [
{ "id": 0, "name": "Piecewise", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0 }
]
}]
}"#;
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",
"operational_start_date": "2024-01-01",
"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",
"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 },
"unit_groups": [
{ "id": 0, "name": "ConstantLoss", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0 }
]
}]
}"#;
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",
"operational_start_date": "2024-01-01",
"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",
"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 },
"unit_groups": [
{ "id": 0, "name": "Override", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0 }
]
}]
}"#;
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",
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 500.0,
"min_generation_mw": 0.0,
"max_generation_mw": 250.0
},
"unit_groups": [
{ "id": 0, "name": "NoOverride", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.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.turbined_cost - g.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_min_rate_defaults_to_zero() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Fill",
"operational_start_date": "2024-01-01",
"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",
"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 },
"unit_groups": [
{ "id": 0, "name": "Fill", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0 }
]
}]
}"#;
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_min_rate_m3s,
}) if (*filling_min_rate_m3s - 0.0).abs() < f64::EPSILON
));
}
#[test]
fn test_duplicate_hydro_id() {
let entry = r#"{
"id": 5, "name": "Alpha",
"operational_start_date": "2024-01-01",
"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",
"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",
"operational_start_date": "2024-01-01",
"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",
"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",
"operational_start_date": "2024-01-01",
"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",
"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",
"operational_start_date": "2024-01-01",
"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",
"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",
"operational_start_date": "2024-01-01",
"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",
"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",
"operational_start_date": "2024-01-01",
"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",
"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",
"operational_start_date": "2024-01-01",
"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",
"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",
"operational_start_date": "2024-01-01",
"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",
"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",
"operational_start_date": "2024-01-01",
"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_travel_time_hours_deserializes_when_present() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Upstream",
"operational_start_date": "2024-01-01",
"downstream_id": 1,
"travel_time_hours": 360.0,
"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",
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
},
"unit_groups": [
{ "id": 0, "name": "Upstream", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0 }
]
}]
}"#;
let f = write_json(json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
assert_eq!(hydros[0].travel_time_hours, Some(360.0));
}
#[test]
fn test_travel_time_hours_absent_is_none() {
let json = format!(r#"{{ "hydros": [{MINIMAL_HYDRO_JSON}] }}"#);
let f = write_json(&json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
assert_eq!(hydros[0].travel_time_hours, None);
}
#[test]
fn test_declaration_order_invariance() {
let entry_a = r#"{
"id": 0, "name": "Alpha",
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
},
"unit_groups": [
{ "id": 0, "name": "Alpha", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0 }
]
}"#;
let entry_b = r#"{
"id": 1, "name": "Beta",
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 0.0, "max_turbined_m3s": 1000.0,
"min_generation_mw": 0.0, "max_generation_mw": 800.0
},
"unit_groups": [
{ "id": 0, "name": "Beta", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 800.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 1000.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",
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
},
"unit_groups": [
{ "id": 0, "name": "Deg", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.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:?}"
);
}
const HYDRO_WITH_EVAP_BASE: &str = r#"{
"id": 0, "name": "ReservoirEvap",
"operational_start_date": "2024-01-01",
"downstream_id": null,
"reservoir": { "min_storage_hm3": 1000.0, "max_storage_hm3": 20000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 1000.0,
"min_generation_mw": 0.0,
"max_generation_mw": 750.0
},
"unit_groups": [
{ "id": 0, "name": "ReservoirEvap", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 750.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 1000.0 }
]
}"#;
#[test]
fn test_evaporation_reference_volumes_happy_path() {
let json = r#"{
"hydros": [{
"id": 0, "name": "ReservoirEvap",
"operational_start_date": "2024-01-01",
"downstream_id": null,
"reservoir": { "min_storage_hm3": 1000.0, "max_storage_hm3": 20000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 1000.0,
"min_generation_mw": 0.0,
"max_generation_mw": 750.0
},
"evaporation": {
"coefficients_mm": [150, 130, 120, 90, 60, 40, 30, 40, 70, 100, 130, 150],
"reference_volumes_hm3": [15000, 12000, 10000, 8000, 6000, 5000, 5500, 7000, 9000, 11000, 13000, 14500]
},
"unit_groups": [
{ "id": 0, "name": "ReservoirEvap", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 750.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 1000.0 }
]
}]
}"#;
let f = write_json(json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
let ref_vols = hydros[0]
.evaporation_reference_volumes_hm3
.expect("reference_volumes_hm3 should be Some");
assert_eq!(ref_vols.len(), 12);
assert!((ref_vols[0] - 15000.0).abs() < f64::EPSILON);
assert!((ref_vols[5] - 5000.0).abs() < f64::EPSILON);
assert!((ref_vols[11] - 14500.0).abs() < f64::EPSILON);
}
#[test]
fn test_evaporation_reference_volumes_absent_is_none() {
let json = r#"{
"hydros": [{
"id": 0, "name": "ReservoirEvap",
"operational_start_date": "2024-01-01",
"downstream_id": null,
"reservoir": { "min_storage_hm3": 1000.0, "max_storage_hm3": 20000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 1000.0,
"min_generation_mw": 0.0,
"max_generation_mw": 750.0
},
"evaporation": { "coefficients_mm": [150, 130, 120, 90, 60, 40, 30, 40, 70, 100, 130, 150] },
"unit_groups": [
{ "id": 0, "name": "ReservoirEvap", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 750.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 1000.0 }
]
}]
}"#;
let f = write_json(json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
assert!(
hydros[0].evaporation_reference_volumes_hm3.is_none(),
"reference_volumes_hm3 should be None when key is absent from JSON"
);
}
#[test]
fn test_evaporation_reference_volumes_wrong_length() {
let json = r#"{
"hydros": [{
"id": 0, "name": "ReservoirEvap",
"operational_start_date": "2024-01-01",
"downstream_id": null,
"reservoir": { "min_storage_hm3": 1000.0, "max_storage_hm3": 20000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 1000.0,
"min_generation_mw": 0.0,
"max_generation_mw": 750.0
},
"evaporation": {
"coefficients_mm": [150, 130, 120, 90, 60, 40, 30, 40, 70, 100, 130, 150],
"reference_volumes_hm3": [10000, 9000, 8000, 7000, 6000, 5000, 5500, 6500, 7500, 8500, 9500]
}
}]
}"#;
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("reference_volumes_hm3"),
"field should contain 'reference_volumes_hm3', got: {field}"
);
assert!(
message.contains("exactly 12 elements"),
"message should contain 'exactly 12 elements', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_evaporation_reference_volumes_nan_value() {
let evap = RawEvaporation {
coefficients_mm: vec![0.0; 12],
reference_volumes_hm3: Some({
let mut v = vec![5000.0; 12];
v[3] = f64::NAN;
v
}),
};
let path = Path::new("hydros.json");
let err = validate_evaporation(&evap, 0, path, 1000.0, 20000.0).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("reference_volumes_hm3"),
"field should contain 'reference_volumes_hm3', got: {field}"
);
assert!(
message.contains("finite"),
"message should contain 'finite', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_evaporation_reference_volumes_exceeds_max_storage() {
let json = r#"{
"hydros": [{
"id": 0, "name": "ReservoirEvap",
"operational_start_date": "2024-01-01",
"downstream_id": null,
"reservoir": { "min_storage_hm3": 1000.0, "max_storage_hm3": 20000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 1000.0,
"min_generation_mw": 0.0,
"max_generation_mw": 750.0
},
"evaporation": {
"coefficients_mm": [150, 130, 120, 90, 60, 40, 30, 40, 70, 100, 130, 150],
"reference_volumes_hm3": [25000, 12000, 10000, 8000, 6000, 5000, 5500, 7000, 9000, 11000, 13000, 14500]
}
}]
}"#;
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("reference_volumes_hm3"),
"field should contain 'reference_volumes_hm3', got: {field}"
);
assert!(
message.contains("max_storage_hm3"),
"message should contain 'max_storage_hm3', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_evaporation_reference_volumes_below_min_storage() {
let json = r#"{
"hydros": [{
"id": 0, "name": "ReservoirEvap",
"operational_start_date": "2024-01-01",
"downstream_id": null,
"reservoir": { "min_storage_hm3": 1000.0, "max_storage_hm3": 20000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 1000.0,
"min_generation_mw": 0.0,
"max_generation_mw": 750.0
},
"evaporation": {
"coefficients_mm": [150, 130, 120, 90, 60, 40, 30, 40, 70, 100, 130, 150],
"reference_volumes_hm3": [500, 12000, 10000, 8000, 6000, 5000, 5500, 7000, 9000, 11000, 13000, 14500]
}
}]
}"#;
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("reference_volumes_hm3"),
"field should contain 'reference_volumes_hm3', 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_no_evaporation_block_both_fields_none() {
let json = format!(r#"{{ "hydros": [{HYDRO_WITH_EVAP_BASE}] }}"#);
let f = write_json(&json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
assert!(hydros[0].evaporation_coefficients_mm.is_none());
assert!(hydros[0].evaporation_reference_volumes_hm3.is_none());
}
#[test]
fn hydros_json_rejects_legacy_inline_productivity() {
let json = r#"{
"hydros": [{
"id": 0, "name": "LegacyPlant",
"operational_start_date": "2024-01-01",
"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": 1.0,
"min_turbined_m3s": 0.0,
"max_turbined_m3s": 100.0,
"min_generation_mw": 0.0,
"max_generation_mw": 100.0
}
}]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_hydros(f.path(), &global).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("productivity_mw_per_m3s"),
"error message should name the rejected field 'productivity_mw_per_m3s', got: {msg}"
);
}
#[test]
fn test_legacy_plant_bus_id_is_rejected() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Legacy", "bus_id": 0,
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
},
"unit_groups": [
{ "id": 0, "name": "Legacy", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0 }
]
}]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_hydros(f.path(), &global).unwrap_err();
assert!(
matches!(err, LoadError::ParseError { .. }),
"expected ParseError for a deck still carrying plant-level bus_id, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("bus_id"),
"error message should name the rejected field 'bus_id', got: {msg}"
);
}
#[test]
fn test_schema_field_is_ignored() {
let json = r#"{
"$schema": "https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/schemas/hydros.schema.json",
"hydros": [{
"id": 0, "name": "H",
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 0.0, "max_turbined_m3s": 100.0,
"min_generation_mw": 0.0, "max_generation_mw": 50.0
},
"unit_groups": [
{ "id": 0, "name": "H", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 50.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": 100.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:?}"
);
}
#[test]
fn test_absent_unit_groups_is_rejected() {
let hydro_a = r#"{
"id": 1, "name": "AlphaPlant",
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 5.0, "max_turbined_m3s": 200.0,
"min_generation_mw": 10.0, "max_generation_mw": 90.0
}
}"#;
let hydro_b = r#"{
"id": 2, "name": "BetaPlant",
"operational_start_date": "2024-01-01",
"downstream_id": null,
"reservoir": { "min_storage_hm3": 0.0, "max_storage_hm3": 2000.0 },
"outflow": { "min_outflow_m3s": 0.0, "max_outflow_m3s": null },
"generation": {
"model": "constant_productivity",
"min_turbined_m3s": 15.0, "max_turbined_m3s": 300.0,
"min_generation_mw": 25.0, "max_generation_mw": 150.0
}
}"#;
let json = format!(r#"{{ "hydros": [{hydro_a}, {hydro_b}] }}"#);
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_eq!(field, "hydros[0].unit_groups");
assert!(
message.contains("unit_groups is required"),
"message should state the declare-or-reject rule, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_null_unit_groups_is_rejected() {
let json = r#"{
"hydros": [{
"id": 0, "name": "NullGroups",
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
},
"unit_groups": null
}]
}"#;
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_eq!(field, "hydros[0].unit_groups");
assert!(
message.contains("unit_groups is required"),
"message should state the declare-or-reject rule, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_declared_unit_groups_are_parsed_and_sorted_by_id() {
let json = r#"{
"hydros": [{
"id": 9, "name": "Plant9",
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
},
"unit_groups": [
{ "id": 7, "name": "G7", "bus_id": 40,
"min_generation_mw": 11.0, "max_generation_mw": 21.0,
"min_turbined_m3s": 31.0, "max_turbined_m3s": 41.0 },
{ "id": 3, "name": "G3", "bus_id": 40,
"min_generation_mw": 12.0, "max_generation_mw": 22.0,
"min_turbined_m3s": 32.0, "max_turbined_m3s": 42.0 },
{ "id": 5, "name": "G5", "bus_id": 50,
"min_generation_mw": 13.0, "max_generation_mw": 23.0,
"min_turbined_m3s": 33.0, "max_turbined_m3s": 43.0 }
]
}]
}"#;
let f = write_json(json);
let global = make_global();
let hydros = parse_hydros(f.path(), &global).unwrap();
assert_eq!(hydros.len(), 1);
let groups = &hydros[0].unit_groups;
assert_eq!(groups.len(), 3);
assert_eq!(groups[0].id, EntityId(3));
assert_eq!(groups[0].name, "G3");
assert_eq!(groups[0].bus_id, EntityId(40));
assert_eq!(groups[0].min_generation_mw.to_bits(), 12.0_f64.to_bits());
assert_eq!(groups[0].max_generation_mw.to_bits(), 22.0_f64.to_bits());
assert_eq!(groups[0].min_turbined_m3s.to_bits(), 32.0_f64.to_bits());
assert_eq!(groups[0].max_turbined_m3s.to_bits(), 42.0_f64.to_bits());
assert_eq!(groups[1].id, EntityId(5));
assert_eq!(groups[1].name, "G5");
assert_eq!(groups[1].bus_id, EntityId(50));
assert_eq!(groups[1].min_generation_mw.to_bits(), 13.0_f64.to_bits());
assert_eq!(groups[1].max_generation_mw.to_bits(), 23.0_f64.to_bits());
assert_eq!(groups[1].min_turbined_m3s.to_bits(), 33.0_f64.to_bits());
assert_eq!(groups[1].max_turbined_m3s.to_bits(), 43.0_f64.to_bits());
assert_eq!(groups[2].id, EntityId(7));
assert_eq!(groups[2].name, "G7");
assert_eq!(groups[2].bus_id, EntityId(40));
assert_eq!(groups[2].min_generation_mw.to_bits(), 11.0_f64.to_bits());
assert_eq!(groups[2].max_generation_mw.to_bits(), 21.0_f64.to_bits());
assert_eq!(groups[2].min_turbined_m3s.to_bits(), 31.0_f64.to_bits());
assert_eq!(groups[2].max_turbined_m3s.to_bits(), 41.0_f64.to_bits());
assert!(
groups.iter().all(|g| g.id != EntityId(0)),
"no declared group should carry the plant-mirror group's id 0"
);
}
#[test]
fn test_empty_unit_groups_array_is_rejected() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Bad",
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
},
"unit_groups": []
}]
}"#;
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_eq!(field, "hydros[0].unit_groups");
assert!(
message.contains("unit_groups"),
"message should mention 'unit_groups', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_unit_group_negative_min_turbined_is_rejected() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Bad",
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
},
"unit_groups": [
{ "id": 1, "name": "G1", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 100.0,
"min_turbined_m3s": -1.0, "max_turbined_m3s": 100.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 == "hydros[0].unit_groups[0].min_turbined_m3s"),
"expected SchemaError naming hydros[0].unit_groups[0].min_turbined_m3s, got: {err:?}"
);
}
#[test]
fn test_unit_group_negative_max_turbined_is_rejected() {
let json = r#"{
"hydros": [{
"id": 0, "name": "Bad",
"operational_start_date": "2024-01-01",
"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",
"min_turbined_m3s": 0.0, "max_turbined_m3s": 500.0,
"min_generation_mw": 0.0, "max_generation_mw": 250.0
},
"unit_groups": [
{ "id": 1, "name": "G1", "bus_id": 0,
"min_generation_mw": 0.0, "max_generation_mw": 100.0,
"min_turbined_m3s": 0.0, "max_turbined_m3s": -100.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 == "hydros[0].unit_groups[0].max_turbined_m3s"),
"expected SchemaError naming hydros[0].unit_groups[0].max_turbined_m3s, got: {err:?}"
);
}
}