///|
/// Runtime limits and deterministic heuristics for the scheduler.
pub(all) struct SolverConfig {
maximum_candidates_per_task : Int
prefer_solar : Bool
allow_grid_charging : Bool
preserve_reserve_outside_outage : Bool
capacity_violation_penalty : Int
outage_violation_penalty : Int
optional_skip_threshold : Int
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn SolverConfig::default() -> SolverConfig {
{
maximum_candidates_per_task: 256,
prefer_solar: true,
allow_grid_charging: true,
preserve_reserve_outside_outage: true,
capacity_violation_penalty: 100000,
outage_violation_penalty: 1000000,
optional_skip_threshold: 0,
}
}
///|
/// Diagnostic score for a candidate task placement.
pub(all) struct CandidatePlacement {
task_id : String
start_slot : Int
end_slot : Int
energy_cost_component : Int
carbon_component : Int
comfort_component : Int
resilience_component : Int
capacity_excess_w : Int
solar_overlap_wh : Int
total_score : Int
feasible : Bool
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn CandidatePlacement::duration_slots(self : CandidatePlacement) -> Int {
self.end_slot - self.start_slot
}
///|
fn absolute(value : Int) -> Int {
if value < 0 {
-value
} else {
value
}
}
///|
fn minimum(a : Int, b : Int) -> Int {
if a < b {
a
} else {
b
}
}
///|
fn maximum(a : Int, b : Int) -> Int {
if a > b {
a
} else {
b
}
}
///|
fn clamp(value : Int, lower : Int, upper : Int) -> Int {
if value < lower {
lower
} else if value > upper {
upper
} else {
value
}
}
///|
fn battery_power_support(input : PlanningInput) -> Int {
match input.battery {
Some(battery) => battery.maximum_discharge_w
None => 0
}
}
///|
fn candidate_supply_w(input : PlanningInput, slot : Int) -> Int {
input.grid_limit_at(slot) +
input.solar_w.at(slot) +
battery_power_support(input)
}
///|
fn task_sort_order(a : LoadTask, b : LoadTask) -> Int {
if a.mode == Fixed && b.mode != Fixed {
return -1
}
if b.mode == Fixed && a.mode != Fixed {
return 1
}
if a.priority.rank() > b.priority.rank() {
return -1
}
if a.priority.rank() < b.priority.rank() {
return 1
}
let a_slack = a.window_slots() - a.duration_slots
let b_slack = b.window_slots() - b.duration_slots
if a_slack < b_slack {
return -1
}
if a_slack > b_slack {
return 1
}
if a.power_w > b.power_w {
-1
} else if a.power_w < b.power_w {
1
} else {
a.id.compare(b.id)
}
}
///|
fn sorted_tasks(tasks : Array[LoadTask]) -> Array[LoadTask] {
let result = tasks.map(task => task)
result.sort_by(task_sort_order)
result
}
///|
fn slot_energy_wh(power_w : Int, slot_minutes : Int) -> Int {
power_w * slot_minutes / 60
}
///|
fn objective_slot_score(
input : PlanningInput,
task : LoadTask,
slot : Int,
current_load_w : Int,
) -> (Int, Int, Int, Int, Int, Int) {
let weights = input.weights.normalized()
let added_load_w = current_load_w + task.power_w
let solar_w = input.solar_w.at(slot)
let previous_grid_w = maximum(0, current_load_w - solar_w)
let next_grid_w = maximum(0, added_load_w - solar_w)
let incremental_grid_w = next_grid_w - previous_grid_w
let energy_wh = slot_energy_wh(incremental_grid_w, input.slot_minutes)
let cost_component = energy_wh * input.tariff_micro_per_kwh.at(slot) / 100000
let carbon_component = energy_wh * input.carbon_g_per_kwh.at(slot) / 1000
let comfort_component = absolute(slot - task.preferred_start) *
task.comfort_penalty_per_slot
let supply_w = candidate_supply_w(input, slot)
let capacity_excess_w = maximum(0, added_load_w - supply_w)
let resilience_component = if input.has_outage_at(slot) {
match task.priority {
Critical => 0
High => task.power_w / 4
Normal => task.power_w
Low => task.power_w * 3
}
} else {
0
}
let solar_overlap_wh = slot_energy_wh(
minimum(task.power_w, maximum(0, solar_w - current_load_w)),
input.slot_minutes,
)
let total = cost_component * weights.cost +
carbon_component * weights.carbon +
comfort_component * weights.comfort +
resilience_component * weights.resilience -
solar_overlap_wh * (weights.cost + weights.carbon) / 10
(
cost_component, carbon_component, comfort_component, resilience_component, capacity_excess_w,
total,
)
}
///|
/// Evaluate a contiguous placement against the current provisional load.
pub fn evaluate_candidate(
input : PlanningInput,
task : LoadTask,
start_slot : Int,
current_load_w : Array[Int],
config? : SolverConfig = SolverConfig::default(),
) -> CandidatePlacement {
let end_slot = start_slot + task.duration_slots
let mut cost = 0
let mut carbon = 0
let mut comfort = 0
let mut resilience = 0
let mut excess = 0
let mut score = 0
let mut solar_overlap_wh = 0
let mut feasible = start_slot >= task.earliest_start &&
end_slot <= task.latest_end &&
start_slot >= 0 &&
end_slot <= input.horizon_slots
if feasible {
for slot = start_slot; slot < end_slot; slot = slot + 1 {
let current = current_load_w[slot]
let (
slot_cost,
slot_carbon,
slot_comfort,
slot_resilience,
slot_excess,
slot_score,
) = objective_slot_score(input, task, slot, current)
cost = cost + slot_cost
carbon = carbon + slot_carbon
comfort = comfort + slot_comfort
resilience = resilience + slot_resilience
excess = excess + slot_excess
score = score + slot_score
let spare_solar = maximum(0, input.solar_w.at(slot) - current)
solar_overlap_wh = solar_overlap_wh +
slot_energy_wh(minimum(spare_solar, task.power_w), input.slot_minutes)
if slot_excess > 0 {
score = score + slot_excess * config.capacity_violation_penalty
if task.is_required() {
feasible = false
}
}
if input.has_outage_at(slot) && task.priority != Critical {
score = score + config.outage_violation_penalty
}
}
}
{
task_id: task.id,
start_slot,
end_slot,
energy_cost_component: cost,
carbon_component: carbon,
comfort_component: comfort,
resilience_component: resilience,
capacity_excess_w: excess,
solar_overlap_wh,
total_score: score,
feasible,
}
}
///|
pub fn candidate_placements(
input : PlanningInput,
task : LoadTask,
current_load_w : Array[Int],
config? : SolverConfig = SolverConfig::default(),
) -> Array[CandidatePlacement] {
let candidates : Array[CandidatePlacement] = []
let first = if task.mode == Fixed {
task.fixed_start
} else {
task.earliest_start
}
let last = if task.mode == Fixed {
task.fixed_start
} else {
task.latest_start()
}
let mut examined = 0
for start = first; start <= last; start = start + 1 {
if examined >= config.maximum_candidates_per_task {
break
}
candidates.push(
evaluate_candidate(input, task, start, current_load_w, config~),
)
examined = examined + 1
}
candidates.sort_by(fn(a, b) {
if a.feasible && !b.feasible {
-1
} else if b.feasible && !a.feasible {
1
} else if a.total_score < b.total_score {
-1
} else if a.total_score > b.total_score {
1
} else if a.start_slot < b.start_slot {
-1
} else if a.start_slot > b.start_slot {
1
} else {
0
}
})
candidates
}
///|
fn placement_reason(
input : PlanningInput,
task : LoadTask,
placement : CandidatePlacement,
) -> String {
if task.mode == Fixed {
return "fixed by user at slot " + placement.start_slot.to_string()
}
if placement.solar_overlap_wh > 0 {
return "selected slot " +
placement.start_slot.to_string() +
" to use " +
placement.solar_overlap_wh.to_string() +
" Wh of local solar"
}
if placement.start_slot == task.preferred_start {
return "kept the preferred start because it is feasible and competitive"
}
let tariff = input.tariff_micro_per_kwh.at(placement.start_slot)
"moved from preferred slot " +
task.preferred_start.to_string() +
" to slot " +
placement.start_slot.to_string() +
" with tariff " +
tariff.to_string()
}
///|
fn apply_contiguous_task(
input : PlanningInput,
task : LoadTask,
placement : CandidatePlacement,
load_w : Array[Int],
schedule : Array[ScheduleEntry],
) -> Unit {
for slot = placement.start_slot; slot < placement.end_slot; slot = slot + 1 {
load_w[slot] = load_w[slot] + task.power_w
}
schedule.push({
task_id: task.id,
task_name: task.name,
start_slot: placement.start_slot,
end_slot: placement.end_slot,
power_w: task.power_w,
energy_wh: task.energy_wh(input.slot_minutes),
priority: task.priority,
reason: placement_reason(input, task, placement),
})
}
///|
fn interruptible_slot_score(
input : PlanningInput,
task : LoadTask,
slot : Int,
load_w : Array[Int],
) -> Int {
let (_, _, _, _, excess, score) = objective_slot_score(
input,
task,
slot,
load_w[slot],
)
score + excess * 100000
}
///|
fn schedule_interruptible(
input : PlanningInput,
task : LoadTask,
load_w : Array[Int],
schedule : Array[ScheduleEntry],
) -> Bool {
let slots : Array[(Int, Int)] = []
for slot = task.earliest_start; slot < task.latest_end; slot = slot + 1 {
slots.push((slot, interruptible_slot_score(input, task, slot, load_w)))
}
slots.sort_by(fn(a, b) {
if a.1 < b.1 {
-1
} else if a.1 > b.1 {
1
} else {
a.0 - b.0
}
})
if slots.length() < task.duration_slots {
return false
}
let chosen : Array[Int] = []
for index = 0; index < task.duration_slots; index = index + 1 {
chosen.push(slots[index].0)
}
chosen.sort()
let mut interruptions = 0
for index = 1; index < chosen.length(); index = index + 1 {
if chosen[index] != chosen[index - 1] + 1 {
interruptions = interruptions + 1
}
}
if interruptions > task.maximum_interruptions {
return false
}
let mut segment_start = chosen[0]
let mut previous = chosen[0]
for index = 0; index <= chosen.length(); index = index + 1 {
let is_end = index == chosen.length()
if !is_end && index > 0 && chosen[index] == previous + 1 {
previous = chosen[index]
continue
}
if index > 0 || is_end {
let end_slot = previous + 1
for slot = segment_start; slot < end_slot; slot = slot + 1 {
load_w[slot] = load_w[slot] + task.power_w
}
schedule.push({
task_id: task.id,
task_name: task.name,
start_slot: segment_start,
end_slot,
power_w: task.power_w,
energy_wh: slot_energy_wh(
task.power_w,
(end_slot - segment_start) * input.slot_minutes,
),
priority: task.priority,
reason: "selected low-impact interruptible segment",
})
}
if !is_end {
segment_start = chosen[index]
previous = chosen[index]
}
}
true
}
///|
fn build_provisional_schedule(
input : PlanningInput,
config : SolverConfig,
) -> (Array[Int], Array[ScheduleEntry], Array[String], Array[Explanation]) {
let load_w = input.base_load_w.copy_values()
let schedule : Array[ScheduleEntry] = []
let skipped : Array[String] = []
let explanations : Array[Explanation] = []
for task in sorted_tasks(input.tasks) {
if task.mode == Interruptible {
if schedule_interruptible(input, task, load_w, schedule) {
explanations.push(
Explanation::info(
"INTERRUPTIBLE_PLACED",
task.id,
"task was split into feasible low-impact segments",
),
)
} else if task.mode.may_skip() {
skipped.push(task.id)
} else {
skipped.push(task.id)
explanations.push(
Explanation::warning(
"INTERRUPTIBLE_INFEASIBLE",
task.id,
"no segment pattern satisfies the interruption limit",
),
)
}
continue
}
let candidates = candidate_placements(input, task, load_w, config~)
if candidates.length() == 0 {
skipped.push(task.id)
explanations.push(
Explanation::warning(
"NO_CANDIDATE",
task.id,
"task has no candidate start inside its window",
),
)
continue
}
let best = candidates[0]
let skip_threshold = if config.optional_skip_threshold > 0 {
config.optional_skip_threshold
} else {
task.skip_penalty
}
if task.mode.may_skip() &&
(!best.feasible || best.total_score > skip_threshold) {
skipped.push(task.id)
explanations.push(
Explanation::info(
"OPTIONAL_SKIPPED",
task.id,
"optional task was skipped because every placement costs more than its skip penalty",
),
)
} else {
apply_contiguous_task(input, task, best, load_w, schedule)
explanations.push(
Explanation::info(
"TASK_PLACED",
task.id,
placement_reason(input, task, best),
slot=best.start_slot,
),
)
if !best.feasible {
explanations.push(
Explanation::warning(
"CAPACITY_RISK",
task.id,
"best placement may require load curtailment or additional supply",
slot=best.start_slot,
),
)
}
}
}
(load_w, schedule, skipped, explanations)
}
///|
fn sorted_signal(values : Array[Int]) -> Array[Int] {
let result = values.map(value => value)
result.sort()
result
}
///|
fn tariff_thresholds(input : PlanningInput) -> (Int, Int) {
if input.horizon_slots == 0 {
return (0, 0)
}
let values = sorted_signal(input.tariff_micro_per_kwh.values)
let low = values[values.length() / 3]
let high = values[values.length() * 2 / 3]
(low, high)
}
///|
fn critical_load_at(schedule : Array[ScheduleEntry], slot : Int) -> Int {
let mut total = 0
for entry in schedule {
if entry.priority == Critical && entry.contains(slot) {
total = total + entry.power_w
}
}
total
}
///|
fn charge_from_power(
battery : BatterySpec,
state_wh : Int,
requested_power_w : Int,
slot_minutes : Int,
) -> (Int, Int) {
let power = minimum(requested_power_w, battery.maximum_charge_w)
let room_wh = maximum(0, battery.maximum_wh - state_wh)
let input_energy_wh = slot_energy_wh(power, slot_minutes)
let stored_wh = input_energy_wh * battery.charge_efficiency_permille / 1000
if stored_wh <= room_wh {
(power, state_wh + stored_wh)
} else if room_wh == 0 {
(0, state_wh)
} else {
let needed_input_wh = room_wh * 1000 / battery.charge_efficiency_permille
let adjusted_power = needed_input_wh * 60 / slot_minutes
(minimum(power, adjusted_power), battery.maximum_wh)
}
}
///|
fn discharge_to_power(
battery : BatterySpec,
state_wh : Int,
requested_power_w : Int,
slot_minutes : Int,
lower_bound_wh : Int,
) -> (Int, Int) {
let power = minimum(requested_power_w, battery.maximum_discharge_w)
let available_stored_wh = maximum(0, state_wh - lower_bound_wh)
let deliverable_wh = available_stored_wh *
battery.discharge_efficiency_permille /
1000
let requested_output_wh = slot_energy_wh(power, slot_minutes)
if requested_output_wh <= deliverable_wh {
let withdrawn_wh = requested_output_wh *
1000 /
battery.discharge_efficiency_permille
(power, maximum(lower_bound_wh, state_wh - withdrawn_wh))
} else if deliverable_wh <= 0 {
(0, state_wh)
} else {
let adjusted_power = deliverable_wh * 60 / slot_minutes
(minimum(power, adjusted_power), lower_bound_wh)
}
}
///|
struct DispatchResult {
grid_w : Array[Int]
solar_used_w : Array[Int]
unserved_w : Array[Int]
battery_state_wh : Array[Int]
battery_steps : Array[BatteryStep]
metrics : PlanMetrics
explanations : Array[Explanation]
} derive(Debug)
///|
fn dispatch_energy(
input : PlanningInput,
load_w : Array[Int],
schedule : Array[ScheduleEntry],
skipped : Array[String],
config : SolverConfig,
) -> DispatchResult {
let slots = input.horizon_slots
let grid_w = Array::make(slots, 0)
let solar_used_w = Array::make(slots, 0)
let unserved_w = Array::make(slots, 0)
let battery_state_wh = Array::make(slots + 1, 0)
let battery_steps : Array[BatteryStep] = []
let explanations : Array[Explanation] = []
let (low_tariff, high_tariff) = tariff_thresholds(input)
let mut state_wh = match input.battery {
Some(battery) => battery.clamp_state(battery.initial_wh)
None => 0
}
battery_state_wh[0] = state_wh
let mut imported_energy_wh = 0
let mut exported_energy_wh = 0
let mut solar_used_wh = 0
let mut solar_curtailed_wh = 0
let mut battery_charged_wh = 0
let mut battery_discharged_wh = 0
let mut cost_micro = 0
let mut export_credit_micro = 0
let mut carbon_g = 0
let mut unserved_energy_wh = 0
let mut critical_unserved_wh = 0
let mut peak_grid_w = 0
for slot = 0; slot < slots; slot = slot + 1 {
let demand_w = load_w[slot]
let available_solar_w = input.solar_w.at(slot)
let direct_solar_w = minimum(demand_w, available_solar_w)
solar_used_w[slot] = direct_solar_w
solar_used_wh = solar_used_wh +
slot_energy_wh(direct_solar_w, input.slot_minutes)
let mut remaining_demand_w = demand_w - direct_solar_w
let mut surplus_solar_w = available_solar_w - direct_solar_w
let before_wh = state_wh
let mut battery_power_w = 0
let mut battery_source = "idle"
let mut battery_reason = "no dispatch required"
match input.battery {
None => ()
Some(battery) => {
if surplus_solar_w > 0 {
let (charge_w, after_wh) = charge_from_power(
battery,
state_wh,
surplus_solar_w,
input.slot_minutes,
)
if charge_w > 0 {
battery_power_w = charge_w
battery_source = "solar"
battery_reason = "stored surplus rooftop solar"
surplus_solar_w = surplus_solar_w - charge_w
battery_charged_wh = battery_charged_wh +
maximum(0, after_wh - state_wh)
state_wh = after_wh
}
}
let outage = input.has_outage_at(slot)
let grid_limit = input.grid_limit_at(slot)
let must_discharge = remaining_demand_w > grid_limit
let high_price = input.tariff_micro_per_kwh.at(slot) >= high_tariff
if remaining_demand_w > 0 && (outage || must_discharge || high_price) {
let lower_bound = if outage || !config.preserve_reserve_outside_outage {
battery.minimum_wh
} else {
battery.reserve_wh
}
let (discharge_w, after_wh) = discharge_to_power(
battery,
state_wh,
remaining_demand_w,
input.slot_minutes,
lower_bound,
)
if discharge_w > 0 {
battery_power_w = -discharge_w
battery_source = "battery"
battery_reason = if outage {
"supplied load during grid outage"
} else if must_discharge {
"kept grid import below the connection limit"
} else {
"discharged during a high-tariff slot"
}
remaining_demand_w = remaining_demand_w - discharge_w
battery_discharged_wh = battery_discharged_wh +
maximum(0, state_wh - after_wh)
state_wh = after_wh
}
}
if config.allow_grid_charging &&
remaining_demand_w <= input.grid_limit_at(slot) &&
input.tariff_micro_per_kwh.at(slot) <= low_tariff &&
state_wh < battery.reserve_wh &&
!input.has_outage_at(slot) {
let spare_grid_w = maximum(
0,
input.grid_limit_at(slot) - remaining_demand_w,
)
let (charge_w, after_wh) = charge_from_power(
battery,
state_wh,
spare_grid_w,
input.slot_minutes,
)
if charge_w > 0 {
battery_power_w = battery_power_w + charge_w
remaining_demand_w = remaining_demand_w + charge_w
battery_source = "grid"
battery_reason = "charged to reserve during a low-tariff slot"
battery_charged_wh = battery_charged_wh +
maximum(0, after_wh - state_wh)
state_wh = after_wh
}
}
battery_steps.push({
slot,
state_before_wh: before_wh,
power_w: battery_power_w,
state_after_wh: state_wh,
source: battery_source,
reason: battery_reason,
})
}
}
let limit_w = input.grid_limit_at(slot)
let import_w = minimum(remaining_demand_w, limit_w)
grid_w[slot] = import_w
if import_w > peak_grid_w {
peak_grid_w = import_w
}
let import_wh = slot_energy_wh(import_w, input.slot_minutes)
imported_energy_wh = imported_energy_wh + import_wh
cost_micro = cost_micro +
import_wh * input.tariff_micro_per_kwh.at(slot) / 1000
carbon_g = carbon_g + import_wh * input.carbon_g_per_kwh.at(slot) / 1000
let missing_w = maximum(0, remaining_demand_w - import_w)
unserved_w[slot] = missing_w
let missing_wh = slot_energy_wh(missing_w, input.slot_minutes)
unserved_energy_wh = unserved_energy_wh + missing_wh
let critical_w = critical_load_at(schedule, slot)
critical_unserved_wh = critical_unserved_wh +
slot_energy_wh(minimum(missing_w, critical_w), input.slot_minutes)
if missing_w > 0 {
explanations.push(
Explanation::warning(
"UNSERVED_LOAD",
"slot-" + slot.to_string(),
missing_w.to_string() + " W could not be supplied",
slot~,
),
)
}
if surplus_solar_w > 0 {
let surplus_wh = slot_energy_wh(surplus_solar_w, input.slot_minutes)
if input.allow_grid_export && !input.has_outage_at(slot) {
exported_energy_wh = exported_energy_wh + surplus_wh
export_credit_micro = export_credit_micro +
surplus_wh * input.export_credit_micro_per_kwh / 1000
} else {
solar_curtailed_wh = solar_curtailed_wh + surplus_wh
}
}
battery_state_wh[slot + 1] = state_wh
}
let total_demand_wh = load_w.fold(init=0, fn(total, power) {
total + slot_energy_wh(power, input.slot_minutes)
})
let served_wh = maximum(0, total_demand_wh - unserved_energy_wh)
let resilience_permille = if total_demand_wh == 0 {
1000
} else {
served_wh * 1000 / total_demand_wh
}
let metrics : PlanMetrics = {
imported_energy_wh,
exported_energy_wh,
solar_used_wh,
solar_curtailed_wh,
battery_charged_wh,
battery_discharged_wh,
cost_micro,
export_credit_micro,
carbon_g,
comfort_penalty: 0,
unserved_energy_wh,
critical_unserved_wh,
completed_tasks: input.tasks.length() - skipped.length(),
skipped_tasks: skipped.length(),
peak_grid_w,
resilience_permille,
score: 0L,
}
{
grid_w,
solar_used_w,
unserved_w,
battery_state_wh,
battery_steps,
metrics,
explanations,
}
}
///|
fn comfort_penalty(
input : PlanningInput,
schedule : Array[ScheduleEntry],
skipped : Array[String],
) -> Int {
let mut penalty = 0
for task in input.tasks {
let mut first_start : Int? = None
for entry in schedule {
if entry.task_id == task.id {
match first_start {
None => first_start = Some(entry.start_slot)
Some(value) =>
if entry.start_slot < value {
first_start = Some(entry.start_slot)
}
}
}
}
match first_start {
Some(start) =>
penalty = penalty +
absolute(start - task.preferred_start) * task.comfort_penalty_per_slot
None =>
if skipped.contains(task.id) {
penalty = penalty + task.skip_penalty
}
}
}
penalty
}
///|
fn calculate_plan_score(
metrics : PlanMetrics,
weights : ObjectiveWeights,
) -> Int64 {
let normalized = weights.normalized()
let cost_term = metrics.net_cost_micro() / 1000 * normalized.cost
let carbon_term = metrics.carbon_g * normalized.carbon
let comfort_term = metrics.comfort_penalty * normalized.comfort
let resilience_term = (1000 - metrics.resilience_permille) *
normalized.resilience *
100
let wear_term = (metrics.battery_charged_wh + metrics.battery_discharged_wh) *
normalized.battery_wear
cost_term.to_int64() +
carbon_term.to_int64() +
comfort_term.to_int64() +
resilience_term.to_int64() +
wear_term.to_int64()
}
///|
fn required_task_skipped(
input : PlanningInput,
skipped : Array[String],
) -> Bool {
for task in input.tasks {
if task.is_required() && skipped.contains(task.id) {
return true
}
}
false
}
///|
fn finish_explanations(
input : PlanningInput,
metrics : PlanMetrics,
) -> Array[Explanation] {
let result : Array[Explanation] = []
if metrics.solar_used_wh > 0 {
result.push(
Explanation::info(
"SOLAR_USED",
"energy-balance",
metrics.solar_used_wh.to_string() + " Wh of local solar served demand",
),
)
}
if metrics.battery_discharged_wh > 0 {
result.push(
Explanation::info(
"BATTERY_SUPPORT",
"battery",
metrics.battery_discharged_wh.to_string() +
" Wh of stored energy supported the plan",
),
)
}
if metrics.solar_curtailed_wh > 0 {
result.push(
Explanation::warning(
"SOLAR_CURTAILED",
"solar",
metrics.solar_curtailed_wh.to_string() +
" Wh of surplus solar could not be used or exported",
),
)
}
if metrics.critical_unserved_wh > 0 {
result.push(
Explanation::warning(
"CRITICAL_UNSERVED",
"resilience",
metrics.critical_unserved_wh.to_string() +
" Wh of critical demand remained unserved",
),
)
}
if input.outages.length() > 0 {
result.push(
Explanation::info(
"OUTAGE_EVALUATED",
"resilience",
input.outages.length().to_string() + " outage event(s) were evaluated",
),
)
}
result
}
///|
/// Produce a deterministic energy plan for a validated input.
pub fn solve(
original_input : PlanningInput,
config? : SolverConfig = SolverConfig::default(),
) -> PlanResult {
let validation = validate(original_input)
if !validation.is_valid() {
let explanations = validation.issues.map(issue => {
Explanation::warning(
"VALIDATION_" + issue.code.label(),
issue.path,
issue.message + "; " + issue.hint,
slot?=issue.slot,
)
})
return {
..PlanResult::empty(
original_input.title,
maximum(0, original_input.horizon_slots),
original_input.slot_minutes,
),
explanations,
}
}
let input = apply_safe_defaults(original_input)
let (load_w, schedule, skipped, scheduling_explanations) = build_provisional_schedule(
input, config,
)
let dispatch = dispatch_energy(input, load_w, schedule, skipped, config)
let comfort = comfort_penalty(input, schedule, skipped)
let metrics_without_score = { ..dispatch.metrics, comfort_penalty: comfort }
let score = calculate_plan_score(metrics_without_score, input.weights)
let metrics = { ..metrics_without_score, score, }
let required_skipped = required_task_skipped(input, skipped)
let status = if required_skipped || metrics.critical_unserved_wh > 0 {
Infeasible
} else if metrics.unserved_energy_wh > 0 || skipped.length() > 0 {
FeasibleWithCurtailment
} else {
Feasible
}
let explanations = scheduling_explanations
for item in dispatch.explanations {
explanations.push(item)
}
for item in finish_explanations(input, metrics) {
explanations.push(item)
}
{
title: input.title,
status,
slot_minutes: input.slot_minutes,
horizon_slots: input.horizon_slots,
schedule,
battery_steps: dispatch.battery_steps,
load_w,
grid_w: dispatch.grid_w,
solar_used_w: dispatch.solar_used_w,
unserved_w: dispatch.unserved_w,
battery_state_wh: dispatch.battery_state_wh,
skipped_task_ids: skipped,
metrics,
explanations,
}
}
///|
/// Solve the same scenario for all supported policies.
pub fn solve_policy_set(
input : PlanningInput,
config? : SolverConfig = SolverConfig::default(),
) -> Array[(PlanningPolicy, PlanResult)] {
let policies = [
Balanced,
LowestCost,
LowestCarbon,
HighestComfort,
HighestResilience,
]
policies.map(policy => {
let weights = ObjectiveWeights::for_policy(policy)
(policy, solve({ ..input, weights, }, config~))
})
}
///|
fn dominates(a : PlanMetrics, b : PlanMetrics) -> Bool {
let no_worse = a.net_cost_micro() <= b.net_cost_micro() &&
a.carbon_g <= b.carbon_g &&
a.comfort_penalty <= b.comfort_penalty &&
a.unserved_energy_wh <= b.unserved_energy_wh
let strictly_better = a.net_cost_micro() < b.net_cost_micro() ||
a.carbon_g < b.carbon_g ||
a.comfort_penalty < b.comfort_penalty ||
a.unserved_energy_wh < b.unserved_energy_wh
no_worse && strictly_better
}
///|
/// Remove policy results that are worse on every reported objective.
pub fn pareto_frontier(
results : Array[(PlanningPolicy, PlanResult)],
) -> Array[(PlanningPolicy, PlanResult)] {
let frontier : Array[(PlanningPolicy, PlanResult)] = []
for index, candidate in results {
let mut dominated = false
for other_index, other in results {
if index != other_index && dominates(other.1.metrics, candidate.1.metrics) {
dominated = true
break
}
}
if !dominated {
frontier.push(candidate)
}
}
frontier
}
///|
/// Create a simple preferred-time baseline without changing the input contract.
pub fn solve_preferred_baseline(input : PlanningInput) -> PlanResult {
let tasks = input.tasks.map(task => {
if task.mode == Fixed {
task
} else {
let start = clamp(
task.preferred_start,
task.earliest_start,
task.latest_start(),
)
{
..task,
mode: Fixed,
fixed_start: start,
earliest_start: start,
latest_end: start + task.duration_slots,
}
}
})
solve({ ..input, title: input.title + " preferred-time baseline", tasks }, config={
..SolverConfig::default(),
allow_grid_charging: false,
preserve_reserve_outside_outage: true,
})
}
///|
pub(all) struct PlanComparison {
baseline : PlanResult
optimized : PlanResult
cost_saving_micro : Int
carbon_saving_g : Int
peak_reduction_w : Int
resilience_gain_permille : Int
recommendation : String
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn compare_with_baseline(
input : PlanningInput,
config? : SolverConfig = SolverConfig::default(),
) -> PlanComparison {
let baseline = solve_preferred_baseline(input)
let optimized = solve(input, config~)
let cost_saving = baseline.metrics.net_cost_micro() -
optimized.metrics.net_cost_micro()
let carbon_saving = baseline.metrics.carbon_g - optimized.metrics.carbon_g
let peak_reduction = baseline.metrics.peak_grid_w -
optimized.metrics.peak_grid_w
let resilience_gain = optimized.metrics.resilience_permille -
baseline.metrics.resilience_permille
let recommendation = if optimized.status == Infeasible {
"increase supply capacity, battery reserve, or task flexibility"
} else if cost_saving > 0 && carbon_saving > 0 {
"adopt the optimized plan: it lowers both cost and carbon"
} else if resilience_gain > 0 {
"adopt the optimized plan for stronger outage coverage"
} else if cost_saving > 0 {
"adopt the optimized plan for lower energy cost"
} else {
"keep the preferred plan unless the optimized timing is acceptable"
}
{
baseline,
optimized,
cost_saving_micro: cost_saving,
carbon_saving_g: carbon_saving,
peak_reduction_w: peak_reduction,
resilience_gain_permille: resilience_gain,
recommendation,
}
}