use std::collections::{HashMap, HashSet};
use chrono::{NaiveDate, TimeDelta};
use cobre_core::commissioning::commissioning_active;
use cobre_core::temporal::{
Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig, StageStateConfig,
};
use cobre_core::{
AnticipatedCommitmentHistory, AnticipatedConfig, EntityId, PostStudyStages, Thermal,
VariableRef,
};
use cobre_stochastic::season_cast::{DatedWindow, StageCalendar};
use super::super::{ErrorKind, ValidationContext, schema::ParsedData};
use super::envelope_tolerance;
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_stages: &[Stage] = match data.stages.stages.iter().position(|s| s.id >= 0) {
Some(idx) => &data.stages.stages[idx..],
None => &[],
};
let study_stage_ids: Vec<i32> = study_stages.iter().map(|s| s.id).collect();
let n_stages = study_stage_ids.len();
let study_durations = study_stage_durations(data);
let horizon_end = study_stages.last().map(|s| s.end_date);
let extended_axis = build_extended_delivery_axis(data);
for thermal in &data.thermals {
let Some(ref cfg) = thermal.anticipated_config else {
continue;
};
let thermal_id = thermal.id.0;
let classes = classify_deliveries(
*cfg,
extended_axis.as_ref(),
&study_durations,
n_stages,
(thermal.entry_stage_id, thermal.exit_stage_id),
);
let reaches_post_study =
!classes.carried.is_empty() || !classes.fixed_post_study.is_empty();
if let AnticipatedConfig::LeadTime(delta_hours) = *cfg {
let total_horizon_hours: f64 = study_durations.iter().sum();
if delta_hours > total_horizon_hours && !reaches_post_study {
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 && !reaches_post_study {
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 windows_by_id = group_commitments_by_thermal(&ic.past_anticipated_commitments);
let calendar = (!windows_by_id.is_empty()).then(|| StageCalendar::new(study_stages));
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 windows_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 at least one commitment window",
thermal_id.0
),
);
}
Some(records) => {
if let Some(horizon_end) = horizon_end
&& !check_no_straddling_commitment_window(thermal_id, records, horizon_end, ctx)
{
continue;
}
let Some(calendar) = calendar.as_ref() else {
continue;
};
let k_i = lead_delivery_stage_count(*cfg, &study_durations, n_stages);
if check_commitment_coverage(
thermal_id,
records,
calendar,
k_i,
&study_stage_ids,
ctx,
) {
check_committed_value_bounds(thermal, thermal_id, records, ctx);
check_seed_within_window(
thermal,
thermal_id,
records,
calendar,
&study_stage_ids,
ctx,
);
}
}
}
}
let mut reported: HashSet<EntityId> = HashSet::new();
for history in &ic.past_anticipated_commitments {
if !anticipated_thermal_ids.contains(&history.thermal_id)
&& reported.insert(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 group_commitments_by_thermal(
histories: &[AnticipatedCommitmentHistory],
) -> HashMap<EntityId, Vec<&AnticipatedCommitmentHistory>> {
let mut windows_by_id: HashMap<EntityId, Vec<&AnticipatedCommitmentHistory>> = HashMap::new();
for history in histories {
windows_by_id
.entry(history.thermal_id)
.or_default()
.push(history);
}
windows_by_id
}
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 lead_delivery_stage_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 count = 0usize;
for &duration in study_durations.iter().take(n_stages) {
cumulative_hours += duration;
if cumulative_hours > delta_hours {
break;
}
count += 1;
}
count
}
}
}
struct ExtendedDeliveryAxis {
durations: Vec<f64>,
stage_ids: Vec<i32>,
n_stages: usize,
}
fn build_extended_delivery_axis(data: &ParsedData) -> Option<ExtendedDeliveryAxis> {
let post_study = data.post_study_stages.as_ref()?;
let study_durations = study_stage_durations(data);
let n_stages = study_durations.len();
let study_ids: Vec<i32> = data
.stages
.stages
.iter()
.filter(|s| s.id >= 0)
.map(|s| s.id)
.collect();
let max_study_id = study_ids.last().copied().unwrap_or(-1);
let durations: Vec<f64> = study_durations
.iter()
.copied()
.chain(post_study.stages.iter().map(|s| s.duration_hours))
.collect();
let stage_ids: Vec<i32> = study_ids
.into_iter()
.chain(
(0..post_study.stages.len())
.map(|j| max_study_id + 1 + i32::try_from(j).unwrap_or(i32::MAX)),
)
.collect();
Some(ExtendedDeliveryAxis {
durations,
stage_ids,
n_stages,
})
}
fn extended_deciders(mode: AnticipatedConfig, durations: &[f64]) -> Vec<Option<usize>> {
let n_delivery = durations.len();
match mode {
AnticipatedConfig::LeadStages(lead_stages) => {
let lead = lead_stages as usize;
(0..n_delivery).map(|m| m.checked_sub(lead)).collect()
}
AnticipatedConfig::LeadTime(delta_hours) => {
let mut boundaries = Vec::with_capacity(n_delivery + 1);
let mut cumulative = 0.0_f64;
boundaries.push(cumulative);
for &duration in durations {
cumulative += duration;
boundaries.push(cumulative);
}
(0..n_delivery)
.map(|m| {
let target = boundaries[m + 1] - delta_hours;
boundaries
.partition_point(|&boundary| boundary < target)
.checked_sub(1)
})
.collect()
}
}
}
struct DeliveryClasses {
#[allow(dead_code)]
leading_in_study: usize,
fixed_post_study: Vec<usize>,
carried: Vec<usize>,
beyond_reach: Vec<usize>,
commissioning_inactive: Vec<usize>,
}
fn classify_deliveries(
mode: AnticipatedConfig,
axis: Option<&ExtendedDeliveryAxis>,
study_durations: &[f64],
n_stages: usize,
window: (Option<i32>, Option<i32>),
) -> DeliveryClasses {
let leading_in_study = lead_delivery_stage_count(mode, study_durations, n_stages);
let Some(axis) = axis else {
return DeliveryClasses {
leading_in_study,
fixed_post_study: Vec::new(),
carried: Vec::new(),
beyond_reach: Vec::new(),
commissioning_inactive: Vec::new(),
};
};
let (entry, exit) = window;
let deciders = extended_deciders(mode, &axis.durations);
debug_assert_eq!(
leading_in_study,
deciders[..axis.n_stages]
.iter()
.filter(|decider| decider.is_none())
.count(),
"leading_in_study must match the None run of extended_deciders over the study prefix"
);
let mut fixed_post_study = Vec::new();
let mut carried = Vec::new();
let mut beyond_reach = Vec::new();
let mut commissioning_inactive = Vec::new();
for (j, (&stage_id, decider)) in axis.stage_ids[axis.n_stages..]
.iter()
.zip(&deciders[axis.n_stages..])
.enumerate()
{
if !commissioning_active(entry, exit, stage_id) {
commissioning_inactive.push(j);
continue;
}
match decider {
Some(c) if *c < axis.n_stages => carried.push(j),
None => fixed_post_study.push(j),
Some(_) => beyond_reach.push(j),
}
}
debug_assert_eq!(
fixed_post_study.len() + carried.len() + beyond_reach.len() + commissioning_inactive.len(),
axis.stage_ids.len() - axis.n_stages,
"the four post-study classes must partition every post-study index exactly once"
);
DeliveryClasses {
leading_in_study,
fixed_post_study,
carried,
beyond_reach,
commissioning_inactive,
}
}
fn check_no_straddling_commitment_window(
thermal_id: EntityId,
records: &[&AnticipatedCommitmentHistory],
horizon_end: NaiveDate,
ctx: &mut ValidationContext,
) -> bool {
let mut valid = true;
let entity_str = format!("thermals[id={}].anticipated_config", thermal_id.0);
for record in records {
if record.start_date < horizon_end && record.end_date > horizon_end {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments window [{}, {}) straddles the \
study horizon end ({horizon_end}); a single window may not cover both \
in-study and post-study delivery. Declare two windows split at the \
horizon instead: one ending at {horizon_end} for the in-study coverage, \
one starting at {horizon_end} for the post-study coverage.",
thermal_id.0, record.start_date, record.end_date
),
);
valid = false;
}
}
valid
}
#[allow(clippy::float_cmp)] fn check_commitment_coverage(
thermal_id: EntityId,
records: &[&AnticipatedCommitmentHistory],
calendar: &StageCalendar,
k_i: usize,
study_stage_ids: &[i32],
ctx: &mut ValidationContext,
) -> bool {
let windows: Vec<DatedWindow> = records
.iter()
.map(|r| DatedWindow {
start_date: r.start_date,
end_date: r.end_date,
})
.collect();
let mut per_stage = vec![0.0_f64; study_stage_ids.len()];
for window in &windows {
for (total, fraction) in per_stage.iter_mut().zip(calendar.coverage(window)) {
*total += fraction;
}
}
let entity_str = format!("thermals[id={}].anticipated_config", thermal_id.0);
let mut valid = true;
if !calendar.covers_exactly(&windows, k_i) {
let uncovered: Vec<i32> = (0..k_i)
.filter(|&i| per_stage[i] != 1.0)
.map(|i| study_stage_ids[i])
.collect();
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments do not tile the leading {k_i} \
delivery stage(s) at coverage 1.0; study stage id(s) {uncovered:?} are not \
covered exactly once. Write a commitment window (a committed 0 MW is explicit) \
for every leading delivery stage.",
thermal_id.0
),
);
valid = false;
}
let over_covered: Vec<i32> = (k_i..study_stage_ids.len())
.filter(|&i| per_stage[i] != 0.0)
.map(|i| study_stage_ids[i])
.collect();
if !over_covered.is_empty() {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments cover study stage id(s) {over_covered:?} \
beyond the leading {k_i} calendar-derived delivery stage(s); a commitment window \
may not cover a study stage the study itself decides. Shorten the plant's \
anticipated_config lead so its window stays within the leading {k_i} delivery \
stage(s).",
thermal_id.0
),
);
valid = false;
}
valid
}
fn check_committed_value_bounds(
thermal: &Thermal,
thermal_id: EntityId,
records: &[&AnticipatedCommitmentHistory],
ctx: &mut ValidationContext,
) {
let min_mw = thermal.min_generation_mw;
let max_mw = thermal.max_generation_mw;
let min_tolerance = envelope_tolerance(min_mw);
let max_tolerance = envelope_tolerance(max_mw);
let entity_str = format!("thermals[id={}].anticipated_config", thermal_id.0);
for record in records {
let v = record.value_mw;
if v < min_mw - min_tolerance || v > max_mw + max_tolerance {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments window [{}, {}) value_mw = {v} \
is outside the plant's generation bounds [{min_mw}, {max_mw}]; \
the LP delivery equality on the covered stage(s) cannot be \
satisfied and the LP will be infeasible",
thermal_id.0, record.start_date, record.end_date
),
);
}
}
}
#[allow(clippy::float_cmp)] fn check_seed_within_window(
thermal: &Thermal,
thermal_id: EntityId,
records: &[&AnticipatedCommitmentHistory],
calendar: &StageCalendar,
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 record in records {
let v = record.value_mw;
if v == 0.0 {
continue;
}
let window = DatedWindow {
start_date: record.start_date,
end_date: record.end_date,
};
for (i, fraction) in calendar.coverage(&window).into_iter().enumerate() {
if fraction == 0.0 {
continue;
}
let stage_id = study_stage_ids[i];
if !commissioning_active(entry, exit, stage_id) {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments window [{}, {}) value_mw = {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. \
Commit a zero rate at this stage or widen the window.",
thermal_id.0, record.start_date, record.end_date
),
);
}
}
}
}
#[allow(clippy::float_cmp)] fn check_fixed_commitment_within_window(
thermal: &Thermal,
thermal_id: EntityId,
records: &[&AnticipatedCommitmentHistory],
post_calendar: &StageCalendar,
commissioning_inactive: &[usize],
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 record in records {
let v = record.value_mw;
if v == 0.0 {
continue;
}
let window = DatedWindow {
start_date: record.start_date,
end_date: record.end_date,
};
let coverage = post_calendar.coverage(&window);
for &j in commissioning_inactive {
if coverage[j] == 0.0 {
continue;
}
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments window [{}, {}) value_mw = {v} \
covers post-study stage index {j}, which is outside the plant's \
commissioning window [entry={entry:?}, exit={exit:?}); the plant is not \
in service for this stage and the fixed commitment cannot be delivered. \
Declare a zero commitment at this stage, or widen the commissioning window.",
thermal_id.0, record.start_date, record.end_date
),
);
}
}
}
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
),
);
}
}
}
pub(super) fn check_post_study_stages(data: &ParsedData, ctx: &mut ValidationContext) {
let Some(post_study) = data.post_study_stages.as_ref() else {
return;
};
let study_end = data
.stages
.stages
.iter()
.rfind(|s| s.id >= 0)
.map(|s| s.end_date);
let (calendar_stages, well_formed) = build_post_study_calendar(post_study, study_end, ctx);
if !well_formed || calendar_stages.is_empty() {
return;
}
let Some(axis) = build_extended_delivery_axis(data) else {
return;
};
let study_durations = study_stage_durations(data);
let bound_cells: HashSet<(EntityId, usize)> = post_study
.thermal_bounds
.iter()
.map(|b| (b.thermal_id, b.post_study_stage_index))
.collect();
let windows_by_id =
group_commitments_by_thermal(&data.initial_conditions.past_anticipated_commitments);
let post_calendar = StageCalendar::new(&calendar_stages);
for thermal in &data.thermals {
let Some(cfg) = thermal.anticipated_config else {
continue;
};
let classes = classify_deliveries(
cfg,
Some(&axis),
&study_durations,
axis.n_stages,
(thermal.entry_stage_id, thermal.exit_stage_id),
);
let entity_str = format!("thermals[id={}].anticipated_config", thermal.id.0);
for &j in &classes.carried {
if !bound_cells.contains(&(thermal.id, j)) {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(&entity_str),
format!(
"Thermal {}: anticipated lead reaches post-study stage index {j}, but \
post_study_stages.json has no thermal_bounds entry for (thermal_id {}, \
post_study_stage_index {j}).",
thermal.id.0, thermal.id.0
),
);
}
}
let windows = windows_by_id
.get(&thermal.id)
.map(Vec::as_slice)
.unwrap_or_default();
let per_stage =
post_study_coverage_per_stage(windows, &post_calendar, calendar_stages.len());
check_fixed_post_study_tiling(thermal.id, &per_stage, &classes.fixed_post_study, ctx);
check_post_study_window_excludes_unreachable_stages(
thermal.id,
&per_stage,
&classes.carried,
&classes.beyond_reach,
ctx,
);
check_fixed_commitment_within_window(
thermal,
thermal.id,
windows,
&post_calendar,
&classes.commissioning_inactive,
ctx,
);
}
}
fn post_study_coverage_per_stage(
windows: &[&AnticipatedCommitmentHistory],
post_calendar: &StageCalendar,
n_post_study_stages: usize,
) -> Vec<f64> {
let dated: Vec<DatedWindow> = windows
.iter()
.map(|r| DatedWindow {
start_date: r.start_date,
end_date: r.end_date,
})
.collect();
let mut per_stage = vec![0.0_f64; n_post_study_stages];
for window in &dated {
for (total, fraction) in per_stage.iter_mut().zip(post_calendar.coverage(window)) {
*total += fraction;
}
}
per_stage
}
#[allow(clippy::float_cmp)] fn check_fixed_post_study_tiling(
thermal_id: EntityId,
per_stage: &[f64],
fixed_post_study: &[usize],
ctx: &mut ValidationContext,
) {
if fixed_post_study.is_empty() {
return;
}
let uncovered: Vec<usize> = fixed_post_study
.iter()
.copied()
.filter(|&j| per_stage[j] != 1.0)
.collect();
if uncovered.is_empty() {
return;
}
let entity_str = format!("thermals[id={}].anticipated_config", thermal_id.0);
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments do not tile post-study stage \
index(es) {uncovered:?} at coverage 1.0; declare the fixed commitment for \
each (a committed 0 MW is explicit).",
thermal_id.0
),
);
}
#[allow(clippy::float_cmp)] fn check_post_study_window_excludes_unreachable_stages(
thermal_id: EntityId,
per_stage: &[f64],
carried: &[usize],
beyond_reach: &[usize],
ctx: &mut ValidationContext,
) {
let entity_str = format!("thermals[id={}].anticipated_config", thermal_id.0);
let covered_carried: Vec<usize> = carried
.iter()
.copied()
.filter(|&j| per_stage[j] != 0.0)
.collect();
if !covered_carried.is_empty() {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments cover post-study stage \
index(es) {covered_carried:?}, which the study itself decides; a declared \
fixed value there contradicts the study's own decision. Remove the window, \
or lengthen anticipated_config's lead so the delivery becomes \
pre-study-decided.",
thermal_id.0
),
);
}
let covered_beyond_reach: Vec<usize> = beyond_reach
.iter()
.copied()
.filter(|&j| per_stage[j] != 0.0)
.collect();
if !covered_beyond_reach.is_empty() {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments cover post-study stage \
index(es) {covered_beyond_reach:?}, which are past the plant's decision \
reach and cannot be represented. Remove the window, or shorten \
anticipated_config's lead so the delivery falls inside the reach.",
thermal_id.0
),
);
}
}
fn build_post_study_calendar(
post_study: &PostStudyStages,
study_end: Option<NaiveDate>,
ctx: &mut ValidationContext,
) -> (Vec<Stage>, bool) {
let mut calendar_stages: Vec<Stage> = Vec::with_capacity(post_study.stages.len());
let mut well_formed = true;
for (i, stage) in post_study.stages.iter().enumerate() {
match post_study_end_date(stage.start_date, stage.duration_hours) {
Some(end_date) if end_date > stage.start_date => {
calendar_stages.push(make_calendar_stage(
i,
stage.start_date,
end_date,
stage.duration_hours,
));
}
_ => {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(format!("stages[{i}]")),
format!(
"post-study stage starting {} has duration_hours {} that rounds to a \
non-positive whole-day span; declare a duration of at least a day.",
stage.start_date, stage.duration_hours
),
);
well_formed = false;
}
}
}
match (calendar_stages.first(), study_end) {
(Some(first), Some(end)) if first.start_date != end => {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some("stages[0].start_date"),
format!(
"first post-study stage starts {} but the study horizon ends {}; the \
post-study calendar must begin exactly at the study horizon end.",
first.start_date, end
),
);
well_formed = false;
}
(Some(_), None) => {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some("stages"),
"post_study_stages.json is declared but the study has no study stages to anchor \
the post-study calendar against."
.to_string(),
);
well_formed = false;
}
_ => {}
}
for pair in calendar_stages.windows(2) {
if pair[0].end_date != pair[1].start_date {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(format!("stages start_date={}", pair[1].start_date)),
format!(
"post-study stages are not date-contiguous: the stage starting {} ends {} \
but the next stage starts {}; each stage must end exactly where the next \
begins.",
pair[0].start_date, pair[0].end_date, pair[1].start_date
),
);
well_formed = false;
}
}
(calendar_stages, well_formed)
}
#[allow(clippy::cast_possible_truncation)] fn post_study_end_date(start_date: NaiveDate, duration_hours: f64) -> Option<NaiveDate> {
let days = (duration_hours / 24.0).round() as i64;
start_date.checked_add_signed(TimeDelta::days(days))
}
fn make_calendar_stage(
index: usize,
start_date: NaiveDate,
end_date: NaiveDate,
duration_hours: f64,
) -> Stage {
Stage {
index,
id: i32::try_from(index).unwrap_or(i32::MAX),
start_date,
end_date,
season_id: None,
blocks: vec![Block {
index: 0,
name: "post_study".to_string(),
duration_hours,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
}
}
#[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, PolicyGraphType, Stage};
use cobre_core::{
AnticipatedCommitmentHistory, AnticipatedConfig, EntityId, HorizonGraph, PostStudyStage,
PostStudyStages, PostStudyThermalBound, Thermal,
};
use chrono::{NaiveDate, TimeDelta};
use super::super::test_support::*;
use super::super::validate_semantic_hydro_thermal;
use super::{
ExtendedDeliveryAxis, build_extended_delivery_axis, check_anticipated_thermals,
check_post_study_stages, classify_deliveries, extended_deciders, lead_delivery_stage_count,
};
use crate::stages::StagesData;
use crate::validation::schema::ParsedData;
use crate::validation::{ErrorKind, ValidationContext};
fn make_anticipated_thermal(
id: i32,
lead_stages: u32,
entry_stage_id: Option<i32>,
exit_stage_id: Option<i32>,
) -> Thermal {
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>,
) -> Thermal {
Thermal {
anticipated_config: Some(AnticipatedConfig::LeadTime(lead_time_hours)),
entry_stage_id,
exit_stage_id,
..make_thermal(id, 0.0, 500.0)
}
}
fn calendar_anchor() -> NaiveDate {
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()
}
fn stage_boundaries(durations_hours: &[f64]) -> Vec<NaiveDate> {
let mut cursor = calendar_anchor();
let mut dates = vec![cursor];
for &hours in durations_hours {
cursor += TimeDelta::days((hours / 24.0) as i64);
dates.push(cursor);
}
dates
}
fn contiguous_stages(durations_hours: &[f64]) -> Vec<Stage> {
let boundaries = stage_boundaries(durations_hours);
durations_hours
.iter()
.enumerate()
.map(|(i, &hours)| {
let mut stage = make_stage(i as i32);
stage.index = i;
stage.start_date = boundaries[i];
stage.end_date = boundaries[i + 1];
stage.blocks = vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: hours,
}];
stage
})
.collect()
}
fn commitments_on(
thermal_id: i32,
values: &[f64],
durations_hours: &[f64],
) -> Vec<AnticipatedCommitmentHistory> {
let boundaries = stage_boundaries(durations_hours);
values
.iter()
.enumerate()
.map(|(i, &value_mw)| AnticipatedCommitmentHistory {
thermal_id: EntityId::from(thermal_id),
start_date: boundaries[i],
end_date: boundaries[i + 1],
value_mw,
})
.collect()
}
fn commitments(thermal_id: i32, values: &[f64]) -> Vec<AnticipatedCommitmentHistory> {
commitments_on(thermal_id, values, &vec![720.0; values.len().max(1)])
}
fn anticipated_data(
thermals: Vec<Thermal>,
durations_hours: &[f64],
past_anticipated_commitments: Vec<AnticipatedCommitmentHistory>,
) -> ParsedData {
let stages_data = StagesData {
openings_declared: std::collections::HashSet::new(),
stages: contiguous_stages(durations_hours),
policy_graph: HorizonGraph {
stage_discount_rate_overrides: std::collections::HashMap::new(),
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.06,
transitions: vec![],
nodes: Vec::new(),
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_with_durations(
thermals: Vec<Thermal>,
durations_hours: &[f64],
past_anticipated_commitments: Vec<AnticipatedCommitmentHistory>,
) -> ParsedData {
anticipated_data(thermals, durations_hours, past_anticipated_commitments)
}
fn make_data_anticipated(
thermals: Vec<Thermal>,
n_stages: usize,
past_anticipated_commitments: Vec<AnticipatedCommitmentHistory>,
) -> ParsedData {
anticipated_data(
thermals,
&vec![720.0; n_stages],
past_anticipated_commitments,
)
}
fn post_study_from(
study_durations: &[f64],
post_durations: &[f64],
bounds: &[(i32, usize, f64, f64)],
) -> PostStudyStages {
let boundaries = stage_boundaries(study_durations);
let mut cursor = boundaries[study_durations.len()];
let mut stages = Vec::with_capacity(post_durations.len());
for &hours in post_durations {
stages.push(PostStudyStage {
start_date: cursor,
duration_hours: hours,
});
cursor += TimeDelta::days((hours / 24.0) as i64);
}
let thermal_bounds = bounds
.iter()
.map(|&(thermal_id, j, min_mw, max_mw)| PostStudyThermalBound {
thermal_id: EntityId::from(thermal_id),
post_study_stage_index: j,
cost_per_mwh: 100.0,
min_mw,
max_mw,
})
.collect();
PostStudyStages {
stages,
thermal_bounds,
}
}
fn data_with_post_study(
thermals: Vec<Thermal>,
durations_hours: &[f64],
past_anticipated_commitments: Vec<AnticipatedCommitmentHistory>,
post_study: PostStudyStages,
) -> ParsedData {
let mut data = make_data_anticipated_with_durations(
thermals,
durations_hours,
past_anticipated_commitments,
);
data.post_study_stages = Some(post_study);
data
}
#[test]
fn test_valid_anticipated_thermal_ok() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let data = make_data_anticipated(vec![thermal], 5, commitments(1, &[0.0, 0.0]));
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 durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0, 0.0, 0.0], &durations),
);
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 durations = [744.0, 744.0, 744.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0], &durations),
);
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 data = make_data_anticipated(vec![thermal], 5, commitments(1, &[100.0, 200.0]));
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_coverage_beyond_lead_horizon_rejected() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let data = make_data_anticipated(vec![thermal], 5, commitments(1, &[100.0, 200.0, 300.0]));
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("beyond the leading")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one over-coverage error, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains('2'),
"message should name over-covered study stage id 2, got: {msg}"
);
}
#[test]
fn test_under_coverage_gap_in_lead_horizon_rejected() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let data = make_data_anticipated(vec![thermal], 5, commitments(1, &[100.0]));
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("do not tile the leading")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one under-coverage error, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains('1'),
"message should name the uncovered study stage id 1, got: {msg}"
);
}
#[test]
fn commitment_coverage_accepts_a_window_past_the_study_horizon() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let study_durations = vec![720.0; 4];
let post_study = post_study_from(&study_durations, &[720.0], &[]);
let mut commitments = commitments_on(1, &[0.0, 0.0], &study_durations);
commitments.push(AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
start_date: post_study.stages[0].start_date,
end_date: post_study.stages[0].start_date + TimeDelta::days(30),
value_mw: 0.0,
});
let data = data_with_post_study(vec![thermal], &study_durations, commitments, post_study);
let mut ctx = ValidationContext::new();
check_anticipated_thermals(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a window past the study horizon must not be reported as a gap or as \
over-coverage, got: {:?}",
ctx.errors()
);
}
#[test]
fn commitment_coverage_rejects_a_window_on_a_study_decided_stage() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let data = make_data_anticipated(vec![thermal], 4, commitments(1, &[0.0, 0.0, 0.0]));
let mut ctx = ValidationContext::new();
check_anticipated_thermals(&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("beyond the leading")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one over-coverage error, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains('2'),
"message should name over-covered study stage id 2, got: {msg}"
);
assert!(
!msg.contains("past the pre-study delivery horizon"),
"the retired horizon phrasing must not appear, got: {msg}"
);
assert!(
!msg.contains("extend past the horizon"),
"the message must not claim a window may not extend past the horizon, got: {msg}"
);
}
#[test]
fn commitment_coverage_gap_message_is_unchanged() {
let thermal = make_anticipated_thermal(1, 3, None, None);
let data = make_data_anticipated(vec![thermal], 5, commitments(1, &[0.0, 0.0]));
let mut ctx = ValidationContext::new();
check_anticipated_thermals(&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("do not tile the leading")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one gap error, got: {errors:?}"
);
assert_eq!(
relevant[0].message,
"Thermal 1: past_anticipated_commitments do not tile the leading 3 delivery \
stage(s) at coverage 1.0; study stage id(s) [2] are not covered exactly once. \
Write a commitment window (a committed 0 MW is explicit) for every leading \
delivery stage."
);
}
#[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 data =
make_data_anticipated(vec![thermal], 5, commitments(1, &[0.0, 0.0, 0.0, 0.0, 0.0]));
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 durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], &durations),
);
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 test_lead_time_exceeding_horizon_with_reachable_post_study_accepted() {
let thermal = make_lead_time_anticipated_thermal(1, 3000.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let post_study = post_study_from(&durations, &[1440.0], &[(1, 0, 0.0, 500.0)]);
let data = data_with_post_study(vec![thermal], &durations, vec![], post_study);
let mut ctx = ValidationContext::new();
check_anticipated_thermals(&data, &mut ctx);
let errors = ctx.errors();
let lead_cap_errors: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("lead_time exceeds study horizon")
})
.collect();
assert!(
lead_cap_errors.is_empty(),
"a thermal whose lead reaches a declared post-study stage must be exempt from the \
lead-horizon cap, got: {errors:?}"
);
}
#[test]
fn test_lead_time_exceeding_horizon_without_post_study_still_rejected() {
let thermal = make_lead_time_anticipated_thermal(1, 3000.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let data = make_data_anticipated_with_durations(vec![thermal], &durations, vec![]);
assert!(
data.post_study_stages.is_none(),
"fixture must declare no post-study stages"
);
let mut ctx = ValidationContext::new();
check_anticipated_thermals(&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:?}"
);
}
#[test]
fn lead_exceeding_the_horizon_is_accepted_when_it_reaches_a_fixed_post_study_stage() {
let thermal = make_lead_time_anticipated_thermal(1, 2000.0, None, None);
let durations = [720.0];
let post_study = post_study_from(&durations, &[720.0], &[(1, 0, 0.0, 500.0)]);
let data = data_with_post_study(vec![thermal], &durations, vec![], post_study);
let mut ctx = ValidationContext::new();
check_anticipated_thermals(&data, &mut ctx);
let errors = ctx.errors();
let lead_cap_errors: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("lead_time exceeds study horizon")
})
.collect();
assert!(
lead_cap_errors.is_empty(),
"a thermal whose lead reaches only a class-4 (pre-study-decided) post-study stage \
must be exempt from the lead-horizon cap, got: {errors:?}"
);
}
#[test]
fn lead_exceeding_the_horizon_without_post_study_stages_is_still_rejected() {
let thermal = make_lead_time_anticipated_thermal(1, 2000.0, None, None);
let durations = [720.0];
let data = make_data_anticipated_with_durations(vec![thermal], &durations, vec![]);
assert!(
data.post_study_stages.is_none(),
"fixture must declare no post-study stages"
);
let mut ctx = ValidationContext::new();
check_anticipated_thermals(&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:?}"
);
}
#[test]
fn test_post_horizon_delivery_thermal_tiling_all_study_stages_validates() {
let thermal = make_lead_time_anticipated_thermal(1, 3000.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let post_study = post_study_from(&durations, &[1440.0], &[(1, 0, 0.0, 500.0)]);
let data = data_with_post_study(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], &durations),
post_study,
);
let mut ctx = ValidationContext::new();
check_anticipated_thermals(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a post-horizon-delivery thermal tiling every leading study stage must validate \
cleanly through check_anticipated_thermals, got: {:?}",
ctx.errors()
);
}
#[test]
fn full_anticipation_lead_stages_reaching_only_post_study_stages_validates() {
let thermal = make_anticipated_thermal(1, 7, None, None);
let study_durations = [720.0; 4];
let post_durations = [720.0; 4];
let post_study = post_study_from(&study_durations, &post_durations, &[(1, 3, 0.0, 500.0)]);
let concatenated: Vec<f64> = study_durations
.iter()
.copied()
.chain(post_durations[..3].iter().copied())
.collect();
let commitments = commitments_on(1, &[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 500.0], &concatenated);
let data = data_with_post_study(vec![thermal], &study_durations, commitments, post_study);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a full-anticipation LeadStages thermal reaching only post-study stages must \
validate cleanly end-to-end, got: {:?}",
ctx.errors()
);
}
#[test]
fn lead_stages_short_lead_reaching_carried_stage_without_bound_still_rejected() {
let thermal = make_anticipated_thermal(1, 5, None, None);
let study_durations = [720.0; 4];
let post_durations = [720.0, 720.0];
let post_study = post_study_from(&study_durations, &post_durations, &[]);
let concatenated: Vec<f64> = study_durations
.iter()
.copied()
.chain(post_durations[..1].iter().copied())
.collect();
let commitments = commitments_on(1, &[0.0, 0.0, 0.0, 0.0, 0.0], &concatenated);
let data = data_with_post_study(vec![thermal], &study_durations, commitments, post_study);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
assert!(
!errors
.iter()
.any(|e| e.message.contains("lead_stages exceeds study horizon")),
"the widened guard must not fire when the plant reaches a carried post-study \
stage, got: {errors:?}"
);
assert!(
errors
.iter()
.any(|e| e.message.contains("no thermal_bounds entry")
&& e.message.contains("post_study_stage_index 1")),
"check_post_study_stages must still reject the missing bound cell on the \
reached carried stage, got: {errors:?}"
);
}
#[test]
fn lead_stages_and_lead_time_resolve_the_horizon_guard_identically() {
let study_durations = [720.0; 4];
let post_durations = [720.0; 4];
let values = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 500.0];
let concatenated: Vec<f64> = study_durations
.iter()
.copied()
.chain(post_durations[..3].iter().copied())
.collect();
let lead_stages_thermal = make_anticipated_thermal(1, 7, None, None);
let post_study_a =
post_study_from(&study_durations, &post_durations, &[(1, 3, 0.0, 500.0)]);
let data_a = data_with_post_study(
vec![lead_stages_thermal],
&study_durations,
commitments_on(1, &values, &concatenated),
post_study_a,
);
let mut ctx_a = ValidationContext::new();
validate_semantic_hydro_thermal(&data_a, &mut ctx_a);
assert!(
!ctx_a.has_errors(),
"LeadStages(7) must validate cleanly, got: {:?}",
ctx_a.errors()
);
let lead_time_thermal = make_lead_time_anticipated_thermal(1, 5040.0, None, None);
let post_study_b =
post_study_from(&study_durations, &post_durations, &[(1, 3, 0.0, 500.0)]);
let data_b = data_with_post_study(
vec![lead_time_thermal],
&study_durations,
commitments_on(1, &values, &concatenated),
post_study_b,
);
let mut ctx_b = ValidationContext::new();
validate_semantic_hydro_thermal(&data_b, &mut ctx_b);
assert!(
!ctx_b.has_errors(),
"the equivalent LeadTime(5040.0) must validate cleanly identically, got: {:?}",
ctx_b.errors()
);
}
#[test]
fn test_extended_deciders_lead_time_pmo_matches_solver_resolution() {
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0, 720.0];
let deciders = extended_deciders(AnticipatedConfig::LeadTime(720.0), &durations);
assert_eq!(
deciders,
vec![None, None, None, None, Some(3), Some(4), Some(5)]
);
}
#[test]
fn test_extended_deciders_lead_stages_is_bare_shift() {
let durations = [168.0, 720.0, 168.0, 720.0, 168.0, 720.0];
let deciders = extended_deciders(AnticipatedConfig::LeadStages(2), &durations);
assert_eq!(
deciders,
vec![None, None, Some(0), Some(1), Some(2), Some(3)]
);
}
#[test]
fn test_classify_deliveries_uniform_splits_carried_and_fixed_post_study() {
let axis = ExtendedDeliveryAxis {
durations: vec![720.0, 720.0, 720.0, 720.0],
stage_ids: vec![0, 1, 2, 3],
n_stages: 2,
};
let classes = classify_deliveries(
AnticipatedConfig::LeadTime(2500.0),
Some(&axis),
&[720.0, 720.0],
2,
(None, None),
);
assert_eq!(classes.carried, vec![1]);
assert_eq!(classes.fixed_post_study, vec![0]);
}
#[test]
fn test_classify_deliveries_non_uniform_reaches_first_post_study_stage() {
let axis = ExtendedDeliveryAxis {
durations: vec![168.0, 720.0, 720.0, 720.0],
stage_ids: vec![0, 1, 2, 3],
n_stages: 2,
};
let classes = classify_deliveries(
AnticipatedConfig::LeadTime(1000.0),
Some(&axis),
&[168.0, 720.0],
2,
(None, None),
);
assert_eq!(classes.carried, vec![0]);
assert!(classes.fixed_post_study.is_empty());
}
#[test]
fn test_classify_deliveries_commissioning_gate_drops_decommissioned_delivery() {
let axis = ExtendedDeliveryAxis {
durations: vec![720.0, 720.0, 720.0, 720.0],
stage_ids: vec![0, 1, 2, 3],
n_stages: 2,
};
let classes = classify_deliveries(
AnticipatedConfig::LeadTime(2500.0),
Some(&axis),
&[720.0, 720.0],
2,
(None, Some(3)),
);
assert!(
classes.carried.is_empty(),
"the post-study-index-1 delivery is commissioning-inactive, got: {:?}",
classes.carried
);
assert_eq!(classes.fixed_post_study, vec![0]);
assert_eq!(classes.commissioning_inactive, vec![1]);
}
#[test]
fn classify_deliveries_splits_fixed_and_carried_post_study_stages() {
let axis = ExtendedDeliveryAxis {
durations: vec![720.0; 11],
stage_ids: (0..11).collect(),
n_stages: 4,
};
let classes = classify_deliveries(
AnticipatedConfig::LeadTime(5040.0),
Some(&axis),
&[720.0; 4],
4,
(None, None),
);
assert_eq!(classes.leading_in_study, 4);
assert_eq!(classes.fixed_post_study, vec![0, 1, 2]);
assert_eq!(classes.carried, vec![3, 4, 5, 6]);
assert!(classes.beyond_reach.is_empty());
assert!(classes.commissioning_inactive.is_empty());
}
#[test]
fn classify_deliveries_records_a_beyond_reach_post_study_stage() {
let axis = ExtendedDeliveryAxis {
durations: vec![100.0, 100.0, 100.0],
stage_ids: vec![0, 1, 2],
n_stages: 1,
};
let classes = classify_deliveries(
AnticipatedConfig::LeadStages(1),
Some(&axis),
&[100.0],
1,
(None, None),
);
assert_eq!(classes.beyond_reach, vec![1]);
assert!(!classes.carried.contains(&1));
assert!(!classes.fixed_post_study.contains(&1));
assert!(!classes.commissioning_inactive.contains(&1));
}
#[test]
fn classify_deliveries_records_a_commissioning_inactive_post_study_stage() {
let axis = ExtendedDeliveryAxis {
durations: vec![100.0, 100.0, 100.0, 100.0, 100.0],
stage_ids: vec![0, 1, 2, 3, 4],
n_stages: 2,
};
let classes = classify_deliveries(
AnticipatedConfig::LeadStages(1),
Some(&axis),
&[100.0, 100.0],
2,
(None, Some(1)),
);
assert_eq!(classes.commissioning_inactive, vec![0, 1, 2]);
assert!(classes.fixed_post_study.is_empty());
assert!(classes.carried.is_empty());
assert!(classes.beyond_reach.is_empty());
}
#[test]
fn classify_deliveries_without_a_post_study_axis_reports_only_the_leading_count() {
let study_durations = vec![100.0; 5];
let classes = classify_deliveries(
AnticipatedConfig::LeadStages(2),
None,
&study_durations,
5,
(None, None),
);
assert_eq!(
classes.leading_in_study,
lead_delivery_stage_count(AnticipatedConfig::LeadStages(2), &study_durations, 5)
);
assert!(classes.fixed_post_study.is_empty());
assert!(classes.carried.is_empty());
assert!(classes.beyond_reach.is_empty());
assert!(classes.commissioning_inactive.is_empty());
}
#[test]
fn classify_deliveries_leading_count_matches_the_extended_decider_none_run() {
let axis = ExtendedDeliveryAxis {
durations: vec![720.0; 5],
stage_ids: (0..5).collect(),
n_stages: 3,
};
let study_durations = [720.0; 3];
for mode in [
AnticipatedConfig::LeadStages(2),
AnticipatedConfig::LeadTime(1000.0),
] {
let classes = classify_deliveries(mode, Some(&axis), &study_durations, 3, (None, None));
let none_run = extended_deciders(mode, &axis.durations)[..axis.n_stages]
.iter()
.filter(|decider| decider.is_none())
.count();
assert_eq!(
classes.leading_in_study, none_run,
"leading_in_study must match the extended_deciders None run for {mode:?}"
);
}
}
#[test]
fn test_extended_delivery_axis_absent_without_post_study() {
let data =
make_data_anticipated(vec![make_anticipated_thermal(1, 2, None, None)], 3, vec![]);
assert!(build_extended_delivery_axis(&data).is_none());
}
#[test]
fn test_extended_delivery_axis_concatenates_and_continues_ids() {
let durations = [720.0, 720.0];
let post_study = post_study_from(&durations, &[696.0, 744.0], &[]);
let data = data_with_post_study(
vec![make_anticipated_thermal(1, 1, None, None)],
&durations,
commitments_on(1, &[0.0], &durations),
post_study,
);
let Some(axis) = build_extended_delivery_axis(&data) else {
panic!("post-study declared, so the axis must build");
};
assert_eq!(axis.n_stages, 2);
assert_eq!(axis.durations, vec![720.0, 720.0, 696.0, 744.0]);
assert_eq!(axis.stage_ids, vec![0, 1, 2, 3]);
}
#[test]
fn test_missing_post_study_bound_cell_rejected() {
let thermal = make_lead_time_anticipated_thermal(1, 3000.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let post_study = post_study_from(&durations, &[1440.0], &[]);
let data = data_with_post_study(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], &durations),
post_study,
);
let mut ctx = ValidationContext::new();
check_post_study_stages(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("no thermal_bounds entry")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one missing-bound-cell rejection, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("Thermal 1") && msg.contains("post_study_stage_index 0"),
"message should name the plant and post-study stage 0, got: {msg}"
);
}
#[test]
fn test_present_post_study_bound_cell_accepted() {
let thermal = make_lead_time_anticipated_thermal(1, 3000.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let post_study = post_study_from(&durations, &[1440.0], &[(1, 0, 0.0, 500.0)]);
let data = data_with_post_study(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], &durations),
post_study,
);
let mut ctx = ValidationContext::new();
check_post_study_stages(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a reached delivery with its bound cell present must not be rejected, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_pre_study_decided_post_study_delivery_loads_when_tiled() {
let thermal = make_lead_time_anticipated_thermal(1, 2500.0, None, None);
let durations = [720.0, 720.0];
let post_study = post_study_from(
&durations,
&[720.0, 720.0],
&[(1, 0, 0.0, 500.0), (1, 1, 0.0, 500.0)],
);
let data = data_with_post_study(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0, 0.0], &[720.0, 720.0, 720.0]),
post_study,
);
let mut ctx = ValidationContext::new();
check_post_study_stages(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a pre-study-decided post-study delivery tiled by a commitment window must not be \
rejected, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_in_study_decided_post_study_delivery_accepted() {
let thermal = make_lead_time_anticipated_thermal(1, 800.0, None, None);
let durations = [720.0, 720.0];
let post_study = post_study_from(
&durations,
&[720.0, 720.0],
&[(1, 0, 0.0, 500.0), (1, 1, 0.0, 500.0)],
);
let data = data_with_post_study(
vec![thermal],
&durations,
commitments_on(1, &[0.0], &durations),
post_study,
);
let mut ctx = ValidationContext::new();
check_post_study_stages(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"an in-study-decided post-study delivery must not be rejected, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_post_study_calendar_anchor_still_enforced() {
let thermal = make_lead_time_anticipated_thermal(1, 3000.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let mut post_study = post_study_from(&durations, &[1440.0], &[(1, 0, 0.0, 500.0)]);
post_study.stages[0].start_date += TimeDelta::days(1);
let data = data_with_post_study(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], &durations),
post_study,
);
let mut ctx = ValidationContext::new();
check_post_study_stages(&data, &mut ctx);
assert!(
ctx.errors()
.iter()
.any(|e| e.message.contains("post-study calendar must begin exactly")),
"invariant (a) anchor check must still fire, got: {:?}",
ctx.errors()
);
}
#[test]
fn lead_stages_cadence_transition_emits_advisory() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let durations = [168.0, 168.0, 168.0, 744.0, 744.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0], &durations),
);
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 durations = [744.0, 744.0, 744.0, 744.0, 744.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0], &durations),
);
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_lead_delivery_stage_count_lead_time_pmo_calendar() {
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let count = lead_delivery_stage_count(
AnticipatedConfig::LeadTime(720.0),
&durations,
durations.len(),
);
assert_eq!(count, 4);
}
#[test]
fn test_lead_delivery_stage_count_lead_time_uniform_calendar() {
let durations = [720.0; 5];
let count = lead_delivery_stage_count(
AnticipatedConfig::LeadTime(1440.0),
&durations,
durations.len(),
);
assert_eq!(count, 2);
}
#[test]
fn test_lead_delivery_stage_count_lead_stages_clamped() {
let count = lead_delivery_stage_count(AnticipatedConfig::LeadStages(2), &[720.0; 5], 5);
assert_eq!(count, 2);
}
#[test]
fn test_anticipated_lead_time_coverage_pmo_calendar() {
let thermal = make_lead_time_anticipated_thermal(1, 720.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0, 0.0, 0.0], &durations),
);
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("do not tile the leading")
|| e.message.contains("beyond the leading"))
})
.collect();
assert!(
coverage_errors.is_empty(),
"expected no coverage 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 durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0], &durations),
);
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("do not tile the leading")
})
.collect();
assert_eq!(
coverage_errors.len(),
1,
"expected exactly one under-coverage error, got: {errors:?}"
);
let msg = &coverage_errors[0].message;
assert!(
msg.contains('2') && msg.contains('3'),
"message should name the uncovered study stage ids 2 and 3, got: {msg}"
);
}
#[test]
fn test_entry_window_on_anticipated_thermal_accepted() {
let thermal = make_anticipated_thermal(1, 3, Some(4), None);
let data = make_data_anticipated(vec![thermal], 5, commitments(1, &[0.0, 0.0, 0.0]));
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 data = make_data_anticipated(vec![thermal], 5, commitments(1, &[0.0, 0.0, 0.0]));
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 data = make_data_anticipated(vec![thermal], 5, commitments(1, &[100.0, 0.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.message
.contains("outside the plant's commissioning window")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one seed-vs-window error (stage 0 only), got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("study stage id 0") && msg.contains("Thermal 1"),
"error must identify study stage 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 data = make_data_anticipated(vec![thermal], 5, commitments(1, &[50.0]));
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 = 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 data = make_data_anticipated(vec![thermal], 5, commitments(1, &[0.0, 0.0]));
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 data = make_data_anticipated(vec![thermal], 5, commitments(3, &[600.0, 200.0]));
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 (600 MW window only), got: {errors:?}"
);
let msg0 = &relevant[0].message;
assert!(
msg0.contains("Thermal 3"),
"message should contain 'Thermal 3', got: {msg0}"
);
assert!(
msg0.contains("2024-01-01"),
"message should identify the offending window by its start date, 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 = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(2)),
..make_thermal(5, 100.0, 500.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(5, &[200.0, 50.0]));
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 (50 MW window only), got: {errors:?}"
);
let msg1 = &relevant[0].message;
assert!(
msg1.contains("Thermal 5"),
"message should contain 'Thermal 5', 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 = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(3)),
..make_thermal(7, 0.0, 400.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(7, &[0.0, 0.0, 0.0]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"expected no errors for all-zero window values, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_committed_value_zero_accepted() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(9, 0.0, 400.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(9, &[0.0]));
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 = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(11, 100.0, 350.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(11, &[400.0]));
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("400"),
"message should contain the offending value 400, got: {msg}"
);
}
#[test]
fn test_committed_value_within_tolerance_above_max_accepted() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(13, 100.0, 350.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(13, &[350.0 + 1e-8]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a value within relative tolerance of max_generation_mw must not be rejected, \
got: {:?}",
ctx.errors()
);
}
#[test]
fn test_committed_value_within_tolerance_below_min_accepted() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(14, 100.0, 350.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(14, &[100.0 - 1e-8]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a value within relative tolerance of min_generation_mw must not be rejected, \
got: {:?}",
ctx.errors()
);
}
#[test]
fn test_committed_value_beyond_tolerance_above_max_still_rejected() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(15, 100.0, 350.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(15, &[350.0 + 3.5e-6]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let relevant: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("outside the plant's generation bounds")
})
.collect();
assert_eq!(
relevant.len(),
1,
"a value beyond tolerance above max_generation_mw must still be rejected, \
got: {:?}",
ctx.errors()
);
}
#[test]
fn committed_value_bounds_reject_an_out_of_envelope_post_horizon_window() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(21, 0.0, 200.0)
};
let mut records = commitments(21, &[0.0]);
records.push(AnticipatedCommitmentHistory {
thermal_id: EntityId::from(21),
start_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
value_mw: 350.0,
});
let data = make_data_anticipated(vec![thermal], 2, records);
let mut ctx = ValidationContext::new();
check_anticipated_thermals(&data, &mut ctx);
let relevant: Vec<_> = ctx
.errors()
.into_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 envelope violation for the post-horizon window, got: {:?}",
ctx.errors()
);
assert!(
relevant[0].message.contains("350"),
"message should contain the offending value 350, got: {}",
relevant[0].message
);
}
#[test]
fn committed_value_bounds_accept_an_in_envelope_post_horizon_window() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(22, 0.0, 200.0)
};
let mut records = commitments(22, &[0.0]);
records.push(AnticipatedCommitmentHistory {
thermal_id: EntityId::from(22),
start_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
value_mw: 150.0,
});
let data = make_data_anticipated(vec![thermal], 2, records);
let mut ctx = ValidationContext::new();
check_anticipated_thermals(&data, &mut ctx);
assert!(
!ctx.errors()
.iter()
.any(|e| e.message.contains("outside the plant's generation bounds")),
"an in-envelope post-horizon window must not be rejected, got: {:?}",
ctx.errors()
);
}
#[test]
fn committed_value_bounds_accept_an_explicit_zero_at_the_lower_bound() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(23, 0.0, 200.0)
};
let mut records = commitments(23, &[100.0]);
records.push(AnticipatedCommitmentHistory {
thermal_id: EntityId::from(23),
start_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
value_mw: 0.0,
});
let data = make_data_anticipated(vec![thermal], 2, records);
let mut ctx = ValidationContext::new();
check_anticipated_thermals(&data, &mut ctx);
assert!(
!ctx.errors()
.iter()
.any(|e| e.message.contains("outside the plant's generation bounds")),
"an explicit zero at the lower bound on a post-horizon window must not be \
rejected, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_nonzero_value_in_bounds_accepted_k1() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(2, 0.0, 350.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(2, &[204.5647]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
ctx.errors().is_empty(),
"expected no errors for an in-bounds window value, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_k2_two_in_bounds_nonzero_values_accepted() {
let thermal = make_anticipated_thermal(5, 2, None, None); let data = make_data_anticipated(vec![thermal], 5, commitments(5, &[50.0, 30.0]));
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 windows, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_nonzero_in_bounds_seed_emits_no_semantic_ambiguity_warning() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(2)),
..make_thermal(3, 0.0, 350.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(3, &[100.0, 200.0]));
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 data = make_data_anticipated(vec![thermal], 5, commitments(7, &[0.0, 600.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.message.contains("outside the plant's generation bounds")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one bounds error (600 MW window only), got: {errors:?}"
);
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>) -> 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, 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),
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: 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, GenericConstraint, LinearTerm, SlackConfig, VariableRef,
entities::AnticipatedConfig,
};
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(2)),
..make_thermal(3, 0.0, 500.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),
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: None,
};
let mut data = make_data_anticipated(vec![thermal], 5, commitments(3, &[0.0, 0.0]));
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, GenericConstraint, LinearTerm, SlackConfig, VariableRef,
entities::AnticipatedConfig,
};
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(5, 0.0, 300.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,
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: None,
};
let mut data = make_data_anticipated(vec![thermal], 5, commitments(5, &[0.0]));
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, 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,
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: 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:?}"
);
}
}