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)]
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 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 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 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>,
}
#[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>,
}
#[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>,
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>,
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))]
#[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>,
}
pub fn parse_production_models(path: &Path) -> Result<Vec<ProductionModelConfig>, 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, 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);
Ok(configs)
}
fn validate_production_models(models: &[RawProductionModel], 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,
)?;
}
}
}
}
}
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,
)?;
}
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 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),
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),
productivity_mw_per_m3s: raw.productivity_mw_per_m3s,
}
}
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,
}),
}
}
#[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();
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();
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();
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();
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();
let models_desc = parse_production_models(f_desc.path()).unwrap();
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();
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();
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();
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();
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");
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();
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();
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:?}"),
}
}
}