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<FphaConfig>,
pub productivity_override: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SeasonConfig {
pub season_id: i32,
pub model: String,
pub fpha_config: Option<FphaConfig>,
pub productivity_override: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FphaConfig {
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>,
}
#[derive(Deserialize)]
struct RawProductionModelFile {
#[serde(rename = "$schema")]
_schema: Option<String>,
production_models: Vec<RawProductionModel>,
}
#[derive(Deserialize)]
struct RawProductionModel {
hydro_id: i32,
#[serde(flatten)]
selection: RawSelectionMode,
}
#[derive(Deserialize)]
#[serde(tag = "selection_mode", rename_all = "snake_case")]
enum RawSelectionMode {
StageRanges {
stage_ranges: Vec<RawStageRange>,
},
Seasonal {
default_model: String,
seasons: Vec<RawSeasonConfig>,
},
}
#[derive(Deserialize)]
struct RawStageRange {
start_stage_id: i32,
end_stage_id: Option<i32>,
model: String,
fpha_config: Option<RawFphaConfig>,
productivity_override: Option<f64>,
}
#[derive(Deserialize)]
struct RawSeasonConfig {
season_id: i32,
model: String,
fpha_config: Option<RawFphaConfig>,
productivity_override: Option<f64>,
}
#[derive(Deserialize)]
struct RawFphaConfig {
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>,
}
#[allow(clippy::struct_field_names)]
#[derive(Deserialize)]
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() {
if season.model == "fpha" && season.productivity_override.is_some() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!(
"production_models[{entry_idx}].seasons[{season_idx}].productivity_override"
),
message: "productivity_override is not valid for model \"fpha\""
.to_string(),
});
}
if let Some(val) = season.productivity_override {
if val <= 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!(
"production_models[{entry_idx}].seasons[{season_idx}].productivity_override"
),
message: format!(
"productivity_override must be positive, 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 {
if 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
),
});
}
}
if range.model == "fpha" && range.productivity_override.is_some() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!(
"production_models[{entry_idx}].stage_ranges[{range_idx}].productivity_override"
),
message: "productivity_override is not valid for model \"fpha\"".to_string(),
});
}
if let Some(val) = range.productivity_override {
if val <= 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!(
"production_models[{entry_idx}].stage_ranges[{range_idx}].productivity_override"
),
message: format!("productivity_override must be positive, 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: &RawFphaConfig,
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_config),
productivity_override: raw.productivity_override,
}
}
fn convert_season_config(raw: RawSeasonConfig) -> SeasonConfig {
SeasonConfig {
season_id: raw.season_id,
model: raw.model,
fpha_config: raw.fpha_config.map(convert_fpha_config),
productivity_override: raw.productivity_override,
}
}
fn convert_fpha_config(raw: RawFphaConfig) -> FphaConfig {
FphaConfig {
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| convert_fitting_window(&fw)),
}
}
fn convert_fitting_window(raw: &RawFittingWindow) -> FittingWindow {
FittingWindow {
volume_min_hm3: raw.volume_min_hm3,
volume_max_hm3: raw.volume_max_hm3,
volume_min_percentile: raw.volume_min_percentile,
volume_max_percentile: raw.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" }
]
}]
}"#;
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());
}
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" }
]
}
]
}"#;
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" }]
}
]
}"#;
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" }
]
}]
}"#;
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" }
]
}]
}"#;
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" }] },
{ "hydro_id": 5, "selection_mode": "stage_ranges",
"stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity" }] },
{ "hydro_id": 99, "selection_mode": "stage_ranges",
"stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity" }] }
]
}"#;
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" }] },
{ "hydro_id": 5, "selection_mode": "stage_ranges",
"stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity" }] },
{ "hydro_id": 1, "selection_mode": "stage_ranges",
"stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity" }] }
]
}"#;
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 test_productivity_override_present() {
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_override": 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_override, Some(0.85));
}
other => panic!("expected StageRanges, got: {other:?}"),
}
}
#[test]
fn test_productivity_override_absent_defaults_to_none() {
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_override.is_none());
}
other => panic!("expected StageRanges, got: {other:?}"),
}
}
#[test]
fn test_productivity_override_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_override": -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_override_zero_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_override": 0.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_override_rejected_on_fpha() {
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_override": 0.5
}
]
}]
}"#;
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_seasonal_productivity_override() {
let json = r#"{
"production_models": [{
"hydro_id": 0,
"selection_mode": "seasonal",
"default_model": "constant_productivity",
"seasons": [
{
"season_id": 0,
"model": "constant_productivity",
"productivity_override": 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_override, Some(0.75));
}
other => panic!("expected Seasonal, got: {other:?}"),
}
}
}