///|
/// Scheduling semantics for a household task.
pub(all) enum TaskMode {
Fixed
Shiftable
Interruptible
Optional
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn TaskMode::label(self : TaskMode) -> String {
match self {
Fixed => "fixed"
Shiftable => "shiftable"
Interruptible => "interruptible"
Optional => "optional"
}
}
///|
pub fn TaskMode::is_flexible(self : TaskMode) -> Bool {
match self {
Fixed => false
_ => true
}
}
///|
pub fn TaskMode::may_skip(self : TaskMode) -> Bool {
match self {
Optional => true
_ => false
}
}
///|
/// Relative importance of a task during normal and outage operation.
pub(all) enum Priority {
Critical
High
Normal
Low
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn Priority::rank(self : Priority) -> Int {
match self {
Critical => 4
High => 3
Normal => 2
Low => 1
}
}
///|
pub fn Priority::label(self : Priority) -> String {
match self {
Critical => "critical"
High => "high"
Normal => "normal"
Low => "low"
}
}
///|
/// Policy used when several valid plans are available.
pub(all) enum PlanningPolicy {
Balanced
LowestCost
LowestCarbon
HighestComfort
HighestResilience
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn PlanningPolicy::label(self : PlanningPolicy) -> String {
match self {
Balanced => "balanced"
LowestCost => "lowest-cost"
LowestCarbon => "lowest-carbon"
HighestComfort => "highest-comfort"
HighestResilience => "highest-resilience"
}
}
///|
/// A regular time grid represented using integer values.
///
/// Values are deliberately unit-agnostic so the same type can carry prices,
/// carbon intensity, solar power, or a capacity limit.
pub(all) struct IntSeries {
name : String
unit : String
slot_minutes : Int
values : Array[Int]
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn IntSeries::new(
name : String,
unit : String,
slot_minutes : Int,
values : Array[Int],
) -> IntSeries {
{ name, unit, slot_minutes, values }
}
///|
pub fn IntSeries::length(self : IntSeries) -> Int {
self.values.length()
}
///|
pub fn IntSeries::at(self : IntSeries, slot : Int) -> Int {
if slot < 0 || slot >= self.values.length() {
0
} else {
self.values[slot]
}
}
///|
pub fn IntSeries::sum(self : IntSeries) -> Int {
let mut total = 0
for value in self.values {
total = total + value
}
total
}
///|
pub fn IntSeries::minimum(self : IntSeries) -> Int {
if self.values.length() == 0 {
return 0
}
let mut value = self.values[0]
for item in self.values {
if item < value {
value = item
}
}
value
}
///|
pub fn IntSeries::maximum(self : IntSeries) -> Int {
if self.values.length() == 0 {
return 0
}
let mut value = self.values[0]
for item in self.values {
if item > value {
value = item
}
}
value
}
///|
pub fn IntSeries::average(self : IntSeries) -> Int {
if self.values.length() == 0 {
0
} else {
self.sum() / self.values.length()
}
}
///|
pub fn IntSeries::copy_values(self : IntSeries) -> Array[Int] {
self.values.map(value => value)
}
///|
pub fn IntSeries::scale_permille(
self : IntSeries,
factor_permille : Int,
) -> IntSeries {
{ ..self, values: self.values.map(value => value * factor_permille / 1000) }
}
///|
pub fn IntSeries::with_value(
self : IntSeries,
slot : Int,
value : Int,
) -> IntSeries {
let values = self.copy_values()
if slot >= 0 && slot < values.length() {
values[slot] = value
}
{ ..self, values, }
}
///|
/// Battery characteristics use watt-hours, watts, and permille efficiencies.
pub(all) struct BatterySpec {
name : String
capacity_wh : Int
initial_wh : Int
reserve_wh : Int
minimum_wh : Int
maximum_wh : Int
maximum_charge_w : Int
maximum_discharge_w : Int
charge_efficiency_permille : Int
discharge_efficiency_permille : Int
cycle_cost_micro_per_kwh : Int
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn BatterySpec::new(
name : String,
capacity_wh : Int,
initial_wh : Int,
reserve_wh : Int,
maximum_charge_w : Int,
maximum_discharge_w : Int,
) -> BatterySpec {
{
name,
capacity_wh,
initial_wh,
reserve_wh,
minimum_wh: 0,
maximum_wh: capacity_wh,
maximum_charge_w,
maximum_discharge_w,
charge_efficiency_permille: 950,
discharge_efficiency_permille: 950,
cycle_cost_micro_per_kwh: 8000,
}
}
///|
pub fn BatterySpec::usable_wh(self : BatterySpec) -> Int {
let lower = if self.reserve_wh > self.minimum_wh {
self.reserve_wh
} else {
self.minimum_wh
}
if self.maximum_wh <= lower {
0
} else {
self.maximum_wh - lower
}
}
///|
pub fn BatterySpec::clamp_state(self : BatterySpec, state_wh : Int) -> Int {
if state_wh < self.minimum_wh {
self.minimum_wh
} else if state_wh > self.maximum_wh {
self.maximum_wh
} else {
state_wh
}
}
///|
pub fn BatterySpec::state_permille(self : BatterySpec, state_wh : Int) -> Int {
if self.capacity_wh <= 0 {
0
} else {
self.clamp_state(state_wh) * 1000 / self.capacity_wh
}
}
///|
/// A schedulable household load.
pub(all) struct LoadTask {
id : String
name : String
mode : TaskMode
priority : Priority
power_w : Int
duration_slots : Int
earliest_start : Int
latest_end : Int
preferred_start : Int
fixed_start : Int
minimum_run_slots : Int
maximum_interruptions : Int
comfort_penalty_per_slot : Int
skip_penalty : Int
tags : Array[String]
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn LoadTask::shiftable(
id : String,
name : String,
power_w : Int,
duration_slots : Int,
earliest_start : Int,
latest_end : Int,
preferred_start : Int,
priority? : Priority = Normal,
) -> LoadTask {
{
id,
name,
mode: Shiftable,
priority,
power_w,
duration_slots,
earliest_start,
latest_end,
preferred_start,
fixed_start: -1,
minimum_run_slots: duration_slots,
maximum_interruptions: 0,
comfort_penalty_per_slot: 10,
skip_penalty: 100000,
tags: [],
}
}
///|
pub fn LoadTask::fixed(
id : String,
name : String,
power_w : Int,
duration_slots : Int,
fixed_start : Int,
priority? : Priority = Normal,
) -> LoadTask {
{
id,
name,
mode: Fixed,
priority,
power_w,
duration_slots,
earliest_start: fixed_start,
latest_end: fixed_start + duration_slots,
preferred_start: fixed_start,
fixed_start,
minimum_run_slots: duration_slots,
maximum_interruptions: 0,
comfort_penalty_per_slot: 0,
skip_penalty: 100000,
tags: [],
}
}
///|
pub fn LoadTask::interruptible(
id : String,
name : String,
power_w : Int,
duration_slots : Int,
earliest_start : Int,
latest_end : Int,
preferred_start : Int,
maximum_interruptions : Int,
priority? : Priority = Normal,
) -> LoadTask {
{
id,
name,
mode: Interruptible,
priority,
power_w,
duration_slots,
earliest_start,
latest_end,
preferred_start,
fixed_start: -1,
minimum_run_slots: 1,
maximum_interruptions,
comfort_penalty_per_slot: 8,
skip_penalty: 100000,
tags: [],
}
}
///|
pub fn LoadTask::optional(
id : String,
name : String,
power_w : Int,
duration_slots : Int,
earliest_start : Int,
latest_end : Int,
preferred_start : Int,
priority? : Priority = Low,
) -> LoadTask {
{
id,
name,
mode: Optional,
priority,
power_w,
duration_slots,
earliest_start,
latest_end,
preferred_start,
fixed_start: -1,
minimum_run_slots: duration_slots,
maximum_interruptions: 0,
comfort_penalty_per_slot: 5,
skip_penalty: 800,
tags: [],
}
}
///|
pub fn LoadTask::energy_wh(self : LoadTask, slot_minutes : Int) -> Int {
self.power_w * self.duration_slots * slot_minutes / 60
}
///|
pub fn LoadTask::latest_start(self : LoadTask) -> Int {
self.latest_end - self.duration_slots
}
///|
pub fn LoadTask::window_slots(self : LoadTask) -> Int {
if self.latest_end <= self.earliest_start {
0
} else {
self.latest_end - self.earliest_start
}
}
///|
pub fn LoadTask::is_required(self : LoadTask) -> Bool {
!self.mode.may_skip()
}
///|
pub fn LoadTask::with_tag(self : LoadTask, tag : String) -> LoadTask {
let tags = self.tags.map(value => value)
tags.push(tag)
{ ..self, tags, }
}
///|
/// An interval where the public grid is unavailable or capped.
pub(all) struct OutageEvent {
id : String
start_slot : Int
end_slot : Int
grid_limit_w : Int
reserve_override_wh : Int?
description : String
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn OutageEvent::blackout(
id : String,
start_slot : Int,
end_slot : Int,
description? : String = "grid blackout",
) -> OutageEvent {
{
id,
start_slot,
end_slot,
grid_limit_w: 0,
reserve_override_wh: None,
description,
}
}
///|
pub fn OutageEvent::contains(self : OutageEvent, slot : Int) -> Bool {
slot >= self.start_slot && slot < self.end_slot
}
///|
pub fn OutageEvent::duration_slots(self : OutageEvent) -> Int {
if self.end_slot <= self.start_slot {
0
} else {
self.end_slot - self.start_slot
}
}
///|
/// Integer weights make scoring deterministic across all MoonBit backends.
pub(all) struct ObjectiveWeights {
cost : Int
carbon : Int
comfort : Int
resilience : Int
battery_wear : Int
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn ObjectiveWeights::balanced() -> ObjectiveWeights {
{ cost: 35, carbon: 20, comfort: 20, resilience: 20, battery_wear: 5 }
}
///|
pub fn ObjectiveWeights::for_policy(
policy : PlanningPolicy,
) -> ObjectiveWeights {
match policy {
Balanced => ObjectiveWeights::balanced()
LowestCost =>
{ cost: 70, carbon: 10, comfort: 10, resilience: 5, battery_wear: 5 }
LowestCarbon =>
{ cost: 15, carbon: 65, comfort: 10, resilience: 5, battery_wear: 5 }
HighestComfort =>
{ cost: 15, carbon: 10, comfort: 65, resilience: 5, battery_wear: 5 }
HighestResilience =>
{ cost: 10, carbon: 5, comfort: 10, resilience: 70, battery_wear: 5 }
}
}
///|
pub fn ObjectiveWeights::total(self : ObjectiveWeights) -> Int {
self.cost + self.carbon + self.comfort + self.resilience + self.battery_wear
}
///|
pub fn ObjectiveWeights::normalized(
self : ObjectiveWeights,
) -> ObjectiveWeights {
let total = self.total()
if total <= 0 {
ObjectiveWeights::balanced()
} else {
{
cost: self.cost * 100 / total,
carbon: self.carbon * 100 / total,
comfort: self.comfort * 100 / total,
resilience: self.resilience * 100 / total,
battery_wear: self.battery_wear * 100 / total,
}
}
}
///|
/// Complete input contract for the optimizer.
pub(all) struct PlanningInput {
title : String
slot_minutes : Int
horizon_slots : Int
tariff_micro_per_kwh : IntSeries
carbon_g_per_kwh : IntSeries
solar_w : IntSeries
base_load_w : IntSeries
grid_limit_w : IntSeries
tasks : Array[LoadTask]
battery : BatterySpec?
outages : Array[OutageEvent]
weights : ObjectiveWeights
allow_grid_export : Bool
export_credit_micro_per_kwh : Int
random_seed : UInt
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn PlanningInput::empty(title : String, slots : Int) -> PlanningInput {
let values = Array::make(slots, 0)
{
title,
slot_minutes: 60,
horizon_slots: slots,
tariff_micro_per_kwh: IntSeries::new(
"tariff",
"micro/kWh",
60,
Array::make(slots, 100000),
),
carbon_g_per_kwh: IntSeries::new(
"carbon",
"g/kWh",
60,
Array::make(slots, 500),
),
solar_w: IntSeries::new("solar", "W", 60, values.copy()),
base_load_w: IntSeries::new("base-load", "W", 60, values.copy()),
grid_limit_w: IntSeries::new(
"grid-limit",
"W",
60,
Array::make(slots, 10000),
),
tasks: [],
battery: None,
outages: [],
weights: ObjectiveWeights::balanced(),
allow_grid_export: false,
export_credit_micro_per_kwh: 0,
random_seed: 1U,
}
}
///|
pub fn PlanningInput::with_task(
self : PlanningInput,
task : LoadTask,
) -> PlanningInput {
let tasks = self.tasks.map(value => value)
tasks.push(task)
{ ..self, tasks, }
}
///|
pub fn PlanningInput::with_outage(
self : PlanningInput,
outage : OutageEvent,
) -> PlanningInput {
let outages = self.outages.map(value => value)
outages.push(outage)
{ ..self, outages, }
}
///|
pub fn PlanningInput::grid_limit_at(self : PlanningInput, slot : Int) -> Int {
let mut limit = self.grid_limit_w.at(slot)
for outage in self.outages {
if outage.contains(slot) && outage.grid_limit_w < limit {
limit = outage.grid_limit_w
}
}
limit
}
///|
pub fn PlanningInput::has_outage_at(self : PlanningInput, slot : Int) -> Bool {
for outage in self.outages {
if outage.contains(slot) && outage.grid_limit_w == 0 {
return true
}
}
false
}
///|
pub fn PlanningInput::total_task_energy_wh(self : PlanningInput) -> Int {
let mut total = 0
for task in self.tasks {
total = total + task.energy_wh(self.slot_minutes)
}
total
}
///|
pub fn PlanningInput::total_solar_energy_wh(self : PlanningInput) -> Int {
self.solar_w.sum() * self.slot_minutes / 60
}
///|
/// A chosen execution interval. Interruptible tasks may have several entries.
pub(all) struct ScheduleEntry {
task_id : String
task_name : String
start_slot : Int
end_slot : Int
power_w : Int
energy_wh : Int
priority : Priority
reason : String
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn ScheduleEntry::duration_slots(self : ScheduleEntry) -> Int {
self.end_slot - self.start_slot
}
///|
pub fn ScheduleEntry::contains(self : ScheduleEntry, slot : Int) -> Bool {
slot >= self.start_slot && slot < self.end_slot
}
///|
/// Positive battery power means charging; negative means discharging.
pub(all) struct BatteryStep {
slot : Int
state_before_wh : Int
power_w : Int
state_after_wh : Int
source : String
reason : String
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn BatteryStep::charged_wh(self : BatteryStep) -> Int {
if self.state_after_wh > self.state_before_wh {
self.state_after_wh - self.state_before_wh
} else {
0
}
}
///|
pub fn BatteryStep::discharged_wh(self : BatteryStep) -> Int {
if self.state_before_wh > self.state_after_wh {
self.state_before_wh - self.state_after_wh
} else {
0
}
}
///|
/// Aggregate objective values. Money is stored in micro currency units.
pub(all) struct PlanMetrics {
imported_energy_wh : Int
exported_energy_wh : Int
solar_used_wh : Int
solar_curtailed_wh : Int
battery_charged_wh : Int
battery_discharged_wh : Int
cost_micro : Int
export_credit_micro : Int
carbon_g : Int
comfort_penalty : Int
unserved_energy_wh : Int
critical_unserved_wh : Int
completed_tasks : Int
skipped_tasks : Int
peak_grid_w : Int
resilience_permille : Int
score : Int64
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn PlanMetrics::empty() -> PlanMetrics {
{
imported_energy_wh: 0,
exported_energy_wh: 0,
solar_used_wh: 0,
solar_curtailed_wh: 0,
battery_charged_wh: 0,
battery_discharged_wh: 0,
cost_micro: 0,
export_credit_micro: 0,
carbon_g: 0,
comfort_penalty: 0,
unserved_energy_wh: 0,
critical_unserved_wh: 0,
completed_tasks: 0,
skipped_tasks: 0,
peak_grid_w: 0,
resilience_permille: 1000,
score: 0L,
}
}
///|
pub fn PlanMetrics::net_cost_micro(self : PlanMetrics) -> Int {
self.cost_micro - self.export_credit_micro
}
///|
pub fn PlanMetrics::served_energy_wh(self : PlanMetrics) -> Int {
self.imported_energy_wh +
self.solar_used_wh +
self.battery_discharged_wh -
self.exported_energy_wh -
self.battery_charged_wh
}
///|
/// A machine-readable explanation tied to a plan decision.
pub(all) struct Explanation {
code : String
severity : String
subject : String
message : String
slot : Int?
evidence : Array[String]
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn Explanation::info(
code : String,
subject : String,
message : String,
slot? : Int,
) -> Explanation {
{ code, severity: "info", subject, message, slot, evidence: [] }
}
///|
pub fn Explanation::warning(
code : String,
subject : String,
message : String,
slot? : Int,
) -> Explanation {
{ code, severity: "warning", subject, message, slot, evidence: [] }
}
///|
pub(all) enum PlanStatus {
Feasible
FeasibleWithCurtailment
Infeasible
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn PlanStatus::label(self : PlanStatus) -> String {
match self {
Feasible => "feasible"
FeasibleWithCurtailment => "feasible-with-curtailment"
Infeasible => "infeasible"
}
}
///|
/// Full optimizer output used by the CLI, web demo, and JSON API.
pub(all) struct PlanResult {
title : String
status : PlanStatus
slot_minutes : Int
horizon_slots : Int
schedule : Array[ScheduleEntry]
battery_steps : Array[BatteryStep]
load_w : Array[Int]
grid_w : Array[Int]
solar_used_w : Array[Int]
unserved_w : Array[Int]
battery_state_wh : Array[Int]
skipped_task_ids : Array[String]
metrics : PlanMetrics
explanations : Array[Explanation]
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn PlanResult::empty(
title : String,
slots : Int,
slot_minutes : Int,
) -> PlanResult {
{
title,
status: Infeasible,
slot_minutes,
horizon_slots: slots,
schedule: [],
battery_steps: [],
load_w: Array::make(slots, 0),
grid_w: Array::make(slots, 0),
solar_used_w: Array::make(slots, 0),
unserved_w: Array::make(slots, 0),
battery_state_wh: Array::make(slots + 1, 0),
skipped_task_ids: [],
metrics: PlanMetrics::empty(),
explanations: [],
}
}
///|
pub fn PlanResult::entry_for(
self : PlanResult,
task_id : String,
) -> ScheduleEntry? {
for entry in self.schedule {
if entry.task_id == task_id {
return Some(entry)
}
}
None
}
///|
pub fn PlanResult::is_task_scheduled(
self : PlanResult,
task_id : String,
) -> Bool {
self.entry_for(task_id) is Some(_)
}
///|
pub fn PlanResult::grid_peak_slot(self : PlanResult) -> Int {
let mut peak_slot = 0
let mut peak = -1
for index, value in self.grid_w {
if value > peak {
peak = value
peak_slot = index
}
}
peak_slot
}
///|
pub fn PlanResult::total_unserved_wh(self : PlanResult) -> Int {
self.unserved_w.fold(init=0, fn(total, value) { total + value }) *
self.slot_minutes /
60
}
///|
pub fn PlanResult::to_json_string(self : PlanResult) -> String {
self.to_json().stringify(indent=2)
}
///|
pub fn PlanningInput::to_json_string(self : PlanningInput) -> String {
self.to_json().stringify(indent=2)
}
///|
pub fn planning_input_from_json(source : String) -> PlanningInput raise {
let json = @json.parse(source)
@json.from_json(json)
}
///|
pub fn plan_result_from_json(source : String) -> PlanResult raise {
let json = @json.parse(source)
@json.from_json(json)
}