use std::collections::{HashMap, HashSet};
use super::super::{ErrorKind, ValidationContext, schema::ParsedData};
pub(super) fn check_cascade_acyclic(data: &ParsedData, ctx: &mut ValidationContext) {
if data.hydros.is_empty() {
return;
}
let all_ids: Vec<i32> = data.hydros.iter().map(|h| h.id.0).collect();
let downstream_set: HashSet<i32> = all_ids.iter().copied().collect();
let mut adjacency: HashMap<i32, Vec<i32>> =
all_ids.iter().copied().map(|id| (id, Vec::new())).collect();
for hydro in &data.hydros {
if let Some(ds) = hydro.downstream_id {
if downstream_set.contains(&ds.0) {
adjacency.entry(hydro.id.0).or_default().push(ds.0);
}
}
}
let mut in_degree: HashMap<i32, usize> = all_ids.iter().copied().map(|id| (id, 0)).collect();
for hydro in &data.hydros {
if let Some(ds) = hydro.downstream_id {
if downstream_set.contains(&ds.0) {
*in_degree.entry(ds.0).or_insert(0) += 1;
}
}
}
let mut queue: std::collections::VecDeque<i32> = in_degree
.iter()
.filter(|&(_, deg)| *deg == 0)
.map(|(&id, _)| id)
.collect();
let mut visited_count: usize = 0;
while let Some(node) = queue.pop_front() {
visited_count += 1;
if let Some(neighbors) = adjacency.get(&node) {
for &neighbor in neighbors {
let deg = in_degree.entry(neighbor).or_insert(0);
if *deg > 0 {
*deg -= 1;
}
if *deg == 0 {
queue.push_back(neighbor);
}
}
}
}
if visited_count < all_ids.len() {
let mut cycle_participants: Vec<i32> = in_degree
.iter()
.filter(|&(_, deg)| *deg > 0)
.map(|(&id, _)| id)
.collect();
cycle_participants.sort_unstable();
ctx.add_error(
ErrorKind::CycleDetected,
"system/hydros.json",
None::<&str>,
format!(
"hydro cascade contains a cycle involving hydro IDs: [{}]",
cycle_participants
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
),
);
}
}
pub(super) fn check_hydro_bounds(data: &ParsedData, ctx: &mut ValidationContext) {
for hydro in &data.hydros {
let entity_str = format!("Hydro {}", hydro.id.0);
if hydro.min_storage_hm3 > hydro.max_storage_hm3 {
ctx.add_error(
ErrorKind::InvalidValue,
"system/hydros.json",
Some(&entity_str),
format!(
"{entity_str}: min_storage_hm3 ({}) > max_storage_hm3 ({}); storage bounds are inconsistent",
hydro.min_storage_hm3, hydro.max_storage_hm3
),
);
}
if hydro.min_turbined_m3s > hydro.max_turbined_m3s {
ctx.add_error(
ErrorKind::InvalidValue,
"system/hydros.json",
Some(&entity_str),
format!(
"{entity_str}: min_turbined_m3s ({}) > max_turbined_m3s ({}); turbine bounds are inconsistent",
hydro.min_turbined_m3s, hydro.max_turbined_m3s
),
);
}
if let Some(max_outflow) = hydro.max_outflow_m3s {
if hydro.min_outflow_m3s > max_outflow {
ctx.add_error(
ErrorKind::InvalidValue,
"system/hydros.json",
Some(&entity_str),
format!(
"{entity_str}: min_outflow_m3s ({}) > max_outflow_m3s ({}); outflow bounds are inconsistent",
hydro.min_outflow_m3s, max_outflow
),
);
}
}
if hydro.min_generation_mw > hydro.max_generation_mw {
ctx.add_error(
ErrorKind::InvalidValue,
"system/hydros.json",
Some(&entity_str),
format!(
"{entity_str}: min_generation_mw ({}) > max_generation_mw ({}); generation bounds are inconsistent",
hydro.min_generation_mw, hydro.max_generation_mw
),
);
}
}
}
pub(super) fn check_lifecycle_consistency(data: &ParsedData, ctx: &mut ValidationContext) {
for hydro in &data.hydros {
if let (Some(entry), Some(exit)) = (hydro.entry_stage_id, hydro.exit_stage_id) {
if entry >= exit {
let entity_str = format!("Hydro {}", hydro.id.0);
ctx.add_error(
ErrorKind::InvalidValue,
"system/hydros.json",
Some(&entity_str),
format!(
"{entity_str}: entry_stage_id ({entry}) >= exit_stage_id ({exit}); entry must precede exit"
),
);
}
}
}
for line in &data.lines {
if let (Some(entry), Some(exit)) = (line.entry_stage_id, line.exit_stage_id) {
if entry >= exit {
let entity_str = format!("Line {}", line.id.0);
ctx.add_error(
ErrorKind::InvalidValue,
"system/lines.json",
Some(&entity_str),
format!(
"{entity_str}: entry_stage_id ({entry}) >= exit_stage_id ({exit}); entry must precede exit"
),
);
}
}
}
for thermal in &data.thermals {
if let (Some(entry), Some(exit)) = (thermal.entry_stage_id, thermal.exit_stage_id) {
if entry >= exit {
let entity_str = format!("Thermal {}", thermal.id.0);
ctx.add_error(
ErrorKind::InvalidValue,
"system/thermals.json",
Some(&entity_str),
format!(
"{entity_str}: entry_stage_id ({entry}) >= exit_stage_id ({exit}); entry must precede exit"
),
);
}
}
}
}
pub(super) fn check_filling_config(data: &ParsedData, ctx: &mut ValidationContext) {
let study_stage_ids: HashSet<i32> = data
.stages
.stages
.iter()
.filter(|s| s.id >= 0)
.map(|s| s.id)
.collect();
for hydro in &data.hydros {
if let Some(filling) = &hydro.filling {
if !study_stage_ids.contains(&filling.start_stage_id) {
let entity_str = format!("Hydro {}", hydro.id.0);
ctx.add_error(
ErrorKind::InvalidValue,
"system/hydros.json",
Some(&entity_str),
format!(
"{entity_str}: filling.start_stage_id ({}) is not a valid study stage ID",
filling.start_stage_id
),
);
}
}
}
}
pub(super) fn check_geometry_monotonicity(data: &ParsedData, ctx: &mut ValidationContext) {
if data.hydro_geometry.is_empty() {
return;
}
let mut i = 0;
let rows = &data.hydro_geometry;
while i < rows.len() {
let current_hydro_id = rows[i].hydro_id.0;
let group_start = i;
while i < rows.len() && rows[i].hydro_id.0 == current_hydro_id {
i += 1;
}
let group = &rows[group_start..i];
for pair in group.windows(2) {
let prev = &pair[0];
let curr = &pair[1];
let entity_str = format!("Hydro {current_hydro_id}");
if curr.volume_hm3 <= prev.volume_hm3 {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/hydro_geometry.parquet",
Some(&entity_str),
format!(
"{entity_str}: volume_hm3 values are not strictly increasing ({} then {}); geometry curve must have strictly increasing volume",
prev.volume_hm3, curr.volume_hm3
),
);
}
if curr.height_m < prev.height_m {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/hydro_geometry.parquet",
Some(&entity_str),
format!(
"{entity_str}: height_m values are not non-decreasing ({} then {}); geometry curve must have non-decreasing height with volume",
prev.height_m, curr.height_m
),
);
}
if curr.area_km2 < prev.area_km2 {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/hydro_geometry.parquet",
Some(&entity_str),
format!(
"{entity_str}: area_km2 values are not non-decreasing ({} then {}); geometry curve must have non-decreasing area with volume",
prev.area_km2, curr.area_km2
),
);
}
}
}
}
pub(super) fn check_evaporation_geometry_coverage(data: &ParsedData, ctx: &mut ValidationContext) {
let geometry_hydro_ids: HashSet<i32> =
data.hydro_geometry.iter().map(|r| r.hydro_id.0).collect();
for hydro in &data.hydros {
if hydro.evaporation_coefficients_mm.is_some() && !geometry_hydro_ids.contains(&hydro.id.0)
{
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/hydros.json",
Some(format!("Hydro {} (id={})", hydro.name, hydro.id.0)),
format!(
"hydro {} (id={}) has evaporation_coefficients_mm but no geometry data \
in hydro_geometry.parquet; evaporation linearization requires \
area-volume curve data",
hydro.name, hydro.id.0
),
);
}
}
}
pub(super) fn check_fpha_constraints(data: &ParsedData, ctx: &mut ValidationContext) {
if data.fpha_hyperplanes.is_empty() {
return;
}
for row in &data.fpha_hyperplanes {
let entity_str = format!("Hydro {}", row.hydro_id.0);
if row.gamma_v < 0.0 {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/fpha_hyperplanes.parquet",
Some(&entity_str),
format!(
"{entity_str} (stage={}, plane={}): gamma_v ({}) must be non-negative (>= 0); \
power must not decrease with volume/head (zero is valid for constant-head plants)",
row.stage_id.map_or_else(|| "all".to_string(), |s| s.to_string()),
row.plane_id,
row.gamma_v
),
);
}
if row.gamma_s > 0.0 {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/fpha_hyperplanes.parquet",
Some(&entity_str),
format!(
"{entity_str} (stage={}, plane={}): gamma_s ({}) must be non-positive (<= 0); power must not increase with spillage",
row.stage_id.map_or_else(|| "all".to_string(), |s| s.to_string()),
row.plane_id,
row.gamma_s
),
);
}
}
let rows = &data.fpha_hyperplanes;
let mut i = 0;
while i < rows.len() {
let current_hydro_id = rows[i].hydro_id.0;
let current_stage_id = rows[i].stage_id;
let group_start = i;
while i < rows.len()
&& rows[i].hydro_id.0 == current_hydro_id
&& rows[i].stage_id == current_stage_id
{
i += 1;
}
let plane_count = i - group_start;
if plane_count < 1 {
let entity_str = format!("Hydro {current_hydro_id}");
let stage_label = current_stage_id.map_or_else(|| "all".to_string(), |s| s.to_string());
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/fpha_hyperplanes.parquet",
Some(&entity_str),
format!(
"{entity_str} (stage={stage_label}): no FPHA planes defined; \
at least 1 plane is required"
),
);
}
}
}