use super::{ValidationContext, schema::ParsedData};
mod correlation;
mod hydro;
mod scenarios;
mod season;
mod shared;
mod sobol;
mod stages;
mod thermal;
pub(crate) fn validate_semantic_hydro_thermal(data: &ParsedData, ctx: &mut ValidationContext) {
hydro::check_cascade_acyclic(data, ctx);
hydro::check_hydro_bounds(data, ctx);
hydro::check_lifecycle_consistency(data, ctx);
hydro::check_filling_config(data, ctx);
hydro::check_geometry_monotonicity(data, ctx);
hydro::check_evaporation_geometry_coverage(data, ctx);
hydro::check_fpha_constraints(data, ctx);
thermal::check_thermal_generation_bounds(data, ctx);
}
pub(crate) fn validate_semantic_stages_penalties_scenarios(
data: &ParsedData,
ctx: &mut ValidationContext,
) {
stages::check_stage_structure(data, ctx);
sobol::check_sobol_power_of_2(data, ctx);
scenarios::check_penalty_ordering(data, ctx);
scenarios::check_fpha_penalty_rule(data, ctx);
scenarios::check_scenario_models(data, ctx);
correlation::check_correlation_matrices(data, ctx);
correlation::check_correlation_same_type(data, ctx);
scenarios::check_external_scheme_has_files(data, ctx);
scenarios::check_load_factor_consistency(data, ctx);
scenarios::check_estimation_prerequisites(data, ctx);
scenarios::check_past_inflows_coverage(data, ctx);
scenarios::check_past_inflows_season_ids(data, ctx);
season::check_season_id_consistency(data, ctx);
season::check_observation_season_alignment(data, ctx);
}
const PROB_TOLERANCE: f64 = 1e-6;
const CORR_TOLERANCE: f64 = 1e-9;
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::panic,
clippy::too_many_lines,
clippy::doc_markdown,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss
)]
mod tests {
use super::scenarios::*;
use super::season::*;
use super::*;
use cobre_core::{
EntityId,
entities::{Bus, Hydro, HydroGenerationModel, HydroPenalties, Line, Thermal},
initial_conditions::InitialConditions,
penalty::GlobalPenaltyDefaults,
temporal::{
BlockMode, NoiseMethod, PolicyGraph, PolicyGraphType, ScenarioSourceConfig, Stage,
StageRiskConfig, StageStateConfig,
},
};
use crate::{
config::Config,
extensions::{FphaHyperplaneRow, HydroGeometryRow},
stages::StagesData,
validation::{ErrorKind, ValidationContext, schema::ParsedData},
};
fn penalties_all(v: f64) -> HydroPenalties {
HydroPenalties {
spillage_cost: v,
diversion_cost: v,
fpha_turbined_cost: v,
storage_violation_below_cost: v,
filling_target_violation_cost: v,
turbined_violation_below_cost: v,
outflow_violation_below_cost: v,
outflow_violation_above_cost: v,
generation_violation_below_cost: v,
evaporation_violation_cost: v,
water_withdrawal_violation_cost: v,
water_withdrawal_violation_pos_cost: v,
water_withdrawal_violation_neg_cost: v,
evaporation_violation_pos_cost: v,
evaporation_violation_neg_cost: v,
inflow_nonnegativity_cost: 1000.0,
}
}
fn make_hydro(id: i32, downstream_id: Option<i32>) -> Hydro {
Hydro {
id: EntityId::from(id),
name: format!("Hydro {id}"),
bus_id: EntityId::from(1),
downstream_id: downstream_id.map(EntityId::from),
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 1000.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity {
productivity_mw_per_m3s: 1.0,
},
min_turbined_m3s: 0.0,
max_turbined_m3s: 1000.0,
min_generation_mw: 0.0,
max_generation_mw: 1000.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: penalties_all(1.0),
}
}
fn make_thermal(id: i32, min_mw: f64, max_mw: f64) -> Thermal {
Thermal {
id: EntityId::from(id),
name: format!("Thermal {id}"),
bus_id: EntityId::from(1),
entry_stage_id: None,
exit_stage_id: None,
cost_per_mwh: 100.0,
min_generation_mw: min_mw,
max_generation_mw: max_mw,
gnl_config: None,
}
}
fn make_stage(id: i32) -> Stage {
Stage {
id,
index: 0,
start_date: chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: chrono::NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: None,
blocks: vec![],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
}
}
fn make_stages(ids: Vec<i32>) -> StagesData {
StagesData {
stages: ids.into_iter().map(make_stage).collect(),
policy_graph: PolicyGraph {
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.06,
transitions: vec![],
season_map: None,
},
}
}
#[allow(clippy::too_many_arguments)]
fn make_data(
hydros: Vec<Hydro>,
thermals: Vec<Thermal>,
lines: Vec<Line>,
stages: StagesData,
hydro_geometry: Vec<HydroGeometryRow>,
fpha_hyperplanes: Vec<FphaHyperplaneRow>,
) -> ParsedData {
ParsedData {
config: minimal_config(),
penalties: minimal_global_penalties(),
stages,
initial_conditions: InitialConditions {
storage: vec![],
filling_storage: vec![],
past_inflows: vec![],
recent_observations: vec![],
},
buses: vec![Bus {
id: EntityId::from(1),
name: "BUS_1".to_string(),
deficit_segments: vec![],
excess_cost: 100.0,
}],
thermals,
hydros,
lines,
non_controllable_sources: vec![],
pumping_stations: vec![],
energy_contracts: vec![],
hydro_geometry,
production_models: vec![],
fpha_hyperplanes,
inflow_history: vec![],
inflow_seasonal_stats: vec![],
inflow_ar_coefficients: vec![],
external_scenarios: vec![],
external_load_scenarios: vec![],
external_ncs_scenarios: vec![],
load_seasonal_stats: vec![],
load_factors: vec![],
correlation: None,
non_controllable_factors: vec![],
ncs_models: vec![],
thermal_bounds: vec![],
hydro_bounds: vec![],
line_bounds: vec![],
pumping_bounds: vec![],
contract_bounds: vec![],
exchange_factors: vec![],
generic_constraints: vec![],
generic_constraint_bounds: vec![],
penalty_overrides_bus: vec![],
penalty_overrides_line: vec![],
penalty_overrides_hydro: vec![],
penalty_overrides_ncs: vec![],
ncs_bounds: vec![],
}
}
fn minimal_config() -> Config {
let json = r#"{
"training": {
"forward_passes": 10,
"stopping_rules": [
{ "type": "iteration_limit", "limit": 100 }
]
}
}"#;
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), json).unwrap();
crate::config::parse_config(tmp.path()).unwrap()
}
fn minimal_global_penalties() -> GlobalPenaltyDefaults {
use cobre_core::entities::DeficitSegment;
GlobalPenaltyDefaults {
bus_deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 1.0,
}],
bus_excess_cost: 1.0,
line_exchange_cost: 1.0,
hydro: HydroPenalties {
spillage_cost: 1.0,
fpha_turbined_cost: 1.0,
diversion_cost: 1.0,
storage_violation_below_cost: 1.0,
filling_target_violation_cost: 1.0,
turbined_violation_below_cost: 1.0,
outflow_violation_below_cost: 1.0,
outflow_violation_above_cost: 1.0,
generation_violation_below_cost: 1.0,
evaporation_violation_cost: 1.0,
water_withdrawal_violation_cost: 1.0,
water_withdrawal_violation_pos_cost: 1.0,
water_withdrawal_violation_neg_cost: 1.0,
evaporation_violation_pos_cost: 1.0,
evaporation_violation_neg_cost: 1.0,
inflow_nonnegativity_cost: 1000.0,
},
ncs_curtailment_cost: 1.0,
}
}
fn make_fpha_row(hydro_id: i32, stage_id: Option<i32>, plane_id: i32) -> FphaHyperplaneRow {
FphaHyperplaneRow {
hydro_id: EntityId::from(hydro_id),
stage_id,
plane_id,
gamma_0: 100.0,
gamma_v: 0.5, gamma_q: 0.8,
gamma_s: -0.02, kappa: 1.0,
valid_v_min_hm3: None,
valid_v_max_hm3: None,
valid_q_max_m3s: None,
}
}
fn make_geom_row(
hydro_id: i32,
volume_hm3: f64,
height_m: f64,
area_km2: f64,
) -> HydroGeometryRow {
HydroGeometryRow {
hydro_id: EntityId::from(hydro_id),
volume_hm3,
height_m,
area_km2,
}
}
#[test]
fn test_cascade_acyclic_valid() {
let hydros = vec![
make_hydro(1, Some(2)), make_hydro(2, Some(3)), make_hydro(3, None), ];
let data = make_data(hydros, vec![], vec![], make_stages(vec![0]), vec![], vec![]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"valid acyclic cascade should produce no errors, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_cascade_cycle_detected() {
let hydros = vec![
make_hydro(1, Some(2)), make_hydro(2, Some(3)), make_hydro(3, Some(1)), ];
let data = make_data(hydros, vec![], vec![], make_stages(vec![0]), vec![], vec![]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors(), "cycle should produce errors");
let cycle_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::CycleDetected)
.collect();
assert!(
!cycle_errors.is_empty(),
"should have at least one CycleDetected error"
);
}
#[test]
fn test_cascade_empty_hydros() {
let data = make_data(vec![], vec![], vec![], make_stages(vec![0]), vec![], vec![]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(!ctx.has_errors());
}
#[test]
fn test_hydro_storage_min_greater_than_max() {
let mut hydro = make_hydro(5, None);
hydro.min_storage_hm3 = 200.0;
hydro.max_storage_hm3 = 100.0;
let data = make_data(
vec![hydro],
vec![],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert_eq!(relevant.len(), 1, "exactly 1 InvalidValue error expected");
let msg = &relevant[0].message;
assert!(
msg.contains("Hydro 5"),
"message should contain 'Hydro 5', got: {msg}"
);
assert!(
msg.contains("storage"),
"message should contain 'storage', got: {msg}"
);
}
#[test]
fn test_hydro_storage_equal_bounds_valid() {
let mut hydro = make_hydro(1, None);
hydro.min_storage_hm3 = 500.0;
hydro.max_storage_hm3 = 500.0;
let data = make_data(
vec![hydro],
vec![],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"equal storage bounds should be valid, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_hydro_turbine_min_greater_than_max() {
let mut hydro = make_hydro(2, None);
hydro.min_turbined_m3s = 500.0;
hydro.max_turbined_m3s = 100.0;
let data = make_data(
vec![hydro],
vec![],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let turbine_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert!(!turbine_errors.is_empty());
}
#[test]
fn test_hydro_outflow_no_max_no_error() {
let mut hydro = make_hydro(3, None);
hydro.min_outflow_m3s = 999.0;
hydro.max_outflow_m3s = None;
let data = make_data(
vec![hydro],
vec![],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(!ctx.has_errors());
}
#[test]
fn test_hydro_outflow_min_greater_than_max() {
let mut hydro = make_hydro(4, None);
hydro.min_outflow_m3s = 500.0;
hydro.max_outflow_m3s = Some(300.0);
let data = make_data(
vec![hydro],
vec![],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
}
#[test]
fn test_hydro_lifecycle_entry_gte_exit() {
let mut hydro = make_hydro(7, None);
hydro.entry_stage_id = Some(10);
hydro.exit_stage_id = Some(5);
let data = make_data(
vec![hydro],
vec![],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
assert!(
errors.iter().any(|e| e.kind == ErrorKind::InvalidValue),
"should have InvalidValue error for lifecycle"
);
}
#[test]
fn test_hydro_lifecycle_only_entry_no_error() {
let mut hydro = make_hydro(8, None);
hydro.entry_stage_id = Some(5);
hydro.exit_stage_id = None;
let data = make_data(
vec![hydro],
vec![],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"only entry_stage_id set should produce no error, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_hydro_lifecycle_valid() {
let mut hydro = make_hydro(9, None);
hydro.entry_stage_id = Some(0);
hydro.exit_stage_id = Some(10);
let data = make_data(
vec![hydro],
vec![],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(!ctx.has_errors());
}
#[test]
fn test_geometry_empty_no_error() {
let data = make_data(
vec![make_hydro(1, None)],
vec![],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(!ctx.has_errors());
}
#[test]
fn test_geometry_valid_monotonic() {
let geometry = vec![
make_geom_row(1, 10.0, 100.0, 1.0),
make_geom_row(1, 20.0, 110.0, 1.5),
make_geom_row(1, 30.0, 120.0, 2.0),
];
let data = make_data(
vec![make_hydro(1, None)],
vec![],
vec![],
make_stages(vec![0]),
geometry,
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"valid monotonic geometry should produce no errors, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_geometry_non_monotonic_volume() {
let geometry = vec![
make_geom_row(3, 10.0, 100.0, 1.0),
make_geom_row(3, 20.0, 110.0, 1.5),
make_geom_row(3, 20.0, 115.0, 1.6), ];
let data = make_data(
vec![make_hydro(3, None)],
vec![],
vec![],
make_stages(vec![0]),
geometry,
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(!relevant.is_empty(), "should have BusinessRuleViolation");
let msg = &relevant[0].message;
assert!(
msg.contains("Hydro 3"),
"message should contain 'Hydro 3', got: {msg}"
);
assert!(
msg.contains("volume"),
"message should contain 'volume', got: {msg}"
);
}
#[test]
fn test_geometry_non_monotonic_height() {
let geometry = vec![
make_geom_row(2, 10.0, 100.0, 1.0),
make_geom_row(2, 20.0, 90.0, 1.5), make_geom_row(2, 30.0, 110.0, 2.0),
];
let data = make_data(
vec![make_hydro(2, None)],
vec![],
vec![],
make_stages(vec![0]),
geometry,
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(!relevant.is_empty());
let msg = &relevant[0].message;
assert!(
msg.contains("height"),
"message should mention 'height', got: {msg}"
);
}
#[test]
fn test_fpha_one_plane_valid() {
let rows = vec![make_fpha_row(1, Some(0), 0)];
let data = make_data(
vec![make_hydro(1, None)],
vec![],
vec![],
make_stages(vec![0]),
vec![],
rows,
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"1 plane should be valid (minimum is 1), got: {:?}",
ctx.errors()
);
}
#[test]
fn test_fpha_two_planes_valid() {
let rows = vec![make_fpha_row(1, Some(0), 0), make_fpha_row(1, Some(0), 1)];
let data = make_data(
vec![make_hydro(1, None)],
vec![],
vec![],
make_stages(vec![0]),
vec![],
rows,
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"2 planes should be valid (minimum is 1), got: {:?}",
ctx.errors()
);
}
#[test]
fn test_fpha_minimum_planes_valid() {
let rows = vec![
make_fpha_row(1, Some(0), 0),
make_fpha_row(1, Some(0), 1),
make_fpha_row(1, Some(0), 2),
];
let data = make_data(
vec![make_hydro(1, None)],
vec![],
vec![],
make_stages(vec![0]),
vec![],
rows,
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"3 planes should be valid, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_fpha_negative_gamma_v() {
let mut row = make_fpha_row(1, None, 0);
row.gamma_v = -0.5; let rows = vec![row, make_fpha_row(1, None, 1), make_fpha_row(1, None, 2)];
let data = make_data(
vec![make_hydro(1, None)],
vec![],
vec![],
make_stages(vec![0]),
vec![],
rows,
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
assert!(
errors
.iter()
.any(|e| e.kind == ErrorKind::BusinessRuleViolation),
"negative gamma_v should produce BusinessRuleViolation"
);
}
#[test]
fn test_fpha_positive_gamma_s() {
let mut row = make_fpha_row(1, None, 0);
row.gamma_s = 0.1; let rows = vec![row, make_fpha_row(1, None, 1), make_fpha_row(1, None, 2)];
let data = make_data(
vec![make_hydro(1, None)],
vec![],
vec![],
make_stages(vec![0]),
vec![],
rows,
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
assert!(
errors
.iter()
.any(|e| e.kind == ErrorKind::BusinessRuleViolation),
"positive gamma_s should produce BusinessRuleViolation"
);
}
#[test]
fn test_fpha_gamma_s_zero_valid() {
let rows: Vec<FphaHyperplaneRow> = (0..3)
.map(|i| {
let mut r = make_fpha_row(1, None, i);
r.gamma_s = 0.0;
r
})
.collect();
let data = make_data(
vec![make_hydro(1, None)],
vec![],
vec![],
make_stages(vec![0]),
vec![],
rows,
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"gamma_s == 0 should be valid, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_fpha_gamma_v_zero_valid() {
let mut row = make_fpha_row(1, None, 0);
row.gamma_v = 0.0; let rows = vec![row];
let data = make_data(
vec![make_hydro(1, None)],
vec![],
vec![],
make_stages(vec![0]),
vec![],
rows,
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"gamma_v == 0 should be valid for constant-head plants, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_fpha_empty_no_error() {
let data = make_data(
vec![make_hydro(1, None)],
vec![],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(!ctx.has_errors());
}
#[test]
fn test_thermal_generation_min_greater_than_max() {
let thermal = make_thermal(10, 500.0, 100.0); let data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert_eq!(relevant.len(), 1, "exactly 1 InvalidValue error expected");
let msg = &relevant[0].message;
assert!(
msg.contains("Thermal 10"),
"message should contain 'Thermal 10', got: {msg}"
);
}
#[test]
fn test_thermal_generation_equal_bounds_valid() {
let thermal = make_thermal(11, 200.0, 200.0);
let data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(!ctx.has_errors());
}
#[test]
fn test_all_rules_checked_no_short_circuit() {
let mut h1 = make_hydro(1, None);
h1.min_storage_hm3 = 200.0;
h1.max_storage_hm3 = 100.0;
let mut h2 = make_hydro(2, None);
h2.min_generation_mw = 500.0;
h2.max_generation_mw = 100.0;
let data = make_data(
vec![h1, h2],
vec![],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
ctx.errors().len() >= 2,
"both violations should be collected; got {} errors",
ctx.errors().len()
);
}
#[test]
fn test_ac1_valid_data_no_errors() {
let geometry = vec![
make_geom_row(1, 10.0, 100.0, 1.0),
make_geom_row(1, 20.0, 110.0, 2.0),
make_geom_row(1, 30.0, 120.0, 3.0),
];
let fpha: Vec<FphaHyperplaneRow> = (0..3).map(|i| make_fpha_row(1, Some(0), i)).collect();
let data = make_data(
vec![make_hydro(1, None)],
vec![make_thermal(1, 0.0, 500.0)],
vec![],
make_stages(vec![0]),
geometry,
fpha,
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"valid data should produce no errors, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_ac2_hydro_storage_bounds_error() {
let mut hydro = make_hydro(5, None);
hydro.min_storage_hm3 = 200.0;
hydro.max_storage_hm3 = 100.0;
let data = make_data(
vec![hydro],
vec![],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert_eq!(relevant.len(), 1);
let msg = &relevant[0].message;
assert!(msg.contains("Hydro 5"), "message must contain 'Hydro 5'");
assert!(msg.contains("storage"), "message must contain 'storage'");
}
#[test]
fn test_ac3_cycle_detected() {
let hydros = vec![
make_hydro(1, Some(2)),
make_hydro(2, Some(3)),
make_hydro(3, Some(1)),
];
let data = make_data(hydros, vec![], vec![], make_stages(vec![0]), vec![], vec![]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::CycleDetected),
"should have CycleDetected error"
);
}
#[test]
fn test_ac4_geometry_non_monotonic_volume_error() {
let geometry = vec![
make_geom_row(3, 10.0, 100.0, 1.0),
make_geom_row(3, 20.0, 110.0, 1.5),
make_geom_row(3, 20.0, 115.0, 1.6),
];
let data = make_data(
vec![make_hydro(3, None)],
vec![],
vec![],
make_stages(vec![0]),
geometry,
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(!relevant.is_empty(), "should have BusinessRuleViolation");
let msg = &relevant[0].message;
assert!(msg.contains("Hydro 3"), "must contain 'Hydro 3': {msg}");
assert!(msg.contains("volume"), "must contain 'volume': {msg}");
}
#[test]
fn test_ac5_empty_geometry_and_fpha_no_false_positives() {
let data = make_data(
vec![make_hydro(1, None)],
vec![],
vec![],
make_stages(vec![0]),
vec![], vec![], );
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"empty geometry and FPHA should produce no errors, got: {:?}",
ctx.errors()
);
}
use crate::scenarios::{
BlockFactor, InflowArCoefficientRow, InflowSeasonalStatsRow, LoadFactorEntry,
LoadSeasonalStatsRow,
};
use cobre_core::{
entities::DeficitSegment,
scenario::{CorrelationEntity, CorrelationGroup, CorrelationModel, CorrelationProfile},
temporal::{Block, Transition},
};
use std::collections::BTreeMap;
fn make_data_5b(
hydros: Vec<Hydro>,
stages: StagesData,
buses: Vec<Bus>,
inflow_stats: Vec<InflowSeasonalStatsRow>,
inflow_ar: Vec<InflowArCoefficientRow>,
correlation: Option<CorrelationModel>,
) -> ParsedData {
ParsedData {
config: minimal_config(),
penalties: minimal_global_penalties(),
stages,
initial_conditions: InitialConditions {
storage: vec![],
filling_storage: vec![],
past_inflows: vec![],
recent_observations: vec![],
},
buses,
thermals: vec![],
hydros,
lines: vec![],
non_controllable_sources: vec![],
pumping_stations: vec![],
energy_contracts: vec![],
hydro_geometry: vec![],
production_models: vec![],
fpha_hyperplanes: vec![],
inflow_history: vec![],
inflow_seasonal_stats: inflow_stats,
inflow_ar_coefficients: inflow_ar,
external_scenarios: vec![],
external_load_scenarios: vec![],
external_ncs_scenarios: vec![],
load_seasonal_stats: vec![],
load_factors: vec![],
correlation,
non_controllable_factors: vec![],
ncs_models: vec![],
thermal_bounds: vec![],
hydro_bounds: vec![],
line_bounds: vec![],
pumping_bounds: vec![],
contract_bounds: vec![],
exchange_factors: vec![],
generic_constraints: vec![],
generic_constraint_bounds: vec![],
penalty_overrides_bus: vec![],
penalty_overrides_line: vec![],
penalty_overrides_hydro: vec![],
penalty_overrides_ncs: vec![],
ncs_bounds: vec![],
}
}
fn make_hydro_ordered_penalties(id: i32) -> Hydro {
let mut h = make_hydro(id, None);
h.penalties = HydroPenalties {
filling_target_violation_cost: 1000.0,
storage_violation_below_cost: 500.0,
turbined_violation_below_cost: 50.0,
outflow_violation_below_cost: 50.0,
outflow_violation_above_cost: 50.0,
generation_violation_below_cost: 50.0,
evaporation_violation_cost: 50.0,
water_withdrawal_violation_cost: 50.0,
water_withdrawal_violation_pos_cost: 50.0,
water_withdrawal_violation_neg_cost: 50.0,
evaporation_violation_pos_cost: 50.0,
evaporation_violation_neg_cost: 50.0,
spillage_cost: 1.0,
diversion_cost: 1.0,
fpha_turbined_cost: 2.0,
inflow_nonnegativity_cost: 1000.0,
};
h
}
fn make_stages_5b(ids: Vec<i32>) -> StagesData {
StagesData {
stages: ids.into_iter().map(make_stage).collect(),
policy_graph: PolicyGraph {
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.06,
transitions: vec![],
season_map: None,
},
}
}
fn make_bus_with_deficit(id: i32, cost_per_mwh: f64) -> Bus {
Bus {
id: EntityId::from(id),
name: format!("Bus {id}"),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh,
}],
excess_cost: 100.0,
}
}
fn make_corr_group(name: &str, matrix: Vec<Vec<f64>>) -> CorrelationGroup {
CorrelationGroup {
name: name.to_string(),
entities: vec![
CorrelationEntity {
entity_type: "inflow".to_string(),
id: EntityId::from(1),
},
CorrelationEntity {
entity_type: "inflow".to_string(),
id: EntityId::from(2),
},
],
matrix,
}
}
fn make_correlation(group: CorrelationGroup) -> CorrelationModel {
let mut profiles = BTreeMap::new();
profiles.insert(
"default".to_string(),
CorrelationProfile {
groups: vec![group],
},
);
CorrelationModel {
method: "spectral".to_string(),
profiles,
schedule: vec![],
}
}
#[test]
fn test_5b_all_valid_no_errors() {
let hydro = make_hydro_ordered_penalties(1);
let bus = make_bus_with_deficit(1, 75.0);
let group = make_corr_group("All", vec![vec![1.0, 0.8], vec![0.8, 1.0]]);
let corr = make_correlation(group);
let data = make_data_5b(
vec![hydro],
make_stages_5b(vec![0, 1]),
vec![bus],
vec![],
vec![],
Some(corr),
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"valid data should produce no errors, got: {:?}",
ctx.errors()
);
assert!(
ctx.warnings().is_empty(),
"valid data should produce no warnings, got: {:?}",
ctx.warnings()
);
}
#[test]
fn test_5b_transition_invalid_source_id() {
let mut stages = make_stages_5b(vec![0, 1]);
stages.policy_graph.transitions = vec![Transition {
source_id: 99, target_id: 1,
probability: 1.0,
annual_discount_rate_override: None,
}];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
assert!(
errors.iter().any(|e| e.kind == ErrorKind::InvalidValue),
"should have InvalidValue for invalid source_id"
);
}
#[test]
fn test_5b_transition_invalid_target_id() {
let mut stages = make_stages_5b(vec![0, 1]);
stages.policy_graph.transitions = vec![Transition {
source_id: 0,
target_id: 99, probability: 1.0,
annual_discount_rate_override: None,
}];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
assert!(
ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::InvalidValue),
"should have InvalidValue for invalid target_id"
);
}
#[test]
fn test_5b_transition_probability_sum_wrong() {
let mut stages = make_stages_5b(vec![0, 1]);
stages.policy_graph.transitions = vec![Transition {
source_id: 0,
target_id: 1,
probability: 0.5, annual_discount_rate_override: None,
}];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert_eq!(relevant.len(), 1, "exactly 1 InvalidValue error expected");
let msg = &relevant[0].message;
assert!(
msg.contains("probability"),
"message should contain 'probability', got: {msg}"
);
assert!(
msg.contains("stage 0"),
"message should contain 'stage 0', got: {msg}"
);
}
#[test]
fn test_5b_transition_probability_sum_valid() {
let mut stages = make_stages_5b(vec![0, 1, 2]);
stages.policy_graph.transitions = vec![
Transition {
source_id: 0,
target_id: 1,
probability: 0.6,
annual_discount_rate_override: None,
},
Transition {
source_id: 0,
target_id: 2,
probability: 0.4,
annual_discount_rate_override: None,
},
];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let prob_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert!(
prob_errors.is_empty(),
"valid probability sum should produce no InvalidValue errors, got: {prob_errors:?}"
);
}
#[test]
fn test_5b_cyclic_zero_discount_rate() {
let mut stages = make_stages_5b(vec![0]);
stages.policy_graph.graph_type = PolicyGraphType::Cyclic;
stages.policy_graph.annual_discount_rate = 0.0;
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
assert!(
ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::InvalidValue),
"cyclic with 0 discount rate should produce InvalidValue"
);
}
#[test]
fn test_5b_cyclic_positive_discount_rate_valid() {
let mut stages = make_stages_5b(vec![0]);
stages.policy_graph.graph_type = PolicyGraphType::Cyclic;
stages.policy_graph.annual_discount_rate = 0.06;
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let discount_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert!(
discount_errors.is_empty(),
"cyclic with positive discount rate should produce no error, got: {discount_errors:?}"
);
}
#[test]
fn test_5b_block_zero_duration() {
let mut stages = make_stages_5b(vec![0]);
stages.stages[0].blocks = vec![Block {
index: 0,
name: "Peak".to_string(),
duration_hours: 0.0, }];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
assert!(
ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::InvalidValue),
"zero duration block should produce InvalidValue"
);
}
#[test]
fn test_5b_block_positive_duration_valid() {
let mut stages = make_stages_5b(vec![0]);
stages.stages[0].blocks = vec![Block {
index: 0,
name: "Peak".to_string(),
duration_hours: 168.0,
}];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert!(
errors.is_empty(),
"positive block duration should produce no error, got: {errors:?}"
);
}
#[test]
fn test_5b_cvar_alpha_zero_invalid() {
let mut stages = make_stages_5b(vec![0]);
stages.stages[0].risk_config = StageRiskConfig::CVaR {
alpha: 0.0, lambda: 0.5,
};
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
assert!(
ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::InvalidValue),
"CVaR alpha=0.0 should produce InvalidValue"
);
}
#[test]
fn test_5b_cvar_lambda_out_of_range() {
let mut stages = make_stages_5b(vec![0]);
stages.stages[0].risk_config = StageRiskConfig::CVaR {
alpha: 0.95,
lambda: -0.1, };
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
assert!(
ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::InvalidValue),
"CVaR lambda=-0.1 should produce InvalidValue"
);
}
#[test]
fn test_5b_penalty_ordering_filling_less_than_storage_violation() {
let mut hydro = make_hydro_ordered_penalties(7);
hydro.penalties.filling_target_violation_cost = 100.0;
hydro.penalties.storage_violation_below_cost = 200.0;
let data = make_data_5b(
vec![hydro],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let warnings = ctx.warnings();
assert!(
!warnings.is_empty(),
"ordering violation should produce at least 1 warning"
);
let relevant: Vec<_> = warnings
.iter()
.filter(|w| w.kind == ErrorKind::ModelQuality)
.collect();
assert!(
!relevant.is_empty(),
"should have ModelQuality warning for penalty ordering"
);
let msg = &relevant[0].message;
assert!(
msg.contains("filling"),
"message should contain 'filling', got: {msg}"
);
assert!(
msg.contains("storage"),
"message should contain 'storage', got: {msg}"
);
}
#[test]
fn test_5b_fpha_penalty_violated() {
let mut hydro = make_hydro_ordered_penalties(3);
hydro.generation_model = HydroGenerationModel::Fpha;
hydro.penalties.fpha_turbined_cost = -0.01; let data = make_data_5b(
vec![hydro],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert_eq!(
relevant.len(),
1,
"exactly 1 BusinessRuleViolation expected"
);
let msg = &relevant[0].message;
assert!(
msg.contains("Hydro 3"),
"message should contain 'Hydro 3', got: {msg}"
);
assert!(
msg.contains("fpha_turbined_cost"),
"message should contain 'fpha_turbined_cost', got: {msg}"
);
}
#[test]
fn test_5b_fpha_penalty_zero_valid() {
let mut hydro = make_hydro_ordered_penalties(3);
hydro.generation_model = HydroGenerationModel::Fpha;
hydro.penalties.fpha_turbined_cost = 0.0; let data = make_data_5b(
vec![hydro],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(
errors.is_empty(),
"fpha_turbined_cost == 0.0 should be valid for constant-head plants, got: {errors:?}"
);
}
#[test]
fn test_5b_fpha_penalty_equal_spillage_valid() {
let mut hydro = make_hydro_ordered_penalties(3);
hydro.generation_model = HydroGenerationModel::Fpha;
hydro.penalties.fpha_turbined_cost = 1.0;
hydro.penalties.spillage_cost = 1.0; let data = make_data_5b(
vec![hydro],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(
errors.is_empty(),
"fpha_turbined_cost == spillage_cost should be valid, got: {errors:?}"
);
}
#[test]
fn test_5b_fpha_penalty_valid() {
let mut hydro = make_hydro_ordered_penalties(4);
hydro.generation_model = HydroGenerationModel::Fpha;
hydro.penalties.fpha_turbined_cost = 2.0;
hydro.penalties.spillage_cost = 1.0;
let data = make_data_5b(
vec![hydro],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(
errors.is_empty(),
"valid FPHA penalty ordering should produce no BusinessRuleViolation, got: {errors:?}"
);
}
#[test]
fn test_5b_inflow_std_zero_warning() {
let stats = vec![InflowSeasonalStatsRow {
hydro_id: EntityId::from(1),
stage_id: 0,
mean_m3s: 100.0,
std_m3s: 0.0, }];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
stats,
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"std_m3s=0.0 should produce warning, not error, got: {:?}",
ctx.errors()
);
let warnings = ctx.warnings();
assert!(
!warnings.is_empty(),
"std_m3s=0.0 should produce at least 1 ModelQuality warning"
);
assert!(
warnings.iter().any(|w| w.kind == ErrorKind::ModelQuality),
"should have ModelQuality warning"
);
}
#[test]
fn test_5b_residual_std_ratio_consistent_no_error() {
let ar_rows = vec![
InflowArCoefficientRow {
hydro_id: EntityId::from(1),
stage_id: 0,
lag: 1,
coefficient: 0.5,
residual_std_ratio: 0.85,
},
InflowArCoefficientRow {
hydro_id: EntityId::from(1),
stage_id: 0,
lag: 2,
coefficient: 0.3,
residual_std_ratio: 0.85, },
];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
ar_rows,
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let errors = ctx.errors();
let invalid_value_errors: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::InvalidValue && e.message.contains("residual_std_ratio")
})
.collect();
assert!(
invalid_value_errors.is_empty(),
"consistent residual_std_ratio should produce no InvalidValue errors, got: \
{invalid_value_errors:?}"
);
}
#[test]
fn test_5b_residual_std_ratio_inconsistent_error() {
let ar_rows = vec![
InflowArCoefficientRow {
hydro_id: EntityId::from(1),
stage_id: 0,
lag: 1,
coefficient: 0.5,
residual_std_ratio: 0.85,
},
InflowArCoefficientRow {
hydro_id: EntityId::from(1),
stage_id: 0,
lag: 2,
coefficient: 0.3,
residual_std_ratio: 0.90, },
];
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
ar_rows,
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let errors = ctx.errors();
let invalid_value_errors: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert!(
!invalid_value_errors.is_empty(),
"inconsistent residual_std_ratio should produce at least one InvalidValue error"
);
let ratio_error = invalid_value_errors.iter().find(|e| {
e.message.contains("residual_std_ratio") && e.message.contains("inconsistent")
});
assert!(
ratio_error.is_some(),
"InvalidValue error message should contain 'residual_std_ratio' and 'inconsistent', \
got: {invalid_value_errors:?}"
);
}
#[test]
fn test_5b_correlation_asymmetric() {
let group = make_corr_group(
"Asymmetric",
vec![
vec![1.0, 0.8],
vec![0.5, 1.0], ],
);
let corr = make_correlation(group);
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
Some(corr),
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(
!relevant.is_empty(),
"asymmetric matrix should produce BusinessRuleViolation"
);
let msg = &relevant[0].message;
assert!(
msg.contains("symmetric"),
"message should contain 'symmetric', got: {msg}"
);
}
#[test]
fn test_5b_correlation_diagonal_not_one() {
let group = make_corr_group(
"BadDiag",
vec![
vec![0.9, 0.0], vec![0.0, 1.0],
],
);
let corr = make_correlation(group);
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
Some(corr),
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
assert!(
ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::BusinessRuleViolation),
"diagonal != 1.0 should produce BusinessRuleViolation"
);
}
#[test]
fn test_5b_correlation_off_diagonal_out_of_range() {
let group = make_corr_group(
"BadRange",
vec![
vec![1.0, 1.5], vec![1.5, 1.0],
],
);
let corr = make_correlation(group);
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
Some(corr),
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
assert!(
ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::BusinessRuleViolation),
"off-diagonal > 1.0 should produce BusinessRuleViolation"
);
}
#[test]
fn test_5b_correlation_valid_symmetric() {
let group = make_corr_group("Valid", vec![vec![1.0, 0.6], vec![0.6, 1.0]]);
let corr = make_correlation(group);
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
Some(corr),
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"valid symmetric matrix should produce no errors, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_5b_no_correlation_no_inflow_no_false_positives() {
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None, );
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"empty correlation and inflow should produce no errors, got: {:?}",
ctx.errors()
);
}
fn make_stages_with_block(stage_id: i32) -> StagesData {
let mut stage = make_stage(stage_id);
stage.blocks = vec![Block {
index: 0,
name: "FLAT".to_string(),
duration_hours: 744.0,
}];
StagesData {
stages: vec![stage],
policy_graph: PolicyGraph {
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.06,
transitions: vec![],
season_map: None,
},
}
}
#[test]
fn test_5b_load_factors_invalid_block_id() {
let mut data = make_data_5b(
vec![],
make_stages_with_block(0),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
data.load_factors = vec![LoadFactorEntry {
bus_id: EntityId::from(1),
stage_id: 0,
block_factors: vec![BlockFactor {
block_id: 99,
factor: 1.0,
}],
}];
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(ctx.has_errors());
let errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert_eq!(
errors.len(),
1,
"expected 1 BusinessRuleViolation, got: {errors:?}"
);
assert!(
errors[0].file.to_string_lossy().contains("load_factors"),
"error should reference load_factors.json"
);
assert!(
errors[0].message.contains("99"),
"message should mention invalid block_id 99"
);
}
#[test]
fn test_5b_load_factors_deterministic_bus_warning() {
let mut data = make_data_5b(
vec![],
make_stages_with_block(0),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
data.load_seasonal_stats = vec![LoadSeasonalStatsRow {
bus_id: EntityId::from(1),
stage_id: 0,
mean_mw: 100.0,
std_mw: 0.0,
}];
data.load_factors = vec![LoadFactorEntry {
bus_id: EntityId::from(1),
stage_id: 0,
block_factors: vec![BlockFactor {
block_id: 0,
factor: 1.0,
}],
}];
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"deterministic load warning should not produce an error, got: {:?}",
ctx.errors()
);
let warnings = ctx.warnings();
let relevant: Vec<_> = warnings
.iter()
.filter(|w| w.kind == ErrorKind::ModelQuality)
.filter(|w| w.file.to_string_lossy().contains("load_factors"))
.collect();
assert_eq!(
relevant.len(),
1,
"expected 1 ModelQuality warning for load_factors.json, got: {warnings:?}"
);
}
#[test]
fn test_5b_load_factors_empty_no_errors() {
let data = make_data_5b(
vec![],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let load_factor_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.file.to_string_lossy().contains("load_factors"))
.collect();
let load_factor_warnings: Vec<_> = ctx
.warnings()
.into_iter()
.filter(|w| w.file.to_string_lossy().contains("load_factors"))
.collect();
assert!(
load_factor_errors.is_empty() && load_factor_warnings.is_empty(),
"empty load_factors should produce no load-related diagnostics; \
errors: {load_factor_errors:?}, warnings: {load_factor_warnings:?}"
);
}
use cobre_core::temporal::{SeasonCycleType, SeasonDefinition, SeasonMap};
use crate::scenarios::InflowHistoryRow;
fn make_monthly_season_map() -> SeasonMap {
let seasons = (0..12u32)
.map(|m| SeasonDefinition {
id: m as usize,
label: format!("Month{m}"),
month_start: m + 1,
day_start: None,
month_end: None,
day_end: None,
})
.collect();
SeasonMap {
cycle_type: SeasonCycleType::Monthly,
seasons,
}
}
fn make_history_rows(hydro_id: i32, n_obs: usize) -> Vec<InflowHistoryRow> {
let mut rows = Vec::with_capacity(n_obs);
for i in 0..n_obs {
let year = 2000 + (i / 12) as i32;
let month = (i % 12) as u32 + 1;
let date = chrono::NaiveDate::from_ymd_opt(year, month, 15).unwrap();
rows.push(InflowHistoryRow {
hydro_id: EntityId::from(hydro_id),
date,
value_m3s: 100.0,
});
}
rows
}
fn make_stages_with_seasons(n_months: usize, with_season_map: bool) -> StagesData {
let mut stages = Vec::with_capacity(n_months);
for i in 0..n_months {
let year = 2000 + (i / 12) as i32;
let month = (i % 12) as u32 + 1;
let start_date = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
let (end_year, end_month) = if month == 12 {
(year + 1, 1u32)
} else {
(year, month + 1)
};
let end_date = chrono::NaiveDate::from_ymd_opt(end_year, end_month, 1).unwrap();
let season_id = i % 12;
stages.push(Stage {
index: i,
id: i as i32,
start_date,
end_date,
season_id: Some(season_id),
blocks: vec![],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
});
}
let season_map = if with_season_map {
Some(make_monthly_season_map())
} else {
None
};
StagesData {
stages,
policy_graph: PolicyGraph {
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.06,
transitions: vec![],
season_map,
},
}
}
fn make_data_estimation(
hydros: Vec<Hydro>,
stages: StagesData,
inflow_history: Vec<InflowHistoryRow>,
) -> ParsedData {
ParsedData {
config: minimal_config(),
penalties: minimal_global_penalties(),
stages,
initial_conditions: cobre_core::initial_conditions::InitialConditions {
storage: vec![],
filling_storage: vec![],
past_inflows: vec![],
recent_observations: vec![],
},
buses: vec![Bus {
id: EntityId::from(1),
name: "BUS_1".to_string(),
deficit_segments: vec![],
excess_cost: 100.0,
}],
thermals: vec![],
hydros,
lines: vec![],
non_controllable_sources: vec![],
pumping_stations: vec![],
energy_contracts: vec![],
hydro_geometry: vec![],
production_models: vec![],
fpha_hyperplanes: vec![],
inflow_history,
inflow_seasonal_stats: vec![], inflow_ar_coefficients: vec![],
external_scenarios: vec![],
external_load_scenarios: vec![],
external_ncs_scenarios: vec![],
load_seasonal_stats: vec![],
load_factors: vec![],
correlation: None,
non_controllable_factors: vec![],
ncs_models: vec![],
thermal_bounds: vec![],
hydro_bounds: vec![],
line_bounds: vec![],
pumping_bounds: vec![],
contract_bounds: vec![],
exchange_factors: vec![],
generic_constraints: vec![],
generic_constraint_bounds: vec![],
penalty_overrides_bus: vec![],
penalty_overrides_line: vec![],
penalty_overrides_hydro: vec![],
penalty_overrides_ncs: vec![],
ncs_bounds: vec![],
}
}
#[test]
fn test_estimation_requires_season_definitions() {
let history = make_history_rows(1, 12);
let stages = make_stages_with_seasons(12, false);
let data = make_data_estimation(vec![make_hydro(1, None)], stages, history);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let matching: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("season_definitions is required")
})
.collect();
assert!(
!matching.is_empty(),
"expected a BusinessRuleViolation about season_definitions, got errors: {:?}",
ctx.errors()
);
}
#[test]
fn test_estimation_warns_low_observations() {
let history: Vec<InflowHistoryRow> = (0..3)
.map(|y| InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(2000 + y, 1, 15).unwrap(),
value_m3s: 100.0,
})
.collect();
let stages = make_stages_with_seasons(36, true);
let data = make_data_estimation(vec![make_hydro(1, None)], stages, history);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let matching: Vec<_> = ctx
.warnings()
.into_iter()
.filter(|w| {
w.kind == ErrorKind::ModelQuality && w.message.contains("has 3 observations")
})
.collect();
assert!(
!matching.is_empty(),
"expected a ModelQuality warning about 3 observations, got warnings: {:?}",
ctx.warnings()
);
}
#[test]
fn test_estimation_error_missing_hydro() {
let history = make_history_rows(1, 36); let stages = make_stages_with_seasons(36, true);
let hydros = vec![make_hydro(1, None), make_hydro(2, None)];
let data = make_data_estimation(hydros, stages, history);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let matching: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("hydro 2 has no observations")
})
.collect();
assert!(
!matching.is_empty(),
"expected a BusinessRuleViolation for hydro 2, got errors: {:?}",
ctx.errors()
);
}
#[test]
fn test_no_estimation_when_stats_and_coefficients_present() {
use crate::scenarios::InflowSeasonalStatsRow;
let history = make_history_rows(1, 12);
let stages = make_stages_with_seasons(12, false);
let stats = vec![InflowSeasonalStatsRow {
hydro_id: EntityId::from(1),
stage_id: 0,
mean_m3s: 500.0,
std_m3s: 50.0,
}];
let ar_coefficients = vec![make_ar_row(1, 0, 1)];
let mut data = make_data_estimation(vec![make_hydro(1, None)], stages, history);
data.inflow_seasonal_stats = stats;
data.inflow_ar_coefficients = ar_coefficients;
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let estimation_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.file.to_string_lossy().contains("inflow_history.parquet"))
.collect();
let estimation_warnings: Vec<_> = ctx
.warnings()
.into_iter()
.filter(|w| w.file.to_string_lossy().contains("inflow_history.parquet"))
.collect();
assert!(
estimation_errors.is_empty() && estimation_warnings.is_empty(),
"stats+coefficients present should disable estimation checks; \
errors: {estimation_errors:?}, warnings: {estimation_warnings:?}"
);
}
#[test]
fn test_estimation_active_when_stats_present_but_coefficients_absent() {
use crate::scenarios::InflowSeasonalStatsRow;
let history = make_history_rows(1, 12);
let stages = make_stages_with_seasons(12, false);
let stats = vec![InflowSeasonalStatsRow {
hydro_id: EntityId::from(1),
stage_id: 0,
mean_m3s: 500.0,
std_m3s: 50.0,
}];
let mut data = make_data_estimation(vec![make_hydro(1, None)], stages, history);
data.inflow_seasonal_stats = stats;
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let estimation_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.file.to_string_lossy().contains("inflow_history.parquet")
&& e.message.contains("season_definitions")
})
.collect();
assert!(
!estimation_errors.is_empty(),
"stats present without coefficients should trigger estimation checks; \
got errors: {:?}",
ctx.errors()
);
}
fn make_ar_row(hydro_id: i32, stage_id: i32, lag: i32) -> InflowArCoefficientRow {
InflowArCoefficientRow {
hydro_id: EntityId::from(hydro_id),
stage_id,
lag,
coefficient: 0.5,
residual_std_ratio: 0.9,
}
}
fn make_data_past_inflows(
hydros: Vec<Hydro>,
inflow_lags_enabled: bool,
past_inflows: Vec<cobre_core::HydroPastInflows>,
inflow_ar_coefficients: Vec<InflowArCoefficientRow>,
) -> ParsedData {
use cobre_core::EntityId as EId;
let stage_0_start = chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
let stage_0 = Stage {
id: 0,
index: 0,
start_date: stage_0_start,
end_date: stage_0_start
.checked_add_months(chrono::Months::new(1))
.unwrap_or(stage_0_start),
season_id: None,
blocks: vec![],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: inflow_lags_enabled,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
};
ParsedData {
config: minimal_config(),
penalties: minimal_global_penalties(),
stages: StagesData {
stages: vec![stage_0],
policy_graph: PolicyGraph {
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.06,
transitions: vec![],
season_map: None,
},
},
initial_conditions: cobre_core::InitialConditions {
storage: vec![],
filling_storage: vec![],
past_inflows,
recent_observations: vec![],
},
buses: vec![Bus {
id: EId::from(1),
name: "BUS_1".to_string(),
deficit_segments: vec![],
excess_cost: 100.0,
}],
thermals: vec![],
hydros,
lines: vec![],
non_controllable_sources: vec![],
pumping_stations: vec![],
energy_contracts: vec![],
hydro_geometry: vec![],
production_models: vec![],
fpha_hyperplanes: vec![],
inflow_history: vec![],
inflow_seasonal_stats: vec![crate::scenarios::InflowSeasonalStatsRow {
hydro_id: EId::from(1),
stage_id: 0,
mean_m3s: 500.0,
std_m3s: 50.0,
}],
inflow_ar_coefficients,
external_scenarios: vec![],
external_load_scenarios: vec![],
external_ncs_scenarios: vec![],
load_seasonal_stats: vec![],
load_factors: vec![],
correlation: None,
non_controllable_factors: vec![],
ncs_models: vec![],
thermal_bounds: vec![],
hydro_bounds: vec![],
line_bounds: vec![],
pumping_bounds: vec![],
contract_bounds: vec![],
exchange_factors: vec![],
generic_constraints: vec![],
generic_constraint_bounds: vec![],
penalty_overrides_bus: vec![],
penalty_overrides_line: vec![],
penalty_overrides_hydro: vec![],
penalty_overrides_ncs: vec![],
ncs_bounds: vec![],
}
}
#[test]
fn test_rule22_lags_enabled_no_past_inflows_errors() {
let ar_rows = vec![
make_ar_row(1, 0, 1),
make_ar_row(1, 0, 2),
make_ar_row(1, 0, 3),
];
let data = make_data_past_inflows(
vec![make_hydro(1, None)],
true,
vec![], ar_rows,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let matching: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("inflow_lags is enabled")
})
.collect();
assert_eq!(
matching.len(),
1,
"expected exactly one rule-22 BusinessRuleViolation, got: {:?}",
ctx.errors()
);
assert!(
matching[0]
.file
.to_string_lossy()
.contains("initial_conditions.json"),
"error file should reference initial_conditions.json"
);
}
#[test]
fn test_rule23_sufficient_past_inflows_no_error() {
let ar_rows = vec![
make_ar_row(1, 0, 1),
make_ar_row(1, 0, 2),
make_ar_row(1, 0, 3),
];
let past = vec![cobre_core::HydroPastInflows {
hydro_id: EntityId::from(1),
values_m3s: vec![300.0, 200.0, 100.0], season_ids: None,
}];
let data = make_data_past_inflows(vec![make_hydro(1, None)], true, past, ar_rows);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let lag_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file.to_string_lossy().contains("initial_conditions.json")
})
.collect();
assert!(
lag_errors.is_empty(),
"sufficient past_inflows should produce no errors, got: {lag_errors:?}"
);
}
#[test]
fn test_rule23_insufficient_past_inflows_errors() {
let ar_rows = vec![
make_ar_row(1, 0, 1),
make_ar_row(1, 0, 2),
make_ar_row(1, 0, 3),
];
let past = vec![cobre_core::HydroPastInflows {
hydro_id: EntityId::from(1),
values_m3s: vec![200.0, 100.0], season_ids: None,
}];
let data = make_data_past_inflows(vec![make_hydro(1, None)], true, past, ar_rows);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let coverage_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("Hydro 1")
&& e.message.contains("insufficient past_inflows")
})
.collect();
assert!(
!coverage_errors.is_empty(),
"insufficient past_inflows should produce a BusinessRuleViolation for Hydro 1; got errors: {:?}",
ctx.errors()
);
}
#[test]
fn test_rules_skip_when_lags_disabled() {
let ar_rows = vec![make_ar_row(1, 0, 1), make_ar_row(1, 0, 2)];
let data = make_data_past_inflows(
vec![make_hydro(1, None)],
false, vec![], ar_rows,
);
let mut ctx = ValidationContext::new();
check_past_inflows_coverage(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"lags disabled should produce no rule-22/23/24 errors; got: {:?}",
ctx.errors()
);
}
#[test]
fn test_rules_skip_when_par_order_zero() {
let data = make_data_past_inflows(
vec![make_hydro(1, None)],
true, vec![], vec![], );
let mut ctx = ValidationContext::new();
check_past_inflows_coverage(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"no AR coefficients should produce no rule-22/23/24 errors; got: {:?}",
ctx.errors()
);
}
#[test]
fn test_rule24_unknown_hydro_in_past_inflows_errors() {
let past = vec![
cobre_core::HydroPastInflows {
hydro_id: EntityId::from(1),
values_m3s: vec![100.0],
season_ids: None,
},
cobre_core::HydroPastInflows {
hydro_id: EntityId::from(99), values_m3s: vec![50.0],
season_ids: None,
},
];
let ar_rows = vec![make_ar_row(1, 0, 1)];
let data = make_data_past_inflows(
vec![make_hydro(1, None)], true,
past,
ar_rows,
);
let mut ctx = ValidationContext::new();
check_past_inflows_coverage(&data, &mut ctx);
let rule24_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation && e.message.contains("Hydro 99")
})
.collect();
assert!(
!rule24_errors.is_empty(),
"unknown hydro 99 in past_inflows should produce a BusinessRuleViolation; \
got errors: {:?}",
ctx.errors()
);
}
fn make_data_past_inflows_with_season_map(
hydros: Vec<Hydro>,
past_inflows: Vec<cobre_core::HydroPastInflows>,
inflow_ar_coefficients: Vec<InflowArCoefficientRow>,
num_seasons: usize,
) -> ParsedData {
use cobre_core::temporal::{SeasonCycleType, SeasonDefinition, SeasonMap};
let seasons = (0..num_seasons)
.map(|i| SeasonDefinition {
id: i,
label: format!("Season{i}"),
month_start: (i % 12 + 1) as u32,
day_start: None,
month_end: None,
day_end: None,
})
.collect();
let season_map = SeasonMap {
cycle_type: SeasonCycleType::Monthly,
seasons,
};
let mut data = make_data_past_inflows(hydros, true, past_inflows, inflow_ar_coefficients);
data.stages.policy_graph.season_map = Some(season_map);
data
}
#[test]
fn test_past_inflows_season_ids_invalid_season() {
let past = vec![cobre_core::HydroPastInflows {
hydro_id: EntityId::from(1),
values_m3s: vec![300.0, 200.0],
season_ids: Some(vec![0, 99]), }];
let ar_rows = vec![make_ar_row(1, 0, 1), make_ar_row(1, 0, 2)];
let data = make_data_past_inflows_with_season_map(
vec![make_hydro(1, None)],
past,
ar_rows,
5, );
let mut ctx = ValidationContext::new();
check_past_inflows_season_ids(&data, &mut ctx);
let rule32_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("season_id")
&& e.message.contains("99")
})
.collect();
assert!(
!rule32_errors.is_empty(),
"invalid season_id 99 should produce a BusinessRuleViolation; \
got errors: {:?}",
ctx.errors()
);
}
#[test]
fn test_past_inflows_season_ids_valid() {
let past = vec![cobre_core::HydroPastInflows {
hydro_id: EntityId::from(1),
values_m3s: vec![300.0, 200.0],
season_ids: Some(vec![3, 2]), }];
let ar_rows = vec![make_ar_row(1, 0, 1), make_ar_row(1, 0, 2)];
let data = make_data_past_inflows_with_season_map(
vec![make_hydro(1, None)],
past,
ar_rows,
5, );
let mut ctx = ValidationContext::new();
check_past_inflows_season_ids(&data, &mut ctx);
let rule32_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file.to_string_lossy().contains("initial_conditions.json")
&& e.message.contains("season_id")
})
.collect();
assert!(
rule32_errors.is_empty(),
"valid season_ids should produce no rule-32 errors; got: {:?}",
ctx.errors()
);
}
#[test]
fn test_past_inflows_season_ids_no_season_map_skipped() {
let past = vec![cobre_core::HydroPastInflows {
hydro_id: EntityId::from(1),
values_m3s: vec![300.0],
season_ids: Some(vec![999]), }];
let ar_rows = vec![make_ar_row(1, 0, 1)];
let data = make_data_past_inflows(vec![make_hydro(1, None)], true, past, ar_rows);
let mut ctx = ValidationContext::new();
check_past_inflows_season_ids(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"no season_map means rule 32 should be skipped; got: {:?}",
ctx.errors()
);
}
#[test]
fn test_sobol_non_power_of_2_emits_warning() {
let mut stages = make_stages_5b(vec![0]);
stages.stages[0].scenario_config = ScenarioSourceConfig {
branching_factor: 50,
noise_method: NoiseMethod::QmcSobol,
};
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let all_warnings = ctx.warnings();
let quality_warnings: Vec<_> = all_warnings
.iter()
.filter(|w| w.kind == ErrorKind::ModelQuality && w.message.contains("qmc_sobol"))
.collect();
assert_eq!(
quality_warnings.len(),
1,
"expected exactly 1 ModelQuality warning, got: {:?}",
ctx.warnings()
);
let msg = &quality_warnings[0].message;
assert!(
msg.contains("50"),
"warning message should contain the branching factor '50', got: {msg}"
);
assert!(
msg.contains("Stage "),
"warning message should contain 'Stage ', got: {msg}"
);
}
#[test]
fn test_sobol_power_of_2_no_warning() {
let mut stages = make_stages_5b(vec![0]);
stages.stages[0].scenario_config = ScenarioSourceConfig {
branching_factor: 64,
noise_method: NoiseMethod::QmcSobol,
};
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let all_warnings = ctx.warnings();
let quality_warnings: Vec<_> = all_warnings
.iter()
.filter(|w| w.kind == ErrorKind::ModelQuality && w.message.contains("qmc_sobol"))
.collect();
assert!(
quality_warnings.is_empty(),
"branching_factor=64 (power of 2) should produce no ModelQuality warnings, \
got: {quality_warnings:?}"
);
}
#[test]
fn test_saa_non_power_of_2_no_warning() {
let mut stages = make_stages_5b(vec![0]);
stages.stages[0].scenario_config = ScenarioSourceConfig {
branching_factor: 50,
noise_method: NoiseMethod::Saa,
};
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let all_warnings = ctx.warnings();
let quality_warnings: Vec<_> = all_warnings
.iter()
.filter(|w| w.kind == ErrorKind::ModelQuality && w.message.contains("qmc_sobol"))
.collect();
assert!(
quality_warnings.is_empty(),
"SAA with non-power-of-2 branching factor should produce no ModelQuality warnings, \
got: {quality_warnings:?}"
);
}
#[test]
fn test_sobol_mixed_stages_only_warns_non_power() {
let mut stages = make_stages_5b(vec![0, 1]);
stages.stages[0].scenario_config = ScenarioSourceConfig {
branching_factor: 100,
noise_method: NoiseMethod::QmcSobol,
};
stages.stages[1].scenario_config = ScenarioSourceConfig {
branching_factor: 128,
noise_method: NoiseMethod::QmcSobol,
};
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let all_warnings = ctx.warnings();
let quality_warnings: Vec<_> = all_warnings
.iter()
.filter(|w| w.kind == ErrorKind::ModelQuality && w.message.contains("qmc_sobol"))
.collect();
assert_eq!(
quality_warnings.len(),
1,
"expected exactly 1 ModelQuality warning (for stage 0 only), got: {:?}",
ctx.warnings()
);
let msg = &quality_warnings[0].message;
assert!(
msg.contains("Stage 0"),
"warning should be for stage 0, got: {msg}"
);
assert!(
msg.contains("100"),
"warning should mention branching_factor 100, got: {msg}"
);
}
fn config_with_training_external_inflow() -> Config {
let json = r#"{
"training": {
"forward_passes": 10,
"stopping_rules": [
{ "type": "iteration_limit", "limit": 100 }
],
"scenario_source": {
"seed": 42,
"inflow": { "scheme": "external" }
}
}
}"#;
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), json).unwrap();
crate::config::parse_config(tmp.path()).unwrap()
}
fn config_with_simulation_external_load() -> Config {
let json = r#"{
"training": {
"forward_passes": 10,
"stopping_rules": [
{ "type": "iteration_limit", "limit": 100 }
]
},
"simulation": {
"scenario_source": {
"seed": 7,
"load": { "scheme": "external" }
}
}
}"#;
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), json).unwrap();
crate::config::parse_config(tmp.path()).unwrap()
}
#[test]
fn test_training_external_inflow_without_file_is_error() {
let mut data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 75.0)],
vec![],
vec![],
None,
);
data.config = config_with_training_external_inflow();
data.external_scenarios = vec![];
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let errors = ctx.errors();
let matching: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file == std::path::Path::new("config.json")
&& e.entity
.as_deref()
.is_some_and(|f| f.contains("training.scenario_source.inflow"))
})
.collect();
assert_eq!(
matching.len(),
1,
"expected 1 error for missing external inflow file (training), got: {errors:?}"
);
}
#[test]
fn test_simulation_external_load_without_file_is_error() {
let mut data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 75.0)],
vec![],
vec![],
None,
);
data.config = config_with_simulation_external_load();
data.external_load_scenarios = vec![];
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let errors = ctx.errors();
let matching: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file == std::path::Path::new("config.json")
&& e.entity
.as_deref()
.is_some_and(|f| f.contains("simulation.scenario_source.load"))
})
.collect();
assert_eq!(
matching.len(),
1,
"expected 1 error for missing external load file (simulation), got: {errors:?}"
);
}
#[test]
fn test_training_external_inflow_with_file_is_ok() {
use cobre_core::scenario::ExternalScenarioRow;
let mut data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
make_stages_5b(vec![0]),
vec![make_bus_with_deficit(1, 75.0)],
vec![],
vec![],
None,
);
data.config = config_with_training_external_inflow();
data.external_scenarios = vec![ExternalScenarioRow {
hydro_id: EntityId::from(1),
stage_id: 0,
scenario_id: 1,
value_m3s: 10.0,
}];
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let errors = ctx.errors();
let external_errors: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file == std::path::Path::new("config.json")
})
.collect();
assert!(
external_errors.is_empty(),
"no external-file errors expected when file is present, got: {external_errors:?}"
);
}
fn make_stages_with_explicit_season_map(num_stages: usize, num_seasons: usize) -> StagesData {
let seasons = (0..num_seasons)
.map(|i| SeasonDefinition {
id: i,
label: format!("Season{i}"),
month_start: (i % 12 + 1) as u32,
day_start: None,
month_end: None,
day_end: None,
})
.collect();
let season_map = SeasonMap {
cycle_type: SeasonCycleType::Monthly,
seasons,
};
let stages = (0..num_stages)
.map(|i| Stage {
id: i as i32,
index: i,
start_date: chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: chrono::NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: Some(i % num_seasons),
blocks: vec![],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
StagesData {
stages,
policy_graph: PolicyGraph {
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.06,
transitions: vec![],
season_map: Some(season_map),
},
}
}
#[test]
fn test_season_id_range_coverage_valid_monthly() {
let stages = make_stages_with_explicit_season_map(12, 12);
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let rule27_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file == std::path::Path::new("stages.json")
&& e.message.contains("season_definitions")
})
.collect();
assert!(
rule27_errors.is_empty(),
"all valid season_ids should produce no rule-27 errors; got: {:?}",
ctx.errors()
);
}
#[test]
fn test_season_id_range_coverage_undefined_season() {
let mut stages = make_stages_with_explicit_season_map(12, 12);
stages.stages[5].season_id = Some(15);
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
validate_semantic_stages_penalties_scenarios(&data, &mut ctx);
let rule27_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("stage 5")
&& e.message.contains("season_id 15")
})
.collect();
assert_eq!(
rule27_errors.len(),
1,
"expected exactly one rule-27 error for stage 5 / season_id 15; got: {:?}",
ctx.errors()
);
assert!(
rule27_errors[0].message.contains("season_definitions"),
"error message should mention season_definitions; got: {}",
rule27_errors[0].message
);
}
#[test]
fn test_season_id_range_coverage_no_season_map() {
let mut stages = make_stages_5b(vec![0, 1, 2]);
stages.stages[0].season_id = Some(0);
stages.stages[1].season_id = Some(1);
stages.stages[2].season_id = Some(99);
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
check_season_id_consistency(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"no season_map means rule 27 should be skipped entirely; got: {:?}",
ctx.errors()
);
}
#[test]
fn test_season_id_range_coverage_multiple_violations() {
let mut stages = make_stages_with_explicit_season_map(12, 12);
stages.stages[3].season_id = Some(20);
stages.stages[7].season_id = Some(55);
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
check_season_id_consistency(&data, &mut ctx);
let rule27_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("season_definitions")
})
.collect();
assert_eq!(
rule27_errors.len(),
2,
"expected two rule-27 errors (one per offending stage); got: {:?}",
ctx.errors()
);
let has_stage3 = rule27_errors
.iter()
.any(|e| e.message.contains("stage 3") && e.message.contains("season_id 20"));
let has_stage7 = rule27_errors
.iter()
.any(|e| e.message.contains("stage 7") && e.message.contains("season_id 55"));
assert!(has_stage3, "expected an error for stage 3 / season_id 20");
assert!(has_stage7, "expected an error for stage 7 / season_id 55");
}
fn make_stages_for_resolution_check(
stage_specs: Vec<(i32, chrono::NaiveDate, chrono::NaiveDate, usize)>,
) -> StagesData {
let season_ids: std::collections::BTreeSet<usize> =
stage_specs.iter().map(|&(_, _, _, sid)| sid).collect();
let seasons: Vec<SeasonDefinition> = season_ids
.iter()
.enumerate()
.map(|(pos, &id)| SeasonDefinition {
id,
label: format!("Season{id}"),
month_start: (pos % 12 + 1) as u32,
day_start: None,
month_end: None,
day_end: None,
})
.collect();
let season_map = SeasonMap {
cycle_type: SeasonCycleType::Monthly,
seasons,
};
let stages = stage_specs
.into_iter()
.enumerate()
.map(|(index, (id, start_date, end_date, season_id))| Stage {
id,
index,
start_date,
end_date,
season_id: Some(season_id),
blocks: vec![],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
StagesData {
stages,
policy_graph: PolicyGraph {
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.06,
transitions: vec![],
season_map: Some(season_map),
},
}
}
#[test]
fn test_resolution_consistency_monthly_valid() {
use chrono::NaiveDate;
let specs = vec![
(
0,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
0,
), (
1,
NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
1,
), (
2,
NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 4, 1).unwrap(),
2,
), (
3,
NaiveDate::from_ymd_opt(2024, 4, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 5, 1).unwrap(),
3,
), (
4,
NaiveDate::from_ymd_opt(2024, 5, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
4,
), (
5,
NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
5,
), (
6,
NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
6,
), (
7,
NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 9, 1).unwrap(),
7,
), (
8,
NaiveDate::from_ymd_opt(2024, 9, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(),
8,
), (
9,
NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 11, 1).unwrap(),
9,
), (
10,
NaiveDate::from_ymd_opt(2024, 11, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 12, 1).unwrap(),
10,
), (
11,
NaiveDate::from_ymd_opt(2024, 12, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
11,
), ];
let stages = make_stages_for_resolution_check(specs);
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
check_season_id_consistency(&data, &mut ctx);
let rule29_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("incompatible durations")
})
.collect();
assert!(
rule29_errors.is_empty(),
"monthly study with 28-31d stages should produce no rule-29 errors; got: {rule29_errors:?}"
);
}
#[test]
fn test_resolution_consistency_mixed_monthly_quarterly() {
use chrono::NaiveDate;
let specs = vec![
(
0,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
0,
), (
1,
NaiveDate::from_ymd_opt(2024, 4, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
0,
), ];
let stages = make_stages_for_resolution_check(specs);
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
check_season_id_consistency(&data, &mut ctx);
let rule29_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("incompatible durations")
})
.collect();
assert_eq!(
rule29_errors.len(),
1,
"expected exactly one rule-29 error for season_id 0; got: {rule29_errors:?}"
);
let msg = &rule29_errors[0].message;
assert!(
msg.contains("season_id 0"),
"error message must mention season_id 0; got: {msg}"
);
assert!(
msg.contains("stage 0") && msg.contains("stage 1"),
"error message must list both conflicting stage IDs; got: {msg}"
);
assert!(
msg.contains("30d") && msg.contains("91d"),
"error message must include durations; got: {msg}"
);
}
#[test]
fn test_resolution_consistency_disjoint_resolutions() {
use chrono::NaiveDate;
let monthly: Vec<(i32, chrono::NaiveDate, chrono::NaiveDate, usize)> = vec![
(
0,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
0,
),
(
1,
NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
1,
),
(
2,
NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 4, 1).unwrap(),
2,
),
(
3,
NaiveDate::from_ymd_opt(2024, 4, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 5, 1).unwrap(),
3,
),
(
4,
NaiveDate::from_ymd_opt(2024, 5, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
4,
),
(
5,
NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
5,
),
(
6,
NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
6,
),
(
7,
NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 9, 1).unwrap(),
7,
),
(
8,
NaiveDate::from_ymd_opt(2024, 9, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(),
8,
),
(
9,
NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 11, 1).unwrap(),
9,
),
(
10,
NaiveDate::from_ymd_opt(2024, 11, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 12, 1).unwrap(),
10,
),
(
11,
NaiveDate::from_ymd_opt(2024, 12, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
11,
),
];
let quarterly: Vec<(i32, chrono::NaiveDate, chrono::NaiveDate, usize)> = vec![
(
12,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 4, 1).unwrap(),
12,
), (
13,
NaiveDate::from_ymd_opt(2024, 4, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
13,
), (
14,
NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(),
14,
), (
15,
NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
15,
), ];
let specs: Vec<_> = monthly.into_iter().chain(quarterly).collect();
let stages = make_stages_for_resolution_check(specs);
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
check_season_id_consistency(&data, &mut ctx);
let rule29_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("incompatible durations")
})
.collect();
assert!(
rule29_errors.is_empty(),
"disjoint monthly (0-11) and quarterly (12-15) season_ids should produce no rule-29 errors; got: {rule29_errors:?}"
);
}
#[test]
fn test_resolution_consistency_weekly_vs_monthly() {
use chrono::NaiveDate;
let specs = vec![
(
0,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 1, 8).unwrap(),
3,
), (
1,
NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 3, 2).unwrap(),
3,
), ];
let stages = make_stages_for_resolution_check(specs);
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
check_season_id_consistency(&data, &mut ctx);
let rule29_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("incompatible durations")
})
.collect();
assert_eq!(
rule29_errors.len(),
1,
"expected exactly one rule-29 error for season_id 3; got: {rule29_errors:?}"
);
let msg = &rule29_errors[0].message;
assert!(
msg.contains("season_id 3"),
"error message must mention season_id 3; got: {msg}"
);
assert!(
msg.contains("7d") && msg.contains("30d"),
"error message must include both stage durations; got: {msg}"
);
}
#[test]
fn test_observation_coverage_all_seasons_have_obs() {
let stages = make_stages_with_seasons(36, true);
let history = make_history_rows(1, 36);
let data = make_data_estimation(vec![make_hydro(1, None)], stages, history);
let mut ctx = ValidationContext::new();
check_season_id_consistency(&data, &mut ctx);
let rule28_warnings: Vec<_> = ctx
.warnings()
.into_iter()
.filter(|w| {
w.kind == ErrorKind::ModelQuality
&& w.message.contains("has no inflow observations")
})
.collect();
assert!(
rule28_warnings.is_empty(),
"all seasons with observations should produce no rule-28 warnings; got: {rule28_warnings:?}"
);
}
#[test]
fn test_observation_coverage_season_missing_obs_non_external() {
let stages = make_stages_with_seasons(36, true);
let mut history = Vec::new();
for i in 0..36usize {
let month_index = i % 12;
if month_index == 5 {
continue; }
let year = 2000 + (i / 12) as i32;
let month = month_index as u32 + 1;
history.push(InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(year, month, 15).unwrap(),
value_m3s: 100.0,
});
}
let data = make_data_estimation(vec![make_hydro(1, None)], stages, history);
let mut ctx = ValidationContext::new();
check_season_id_consistency(&data, &mut ctx);
let rule28_warnings: Vec<_> = ctx
.warnings()
.into_iter()
.filter(|w| {
w.kind == ErrorKind::ModelQuality
&& w.message.contains("has no inflow observations")
})
.collect();
assert_eq!(
rule28_warnings.len(),
1,
"expected exactly one rule-28 warning for season 5; got: {rule28_warnings:?}"
);
let msg = &rule28_warnings[0].message;
assert!(
msg.contains("season 5"),
"warning message must mention season 5; got: {msg}"
);
}
#[test]
fn test_observation_coverage_season_missing_obs_external() {
let stages = make_stages_with_seasons(36, true);
let mut history = Vec::new();
for i in 0..36usize {
let month_index = i % 12;
if month_index == 5 {
continue;
}
let year = 2000 + (i / 12) as i32;
let month = month_index as u32 + 1;
history.push(InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(year, month, 15).unwrap(),
value_m3s: 100.0,
});
}
let mut data = make_data_estimation(vec![make_hydro(1, None)], stages, history);
data.config = config_with_training_external_inflow();
let mut ctx = ValidationContext::new();
check_season_id_consistency(&data, &mut ctx);
let rule28_warnings: Vec<_> = ctx
.warnings()
.into_iter()
.filter(|w| {
w.kind == ErrorKind::ModelQuality
&& w.message.contains("has no inflow observations")
})
.collect();
assert!(
rule28_warnings.is_empty(),
"External inflow scheme should suppress rule-28 warnings; got: {rule28_warnings:?}"
);
}
#[test]
fn test_contiguity_no_gaps() {
let stages = make_stages_with_explicit_season_map(12, 12);
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
check_season_id_consistency(&data, &mut ctx);
let rule30_warnings: Vec<_> = ctx
.warnings()
.into_iter()
.filter(|w| {
w.kind == ErrorKind::ModelQuality
&& w.message.contains("not referenced by any stage")
})
.collect();
assert!(
rule30_warnings.is_empty(),
"no gaps should produce no rule-30 warnings; got: {rule30_warnings:?}"
);
}
#[test]
fn test_contiguity_gap_detected() {
let seasons: Vec<SeasonDefinition> = (0..6)
.map(|i| SeasonDefinition {
id: i,
label: format!("Season{i}"),
month_start: (i % 12 + 1) as u32,
day_start: None,
month_end: None,
day_end: None,
})
.collect();
let season_map = SeasonMap {
cycle_type: SeasonCycleType::Monthly,
seasons,
};
let referenced = [0usize, 1, 2, 4, 5];
let stages_vec: Vec<Stage> = referenced
.iter()
.enumerate()
.map(|(idx, &sid)| Stage {
id: idx as i32,
index: idx,
start_date: chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: chrono::NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: Some(sid),
blocks: vec![],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
let stages = StagesData {
stages: stages_vec,
policy_graph: PolicyGraph {
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.06,
transitions: vec![],
season_map: Some(season_map),
},
};
let data = make_data_5b(
vec![make_hydro_ordered_penalties(1)],
stages,
vec![make_bus_with_deficit(1, 10.0)],
vec![],
vec![],
None,
);
let mut ctx = ValidationContext::new();
check_season_id_consistency(&data, &mut ctx);
let rule30_warnings: Vec<_> = ctx
.warnings()
.into_iter()
.filter(|w| {
w.kind == ErrorKind::ModelQuality
&& w.message.contains("not referenced by any stage")
})
.collect();
assert_eq!(
rule30_warnings.len(),
1,
"expected exactly one rule-30 warning for season 3; got: {rule30_warnings:?}"
);
let msg = &rule30_warnings[0].message;
assert!(
msg.contains("season 3"),
"warning message must mention season 3; got: {msg}"
);
}
#[test]
fn test_observation_alignment_valid_monthly() {
let stages = make_stages_with_seasons(36, true);
let history = make_history_rows(1, 36);
let data = make_data_estimation(vec![make_hydro(1, None)], stages, history);
let mut ctx = ValidationContext::new();
check_observation_season_alignment(&data, &mut ctx);
let rule31_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(
rule31_errors.is_empty(),
"valid monthly observations should produce no rule-31 errors; got: {rule31_errors:?}"
);
let rule31_warnings: Vec<_> = ctx
.warnings()
.into_iter()
.filter(|w| {
w.kind == ErrorKind::BusinessRuleViolation && w.message.contains("aggregated")
})
.collect();
assert!(
rule31_warnings.is_empty(),
"valid monthly observations should produce no aggregation warnings; got: {rule31_warnings:?}"
);
}
#[test]
fn test_observation_alignment_duplicate_obs() {
let mut history = vec![
InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(2020, 1, 5).unwrap(),
value_m3s: 100.0,
},
InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(2020, 1, 20).unwrap(),
value_m3s: 200.0,
},
];
for month in 2u32..=12 {
history.push(InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(2020, month, 15).unwrap(),
value_m3s: f64::from(month) * 10.0,
});
}
let mut stages_2020 = make_stages_with_seasons(12, true);
for (i, stage) in stages_2020.stages.iter_mut().enumerate() {
let month = (i % 12) as u32 + 1;
stage.start_date = chrono::NaiveDate::from_ymd_opt(2020, month, 1).unwrap();
let (end_year, end_month) = if month == 12 {
(2021, 1u32)
} else {
(2020, month + 1)
};
stage.end_date = chrono::NaiveDate::from_ymd_opt(end_year, end_month, 1).unwrap();
}
let data = make_data_estimation(vec![make_hydro(1, None)], stages_2020, history);
let mut ctx = ValidationContext::new();
check_observation_season_alignment(&data, &mut ctx);
assert!(
ctx.errors().is_empty(),
"finer-than-season observations must not produce errors; got: {:?}",
ctx.errors()
);
let rule31_warnings: Vec<_> = ctx
.warnings()
.into_iter()
.filter(|w| {
w.kind == ErrorKind::BusinessRuleViolation
&& w.message.contains("will be aggregated")
})
.collect();
assert_eq!(
rule31_warnings.len(),
1,
"expected exactly one rule-31 aggregation warning; got: {rule31_warnings:?}"
);
let msg = &rule31_warnings[0].message;
assert!(
msg.contains("hydro 1"),
"warning must mention hydro 1; got: {msg}"
);
assert!(
msg.contains("season 0"),
"warning must mention season 0; got: {msg}"
);
assert!(
msg.contains("year 2020"),
"warning must mention year 2020; got: {msg}"
);
assert!(
msg.contains(" 2 ") || msg.contains("has 2 observations"),
"warning must mention count 2; got: {msg}"
);
assert_eq!(
rule31_warnings[0].entity,
Some("Hydro 1".to_string()),
"entity context must be 'Hydro 1'; got: {:?}",
rule31_warnings[0].entity
);
}
#[test]
fn test_observation_alignment_coarser_than_season() {
let mut stages_2020 = make_stages_with_seasons(12, true);
for (i, stage) in stages_2020.stages.iter_mut().enumerate() {
let month = (i % 12) as u32 + 1;
stage.start_date = chrono::NaiveDate::from_ymd_opt(2020, month, 1).unwrap();
let (end_year, end_month) = if month == 12 {
(2021, 1u32)
} else {
(2020, month + 1)
};
stage.end_date = chrono::NaiveDate::from_ymd_opt(end_year, end_month, 1).unwrap();
}
let history = vec![
InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(2018, 1, 15).unwrap(), value_m3s: 50.0,
},
InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(2019, 2, 15).unwrap(), value_m3s: 100.0,
},
InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(2019, 5, 15).unwrap(), value_m3s: 200.0,
},
InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(2019, 8, 15).unwrap(), value_m3s: 300.0,
},
InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(2019, 11, 15).unwrap(), value_m3s: 400.0,
},
InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(), value_m3s: 50.0,
},
];
let data = make_data_estimation(vec![make_hydro(1, None)], stages_2020, history);
let mut ctx = ValidationContext::new();
check_observation_season_alignment(&data, &mut ctx);
let coarser_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("coarser-than-season")
})
.collect();
assert!(
!coarser_errors.is_empty(),
"coarser-than-season observations must produce at least one BusinessRuleViolation error; got none"
);
for e in &coarser_errors {
assert!(
e.message.contains("cannot be disaggregated"),
"error message must mention 'cannot be disaggregated'; got: {}",
e.message
);
}
}
#[test]
fn test_observation_alignment_no_season_map() {
let stages = make_stages_with_seasons(12, false);
let history = make_history_rows(1, 12);
let data = make_data_estimation(vec![make_hydro(1, None)], stages, history);
let mut ctx = ValidationContext::new();
check_observation_season_alignment(&data, &mut ctx);
assert!(
ctx.errors().is_empty(),
"rule 31 must be skipped when season_map is None; got errors: {:?}",
ctx.errors()
);
}
#[test]
fn test_observation_alignment_estimation_inactive() {
let stages = make_stages_with_seasons(12, true);
let history = make_history_rows(1, 12);
let mut data = make_data_estimation(vec![make_hydro(1, None)], stages, history);
data.inflow_seasonal_stats = vec![crate::scenarios::InflowSeasonalStatsRow {
hydro_id: EntityId::from(1),
stage_id: 0,
mean_m3s: 100.0,
std_m3s: 10.0,
}];
data.inflow_ar_coefficients = vec![crate::scenarios::InflowArCoefficientRow {
hydro_id: EntityId::from(1),
stage_id: 0,
lag: 1,
coefficient: 0.5,
residual_std_ratio: 0.9,
}];
let mut ctx = ValidationContext::new();
check_observation_season_alignment(&data, &mut ctx);
assert!(
ctx.errors().is_empty(),
"rule 31 must be skipped when estimation is inactive; got errors: {:?}",
ctx.errors()
);
}
#[test]
fn test_observation_alignment_partial_boundary_years_no_error() {
let stages = make_stages_with_seasons(12, true);
let mut history: Vec<InflowHistoryRow> = Vec::new();
for month in 4u32..=12 {
history.push(InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(1990, month, 15).unwrap(),
value_m3s: 100.0,
});
}
for year in 1991i32..=2019 {
for month in 1u32..=12 {
history.push(InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(year, month, 15).unwrap(),
value_m3s: 100.0,
});
}
}
for month in 1u32..=9 {
history.push(InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(2020, month, 15).unwrap(),
value_m3s: 100.0,
});
}
let data = make_data_estimation(vec![make_hydro(1, None)], stages, history);
let mut ctx = ValidationContext::new();
check_observation_season_alignment(&data, &mut ctx);
let coarser_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("coarser-than-season")
})
.collect();
assert!(
coarser_errors.is_empty(),
"partial boundary years must not produce coarser-than-season errors; got: \
{coarser_errors:?}"
);
}
#[test]
fn test_observation_alignment_missing_interior_season_produces_error() {
let stages = make_stages_with_seasons(12, true);
let mut history: Vec<InflowHistoryRow> = Vec::new();
for year in 1991i32..=2019 {
for month in 1u32..=12 {
if year == 2005 && month == 7 {
continue;
}
history.push(InflowHistoryRow {
hydro_id: EntityId::from(1),
date: chrono::NaiveDate::from_ymd_opt(year, month, 15).unwrap(),
value_m3s: 100.0,
});
}
}
let data = make_data_estimation(vec![make_hydro(1, None)], stages, history);
let mut ctx = ValidationContext::new();
check_observation_season_alignment(&data, &mut ctx);
let coarser_errors: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("coarser-than-season")
})
.collect();
assert!(
!coarser_errors.is_empty(),
"a missing season in an interior year must produce a coarser-than-season error"
);
let target = coarser_errors.iter().find(|e| {
e.message.contains("hydro 1")
&& e.message.contains("season 6")
&& e.message.contains("year 2005")
});
assert!(
target.is_some(),
"expected an error for hydro 1 season 6 year 2005; got: {coarser_errors:?}"
);
}
}