use std::collections::{HashMap, HashSet};
use super::super::{ErrorKind, ValidationContext, schema::ParsedData};
#[allow(clippy::too_many_lines)]
pub(super) fn check_penalty_ordering(data: &ParsedData, ctx: &mut ValidationContext) {
let max_deficit_cost: f64 = data
.buses
.iter()
.flat_map(|b| b.deficit_segments.iter().map(|s| s.cost_per_mwh))
.fold(f64::NEG_INFINITY, f64::max)
.max(0.0);
{
let mut violations: Vec<(i32, f64, f64)> = Vec::new(); for hydro in &data.hydros {
let higher = hydro.penalties.filling_target_violation_cost;
let lower = hydro.penalties.storage_violation_below_cost;
if higher <= lower {
violations.push((hydro.id.0, higher, lower));
}
}
if let Some(worst) = violations.iter().max_by(|a, b| {
(b.2 - b.1)
.partial_cmp(&(a.2 - a.1))
.unwrap_or(std::cmp::Ordering::Equal)
}) {
let count = violations.len();
ctx.add_warning(
ErrorKind::ModelQuality,
"penalties.json",
None::<&str>,
format!(
"Penalty ordering violation: filling_target_violation_cost ({}) should be > \
storage_violation_below_cost ({}) -- {count} hydro(s) affected, \
worst case: Hydro {}",
worst.1, worst.2, worst.0
),
);
}
}
{
let mut violations: Vec<(i32, f64)> = Vec::new(); for hydro in &data.hydros {
let higher = hydro.penalties.storage_violation_below_cost;
if higher <= max_deficit_cost {
violations.push((hydro.id.0, higher));
}
}
if let Some(worst) = violations
.iter()
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
{
let count = violations.len();
ctx.add_warning(
ErrorKind::ModelQuality,
"penalties.json",
None::<&str>,
format!(
"Penalty ordering violation: storage_violation_below_cost ({}) should be > \
max(deficit_segment_costs) ({max_deficit_cost}) -- {count} hydro(s) affected, \
worst case: Hydro {}",
worst.1, worst.0
),
);
}
}
{
let max_cv = |h: &cobre_core::entities::Hydro| {
let p = &h.penalties;
p.turbined_violation_below_cost
.max(p.outflow_violation_below_cost)
.max(p.outflow_violation_above_cost)
.max(p.generation_violation_below_cost)
.max(p.evaporation_violation_cost)
.max(p.water_withdrawal_violation_cost)
};
let max_constraint_cost: f64 = data
.hydros
.iter()
.map(max_cv)
.fold(f64::NEG_INFINITY, f64::max)
.max(0.0);
if !data.hydros.is_empty() && max_deficit_cost <= max_constraint_cost {
if let Some(worst_hydro) = data.hydros.iter().max_by(|a, b| {
max_cv(a)
.partial_cmp(&max_cv(b))
.unwrap_or(std::cmp::Ordering::Equal)
}) {
ctx.add_warning(
ErrorKind::ModelQuality,
"penalties.json",
None::<&str>,
format!(
"Penalty ordering violation: max(deficit_segment_costs) \
({max_deficit_cost}) should be > max(constraint_violation_costs) \
({max_constraint_cost}) -- 1 hydro(s) affected, worst case: Hydro {}",
worst_hydro.id.0
),
);
}
}
}
{
if !data.hydros.is_empty() {
let min_cv = |h: &cobre_core::entities::Hydro| {
let p = &h.penalties;
p.turbined_violation_below_cost
.min(p.outflow_violation_below_cost)
.min(p.outflow_violation_above_cost)
.min(p.generation_violation_below_cost)
.min(p.evaporation_violation_cost)
.min(p.water_withdrawal_violation_cost)
};
let min_constraint_cost: f64 =
data.hydros.iter().map(min_cv).fold(f64::INFINITY, f64::min);
let max_resource_cost: f64 = data
.hydros
.iter()
.map(|h| h.penalties.spillage_cost.max(h.penalties.diversion_cost))
.fold(f64::NEG_INFINITY, f64::max)
.max(0.0);
if min_constraint_cost <= max_resource_cost {
if let Some(worst_hydro) = data.hydros.iter().min_by(|a, b| {
min_cv(a)
.partial_cmp(&min_cv(b))
.unwrap_or(std::cmp::Ordering::Equal)
}) {
ctx.add_warning(
ErrorKind::ModelQuality,
"penalties.json",
None::<&str>,
format!(
"Penalty ordering violation: min(constraint_violation_costs) \
({min_constraint_cost}) should be > max(resource_costs) \
({max_resource_cost}) -- 1 hydro(s) affected, worst case: Hydro {}",
worst_hydro.id.0
),
);
}
}
}
}
{
let mut violations: Vec<(i32, f64)> = Vec::new(); for hydro in &data.hydros {
let min_resource = hydro
.penalties
.spillage_cost
.min(hydro.penalties.diversion_cost);
if min_resource <= 0.0 {
violations.push((hydro.id.0, min_resource));
}
}
if let Some(worst) = violations
.iter()
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
{
let count = violations.len();
ctx.add_warning(
ErrorKind::ModelQuality,
"penalties.json",
None::<&str>,
format!(
"Penalty ordering violation: min(resource_costs) ({}) should be > 0 \
(regularization costs must be positive to prevent LP degeneracy) -- \
{count} hydro(s) affected, worst case: Hydro {}",
worst.1, worst.0
),
);
}
}
}
pub(super) fn check_fpha_penalty_rule(data: &ParsedData, ctx: &mut ValidationContext) {
use cobre_core::entities::HydroGenerationModel;
for hydro in &data.hydros {
if hydro.generation_model == HydroGenerationModel::Fpha {
let fpha_cost = hydro.penalties.turbined_cost;
if fpha_cost < 0.0 {
let entity_str = format!("Hydro {}", hydro.id.0);
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"penalties.json",
Some(&entity_str),
format!(
"{entity_str}: turbined_cost ({fpha_cost}) must be non-negative (>= 0) \
for FPHA hydros; negative values distort LP dispatch"
),
);
}
}
}
}
pub(super) fn check_scenario_models(data: &ParsedData, ctx: &mut ValidationContext) {
for row in &data.inflow_seasonal_stats {
if row.std_m3s == 0.0 {
ctx.add_warning(
ErrorKind::ModelQuality,
"scenarios/inflow_seasonal_stats.parquet",
Some(format!("Hydro {}", row.hydro_id.0)),
format!(
"Hydro {} stage {}: std_m3s is 0.0, indicating deterministic inflow \
(no stochastic component); verify this is intentional",
row.hydro_id.0, row.stage_id
),
);
}
}
{
let mut ratio_by_group: HashMap<(i32, i32), f64> = HashMap::new();
for row in &data.inflow_ar_coefficients {
let key = (row.hydro_id.0, row.stage_id);
match ratio_by_group.entry(key) {
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(row.residual_std_ratio);
}
std::collections::hash_map::Entry::Occupied(e) => {
if (*e.get() - row.residual_std_ratio).abs() > f64::EPSILON {
ctx.add_error(
ErrorKind::InvalidValue,
"scenarios/inflow_ar_coefficients.parquet",
Some(format!("Hydro {}", row.hydro_id.0)),
format!(
"Hydro {} stage {}: inconsistent residual_std_ratio across \
lag rows (first={}, current={}); all lags must share the \
same ratio",
row.hydro_id.0,
row.stage_id,
e.get(),
row.residual_std_ratio,
),
);
}
}
}
}
}
}
pub(super) fn check_external_scheme_has_files(data: &ParsedData, ctx: &mut ValidationContext) {
use cobre_core::scenario::SamplingScheme;
use std::path::Path;
let Ok(training_source) = data
.config
.training_scenario_source(Path::new("config.json"))
else {
return;
};
let Ok(simulation_source) = data
.config
.simulation_scenario_source(Path::new("config.json"))
else {
return;
};
let sources: &[(&str, &_)] = if data.config.simulation.scenario_source.is_some() {
&[
("training", &training_source),
("simulation", &simulation_source),
]
} else {
&[("training", &training_source)]
};
let mut check_external =
|section: &str, scheme: SamplingScheme, class_name: &str, is_empty: bool| {
if scheme == SamplingScheme::External && is_empty {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"config.json",
Some(format!("{section}.scenario_source.{class_name}")),
format!(
"{class_name} class uses 'external' scheme but no \
external_{class_name}_scenarios.parquet data was found; \
external scheme requires corresponding scenario file"
),
);
}
};
for (section, source) in sources {
check_external(
section,
source.inflow_scheme,
"inflow",
data.external_scenarios.is_empty(),
);
check_external(
section,
source.load_scheme,
"load",
data.external_load_scenarios.is_empty(),
);
check_external(
section,
source.ncs_scheme,
"ncs",
data.external_ncs_scenarios.is_empty(),
);
}
}
pub(super) fn check_load_factor_consistency(data: &ParsedData, ctx: &mut ValidationContext) {
if data.load_factors.is_empty() {
return;
}
let stage_block_indices: HashMap<i32, HashSet<usize>> = data
.stages
.stages
.iter()
.filter(|s| s.id >= 0)
.map(|s| {
let indices: HashSet<usize> = s.blocks.iter().map(|b| b.index).collect();
(s.id, indices)
})
.collect();
let load_std: HashMap<(i32, i32), f64> = data
.load_seasonal_stats
.iter()
.map(|row| ((row.bus_id.0, row.stage_id), row.std_mw))
.collect();
for (i, entry) in data.load_factors.iter().enumerate() {
if let Some(valid_indices) = stage_block_indices.get(&entry.stage_id) {
for bf in &entry.block_factors {
let block_idx = usize::try_from(bf.block_id).unwrap_or(usize::MAX);
if !valid_indices.contains(&block_idx) {
let sorted: Vec<usize> = {
let mut v: Vec<usize> = valid_indices.iter().copied().collect();
v.sort_unstable();
v
};
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"scenarios/load_factors.json",
Some(format!("LoadFactorEntry[{i}]")),
format!(
"LoadFactorEntry[{i}] has block_id {} which is not in the block set \
{sorted:?} for stage {}",
bf.block_id, entry.stage_id
),
);
}
}
}
let key = (entry.bus_id.0, entry.stage_id);
if let Some(&std_mw) = load_std.get(&key) {
if std_mw == 0.0 {
ctx.add_warning(
ErrorKind::ModelQuality,
"scenarios/load_factors.json",
Some(format!("LoadFactorEntry[{i}]")),
format!(
"LoadFactorEntry[{i}] (bus {}, stage {}) references a deterministic load \
(std_mw == 0.0); block factors have no effect on deterministic loads",
entry.bus_id.0, entry.stage_id
),
);
}
}
}
}
pub(super) fn check_estimation_prerequisites(data: &ParsedData, ctx: &mut ValidationContext) {
let has_history = !data.inflow_history.is_empty();
let has_stats = !data.inflow_seasonal_stats.is_empty();
let has_ar_coefficients = !data.inflow_ar_coefficients.is_empty();
let estimation_active = has_history && !(has_stats && has_ar_coefficients);
if !estimation_active {
return;
}
if data.stages.policy_graph.season_map.is_none() {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"scenarios/inflow_history.parquet",
None::<&str>,
"season_definitions is required in stages.json when estimating from \
inflow_history.parquet; add a season_definitions section to stages.json",
);
}
let hydro_ids_in_history: HashSet<i32> =
data.inflow_history.iter().map(|r| r.hydro_id.0).collect();
let mut missing_hydros: Vec<i32> = data
.hydros
.iter()
.filter(|h| !hydro_ids_in_history.contains(&h.id.0))
.map(|h| h.id.0)
.collect();
missing_hydros.sort_unstable();
for id in missing_hydros {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"scenarios/inflow_history.parquet",
Some(format!("Hydro {id}")),
format!(
"hydro {id} has no observations in inflow_history.parquet but estimation \
is required; add historical inflow data for this hydro"
),
);
}
if let Some(_season_map) = &data.stages.policy_graph.season_map {
let min_obs = data.config.estimation.min_observations_per_season as usize;
let stage_index: Vec<(chrono::NaiveDate, chrono::NaiveDate, usize)> = data
.stages
.stages
.iter()
.filter_map(|s| s.season_id.map(|sid| (s.start_date, s.end_date, sid)))
.collect();
let mut counts: HashMap<(i32, usize), usize> = HashMap::new();
for row in &data.inflow_history {
let pos = stage_index.partition_point(|(start, _, _)| *start <= row.date);
let season_id = if pos > 0 {
let (_, end_date, sid) = stage_index[pos - 1];
if row.date < end_date { Some(sid) } else { None }
} else {
None
};
if let Some(sid) = season_id {
*counts.entry((row.hydro_id.0, sid)).or_insert(0) += 1;
}
}
let mut violations: Vec<(i32, usize, usize)> = counts
.iter()
.filter(|&(_, n)| *n < min_obs)
.map(|(&(hid, sid), &n)| (hid, sid, n))
.collect();
violations.sort_unstable_by_key(|&(hid, sid, _)| (hid, sid));
for (hid, sid, n) in violations {
ctx.add_warning(
ErrorKind::ModelQuality,
"scenarios/inflow_history.parquet",
Some(format!("Hydro {hid}")),
format!(
"hydro {hid} season {sid} has {n} observations \
(minimum recommended: {min_obs}); estimation accuracy may be \
insufficient with so few observations"
),
);
}
}
}
pub(super) fn check_past_inflows_coverage(data: &ParsedData, ctx: &mut ValidationContext) {
let lags_enabled = data
.stages
.stages
.iter()
.filter(|s| s.id >= 0)
.any(|s| s.state_config.inflow_lags);
if !lags_enabled {
return;
}
let max_order_overall: i32 = data
.inflow_ar_coefficients
.iter()
.map(|c| c.lag)
.max()
.unwrap_or(0);
if max_order_overall == 0 {
return;
}
let past_inflows = &data.initial_conditions.past_inflows;
if past_inflows.is_empty() {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
None::<&str>,
"inflow_lags is enabled with PAR order > 0 but initial_conditions.json has no past_inflows entries; lag initialization requires past inflow values",
);
return; }
let mut max_order_per_hydro: HashMap<i32, i32> = HashMap::new();
for row in &data.inflow_ar_coefficients {
let entry = max_order_per_hydro.entry(row.hydro_id.0).or_insert(0);
if row.lag > *entry {
*entry = row.lag;
}
}
let past_inflows_len: HashMap<i32, usize> = past_inflows
.iter()
.map(|pi| (pi.hydro_id.0, pi.values_m3s.len()))
.collect();
{
let mut coverage_violations: Vec<(i32, i32, usize)> = Vec::new(); for (&hydro_id, &order) in &max_order_per_hydro {
if order == 0 {
continue;
}
let required = usize::try_from(order).unwrap_or(usize::MAX);
let provided = past_inflows_len.get(&hydro_id).copied().unwrap_or(0);
if provided < required {
coverage_violations.push((hydro_id, order, provided));
}
}
coverage_violations.sort_unstable_by_key(|&(hid, _, _)| hid);
for (hydro_id, order, provided) in coverage_violations {
let entity_str = format!("Hydro {hydro_id}");
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Hydro {hydro_id}: insufficient past_inflows for lag initialization; \
PAR order is {order} but initial_conditions.json provides only \
{provided} value(s) in past_inflows (need at least {order})"
),
);
}
}
{
let hydro_registry: HashSet<i32> = data.hydros.iter().map(|h| h.id.0).collect();
let past_inflow_ids: HashSet<i32> = past_inflows.iter().map(|pi| pi.hydro_id.0).collect();
let mut unknown_ids: Vec<i32> = past_inflow_ids
.difference(&hydro_registry)
.copied()
.collect();
unknown_ids.sort_unstable();
for id in unknown_ids {
let entity_str = format!("Hydro {id}");
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Hydro {id} appears in past_inflows but does not exist \
in the hydro registry (system/hydros.json); \
remove the unknown hydro or add it to the registry"
),
);
}
}
}
pub(super) fn check_past_inflows_season_ids(data: &ParsedData, ctx: &mut ValidationContext) {
let Some(season_map) = &data.stages.policy_graph.season_map else {
return;
};
let mut max_order_per_hydro: HashMap<i32, i32> = HashMap::new();
for row in &data.inflow_ar_coefficients {
let entry = max_order_per_hydro.entry(row.hydro_id.0).or_insert(0);
if row.lag > *entry {
*entry = row.lag;
}
}
let valid_ids: HashSet<usize> = season_map.seasons.iter().map(|s| s.id).collect();
let mut sorted_valid_ids: Vec<usize> = valid_ids.iter().copied().collect();
sorted_valid_ids.sort_unstable();
for pi in &data.initial_conditions.past_inflows {
let par_order = max_order_per_hydro
.get(&pi.hydro_id.0)
.copied()
.unwrap_or(0);
if par_order == 0 {
continue;
}
let Some(season_ids) = &pi.season_ids else {
continue;
};
for &sid in season_ids {
let sid_usize = sid as usize;
if !valid_ids.contains(&sid_usize) {
let entity_str = format!("Hydro {}", pi.hydro_id.0);
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Hydro {}: past_inflows.season_ids contains season_id {} which is \
not defined in season_definitions; valid season IDs are {:?}",
pi.hydro_id.0, sid, sorted_valid_ids,
),
);
}
}
}
}
#[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::super::test_support::*;
use super::super::validate_semantic_stages_penalties_scenarios;
use super::*;
use crate::{
scenarios::{
BlockFactor, InflowArCoefficientRow, InflowSeasonalStatsRow, LoadFactorEntry,
LoadSeasonalStatsRow,
},
stages::StagesData,
validation::{ErrorKind, ValidationContext},
};
use cobre_core::{
EntityId,
entities::HydroGenerationModel,
temporal::{Block, PolicyGraph, PolicyGraphType},
};
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_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.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("turbined_cost"),
"message should contain '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.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(),
"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.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(),
"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.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_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:?}"
);
}
#[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<crate::scenarios::InflowHistoryRow> = (0..3)
.map(|y| crate::scenarios::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() {
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() {
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()
);
}
#[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()
);
}
#[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_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:?}"
);
}
}