///|
/// Stable validation codes suitable for CLI and web clients.
pub(all) enum ValidationCode {
InvalidHorizon
InvalidSlotMinutes
SeriesLengthMismatch
SeriesSlotMismatch
NegativeSeriesValue
InvalidTaskId
DuplicateTaskId
InvalidTaskPower
InvalidTaskDuration
InvalidTaskWindow
FixedTaskOutsideWindow
InvalidInterruptionRule
InvalidBatteryCapacity
InvalidBatteryState
InvalidBatteryPower
InvalidBatteryEfficiency
InvalidBatteryReserve
InvalidOutageWindow
InvalidGridLimit
InvalidObjectiveWeights
ImpossibleRequiredTask
PeakCapacityRisk
MissingTariffVariation
MissingCarbonVariation
ExportWithoutCredit
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn ValidationCode::label(self : ValidationCode) -> String {
match self {
InvalidHorizon => "invalid-horizon"
InvalidSlotMinutes => "invalid-slot-minutes"
SeriesLengthMismatch => "series-length-mismatch"
SeriesSlotMismatch => "series-slot-mismatch"
NegativeSeriesValue => "negative-series-value"
InvalidTaskId => "invalid-task-id"
DuplicateTaskId => "duplicate-task-id"
InvalidTaskPower => "invalid-task-power"
InvalidTaskDuration => "invalid-task-duration"
InvalidTaskWindow => "invalid-task-window"
FixedTaskOutsideWindow => "fixed-task-outside-window"
InvalidInterruptionRule => "invalid-interruption-rule"
InvalidBatteryCapacity => "invalid-battery-capacity"
InvalidBatteryState => "invalid-battery-state"
InvalidBatteryPower => "invalid-battery-power"
InvalidBatteryEfficiency => "invalid-battery-efficiency"
InvalidBatteryReserve => "invalid-battery-reserve"
InvalidOutageWindow => "invalid-outage-window"
InvalidGridLimit => "invalid-grid-limit"
InvalidObjectiveWeights => "invalid-objective-weights"
ImpossibleRequiredTask => "impossible-required-task"
PeakCapacityRisk => "peak-capacity-risk"
MissingTariffVariation => "missing-tariff-variation"
MissingCarbonVariation => "missing-carbon-variation"
ExportWithoutCredit => "export-without-credit"
}
}
///|
pub(all) enum IssueLevel {
Error
Warning
Advice
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn IssueLevel::label(self : IssueLevel) -> String {
match self {
Error => "error"
Warning => "warning"
Advice => "advice"
}
}
///|
pub(all) struct ValidationIssue {
code : ValidationCode
level : IssueLevel
path : String
message : String
hint : String
slot : Int?
task_id : String?
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn ValidationIssue::error(
code : ValidationCode,
path : String,
message : String,
hint? : String = "correct the input before optimization",
) -> ValidationIssue {
{ code, level: Error, path, message, hint, slot: None, task_id: None }
}
///|
pub fn ValidationIssue::warning(
code : ValidationCode,
path : String,
message : String,
hint? : String = "review this value",
) -> ValidationIssue {
{ code, level: Warning, path, message, hint, slot: None, task_id: None }
}
///|
pub fn ValidationIssue::advice(
code : ValidationCode,
path : String,
message : String,
hint : String,
) -> ValidationIssue {
{ code, level: Advice, path, message, hint, slot: None, task_id: None }
}
///|
pub fn ValidationIssue::for_task(
self : ValidationIssue,
task_id : String,
) -> ValidationIssue {
{ ..self, task_id: Some(task_id) }
}
///|
pub fn ValidationIssue::at_slot(
self : ValidationIssue,
slot : Int,
) -> ValidationIssue {
{ ..self, slot: Some(slot) }
}
///|
pub(all) struct ValidationReport {
issues : Array[ValidationIssue]
checked_tasks : Int
checked_slots : Int
estimated_required_peak_w : Int
estimated_required_energy_wh : Int
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn ValidationReport::empty() -> ValidationReport {
{
issues: [],
checked_tasks: 0,
checked_slots: 0,
estimated_required_peak_w: 0,
estimated_required_energy_wh: 0,
}
}
///|
pub fn ValidationReport::error_count(self : ValidationReport) -> Int {
let mut count = 0
for issue in self.issues {
if issue.level == Error {
count = count + 1
}
}
count
}
///|
pub fn ValidationReport::warning_count(self : ValidationReport) -> Int {
let mut count = 0
for issue in self.issues {
if issue.level == Warning {
count = count + 1
}
}
count
}
///|
pub fn ValidationReport::advice_count(self : ValidationReport) -> Int {
let mut count = 0
for issue in self.issues {
if issue.level == Advice {
count = count + 1
}
}
count
}
///|
pub fn ValidationReport::is_valid(self : ValidationReport) -> Bool {
self.error_count() == 0
}
///|
pub fn ValidationReport::has_code(
self : ValidationReport,
code : ValidationCode,
) -> Bool {
for issue in self.issues {
if issue.code == code {
return true
}
}
false
}
///|
pub fn ValidationReport::messages(self : ValidationReport) -> Array[String] {
self.issues.map(issue => {
issue.level.label() + ": " + issue.path + ": " + issue.message
})
}
///|
fn push_issue(issues : Array[ValidationIssue], issue : ValidationIssue) -> Unit {
issues.push(issue)
}
///|
fn is_blank(value : String) -> Bool {
value.trim().length() == 0
}
///|
fn valid_slot_minutes(value : Int) -> Bool {
value == 1 ||
value == 5 ||
value == 10 ||
value == 15 ||
value == 20 ||
value == 30 ||
value == 60
}
///|
fn validate_series(
series : IntSeries,
expected_slots : Int,
expected_slot_minutes : Int,
path : String,
allow_negative : Bool,
issues : Array[ValidationIssue],
) -> Unit {
if series.length() != expected_slots {
push_issue(
issues,
ValidationIssue::error(
SeriesLengthMismatch,
path + ".values",
"expected " +
expected_slots.to_string() +
" slots but found " +
series.length().to_string(),
hint="make every time series match horizon_slots",
),
)
}
if series.slot_minutes != expected_slot_minutes {
push_issue(
issues,
ValidationIssue::error(
SeriesSlotMismatch,
path + ".slot_minutes",
"series resolution does not match the planning resolution",
hint="resample the series before planning",
),
)
}
if !allow_negative {
for slot, value in series.values {
if value < 0 {
push_issue(
issues,
ValidationIssue::error(
NegativeSeriesValue,
path + ".values",
"negative values are not allowed",
).at_slot(slot),
)
}
}
}
}
///|
fn validate_task(
task : LoadTask,
horizon : Int,
slot_minutes : Int,
issues : Array[ValidationIssue],
) -> Unit {
let path = "tasks[" + task.id + "]"
if is_blank(task.id) {
push_issue(
issues,
ValidationIssue::error(
InvalidTaskId,
path + ".id",
"task id must not be blank",
).for_task(task.id),
)
}
if task.power_w <= 0 {
push_issue(
issues,
ValidationIssue::error(
InvalidTaskPower,
path + ".power_w",
"task power must be positive",
).for_task(task.id),
)
}
if task.duration_slots <= 0 {
push_issue(
issues,
ValidationIssue::error(
InvalidTaskDuration,
path + ".duration_slots",
"task duration must be positive",
).for_task(task.id),
)
}
if task.earliest_start < 0 ||
task.latest_end > horizon ||
task.latest_end <= task.earliest_start {
push_issue(
issues,
ValidationIssue::error(
InvalidTaskWindow,
path,
"task window must be inside the planning horizon",
hint="adjust earliest_start and latest_end",
).for_task(task.id),
)
}
if task.duration_slots > task.window_slots() {
let issue = ValidationIssue::error(
ImpossibleRequiredTask,
path,
"task duration is longer than its available window",
hint="increase the window or shorten the task",
).for_task(task.id)
if task.mode.may_skip() {
push_issue(issues, { ..issue, level: Warning })
} else {
push_issue(issues, issue)
}
}
if task.mode == Fixed &&
(
task.fixed_start < task.earliest_start ||
task.fixed_start + task.duration_slots > task.latest_end
) {
push_issue(
issues,
ValidationIssue::error(
FixedTaskOutsideWindow,
path + ".fixed_start",
"fixed execution interval is outside its declared window",
).for_task(task.id),
)
}
if task.mode == Interruptible {
if task.minimum_run_slots <= 0 ||
task.minimum_run_slots > task.duration_slots ||
task.maximum_interruptions < 0 {
push_issue(
issues,
ValidationIssue::error(
InvalidInterruptionRule,
path,
"interruptible task has inconsistent segment constraints",
).for_task(task.id),
)
}
} else if task.maximum_interruptions != 0 {
push_issue(
issues,
ValidationIssue::warning(
InvalidInterruptionRule,
path + ".maximum_interruptions",
"maximum_interruptions is ignored for a non-interruptible task",
hint="set maximum_interruptions to zero",
).for_task(task.id),
)
}
if task.energy_wh(slot_minutes) <= 0 &&
task.power_w > 0 &&
task.duration_slots > 0 {
push_issue(
issues,
ValidationIssue::warning(
InvalidTaskDuration,
path,
"task energy rounds to zero at this time resolution",
hint="use a finer slot duration or larger energy unit",
).for_task(task.id),
)
}
}
///|
fn validate_duplicate_ids(
tasks : Array[LoadTask],
issues : Array[ValidationIssue],
) -> Unit {
let seen : Map[String, Int] = Map([])
for task in tasks {
let count = seen.get(task.id).unwrap_or(0) + 1
seen[task.id] = count
if count == 2 {
push_issue(
issues,
ValidationIssue::error(
DuplicateTaskId,
"tasks",
"task id '" + task.id + "' occurs more than once",
hint="assign a stable unique id to every task",
).for_task(task.id),
)
}
}
}
///|
fn validate_battery(
battery : BatterySpec,
issues : Array[ValidationIssue],
) -> Unit {
let path = "battery"
if battery.capacity_wh <= 0 ||
battery.maximum_wh <= 0 ||
battery.maximum_wh > battery.capacity_wh {
push_issue(
issues,
ValidationIssue::error(
InvalidBatteryCapacity,
path + ".capacity_wh",
"battery capacity and maximum state must be positive and consistent",
),
)
}
if battery.minimum_wh < 0 ||
battery.initial_wh < battery.minimum_wh ||
battery.initial_wh > battery.maximum_wh {
push_issue(
issues,
ValidationIssue::error(
InvalidBatteryState,
path + ".initial_wh",
"initial state must be inside the battery state bounds",
),
)
}
if battery.reserve_wh < battery.minimum_wh ||
battery.reserve_wh > battery.maximum_wh {
push_issue(
issues,
ValidationIssue::error(
InvalidBatteryReserve,
path + ".reserve_wh",
"reserve must be inside the usable state range",
),
)
}
if battery.maximum_charge_w <= 0 || battery.maximum_discharge_w <= 0 {
push_issue(
issues,
ValidationIssue::error(
InvalidBatteryPower,
path,
"charge and discharge limits must be positive",
),
)
}
if battery.charge_efficiency_permille <= 0 ||
battery.charge_efficiency_permille > 1000 ||
battery.discharge_efficiency_permille <= 0 ||
battery.discharge_efficiency_permille > 1000 {
push_issue(
issues,
ValidationIssue::error(
InvalidBatteryEfficiency,
path,
"battery efficiencies must be between 1 and 1000 permille",
),
)
}
}
///|
fn validate_outages(
outages : Array[OutageEvent],
horizon : Int,
issues : Array[ValidationIssue],
) -> Unit {
for outage in outages {
let path = "outages[" + outage.id + "]"
if outage.start_slot < 0 ||
outage.end_slot > horizon ||
outage.end_slot <= outage.start_slot {
push_issue(
issues,
ValidationIssue::error(
InvalidOutageWindow,
path,
"outage interval must be non-empty and inside the horizon",
),
)
}
if outage.grid_limit_w < 0 {
push_issue(
issues,
ValidationIssue::error(
InvalidGridLimit,
path + ".grid_limit_w",
"outage grid limit cannot be negative",
),
)
}
}
}
///|
fn estimate_required_peak(input : PlanningInput) -> Int {
let slots = Array::make(input.horizon_slots, 0)
for task in input.tasks {
if task.mode == Fixed && task.is_required() {
let start = task.fixed_start
let end = start + task.duration_slots
for slot = start; slot < end; slot = slot + 1 {
if slot >= 0 && slot < slots.length() {
slots[slot] = slots[slot] + task.power_w
}
}
}
}
let mut peak = 0
for slot, fixed_w in slots {
let value = fixed_w + input.base_load_w.at(slot)
if value > peak {
peak = value
}
}
peak
}
///|
fn validate_capacity_risk(
input : PlanningInput,
required_peak : Int,
issues : Array[ValidationIssue],
) -> Unit {
for slot = 0; slot < input.horizon_slots; slot = slot + 1 {
let limit = input.grid_limit_at(slot) + input.solar_w.at(slot)
let battery_support = match input.battery {
Some(battery) => battery.maximum_discharge_w
None => 0
}
if input.base_load_w.at(slot) > limit + battery_support {
push_issue(
issues,
ValidationIssue::warning(
PeakCapacityRisk,
"grid_limit_w",
"base load alone may exceed available supply",
hint="increase capacity, add storage, or mark load shedding priorities",
).at_slot(slot),
)
}
}
let maximum_battery_support = match input.battery {
Some(battery) => battery.maximum_discharge_w
None => 0
}
let maximum_supply = input.grid_limit_w.maximum() +
input.solar_w.maximum() +
maximum_battery_support
if required_peak > maximum_supply {
push_issue(
issues,
ValidationIssue::warning(
PeakCapacityRisk,
"tasks",
"simultaneous fixed tasks may exceed maximum supply capacity",
hint="move fixed tasks or increase the connection limit",
),
)
}
}
///|
fn validate_signal_quality(
input : PlanningInput,
issues : Array[ValidationIssue],
) -> Unit {
if input.tariff_micro_per_kwh.minimum() ==
input.tariff_micro_per_kwh.maximum() {
push_issue(
issues,
ValidationIssue::advice(
MissingTariffVariation,
"tariff_micro_per_kwh",
"tariff is constant, so load shifting cannot reduce energy cost",
"add time-of-use prices or choose a carbon/comfort objective",
),
)
}
if input.carbon_g_per_kwh.minimum() == input.carbon_g_per_kwh.maximum() {
push_issue(
issues,
ValidationIssue::advice(
MissingCarbonVariation,
"carbon_g_per_kwh",
"carbon intensity is constant across the horizon",
"provide a time-varying carbon forecast for carbon-aware scheduling",
),
)
}
if input.allow_grid_export && input.export_credit_micro_per_kwh <= 0 {
push_issue(
issues,
ValidationIssue::warning(
ExportWithoutCredit,
"export_credit_micro_per_kwh",
"grid export is enabled without a positive export credit",
hint="set the feed-in credit or disable export",
),
)
}
}
///|
/// Validate a complete planning request without modifying it.
pub fn validate(input : PlanningInput) -> ValidationReport {
let issues : Array[ValidationIssue] = []
if input.horizon_slots <= 0 || input.horizon_slots > 10000 {
push_issue(
issues,
ValidationIssue::error(
InvalidHorizon,
"horizon_slots",
"horizon must contain between 1 and 10000 slots",
),
)
}
if !valid_slot_minutes(input.slot_minutes) {
push_issue(
issues,
ValidationIssue::error(
InvalidSlotMinutes,
"slot_minutes",
"slot duration must be one of 1, 5, 10, 15, 20, 30, or 60 minutes",
),
)
}
validate_series(
input.tariff_micro_per_kwh,
input.horizon_slots,
input.slot_minutes,
"tariff_micro_per_kwh",
false,
issues,
)
validate_series(
input.carbon_g_per_kwh,
input.horizon_slots,
input.slot_minutes,
"carbon_g_per_kwh",
false,
issues,
)
validate_series(
input.solar_w,
input.horizon_slots,
input.slot_minutes,
"solar_w",
false,
issues,
)
validate_series(
input.base_load_w,
input.horizon_slots,
input.slot_minutes,
"base_load_w",
false,
issues,
)
validate_series(
input.grid_limit_w,
input.horizon_slots,
input.slot_minutes,
"grid_limit_w",
false,
issues,
)
validate_duplicate_ids(input.tasks, issues)
for task in input.tasks {
validate_task(task, input.horizon_slots, input.slot_minutes, issues)
}
match input.battery {
Some(battery) => validate_battery(battery, issues)
None => ()
}
validate_outages(input.outages, input.horizon_slots, issues)
if input.weights.total() <= 0 {
push_issue(
issues,
ValidationIssue::error(
InvalidObjectiveWeights,
"weights",
"at least one objective weight must be positive",
),
)
}
let required_peak = estimate_required_peak(input)
validate_capacity_risk(input, required_peak, issues)
validate_signal_quality(input, issues)
let mut required_energy = 0
for task in input.tasks {
if task.is_required() {
required_energy = required_energy + task.energy_wh(input.slot_minutes)
}
}
{
issues,
checked_tasks: input.tasks.length(),
checked_slots: input.horizon_slots,
estimated_required_peak_w: required_peak,
estimated_required_energy_wh: required_energy,
}
}
///|
/// Return a repaired copy for safe, explicitly documented defaults.
/// Structural errors such as impossible windows are intentionally not hidden.
pub fn apply_safe_defaults(input : PlanningInput) -> PlanningInput {
let weights = if input.weights.total() <= 0 {
ObjectiveWeights::balanced()
} else {
input.weights.normalized()
}
let battery = match input.battery {
None => None
Some(value) => {
let minimum = if value.minimum_wh < 0 { 0 } else { value.minimum_wh }
let maximum = if value.maximum_wh > value.capacity_wh {
value.capacity_wh
} else {
value.maximum_wh
}
let reserve = if value.reserve_wh < minimum {
minimum
} else if value.reserve_wh > maximum {
maximum
} else {
value.reserve_wh
}
Some({
..value,
minimum_wh: minimum,
maximum_wh: maximum,
initial_wh: if value.initial_wh < minimum {
minimum
} else if value.initial_wh > maximum {
maximum
} else {
value.initial_wh
},
reserve_wh: reserve,
charge_efficiency_permille: if value.charge_efficiency_permille <= 0 ||
value.charge_efficiency_permille > 1000 {
950
} else {
value.charge_efficiency_permille
},
discharge_efficiency_permille: if value.discharge_efficiency_permille <=
0 ||
value.discharge_efficiency_permille > 1000 {
950
} else {
value.discharge_efficiency_permille
},
})
}
}
{ ..input, weights, battery }
}