use cobre_core::EntityId;
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
use crate::LoadError;
#[derive(Debug, Clone, PartialEq)]
pub struct ProductionModelConfig {
pub hydro_id: EntityId,
pub selection_mode: SelectionMode,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ProductionModelFile {
pub configs: Vec<ProductionModelConfig>,
pub plane_reduction: Option<PlaneReductionConfig>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SelectionMode {
StageRanges {
ranges: Vec<StageRange>,
},
Seasonal {
default_model: String,
seasons: Vec<SeasonConfig>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct StageRange {
pub start_stage_id: i32,
pub end_stage_id: Option<i32>,
pub model: String,
pub fpha_config: Option<FphaColumnLayout>,
pub reference_volume: Option<ReferenceVolume>,
pub productivity_mw_per_m3s: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SeasonConfig {
pub season_id: i32,
pub model: String,
pub fpha_config: Option<FphaColumnLayout>,
pub reference_volume: Option<ReferenceVolume>,
pub productivity_mw_per_m3s: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FphaColumnLayout {
pub source: String,
pub volume_discretization_points: Option<i32>,
pub turbine_discretization_points: Option<i32>,
pub spillage_discretization_points: Option<i32>,
pub max_planes_per_hydro: Option<i32>,
pub fitting_window: Option<FittingWindow>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PlaneReductionConfig {
Angle {
tolerance_deg: f64,
},
Distance {
tolerance_pct: f64,
n_samples: u32,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct FittingWindow {
pub volume_min_hm3: Option<f64>,
pub volume_max_hm3: Option<f64>,
pub volume_min_percentile: Option<f64>,
pub volume_max_percentile: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ReferenceVolume {
AbsoluteHm3(f64),
Percentile(f64),
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawProductionModelFile {
#[serde(rename = "$schema")]
_schema: Option<String>,
production_models: Vec<RawProductionModel>,
#[serde(default)]
fpha_plane_reduction: Option<RawPlaneReductionConfig>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
struct RawProductionModel {
hydro_id: i32,
#[serde(flatten)]
selection: RawSelectionMode,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(tag = "selection_mode", rename_all = "snake_case")]
enum RawSelectionMode {
StageRanges {
stage_ranges: Vec<RawStageRange>,
},
Seasonal {
default_model: String,
seasons: Vec<RawSeasonConfig>,
},
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawStageRange {
start_stage_id: i32,
end_stage_id: Option<i32>,
model: String,
fpha_config: Option<RawFphaColumnLayout>,
reference_volume: Option<RawReferenceVolume>,
productivity_mw_per_m3s: Option<f64>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSeasonConfig {
season_id: i32,
model: String,
fpha_config: Option<RawFphaColumnLayout>,
reference_volume: Option<RawReferenceVolume>,
productivity_mw_per_m3s: Option<f64>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawFphaColumnLayout {
source: String,
volume_discretization_points: Option<i32>,
turbine_discretization_points: Option<i32>,
spillage_discretization_points: Option<i32>,
max_planes_per_hydro: Option<i32>,
fitting_window: Option<RawFittingWindow>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)]
enum RawPlaneReductionConfig {
Angle {
tolerance_deg: f64,
},
Distance {
tolerance_pct: f64,
n_samples: u32,
},
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[allow(clippy::struct_field_names)]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawFittingWindow {
volume_min_hm3: Option<f64>,
volume_max_hm3: Option<f64>,
volume_min_percentile: Option<f64>,
volume_max_percentile: Option<f64>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawReferenceVolume {
volume_hm3: Option<f64>,
percentile: Option<f64>,
}
pub fn parse_production_models(path: &Path) -> Result<ProductionModelFile, LoadError> {
let raw_text = std::fs::read_to_string(path).map_err(|e| LoadError::io(path, e))?;
let raw: RawProductionModelFile = serde_json::from_str(&raw_text).map_err(|e| {
let msg = e.to_string();
if msg.contains("unknown variant") {
LoadError::SchemaError {
path: path.to_path_buf(),
field: "selection_mode".to_string(),
message: msg,
}
} else {
LoadError::parse(path, msg)
}
})?;
validate_production_models(
&raw.production_models,
raw.fpha_plane_reduction.as_ref(),
path,
)?;
let mut configs: Vec<ProductionModelConfig> = raw
.production_models
.into_iter()
.map(convert_production_model)
.collect();
configs.sort_by_key(|c| c.hydro_id.0);
let plane_reduction = raw
.fpha_plane_reduction
.as_ref()
.map(convert_plane_reduction);
Ok(ProductionModelFile {
configs,
plane_reduction,
})
}
fn validate_production_models(
models: &[RawProductionModel],
plane_reduction: Option<&RawPlaneReductionConfig>,
path: &Path,
) -> Result<(), LoadError> {
let mut seen_ids: HashSet<i32> = HashSet::new();
for (entry_idx, model) in models.iter().enumerate() {
if !seen_ids.insert(model.hydro_id) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("production_models[{entry_idx}].hydro_id"),
message: format!(
"duplicate hydro_id {} — each hydro may appear at most once",
model.hydro_id
),
});
}
match &model.selection {
RawSelectionMode::StageRanges { stage_ranges } => {
for (range_idx, range) in stage_ranges.iter().enumerate() {
validate_stage_range(range, entry_idx, range_idx, path)?;
}
}
RawSelectionMode::Seasonal { seasons, .. } => {
for (season_idx, season) in seasons.iter().enumerate() {
let field_base = format!(
"production_models[{entry_idx}].seasons[{season_idx}].productivity_mw_per_m3s"
);
if season.model == "fpha" && season.productivity_mw_per_m3s.is_some() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: field_base,
message: "productivity_mw_per_m3s must not be set when model is 'fpha'"
.to_string(),
});
}
if season.model != "fpha"
&& let Some(val) = season.productivity_mw_per_m3s
&& (val < 0.0 || !val.is_finite())
{
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: field_base,
message: format!(
"productivity_mw_per_m3s must be finite and non-negative, got {val}"
),
});
}
if let Some(cfg) = &season.fpha_config {
validate_fitting_window(
cfg,
&format!(
"production_models[{entry_idx}].seasons[{season_idx}].fpha_config.fitting_window"
),
path,
)?;
}
if let Some(rv) = &season.reference_volume {
validate_reference_volume(
rv,
&format!(
"production_models[{entry_idx}].seasons[{season_idx}].reference_volume"
),
path,
)?;
}
}
}
}
}
if let Some(reduction) = plane_reduction {
validate_plane_reduction(reduction, path)?;
}
Ok(())
}
fn validate_stage_range(
range: &RawStageRange,
entry_idx: usize,
range_idx: usize,
path: &Path,
) -> Result<(), LoadError> {
if let Some(end) = range.end_stage_id
&& range.start_stage_id > end
{
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!(
"production_models[{entry_idx}].stage_ranges[{range_idx}].start_stage_id"
),
message: format!(
"stage_ranges entry has start_stage_id ({}) > end_stage_id ({}); \
start_stage_id must be <= end_stage_id",
range.start_stage_id, end
),
});
}
let field_base =
format!("production_models[{entry_idx}].stage_ranges[{range_idx}].productivity_mw_per_m3s");
if range.model == "fpha" && range.productivity_mw_per_m3s.is_some() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: field_base,
message: "productivity_mw_per_m3s must not be set when model is 'fpha'".to_string(),
});
}
if range.model != "fpha"
&& let Some(val) = range.productivity_mw_per_m3s
&& (val < 0.0 || !val.is_finite())
{
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: field_base,
message: format!("productivity_mw_per_m3s must be finite and non-negative, got {val}"),
});
}
if let Some(cfg) = &range.fpha_config {
validate_fitting_window(
cfg,
&format!(
"production_models[{entry_idx}].stage_ranges[{range_idx}].fpha_config.fitting_window"
),
path,
)?;
}
if let Some(rv) = &range.reference_volume {
validate_reference_volume(
rv,
&format!("production_models[{entry_idx}].stage_ranges[{range_idx}].reference_volume"),
path,
)?;
}
Ok(())
}
fn validate_fitting_window(
cfg: &RawFphaColumnLayout,
field_prefix: &str,
path: &Path,
) -> Result<(), LoadError> {
let Some(fw) = &cfg.fitting_window else {
return Ok(());
};
if fw.volume_min_hm3.is_some() && fw.volume_min_percentile.is_some() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: field_prefix.to_string(),
message: "mutually exclusive bounds: volume_min_hm3 and volume_min_percentile \
cannot both be set; use absolute bounds OR percentiles, not both"
.to_string(),
});
}
if fw.volume_max_hm3.is_some() && fw.volume_max_percentile.is_some() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: field_prefix.to_string(),
message: "mutually exclusive bounds: volume_max_hm3 and volume_max_percentile \
cannot both be set; use absolute bounds OR percentiles, not both"
.to_string(),
});
}
Ok(())
}
fn validate_reference_volume(
rv: &RawReferenceVolume,
field_prefix: &str,
path: &Path,
) -> Result<(), LoadError> {
if rv.volume_hm3.is_some() && rv.percentile.is_some() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: field_prefix.to_string(),
message: "mutually exclusive fields: volume_hm3 and percentile cannot both be \
set; use an absolute volume OR a percentile, not both"
.to_string(),
});
}
if rv.volume_hm3.is_none() && rv.percentile.is_none() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: field_prefix.to_string(),
message: "reference_volume must set exactly one of volume_hm3 or percentile"
.to_string(),
});
}
if let Some(vol) = rv.volume_hm3
&& (!vol.is_finite() || vol <= 0.0)
{
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: field_prefix.to_string(),
message: format!("volume_hm3 must be finite and > 0.0, got {vol}"),
});
}
if let Some(pct) = rv.percentile
&& (!pct.is_finite() || !(0.0..=1.0).contains(&pct))
{
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: field_prefix.to_string(),
message: format!("percentile must be finite and in [0.0, 1.0], got {pct}"),
});
}
Ok(())
}
fn validate_plane_reduction(
reduction: &RawPlaneReductionConfig,
path: &Path,
) -> Result<(), LoadError> {
match reduction {
RawPlaneReductionConfig::Angle { tolerance_deg } => {
if !tolerance_deg.is_finite() || *tolerance_deg < 0.0 || *tolerance_deg > 90.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: "fpha_plane_reduction".to_string(),
message: format!(
"angle tolerance_deg must be finite and in [0, 90], got {tolerance_deg}"
),
});
}
}
RawPlaneReductionConfig::Distance {
tolerance_pct,
n_samples,
} => {
if !tolerance_pct.is_finite() || *tolerance_pct < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: "fpha_plane_reduction".to_string(),
message: format!(
"distance tolerance_pct must be finite and >= 0, got {tolerance_pct}"
),
});
}
if *n_samples < 1 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: "fpha_plane_reduction".to_string(),
message: format!("distance n_samples must be >= 1, got {n_samples}"),
});
}
}
}
Ok(())
}
fn convert_production_model(raw: RawProductionModel) -> ProductionModelConfig {
let selection_mode = match raw.selection {
RawSelectionMode::StageRanges { stage_ranges } => SelectionMode::StageRanges {
ranges: stage_ranges.into_iter().map(convert_stage_range).collect(),
},
RawSelectionMode::Seasonal {
default_model,
seasons,
} => SelectionMode::Seasonal {
default_model,
seasons: seasons.into_iter().map(convert_season_config).collect(),
},
};
ProductionModelConfig {
hydro_id: EntityId::from(raw.hydro_id),
selection_mode,
}
}
fn convert_stage_range(raw: RawStageRange) -> StageRange {
StageRange {
start_stage_id: raw.start_stage_id,
end_stage_id: raw.end_stage_id,
model: raw.model,
fpha_config: raw.fpha_config.map(convert_fpha_column_layout),
reference_volume: raw.reference_volume.as_ref().map(convert_reference_volume),
productivity_mw_per_m3s: raw.productivity_mw_per_m3s,
}
}
fn convert_season_config(raw: RawSeasonConfig) -> SeasonConfig {
SeasonConfig {
season_id: raw.season_id,
model: raw.model,
fpha_config: raw.fpha_config.map(convert_fpha_column_layout),
reference_volume: raw.reference_volume.as_ref().map(convert_reference_volume),
productivity_mw_per_m3s: raw.productivity_mw_per_m3s,
}
}
fn convert_reference_volume(raw: &RawReferenceVolume) -> ReferenceVolume {
match raw.volume_hm3 {
Some(vol) => ReferenceVolume::AbsoluteHm3(vol),
None => ReferenceVolume::Percentile(raw.percentile.unwrap_or_default()),
}
}
fn convert_fpha_column_layout(raw: RawFphaColumnLayout) -> FphaColumnLayout {
FphaColumnLayout {
source: raw.source,
volume_discretization_points: raw.volume_discretization_points,
turbine_discretization_points: raw.turbine_discretization_points,
spillage_discretization_points: raw.spillage_discretization_points,
max_planes_per_hydro: raw.max_planes_per_hydro,
fitting_window: raw.fitting_window.map(|fw| FittingWindow {
volume_min_hm3: fw.volume_min_hm3,
volume_max_hm3: fw.volume_max_hm3,
volume_min_percentile: fw.volume_min_percentile,
volume_max_percentile: fw.volume_max_percentile,
}),
}
}
fn convert_plane_reduction(raw: &RawPlaneReductionConfig) -> PlaneReductionConfig {
match raw {
RawPlaneReductionConfig::Angle { tolerance_deg } => PlaneReductionConfig::Angle {
tolerance_deg: *tolerance_deg,
},
RawPlaneReductionConfig::Distance {
tolerance_pct,
n_samples,
} => PlaneReductionConfig::Distance {
tolerance_pct: *tolerance_pct,
n_samples: *n_samples,
},
}
}
#[cfg(test)]
#[allow(
clippy::doc_markdown,
clippy::expect_used,
clippy::match_wildcard_for_single_variants,
clippy::panic,
clippy::too_many_lines,
clippy::unwrap_used
)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_json(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn test_valid_stage_ranges_mode() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [
{
"start_stage_id": 0, "end_stage_id": 24,
"model": "fpha",
"fpha_config": {
"source": "computed",
"volume_discretization_points": 7,
"turbine_discretization_points": 15,
"fitting_window": { "volume_min_hm3": null, "volume_max_hm3": null }
}
},
{
"start_stage_id": 25, "end_stage_id": null,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.9
}
]
}]
}"#;
let f = write_json(json);
let models = parse_production_models(f.path()).unwrap().configs;
assert_eq!(models.len(), 1);
let m = &models[0];
assert_eq!(m.hydro_id, EntityId::from(0));
match &m.selection_mode {
SelectionMode::StageRanges { ranges } => {
assert_eq!(ranges.len(), 2);
assert_eq!(ranges[0].start_stage_id, 0);
assert_eq!(ranges[0].end_stage_id, Some(24));
assert_eq!(ranges[0].model, "fpha");
let fpha = ranges[0].fpha_config.as_ref().unwrap();
assert_eq!(fpha.source, "computed");
assert_eq!(fpha.volume_discretization_points, Some(7));
assert_eq!(fpha.turbine_discretization_points, Some(15));
let fw = fpha.fitting_window.as_ref().unwrap();
assert!(fw.volume_min_hm3.is_none());
assert!(fw.volume_max_hm3.is_none());
assert_eq!(ranges[1].start_stage_id, 25);
assert!(ranges[1].end_stage_id.is_none());
assert_eq!(ranges[1].model, "constant_productivity");
assert!(ranges[1].fpha_config.is_none());
assert_eq!(ranges[1].productivity_mw_per_m3s, Some(0.9));
}
other => panic!("expected StageRanges, got: {other:?}"),
}
}
#[test]
fn test_valid_seasonal_mode() {
let json = r#"{
"production_models": [{
"hydro_id": 5,
"selection_mode": "seasonal",
"default_model": "linearized_head",
"seasons": [
{
"season_id": 0,
"model": "fpha",
"fpha_config": { "source": "computed", "volume_discretization_points": 5 }
},
{
"season_id": 1, "model": "fpha",
"fpha_config": { "source": "computed", "turbine_discretization_points": 10 }
}
]
}]
}"#;
let f = write_json(json);
let models = parse_production_models(f.path()).unwrap().configs;
assert_eq!(models.len(), 1);
let m = &models[0];
assert_eq!(m.hydro_id, EntityId::from(5));
match &m.selection_mode {
SelectionMode::Seasonal {
default_model,
seasons,
} => {
assert_eq!(default_model, "linearized_head");
assert_eq!(seasons.len(), 2);
assert_eq!(seasons[0].season_id, 0);
assert_eq!(seasons[0].model, "fpha");
let fpha0 = seasons[0].fpha_config.as_ref().unwrap();
assert_eq!(fpha0.source, "computed");
assert_eq!(fpha0.volume_discretization_points, Some(5));
assert!(fpha0.turbine_discretization_points.is_none());
assert_eq!(seasons[1].season_id, 1);
let fpha1 = seasons[1].fpha_config.as_ref().unwrap();
assert_eq!(fpha1.turbine_discretization_points, Some(10));
assert!(fpha1.volume_discretization_points.is_none());
}
other => panic!("expected Seasonal, got: {other:?}"),
}
}
#[test]
fn test_mixed_modes_sorted_by_hydro_id() {
let json = r#"{
"production_models": [
{
"hydro_id": 10,
"selection_mode": "seasonal",
"default_model": "constant_productivity",
"seasons": []
},
{
"hydro_id": 3,
"selection_mode": "stage_ranges",
"stage_ranges": [
{
"start_stage_id": 0, "end_stage_id": null,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.8
}
]
}
]
}"#;
let f = write_json(json);
let models = parse_production_models(f.path()).unwrap().configs;
assert_eq!(models.len(), 2);
assert_eq!(models[0].hydro_id, EntityId::from(3));
assert_eq!(models[1].hydro_id, EntityId::from(10));
assert!(matches!(
models[0].selection_mode,
SelectionMode::StageRanges { .. }
));
assert!(matches!(
models[1].selection_mode,
SelectionMode::Seasonal { .. }
));
}
#[test]
fn test_duplicate_hydro_id() {
let json = r#"{
"production_models": [
{
"hydro_id": 5,
"selection_mode": "stage_ranges",
"stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "fpha", "fpha_config": { "source": "computed" } }]
},
{
"hydro_id": 5,
"selection_mode": "stage_ranges",
"stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }]
}
]
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("hydro_id"),
"field should mention hydro_id, got: {field}"
);
assert!(
message.contains("duplicate"),
"message should mention duplicate, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_invalid_stage_range_start_greater_than_end() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [
{
"start_stage_id": 25, "end_stage_id": 10,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.9
}
]
}]
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("stage_ranges"),
"field should contain 'stage_ranges', got: {field}"
);
assert!(
message.contains("start_stage_id"),
"message should contain 'start_stage_id', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_stage_range_start_equals_end_is_valid() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [
{
"start_stage_id": 5, "end_stage_id": 5,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.9
}
]
}]
}"#;
let f = write_json(json);
let result = parse_production_models(f.path());
assert!(
result.is_ok(),
"equal start==end should be valid, got: {result:?}"
);
}
#[test]
fn test_mutually_exclusive_fitting_window_min() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [{
"start_stage_id": 0, "end_stage_id": null,
"model": "fpha",
"fpha_config": {
"source": "computed",
"fitting_window": {
"volume_min_hm3": 1000.0,
"volume_max_hm3": null,
"volume_min_percentile": 0.1,
"volume_max_percentile": null
}
}
}]
}]
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("mutually exclusive"),
"message should contain 'mutually exclusive', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_mutually_exclusive_fitting_window_max() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [{
"start_stage_id": 0, "end_stage_id": null,
"model": "fpha",
"fpha_config": {
"source": "computed",
"fitting_window": {
"volume_min_hm3": null,
"volume_max_hm3": 8000.0,
"volume_min_percentile": null,
"volume_max_percentile": 0.9
}
}
}]
}]
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("mutually exclusive"),
"message should contain 'mutually exclusive', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_mutually_exclusive_fitting_window_seasonal() {
let json = r#"{
"production_models": [{
"hydro_id": 1,
"selection_mode": "seasonal",
"default_model": "constant_productivity",
"seasons": [{
"season_id": 0,
"model": "fpha",
"fpha_config": {
"source": "computed",
"fitting_window": {
"volume_min_hm3": 500.0,
"volume_min_percentile": 0.2
}
}
}]
}]
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
assert!(
matches!(err, LoadError::SchemaError { .. }),
"expected SchemaError, got: {err:?}"
);
}
#[test]
fn test_file_not_found() {
let path = Path::new("/nonexistent/path/hydro_production_models.json");
let err = parse_production_models(path).unwrap_err();
match &err {
LoadError::IoError { path: p, .. } => {
assert_eq!(p, path);
}
other => panic!("expected IoError, got: {other:?}"),
}
}
#[test]
fn test_unknown_selection_mode() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "unknown_mode"
}]
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
assert!(
matches!(err, LoadError::SchemaError { .. }),
"expected SchemaError for unknown selection_mode, got: {err:?}"
);
}
#[test]
fn test_empty_array_returns_empty_vec() {
let json = r#"{ "production_models": [] }"#;
let f = write_json(json);
let models = parse_production_models(f.path()).unwrap().configs;
assert!(models.is_empty());
}
#[test]
fn test_declaration_order_invariance() {
let json_asc = r#"{
"production_models": [
{ "hydro_id": 1, "selection_mode": "stage_ranges",
"stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] },
{ "hydro_id": 5, "selection_mode": "stage_ranges",
"stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] },
{ "hydro_id": 99, "selection_mode": "stage_ranges",
"stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] }
]
}"#;
let json_desc = r#"{
"production_models": [
{ "hydro_id": 99, "selection_mode": "stage_ranges",
"stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] },
{ "hydro_id": 5, "selection_mode": "stage_ranges",
"stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] },
{ "hydro_id": 1, "selection_mode": "stage_ranges",
"stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] }
]
}"#;
let f_asc = write_json(json_asc);
let f_desc = write_json(json_desc);
let models_asc = parse_production_models(f_asc.path()).unwrap().configs;
let models_desc = parse_production_models(f_desc.path()).unwrap().configs;
let ids_asc: Vec<i32> = models_asc.iter().map(|m| m.hydro_id.0).collect();
let ids_desc: Vec<i32> = models_desc.iter().map(|m| m.hydro_id.0).collect();
assert_eq!(
ids_asc, ids_desc,
"output order must be hydro_id-sorted regardless of input"
);
assert_eq!(ids_asc, vec![1, 5, 99]);
}
#[test]
fn test_fpha_config_without_fitting_window() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [{
"start_stage_id": 0, "end_stage_id": null,
"model": "fpha",
"fpha_config": { "source": "precomputed" }
}]
}]
}"#;
let f = write_json(json);
let models = parse_production_models(f.path()).unwrap().configs;
assert_eq!(models.len(), 1);
match &models[0].selection_mode {
SelectionMode::StageRanges { ranges } => {
let fpha = ranges[0].fpha_config.as_ref().unwrap();
assert_eq!(fpha.source, "precomputed");
assert!(fpha.fitting_window.is_none());
}
other => panic!("expected StageRanges, got: {other:?}"),
}
}
#[test]
fn constant_productivity_requires_coefficient() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [
{
"start_stage_id": 0, "end_stage_id": 24,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.85
}
]
}]
}"#;
let f = write_json(json);
let models = parse_production_models(f.path()).unwrap().configs;
match &models[0].selection_mode {
SelectionMode::StageRanges { ranges } => {
assert_eq!(ranges[0].productivity_mw_per_m3s, Some(0.85));
}
other => panic!("expected StageRanges, got: {other:?}"),
}
}
#[test]
fn test_non_fpha_stage_range_without_productivity_is_accepted() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [
{
"start_stage_id": 0, "end_stage_id": 24,
"model": "constant_productivity"
}
]
}]
}"#;
let f = write_json(json);
let models = parse_production_models(f.path()).unwrap().configs;
match &models[0].selection_mode {
SelectionMode::StageRanges { ranges } => {
assert!(
ranges[0].productivity_mw_per_m3s.is_none(),
"expected None when field is omitted, got: {:?}",
ranges[0].productivity_mw_per_m3s
);
}
other => panic!("expected StageRanges, got: {other:?}"),
}
}
#[test]
fn test_non_fpha_stage_range_with_null_productivity_is_accepted() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [
{
"start_stage_id": 0, "end_stage_id": 24,
"model": "linearized_head",
"productivity_mw_per_m3s": null
}
]
}]
}"#;
let f = write_json(json);
let models = parse_production_models(f.path()).unwrap().configs;
match &models[0].selection_mode {
SelectionMode::StageRanges { ranges } => {
assert!(
ranges[0].productivity_mw_per_m3s.is_none(),
"expected None when field is null, got: {:?}",
ranges[0].productivity_mw_per_m3s
);
}
other => panic!("expected StageRanges, got: {other:?}"),
}
}
#[test]
fn fpha_rejects_coefficient() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [
{
"start_stage_id": 0, "end_stage_id": 24,
"model": "fpha",
"fpha_config": { "source": "computed" },
"productivity_mw_per_m3s": 1.0
}
]
}]
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("productivity_mw_per_m3s"),
"field should contain 'productivity_mw_per_m3s', got: {field}"
);
assert_eq!(
message, "productivity_mw_per_m3s must not be set when model is 'fpha'",
"message must match exactly"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_productivity_negative_rejected() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [
{
"start_stage_id": 0, "end_stage_id": 24,
"model": "constant_productivity",
"productivity_mw_per_m3s": -1.0
}
]
}]
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
assert!(
matches!(err, LoadError::SchemaError { .. }),
"expected SchemaError, got: {err:?}"
);
}
#[test]
fn test_productivity_zero_accepted() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [
{
"start_stage_id": 0, "end_stage_id": 24,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.0
}
]
}]
}"#;
let f = write_json(json);
let parsed = parse_production_models(f.path())
.expect("zero productivity must be accepted as a planned-outage marker")
.configs;
let SelectionMode::StageRanges { ranges } = &parsed[0].selection_mode else {
panic!("expected StageRanges");
};
assert_eq!(ranges[0].productivity_mw_per_m3s, Some(0.0));
}
#[test]
fn test_seasonal_productivity_mw_per_m3s() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "seasonal",
"default_model": "constant_productivity",
"seasons": [
{
"season_id": 0,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.75
}
]
}]
}"#;
let f = write_json(json);
let models = parse_production_models(f.path()).unwrap().configs;
match &models[0].selection_mode {
SelectionMode::Seasonal { seasons, .. } => {
assert_eq!(seasons[0].productivity_mw_per_m3s, Some(0.75));
}
other => panic!("expected Seasonal, got: {other:?}"),
}
}
#[test]
fn test_non_fpha_seasonal_without_productivity_is_accepted() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "seasonal",
"default_model": "constant_productivity",
"seasons": [
{
"season_id": 0,
"model": "constant_productivity"
}
]
}]
}"#;
let f = write_json(json);
let models = parse_production_models(f.path()).unwrap().configs;
match &models[0].selection_mode {
SelectionMode::Seasonal { seasons, .. } => {
assert!(
seasons[0].productivity_mw_per_m3s.is_none(),
"expected None when field is omitted, got: {:?}",
seasons[0].productivity_mw_per_m3s
);
}
other => panic!("expected Seasonal, got: {other:?}"),
}
}
#[test]
fn test_fpha_stage_range_with_productivity_still_rejected() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [
{
"start_stage_id": 0, "end_stage_id": 24,
"model": "fpha",
"fpha_config": { "source": "computed" },
"productivity_mw_per_m3s": 0.9
}
]
}]
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("must not be set when model is 'fpha'"),
"message should mention fpha rejection, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_negative_productivity_still_rejected() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [
{
"start_stage_id": 0, "end_stage_id": 24,
"model": "constant_productivity",
"productivity_mw_per_m3s": -0.1
}
]
}]
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("productivity_mw_per_m3s must be finite and non-negative"),
"message should mention non-negative requirement, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_plane_reduction_absent_is_none() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "fpha", "fpha_config": { "source": "computed" } }]
}]
}"#;
let f = write_json(json);
let file = parse_production_models(f.path()).unwrap();
assert!(
file.plane_reduction.is_none(),
"absent block must resolve to None, got: {:?}",
file.plane_reduction
);
assert_eq!(file.configs.len(), 1);
}
#[test]
fn test_plane_reduction_angle_valid() {
let json = r#"{
"production_models": [],
"fpha_plane_reduction": { "method": "angle", "tolerance_deg": 5.0 }
}"#;
let f = write_json(json);
let file = parse_production_models(f.path()).unwrap();
assert_eq!(
file.plane_reduction,
Some(PlaneReductionConfig::Angle { tolerance_deg: 5.0 })
);
}
#[test]
fn test_plane_reduction_distance_valid() {
let json = r#"{
"production_models": [],
"fpha_plane_reduction": { "method": "distance", "tolerance_pct": 0.5, "n_samples": 64 }
}"#;
let f = write_json(json);
let file = parse_production_models(f.path()).unwrap();
assert_eq!(
file.plane_reduction,
Some(PlaneReductionConfig::Distance {
tolerance_pct: 0.5,
n_samples: 64
})
);
}
#[test]
fn test_plane_reduction_angle_out_of_range() {
let json = r#"{
"production_models": [],
"fpha_plane_reduction": { "method": "angle", "tolerance_deg": 95.0 }
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert_eq!(field, "fpha_plane_reduction");
assert!(
message.contains("[0, 90]"),
"message should name the [0, 90] range, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_plane_reduction_distance_negative_tolerance() {
let json = r#"{
"production_models": [],
"fpha_plane_reduction": { "method": "distance", "tolerance_pct": -1.0, "n_samples": 64 }
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, .. } => {
assert_eq!(field, "fpha_plane_reduction");
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_plane_reduction_distance_zero_samples() {
let json = r#"{
"production_models": [],
"fpha_plane_reduction": { "method": "distance", "tolerance_pct": 0.5, "n_samples": 0 }
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, .. } => {
assert_eq!(field, "fpha_plane_reduction");
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_plane_reduction_cross_method_field_rejected() {
let json = r#"{
"production_models": [],
"fpha_plane_reduction": { "method": "angle", "tolerance_pct": 5.0 }
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
assert!(
matches!(
err,
LoadError::SchemaError { .. } | LoadError::ParseError { .. }
),
"cross-method field must be rejected, got: {err:?}"
);
}
#[test]
fn test_plane_reduction_foreign_field_alongside_required_is_rejected() {
let json = r#"{
"production_models": [],
"fpha_plane_reduction": { "method": "angle", "tolerance_deg": 2.0, "tolerance_pct": 5.0 }
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
assert!(
matches!(
err,
LoadError::SchemaError { .. } | LoadError::ParseError { .. }
),
"a foreign field alongside the required one must be rejected by deny_unknown_fields, got: {err:?}"
);
}
#[test]
fn reference_volume_absolute_parses() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [{
"start_stage_id": 0, "end_stage_id": null,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.9,
"reference_volume": { "volume_hm3": 1234.5 }
}]
}]
}"#;
let f = write_json(json);
let models = parse_production_models(f.path()).unwrap().configs;
match &models[0].selection_mode {
SelectionMode::StageRanges { ranges } => {
assert_eq!(
ranges[0].reference_volume,
Some(ReferenceVolume::AbsoluteHm3(1234.5))
);
}
other => panic!("expected StageRanges, got: {other:?}"),
}
}
#[test]
fn reference_volume_percentile_parses() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [{
"start_stage_id": 0, "end_stage_id": null,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.9,
"reference_volume": { "percentile": 0.5 }
}]
}]
}"#;
let f = write_json(json);
let models = parse_production_models(f.path()).unwrap().configs;
match &models[0].selection_mode {
SelectionMode::StageRanges { ranges } => {
assert_eq!(
ranges[0].reference_volume,
Some(ReferenceVolume::Percentile(0.5))
);
}
other => panic!("expected StageRanges, got: {other:?}"),
}
}
#[test]
fn reference_volume_both_set_is_rejected() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "seasonal",
"default_model": "constant_productivity",
"seasons": [{
"season_id": 0,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.9,
"reference_volume": { "volume_hm3": 1.0, "percentile": 0.5 }
}]
}]
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
message.contains("mutually exclusive"),
"message should contain 'mutually exclusive', got: {message}"
);
assert!(
field.contains("seasons"),
"field should name seasons, got: {field}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn reference_volume_neither_set_is_rejected() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [{
"start_stage_id": 0, "end_stage_id": null,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.9,
"reference_volume": {}
}]
}]
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("exactly one"),
"message should require exactly one field, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn reference_volume_percentile_out_of_range_is_rejected() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [{
"start_stage_id": 0, "end_stage_id": null,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.9,
"reference_volume": { "percentile": 1.5 }
}]
}]
}"#;
let f = write_json(json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("[0.0, 1.0]"),
"message should cite the [0.0, 1.0] range, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn reference_volume_nonpositive_volume_is_rejected() {
for bad in ["0.0", "-5.0"] {
let json = format!(
r#"{{
"production_models": [{{
"hydro_id": 0,
"selection_mode": "stage_ranges",
"stage_ranges": [{{
"start_stage_id": 0, "end_stage_id": null,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.9,
"reference_volume": {{ "volume_hm3": {bad} }}
}}]
}}]
}}"#
);
let f = write_json(&json);
let err = parse_production_models(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("> 0.0"),
"message should require > 0.0, got: {message}"
);
}
other => panic!("expected SchemaError for volume_hm3={bad}, got: {other:?}"),
}
}
}
#[test]
fn reference_volume_on_season_entry_parses() {
let json = r#"{
"production_models": [{
"hydro_id": 7,
"selection_mode": "seasonal",
"default_model": "constant_productivity",
"seasons": [{
"season_id": 0,
"model": "constant_productivity",
"productivity_mw_per_m3s": 0.9,
"reference_volume": { "volume_hm3": 800.0 }
}]
}]
}"#;
let f = write_json(json);
let models = parse_production_models(f.path()).unwrap().configs;
match &models[0].selection_mode {
SelectionMode::Seasonal { seasons, .. } => {
assert_eq!(
seasons[0].reference_volume,
Some(ReferenceVolume::AbsoluteHm3(800.0))
);
}
other => panic!("expected Seasonal, got: {other:?}"),
}
}
#[cfg(feature = "schema")]
#[test]
fn reference_volume_appears_in_generated_schema() {
let schema = schemars::schema_for!(RawProductionModelFile);
let value = serde_json::to_value(&schema).unwrap();
let text = serde_json::to_string(&value).unwrap();
assert!(
text.contains("reference_volume"),
"generated schema must expose the reference_volume property"
);
}
}