use std::collections::{HashMap, HashSet};
use cobre_core::{AnticipatedCommitmentHistory, AnticipatedConfig, EntityId, VariableRef};
use super::super::{ErrorKind, ValidationContext, schema::ParsedData};
pub(super) fn check_thermal_generation_bounds(data: &ParsedData, ctx: &mut ValidationContext) {
for thermal in &data.thermals {
if thermal.min_generation_mw > thermal.max_generation_mw {
let entity_str = format!("Thermal {}", thermal.id.0);
ctx.add_error(
ErrorKind::InvalidValue,
"system/thermals.json",
Some(&entity_str),
format!(
"{entity_str}: min_generation_mw ({}) > max_generation_mw ({}); generation bounds are inconsistent",
thermal.min_generation_mw, thermal.max_generation_mw
),
);
}
}
}
pub(super) fn check_anticipated_thermals(data: &ParsedData, ctx: &mut ValidationContext) {
let study_stage_ids: Vec<i32> = data
.stages
.stages
.iter()
.filter(|s| s.id >= 0)
.map(|s| s.id)
.collect();
let n_stages = study_stage_ids.len();
let study_durations = study_stage_durations(data);
for thermal in &data.thermals {
let Some(ref cfg) = thermal.anticipated_config else {
continue;
};
let thermal_id = thermal.id.0;
if let AnticipatedConfig::LeadTime(delta_hours) = *cfg {
let total_horizon_hours: f64 = study_durations.iter().sum();
if delta_hours > total_horizon_hours {
let entity_str = format!("thermals[id={thermal_id}].anticipated_config.lead_time");
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/thermals.json",
Some(&entity_str),
format!(
"Thermal {thermal_id}: lead_time exceeds study horizon \
(lead_time={delta_hours}, total_horizon_hours={total_horizon_hours}); \
the plant can never deliver within the study horizon"
),
);
}
}
let Some(k) = cfg.lead_stages() else {
continue;
};
let entity_str = format!("thermals[id={thermal_id}].anticipated_config.lead_stages");
if k == 0 {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/thermals.json",
Some(&entity_str),
format!("Thermal {thermal_id}: anticipated_config.lead_stages must be >= 1, got 0"),
);
continue;
}
let k_u = k as usize;
if k_u > n_stages {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/thermals.json",
Some(&entity_str),
format!(
"Thermal {thermal_id}: lead_stages exceeds study horizon \
(lead_stages={k}, n_stages={n_stages}); \
the plant can never deliver within the study horizon"
),
);
}
}
let ic = &data.initial_conditions;
let history_by_id: HashMap<EntityId, &AnticipatedCommitmentHistory> = ic
.past_anticipated_commitments
.iter()
.map(|history| (history.thermal_id, history))
.collect();
let anticipated_thermal_ids: HashSet<EntityId> = data
.thermals
.iter()
.filter(|t| t.anticipated_config.is_some())
.map(|t| t.id)
.collect();
for thermal in &data.thermals {
let Some(ref cfg) = thermal.anticipated_config else {
continue;
};
let thermal_id = thermal.id;
match history_by_id.get(&thermal_id) {
None => {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some("initial_conditions.past_anticipated_commitments"),
format!(
"Thermal {}: missing entry in initial_conditions.past_anticipated_commitments; \
every anticipated thermal must have a corresponding history entry",
thermal_id.0
),
);
}
Some(history) => {
let expected =
required_anticipated_commitment_count(*cfg, &study_durations, n_stages);
let actual = history.values_mw.len();
if actual == expected {
check_committed_value_bounds(thermal, thermal_id, &history.values_mw, ctx);
check_seed_within_window(
thermal,
thermal_id,
&history.values_mw,
&study_stage_ids,
ctx,
);
} else {
let entity_str = format!("thermals[id={}].anticipated_config", thermal_id.0);
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments.values_mw has wrong length: \
expected {expected} values, got {actual} (calendar-derived count of \
pre-study-committed delivery stages)",
thermal_id.0
),
);
}
}
}
}
for history in &ic.past_anticipated_commitments {
if !anticipated_thermal_ids.contains(&history.thermal_id) {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(format!(
"initial_conditions.past_anticipated_commitments[thermal_id={}]",
history.thermal_id.0
)),
format!(
"Thermal {}: referenced in past_anticipated_commitments \
but is not an anticipated thermal (anticipated_config is None or thermal does not exist)",
history.thermal_id.0
),
);
}
}
}
pub(super) fn check_anticipated_cadence_transition(data: &ParsedData, ctx: &mut ValidationContext) {
let study_durations = study_stage_durations(data);
let n_stages = study_durations.len();
if n_stages < 2 {
return;
}
for thermal in &data.thermals {
let Some(cfg) = thermal.anticipated_config else {
continue;
};
let Some(k) = cfg.lead_stages() else {
continue;
};
let thermal_id = thermal.id.0;
let k_u = usize::try_from(k).unwrap_or(usize::MAX);
let decision_start = usize::try_from(thermal.entry_stage_id.unwrap_or(0).max(0))
.unwrap_or(0)
.min(n_stages - 1);
let decision_end_id = thermal.exit_stage_id.map_or_else(
|| i32::try_from(n_stages - 1).unwrap_or(i32::MAX),
|exit| exit - 1,
);
if decision_end_id < 0 {
continue;
}
let decision_end = usize::try_from(decision_end_id)
.unwrap_or(0)
.min(n_stages - 1);
if decision_start > decision_end {
continue;
}
let window_end = decision_end.saturating_add(k_u).min(n_stages - 1);
let entity_str = format!("thermals[id={thermal_id}].anticipated_config.lead_stages");
for i in decision_start..window_end {
let (prev, next) = (study_durations[i], study_durations[i + 1]);
if (prev - next).abs() > 1e-9 {
ctx.add_warning(
ErrorKind::ModelQuality,
"system/thermals.json",
Some(&entity_str),
format!(
"Thermal {thermal_id}: anticipated_config.lead_stages={k} active window \
spans a stage-cadence transition between stage {i} ({prev}h) and stage \
{} ({next}h); a fixed stage-count lead delivers a different physical \
lead on each side of the transition. Consider anticipated_config.lead_time, \
which anchors the lead to physical hours instead of a stage count.",
i + 1
),
);
break;
}
}
}
}
fn study_stage_durations(data: &ParsedData) -> Vec<f64> {
data.stages
.stages
.iter()
.filter(|s| s.id >= 0)
.map(|s| s.blocks.iter().map(|b| b.duration_hours).sum())
.collect()
}
fn required_anticipated_commitment_count(
mode: AnticipatedConfig,
study_durations: &[f64],
n_stages: usize,
) -> usize {
match mode {
AnticipatedConfig::LeadStages(lead_stages) => (lead_stages as usize).min(n_stages),
AnticipatedConfig::LeadTime(delta_hours) => {
let mut cumulative_hours = 0.0_f64;
let mut required = 0usize;
for &duration in study_durations.iter().take(n_stages) {
cumulative_hours += duration;
if cumulative_hours > delta_hours {
break;
}
required += 1;
}
required
}
}
}
fn check_committed_value_bounds(
thermal: &cobre_core::entities::Thermal,
thermal_id: EntityId,
values_mw: &[f64],
ctx: &mut ValidationContext,
) {
let min_mw = thermal.min_generation_mw;
let max_mw = thermal.max_generation_mw;
let entity_str = format!("thermals[id={}].anticipated_config", thermal_id.0);
for (j, &v) in values_mw.iter().enumerate() {
if v < min_mw || v > max_mw {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments.values_mw[{j}] = {v} \
is outside the plant's generation bounds [{min_mw}, {max_mw}]; \
the LP delivery equality at the corresponding stage cannot be \
satisfied and the LP will be infeasible",
thermal_id.0
),
);
}
}
}
fn check_seed_within_window(
thermal: &cobre_core::entities::Thermal,
thermal_id: EntityId,
values_mw: &[f64],
study_stage_ids: &[i32],
ctx: &mut ValidationContext,
) {
let entry = thermal.entry_stage_id;
let exit = thermal.exit_stage_id;
if entry.is_none() && exit.is_none() {
return;
}
let entity_str = format!("thermals[id={}].anticipated_config", thermal_id.0);
for (k, &v) in values_mw.iter().enumerate() {
if v == 0.0 {
continue;
}
let Some(&stage_id) = study_stage_ids.get(k) else {
continue;
};
let in_window = entry.is_none_or(|e| e <= stage_id) && exit.is_none_or(|e| stage_id < e);
if !in_window {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments.values_mw[{k}] = {v} \
matures at study stage id {stage_id}, which is outside the \
plant's commissioning window [entry={entry:?}, exit={exit:?}); \
the matured generation column is pinned to [0, 0] there, so the \
fishing equality reads 0 == {v} and the LP is infeasible. \
Seed a zero value at this stage or widen the window.",
thermal_id.0
),
);
}
}
}
pub(super) fn check_anticipated_decision_target_is_anticipated(
data: &ParsedData,
ctx: &mut ValidationContext,
) {
let anticipated_ids: HashSet<EntityId> = data
.thermals
.iter()
.filter(|t| t.anticipated_config.is_some())
.map(|t| t.id)
.collect();
for constraint in &data.generic_constraints {
for term in &constraint.expression.terms {
if let VariableRef::AnticipatedDecision { thermal_id } = term.variable
&& !anticipated_ids.contains(&thermal_id)
{
let entity_str = format!("constraint[id={}]", constraint.id.0);
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"constraints/generic_constraints.json",
Some(&entity_str),
format!(
"Constraint \"{}\": anticipated_decision({}) references Thermal {} \
which is not an anticipated thermal (anticipated_config is None). \
The anticipated_decision column only exists for plants with \
anticipated_config set.",
constraint.name, thermal_id.0, thermal_id.0,
),
);
}
}
}
}
pub(super) fn warn_thermal_generation_on_anticipated_thermal(
data: &ParsedData,
ctx: &mut ValidationContext,
) {
let anticipated_ids: HashSet<EntityId> = data
.thermals
.iter()
.filter(|t| t.anticipated_config.is_some())
.map(|t| t.id)
.collect();
if anticipated_ids.is_empty() {
return;
}
for constraint in &data.generic_constraints {
for term in &constraint.expression.terms {
if let VariableRef::ThermalGeneration { thermal_id, .. } = term.variable
&& anticipated_ids.contains(&thermal_id)
{
let entity_str = format!("constraint[id={}]", constraint.id.0);
ctx.add_warning(
ErrorKind::SemanticAmbiguity,
"constraints/generic_constraints.json",
Some(&entity_str),
format!(
"Constraint \"{}\": thermal_generation({id}) references an \
anticipated thermal. thermal_generation refers to the \
per-block generation at the delivery stage, not the \
forward commitment. If you intend to constrain the \
commitment itself, use anticipated_decision({id}) instead.",
constraint.name,
id = thermal_id.0,
),
);
}
}
}
}
pub(super) fn check_thermal_bounds_override_stage_range(
data: &ParsedData,
ctx: &mut ValidationContext,
) {
let n_stages = data.stages.stages.iter().filter(|s| s.id >= 0).count();
let n_stages_i = i64::try_from(n_stages).unwrap_or(i64::MAX);
for row in &data.thermal_bounds {
let s = i64::from(row.stage_id);
if s < 0 || s >= n_stages_i {
let entity_str = format!("thermal_id={}, stage_id={}", row.thermal_id.0, row.stage_id);
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"constraints/thermal_bounds.parquet",
Some(&entity_str),
format!(
"Thermal {}: thermal_bounds override at stage_id={} is \
outside the study horizon [0, {}); per-stage thermal \
overrides past the horizon are not allowed",
row.thermal_id.0, row.stage_id, n_stages
),
);
}
}
}
#[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 cobre_core::temporal::{Block, PolicyGraph, PolicyGraphType, Stage};
use cobre_core::{AnticipatedCommitmentHistory, EntityId, entities::AnticipatedConfig};
use super::super::test_support::*;
use super::super::validate_semantic_hydro_thermal;
use super::required_anticipated_commitment_count;
use crate::stages::StagesData;
use crate::validation::{ErrorKind, ValidationContext};
fn make_anticipated_thermal(
id: i32,
lead_stages: u32,
entry_stage_id: Option<i32>,
exit_stage_id: Option<i32>,
) -> cobre_core::entities::Thermal {
cobre_core::entities::Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(lead_stages)),
entry_stage_id,
exit_stage_id,
..make_thermal(id, 0.0, 500.0)
}
}
fn make_lead_time_anticipated_thermal(
id: i32,
lead_time_hours: f64,
entry_stage_id: Option<i32>,
exit_stage_id: Option<i32>,
) -> cobre_core::entities::Thermal {
cobre_core::entities::Thermal {
anticipated_config: Some(AnticipatedConfig::LeadTime(lead_time_hours)),
entry_stage_id,
exit_stage_id,
..make_thermal(id, 0.0, 500.0)
}
}
fn make_study_stage(id: i32, duration_hours: f64) -> Stage {
let mut stage = make_stage(id);
stage.blocks = vec![Block {
index: 0,
name: "FLAT".to_string(),
duration_hours,
}];
stage
}
fn make_data_anticipated_with_durations(
thermals: Vec<cobre_core::entities::Thermal>,
durations_hours: &[f64],
past_anticipated_commitments: Vec<AnticipatedCommitmentHistory>,
) -> crate::validation::schema::ParsedData {
let stages: Vec<Stage> = durations_hours
.iter()
.enumerate()
.map(|(id, &duration)| make_study_stage(id as i32, duration))
.collect();
let stages_data = StagesData {
stages,
policy_graph: PolicyGraph {
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.06,
transitions: vec![],
season_map: None,
},
};
let mut data = make_data(vec![], thermals, vec![], stages_data, vec![], vec![]);
data.initial_conditions.past_anticipated_commitments = past_anticipated_commitments;
data
}
fn make_data_anticipated(
thermals: Vec<cobre_core::entities::Thermal>,
n_stages: usize,
past_anticipated_commitments: Vec<AnticipatedCommitmentHistory>,
) -> crate::validation::schema::ParsedData {
let stage_ids: Vec<i32> = (0..n_stages as i32).collect();
let mut data = make_data(
vec![],
thermals,
vec![],
make_stages(stage_ids),
vec![],
vec![],
);
data.initial_conditions.past_anticipated_commitments = past_anticipated_commitments;
data
}
#[test]
fn test_valid_anticipated_thermal_ok() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"expected no errors, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_lead_time_thermal_accepted() {
let thermal = make_lead_time_anticipated_thermal(1, 720.0, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0, 0.0, 0.0],
};
let data = make_data_anticipated_with_durations(
vec![thermal],
&[168.0, 168.0, 168.0, 168.0, 720.0, 720.0],
vec![history],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::NotImplemented),
"LeadTime must no longer be rejected as NotImplemented, got: {:?}",
ctx.errors()
);
assert!(
!ctx.has_errors(),
"a valid LeadTime thermal with calendar-covered commitments must pass \
validation entirely, got: {:?}",
ctx.errors()
);
}
#[test]
fn lead_time_single_decider_passes_validation() {
let thermal = make_lead_time_anticipated_thermal(1, 744.0, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0],
};
let data = make_data_anticipated_with_durations(
vec![thermal],
&[744.0, 744.0, 744.0],
vec![history],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a valid single-decider LeadTime thermal must pass validation, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_missing_history_entry_error() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let data = make_data_anticipated(vec![thermal], 5, 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(),
"expected BusinessRuleViolation, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("Thermal 1"),
"message should contain 'Thermal 1', got: {msg}"
);
assert!(
msg.contains("missing"),
"message should contain 'missing', got: {msg}"
);
let file = relevant[0].file.to_string_lossy();
assert!(
file.contains("initial_conditions"),
"file path should reference initial_conditions, got: {file}"
);
let entity = relevant[0].entity.as_deref().unwrap_or("");
assert!(
entity.contains("initial_conditions.past_anticipated_commitments"),
"entity should contain 'initial_conditions.past_anticipated_commitments', got: {entity}"
);
}
#[test]
fn test_history_entry_for_non_anticipated_thermal_error() {
let thermal = make_thermal(1, 0.0, 500.0);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![100.0, 200.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
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(),
"expected BusinessRuleViolation, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("not an anticipated thermal"),
"message should contain 'not an anticipated thermal', got: {msg}"
);
}
#[test]
fn test_over_length_values_mw_error() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![100.0, 200.0, 300.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
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(),
"expected BusinessRuleViolation, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("expected 2 values, got 3"),
"message should contain 'expected 2 values, got 3', got: {msg}"
);
}
#[test]
fn test_under_length_values_mw_error() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![100.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
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(),
"expected BusinessRuleViolation, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("expected 2 values, got 1"),
"message should contain 'expected 2 values, got 1', got: {msg}"
);
}
#[test]
fn test_lead_stages_exceeds_study_horizon_error() {
let thermal = make_anticipated_thermal(1, 10, None, None);
let data = make_data_anticipated(vec![thermal], 5, 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
&& e.message.contains("lead_stages exceeds study horizon")
})
.collect();
assert!(
!relevant.is_empty(),
"expected BusinessRuleViolation with 'lead_stages exceeds study horizon', got: {errors:?}"
);
}
#[test]
fn test_lead_stages_equal_n_stages_ok() {
let thermal = make_anticipated_thermal(1, 5, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0, 0.0, 0.0, 0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"lead_stages == n_stages must be accepted, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_lead_time_exceeds_study_horizon_error() {
let thermal = make_lead_time_anticipated_thermal(1, 3000.0, None, None);
let data = make_data_anticipated_with_durations(
vec![thermal],
&[168.0, 168.0, 168.0, 168.0, 720.0, 720.0],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("lead_time exceeds study horizon")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation with 'lead_time exceeds study horizon', got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("Thermal 1"),
"message should contain 'Thermal 1', got: {msg}"
);
assert!(
msg.contains("3000"),
"message should contain delta_hours 3000, got: {msg}"
);
assert!(
msg.contains("2112"),
"message should contain the total horizon hours 2112, got: {msg}"
);
}
#[test]
fn test_lead_time_equal_total_horizon_ok() {
let thermal = make_lead_time_anticipated_thermal(1, 2112.0, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
};
let data = make_data_anticipated_with_durations(
vec![thermal],
&[168.0, 168.0, 168.0, 168.0, 720.0, 720.0],
vec![history],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"delta_hours == total_horizon_hours must be accepted, got: {:?}",
ctx.errors()
);
}
#[test]
fn lead_stages_cadence_transition_emits_advisory() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0],
};
let data = make_data_anticipated_with_durations(
vec![thermal],
&[168.0, 168.0, 168.0, 744.0, 744.0],
vec![history],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a cadence transition is advisory-only, never an error; got: {:?}",
ctx.errors()
);
let relevant: Vec<_> = ctx
.warnings()
.into_iter()
.filter(|w| w.kind == ErrorKind::ModelQuality && w.message.contains("Thermal 1"))
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one cadence-transition advisory, got: {:?}",
ctx.warnings()
);
let msg = &relevant[0].message;
assert!(
msg.contains("lead_time"),
"advisory must cite lead_time as the physically-anchored alternative, got: {msg}"
);
assert!(
msg.contains("stage 2") && msg.contains("stage 3"),
"advisory must cite the transition stage pair, got: {msg}"
);
}
#[test]
fn lead_stages_uniform_calendar_no_advisory() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0],
};
let data = make_data_anticipated_with_durations(
vec![thermal],
&[744.0, 744.0, 744.0, 744.0, 744.0],
vec![history],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a uniform calendar is valid, got: {:?}",
ctx.errors()
);
assert!(
!ctx.warnings()
.iter()
.any(|w| w.kind == ErrorKind::ModelQuality && w.message.contains("cadence")),
"a uniform calendar must not trigger a cadence-transition advisory, got: {:?}",
ctx.warnings()
);
}
#[test]
fn test_required_anticipated_commitment_count_lead_time_pmo_calendar() {
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let required = required_anticipated_commitment_count(
AnticipatedConfig::LeadTime(720.0),
&durations,
durations.len(),
);
assert_eq!(required, 4);
}
#[test]
fn test_required_anticipated_commitment_count_lead_time_uniform_calendar() {
let durations = [720.0; 5];
let required = required_anticipated_commitment_count(
AnticipatedConfig::LeadTime(1440.0),
&durations,
durations.len(),
);
assert_eq!(required, 2);
}
#[test]
fn test_required_anticipated_commitment_count_lead_stages_clamped() {
let required =
required_anticipated_commitment_count(AnticipatedConfig::LeadStages(2), &[720.0; 5], 5);
assert_eq!(required, 2);
}
#[test]
fn test_anticipated_lead_time_coverage_pmo_calendar() {
let thermal = make_lead_time_anticipated_thermal(1, 720.0, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0, 0.0, 0.0],
};
let data = make_data_anticipated_with_durations(
vec![thermal],
&[168.0, 168.0, 168.0, 168.0, 720.0, 720.0],
vec![history],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let coverage_errors: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation && e.message.contains("wrong length")
})
.collect();
assert!(
coverage_errors.is_empty(),
"expected no coverage-length error, got: {errors:?}"
);
}
#[test]
fn test_anticipated_lead_time_coverage_pmo_calendar_under_coverage_rejected() {
let thermal = make_lead_time_anticipated_thermal(1, 720.0, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0],
};
let data = make_data_anticipated_with_durations(
vec![thermal],
&[168.0, 168.0, 168.0, 168.0, 720.0, 720.0],
vec![history],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let coverage_errors: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation && e.message.contains("wrong length")
})
.collect();
assert_eq!(
coverage_errors.len(),
1,
"expected exactly one coverage-length error, got: {errors:?}"
);
let msg = &coverage_errors[0].message;
assert!(
msg.contains("expected 4"),
"message should name expected count 4, got: {msg}"
);
assert!(
msg.contains("got 2"),
"message should name actual count 2, got: {msg}"
);
}
#[test]
fn test_entry_window_on_anticipated_thermal_accepted() {
let thermal = make_anticipated_thermal(1, 3, Some(4), None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0, 0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"an entry window on an anticipated thermal must be accepted, got: {:?}",
ctx.errors()
);
assert!(
!ctx.errors()
.iter()
.any(|e| e.message.contains("not supported on anticipated thermals")),
"the removed window rejection must not fire"
);
}
#[test]
fn test_exit_window_on_anticipated_thermal_accepted() {
let thermal = make_anticipated_thermal(1, 3, None, Some(2));
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0, 0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"an exit window on an anticipated thermal must be accepted, got: {:?}",
ctx.errors()
);
assert!(
!ctx.errors()
.iter()
.any(|e| e.message.contains("not supported on anticipated thermals")),
"the removed window rejection must not fire"
);
}
#[test]
fn test_nonzero_seed_outside_window_rejected() {
let thermal = make_anticipated_thermal(1, 2, Some(2), Some(4));
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![100.0, 0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message
.contains("outside the plant's commissioning window")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one seed-vs-window error (slot 0 only), got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("values_mw[0]") && msg.contains("Thermal 1"),
"error must identify slot 0 of Thermal 1, got: {msg}"
);
}
#[test]
fn test_nonzero_seed_inside_window_accepted() {
let thermal = make_anticipated_thermal(1, 1, Some(0), Some(5));
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![50.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.errors().iter().any(|e| e
.message
.contains("outside the plant's commissioning window")),
"an in-window nonzero seed must not be rejected, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_window_on_plain_thermal_accepted() {
let thermal = cobre_core::entities::Thermal {
entry_stage_id: Some(1),
exit_stage_id: Some(2),
..make_thermal(1, 0.0, 100.0)
};
let data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(vec![0, 1, 2]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a window on a plain (non-anticipated) thermal must not error, got: {:?}",
ctx.errors()
);
assert!(
!ctx.errors()
.iter()
.any(|e| e.message.contains("not supported on anticipated thermals")),
"the anticipated-only window rejection must not fire for a plain thermal"
);
}
#[test]
fn test_anticipated_thermal_without_window_accepted() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.errors()
.iter()
.any(|e| e.message.contains("not supported on anticipated thermals")),
"a windowless anticipated thermal must not be rejected, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_committed_value_out_of_bounds_error() {
let thermal = make_anticipated_thermal(3, 2, None, None); let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(3),
values_mw: vec![600.0, 200.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
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
&& e.message.contains("outside the plant's generation bounds")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one bounds-violation error (slot 0 only), got: {errors:?}"
);
let msg0 = &relevant[0].message;
assert!(
msg0.contains("Thermal 3"),
"message should contain 'Thermal 3', got: {msg0}"
);
assert!(
msg0.contains("values_mw[0]"),
"message should identify the index [0], got: {msg0}"
);
assert!(
msg0.contains("600"),
"message should contain the offending value 600, got: {msg0}"
);
let file = relevant[0].file.to_string_lossy();
assert!(
file.contains("initial_conditions"),
"file path should reference initial_conditions, got: {file}"
);
let entity = relevant[0].entity.as_deref().unwrap_or("");
assert!(
entity.contains("thermals[id=3].anticipated_config"),
"entity should reference the thermal anticipated_config, got: {entity}"
);
}
#[test]
fn test_committed_value_below_min_gen_bounds_error() {
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(cobre_core::entities::AnticipatedConfig::LeadStages(2)),
..make_thermal(5, 100.0, 500.0)
};
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(5),
values_mw: vec![200.0, 50.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
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
&& e.message.contains("outside the plant's generation bounds")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one bounds-violation error (slot 1 only), got: {errors:?}"
);
let msg1 = &relevant[0].message;
assert!(
msg1.contains("Thermal 5"),
"message should contain 'Thermal 5', got: {msg1}"
);
assert!(
msg1.contains("values_mw[1]"),
"message should identify the index [1], got: {msg1}"
);
assert!(
msg1.contains("50"),
"message should contain the offending value 50, got: {msg1}"
);
}
#[test]
fn test_committed_values_all_zero_ok() {
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(cobre_core::entities::AnticipatedConfig::LeadStages(3)),
..make_thermal(7, 0.0, 400.0)
};
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(7),
values_mw: vec![0.0, 0.0, 0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"expected no errors for all-zero values_mw, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_committed_value_zero_accepted() {
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(cobre_core::entities::AnticipatedConfig::LeadStages(1)),
..make_thermal(9, 0.0, 400.0)
};
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(9),
values_mw: vec![0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"zero value must be accepted, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_committed_value_above_max_bounds_error() {
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(cobre_core::entities::AnticipatedConfig::LeadStages(1)),
..make_thermal(11, 100.0, 350.0)
};
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(11),
values_mw: vec![400.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
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
&& e.message.contains("outside the plant's generation bounds")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one bounds-violation error, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("Thermal 11"),
"message should contain 'Thermal 11', got: {msg}"
);
assert!(
msg.contains("values_mw[0]"),
"message should identify the index [0], got: {msg}"
);
assert!(
msg.contains("400"),
"message should contain the offending value 400, got: {msg}"
);
}
#[test]
fn test_f3_002_nonzero_values_mw_in_bounds_accepted_k1() {
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(cobre_core::entities::AnticipatedConfig::LeadStages(1)),
..make_thermal(2, 0.0, 350.0)
};
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(2),
values_mw: vec![204.5647], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
ctx.errors().is_empty(),
"expected no errors for in-bounds values_mw, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_k2_two_in_bounds_nonzero_values_accepted() {
let thermal = make_anticipated_thermal(5, 2, None, None); let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(5),
values_mw: vec![50.0, 30.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
ctx.errors().is_empty(),
"expected no errors for in-bounds K=2 values_mw, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_nonzero_in_bounds_seed_emits_no_semantic_ambiguity_warning() {
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(cobre_core::entities::AnticipatedConfig::LeadStages(2)),
..make_thermal(3, 0.0, 350.0)
};
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(3),
values_mw: vec![100.0, 200.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let all_warnings = ctx.warnings();
let ambiguity_warnings: Vec<_> = all_warnings
.iter()
.filter(|w| {
w.kind == ErrorKind::SemanticAmbiguity
&& w.file.to_string_lossy().contains("initial_conditions.json")
})
.collect();
assert!(
ambiguity_warnings.is_empty(),
"expected no SemanticAmbiguity warning from initial_conditions.json \
for an in-bounds non-zero seed, got: {ambiguity_warnings:?}"
);
}
#[test]
fn test_k2_mixed_in_bounds_and_out_of_bounds_only_oob_reported() {
let thermal = make_anticipated_thermal(7, 2, None, None); let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(7),
values_mw: vec![0.0, 600.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("outside the plant's generation bounds")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one bounds error (slot 1 only), got: {errors:?}"
);
assert!(
relevant[0].message.contains("values_mw[1]"),
"error must identify slot [1], got: {}",
relevant[0].message
);
assert!(
relevant[0].message.contains("600"),
"error must contain value 600, got: {}",
relevant[0].message
);
}
#[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());
}
fn make_thermal_bounds_row(thermal_id: i32, stage_id: i32) -> crate::ThermalBoundsRow {
crate::ThermalBoundsRow {
thermal_id: EntityId::from(thermal_id),
stage_id,
min_generation_mw: None,
max_generation_mw: None,
cost_per_mwh: None,
block_id: None,
}
}
fn make_data_thermal_bounds(
n_stages: usize,
rows: Vec<crate::ThermalBoundsRow>,
) -> crate::validation::schema::ParsedData {
let thermal = make_thermal(1, 0.0, 100.0);
let stage_ids: Vec<i32> = (0..n_stages as i32).collect();
let mut data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(stage_ids),
vec![],
vec![],
);
data.thermal_bounds = rows;
data
}
#[test]
fn test_thermal_bounds_override_stage_within_horizon_accepted() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, 4)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert!(
relevant.is_empty(),
"expected no thermal_bounds.parquet errors, got: {relevant:?}"
);
}
#[test]
fn test_thermal_bounds_override_stage_equals_n_rejected() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, 5)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation, got: {relevant:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("stage_id=5"),
"message should contain 'stage_id=5', got: {msg}"
);
assert!(
msg.contains("[0, 5)"),
"message should contain '[0, 5)', got: {msg}"
);
assert!(
msg.contains("not allowed"),
"message should contain 'not allowed', got: {msg}"
);
}
#[test]
fn test_thermal_bounds_override_stage_negative_rejected() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, -1)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation for stage_id=-1, got: {relevant:?}"
);
}
#[test]
fn test_thermal_bounds_override_multiple_offending_rows() {
let rows = vec![
make_thermal_bounds_row(1, 0), make_thermal_bounds_row(1, 5), make_thermal_bounds_row(1, 9), ];
let data = make_data_thermal_bounds(5, rows);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
2,
"expected exactly two BusinessRuleViolations, got: {relevant:?}"
);
}
#[test]
fn test_thermal_bounds_override_zero_n_stages_all_rejected() {
let data = make_data_thermal_bounds(0, vec![make_thermal_bounds_row(1, 0)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation when n_stages=0, got: {relevant:?}"
);
}
mod boundary_tests {
use super::*;
#[test]
fn override_at_t_minus_1_acceptance_boundary() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, 4)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert!(
relevant.is_empty(),
"stage_id=4 with n_stages=5 must be accepted, got: {relevant:?}"
);
}
#[test]
fn override_at_t_rejection_boundary() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, 5)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation at stage_id=5, got: {relevant:?}"
);
}
#[test]
fn override_at_t_plus_one_rejection() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, 6)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation at stage_id=6, got: {relevant:?}"
);
}
#[test]
fn override_negative_stage_rejection() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, -1)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation at stage_id=-1, got: {relevant:?}"
);
}
}
#[test]
fn test_anticipated_decision_on_non_anticipated_thermal_error() {
use cobre_core::{
ConstraintExpression, ConstraintSense, GenericConstraint, LinearTerm, SlackConfig,
VariableRef,
};
let thermal = make_thermal(7, 0.0, 500.0); let constraint = GenericConstraint {
id: EntityId::from(1),
name: "bad_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::AnticipatedDecision {
thermal_id: EntityId::from(7),
},
)],
},
sense: ConstraintSense::LessEqual,
slack: SlackConfig {
enabled: false,
penalty: None,
},
};
let stage_ids: Vec<i32> = (0..5).collect();
let mut data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(stage_ids),
vec![],
vec![],
);
data.generic_constraints = vec![constraint];
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
ctx.has_errors(),
"expected error for non-anticipated thermal"
);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(
!relevant.is_empty(),
"expected BusinessRuleViolation, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("bad_constraint"),
"message should contain constraint name, got: {msg}"
);
assert!(
msg.contains('7'),
"message should contain thermal id 7, got: {msg}"
);
assert!(
msg.contains("not an anticipated thermal"),
"message should explain the rule, got: {msg}"
);
let file = relevant[0].file.to_string_lossy();
assert!(
file.contains("generic_constraints.json"),
"file should reference generic_constraints.json, got: {file}"
);
}
#[test]
fn test_anticipated_decision_on_anticipated_thermal_ok() {
use cobre_core::{
ConstraintExpression, ConstraintSense, GenericConstraint, LinearTerm, SlackConfig,
VariableRef, entities::AnticipatedConfig,
};
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(2)),
..make_thermal(3, 0.0, 500.0)
};
let history = cobre_core::AnticipatedCommitmentHistory {
thermal_id: EntityId::from(3),
values_mw: vec![0.0, 0.0],
};
let constraint = GenericConstraint {
id: EntityId::from(10),
name: "valid_anticipated_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::AnticipatedDecision {
thermal_id: EntityId::from(3),
},
)],
},
sense: ConstraintSense::LessEqual,
slack: SlackConfig {
enabled: false,
penalty: None,
},
};
let stage_ids: Vec<i32> = (0..5).collect();
let mut data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(stage_ids),
vec![],
vec![],
);
data.initial_conditions.past_anticipated_commitments = vec![history];
data.generic_constraints = vec![constraint];
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("generic_constraints.json")
})
.collect();
assert!(
relevant.is_empty(),
"anticipated_decision on an anticipated thermal must not produce a BusinessRuleViolation, got: {relevant:?}"
);
}
#[test]
fn test_thermal_generation_on_anticipated_thermal_warns() {
use cobre_core::{
ConstraintExpression, ConstraintSense, GenericConstraint, LinearTerm, SlackConfig,
VariableRef, entities::AnticipatedConfig,
};
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(5, 0.0, 300.0)
};
let history = cobre_core::AnticipatedCommitmentHistory {
thermal_id: EntityId::from(5),
values_mw: vec![0.0],
};
let constraint = GenericConstraint {
id: EntityId::from(20),
name: "ambiguous_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::ThermalGeneration {
thermal_id: EntityId::from(5),
block_id: None,
},
)],
},
sense: ConstraintSense::GreaterEqual,
slack: SlackConfig {
enabled: false,
penalty: None,
},
};
let stage_ids: Vec<i32> = (0..5).collect();
let mut data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(stage_ids),
vec![],
vec![],
);
data.initial_conditions.past_anticipated_commitments = vec![history];
data.generic_constraints = vec![constraint];
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let hard: Vec<_> = errors
.iter()
.filter(|e| {
e.file
.to_string_lossy()
.contains("generic_constraints.json")
})
.collect();
assert!(
hard.is_empty(),
"thermal_generation on anticipated thermal must not produce a hard error, got: {hard:?}"
);
let warnings = ctx.warnings();
let relevant: Vec<_> = warnings
.iter()
.filter(|w| w.kind == ErrorKind::SemanticAmbiguity)
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one SemanticAmbiguity warning, got: {warnings:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("ambiguous_constraint"),
"warning should name the constraint, got: {msg}"
);
assert!(
msg.contains('5'),
"warning should mention thermal id 5, got: {msg}"
);
assert!(
msg.contains("anticipated_decision"),
"warning should suggest anticipated_decision, got: {msg}"
);
let file = relevant[0].file.to_string_lossy();
assert!(
file.contains("generic_constraints.json"),
"file should reference generic_constraints.json, got: {file}"
);
}
#[test]
fn test_thermal_generation_on_non_anticipated_thermal_no_warn() {
use cobre_core::{
ConstraintExpression, ConstraintSense, GenericConstraint, LinearTerm, SlackConfig,
VariableRef,
};
let thermal = make_thermal(9, 0.0, 200.0); let constraint = GenericConstraint {
id: EntityId::from(30),
name: "plain_thermal_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::ThermalGeneration {
thermal_id: EntityId::from(9),
block_id: None,
},
)],
},
sense: ConstraintSense::GreaterEqual,
slack: SlackConfig {
enabled: false,
penalty: None,
},
};
let stage_ids: Vec<i32> = (0..5).collect();
let mut data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(stage_ids),
vec![],
vec![],
);
data.generic_constraints = vec![constraint];
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let warnings = ctx.warnings();
let relevant: Vec<_> = warnings
.iter()
.filter(|w| w.kind == ErrorKind::SemanticAmbiguity)
.collect();
assert!(
relevant.is_empty(),
"thermal_generation on a non-anticipated thermal must not emit SemanticAmbiguity, got: {relevant:?}"
);
}
}