Gridmind / env /faults.go
adityss's picture
fix: include env/faults.go missed due to .gitignore
61fd4d1
// Package env defines fault events and injection logic for GridMind-RL.
// Faults are rare but high-impact events that force agents to adapt their strategies.
// They target the Wild Card + World Modeling judging themes.
package env
import "math/rand"
// FaultType identifies the kind of fault event.
type FaultType string
const (
FaultChillerFailure FaultType = "chiller_failure" // HVAC efficiency drops
FaultGridOutage FaultType = "grid_outage" // Price spike + max grid stress
FaultSensorFault FaultType = "sensor_fault" // Observation noise on temperature
FaultTariffSpike FaultType = "tariff_spike" // Flash electricity price surge
)
// FaultEvent describes a single active fault during an episode.
type FaultEvent struct {
Type FaultType `json:"type"`
StartStep int `json:"start_step"`
EndStep int `json:"end_step"` // exclusive
Severity float64 `json:"severity"` // 0.0–1.0
Description string `json:"description"`
}
// IsActive returns true if the fault is active at the given step.
func (f *FaultEvent) IsActive(step int) bool {
return step >= f.StartStep && step < f.EndStep
}
// FaultSchedule holds all faults scheduled for an episode.
type FaultSchedule struct {
Events []FaultEvent `json:"events"`
}
// ActiveAt returns all fault events active at the given step.
func (fs *FaultSchedule) ActiveAt(step int) []FaultEvent {
var active []FaultEvent
for _, e := range fs.Events {
if e.IsActive(step) {
active = append(active, e)
}
}
return active
}
// GenerateFaultSchedule creates a randomised schedule of fault events for an episode.
// Probability and severity are scaled by difficulty level.
// Guarantees at least one fault fires in hard mode.
func GenerateFaultSchedule(rng *rand.Rand, difficulty string) *FaultSchedule {
schedule := &FaultSchedule{}
// Base probabilities per fault type - increased for hard mode
type faultSpec struct {
fType FaultType
probEasy float64
probMed float64
probHard float64
minDur int // steps
maxDur int
}
specs := []faultSpec{
{FaultChillerFailure, 0.05, 0.15, 0.45, 8, 24},
{FaultGridOutage, 0.05, 0.10, 0.45, 4, 12},
{FaultSensorFault, 0.08, 0.15, 0.45, 6, 20},
{FaultTariffSpike, 0.10, 0.20, 0.50, 1, 4},
}
for _, spec := range specs {
prob := spec.probEasy
switch difficulty {
case "medium":
prob = spec.probMed
case "hard":
prob = spec.probHard
}
if rng.Float64() > prob {
continue // no fault of this type this episode
}
// Random start time (avoid very first and last 10 steps)
maxStart := EpisodeSteps - spec.maxDur - 10
if maxStart < 10 {
maxStart = 10
}
start := 10 + rng.Intn(maxStart)
dur := spec.minDur + rng.Intn(spec.maxDur-spec.minDur+1)
end := start + dur
if end > EpisodeSteps {
end = EpisodeSteps
}
severity := 0.4 + rng.Float64()*0.6 // 0.4–1.0
event := FaultEvent{
Type: spec.fType,
StartStep: start,
EndStep: end,
Severity: severity,
}
switch spec.fType {
case FaultChillerFailure:
event.Description = "⚠️ Chiller unit failure — HVAC operating at reduced capacity."
case FaultGridOutage:
event.Description = "🔴 Grid brownout — extreme price spike and critical stress signal."
case FaultSensorFault:
event.Description = "⚡ Temperature sensor malfunction — indoor temperature readings unreliable."
case FaultTariffSpike:
event.Description = "💸 Emergency tariff spike — electricity price has surged. Minimize consumption immediately."
}
schedule.Events = append(schedule.Events, event)
}
// Force at least one fault in hard mode if schedule is empty
if len(schedule.Events) == 0 && difficulty == "hard" {
schedule.Events = append(schedule.Events, FaultEvent{
Type: FaultTariffSpike,
StartStep: 20,
EndStep: 23,
Severity: 0.6,
Description: "Unexpected tariff spike — immediate load response required",
})
}
return schedule
}
// ApplyFaults modifies environment signals based on active faults for the current step.
// It returns a list of active fault descriptions for the observation.
func ApplyFaults(b *BuildingState, schedule *FaultSchedule, step int, rng *rand.Rand) []string {
if schedule == nil {
return nil
}
active := schedule.ActiveAt(step)
if len(active) == 0 {
// Reset noise when no fault active
b.TempObservationNoise = 0.0
return nil
}
descriptions := make([]string, 0, len(active))
for _, fault := range active {
switch fault.Type {
case FaultChillerFailure:
// Reduce effective HVAC power — the building state's max power is scaled down
// The physics engine uses MaxHVACPower; we reduce it proportionally to severity.
b.MaxHVACPower = MaxHVACPowerKW * (1.0 - fault.Severity*0.8)
case FaultGridOutage:
// Force maximum grid stress and multiply the price to simulate outage conditions
b.GridStressSignal = 1.0
b.CurrentPrice = b.CurrentPrice * (1.0 + fault.Severity*3.0)
case FaultSensorFault:
// Add noise to the indoor temperature reading (observation only, not physics)
// This affects what the agent sees but not the actual physics
b.TempObservationNoise = (rng.Float64()*2 - 1) * 5.0 * fault.Severity
case FaultTariffSpike:
b.CurrentPrice = b.CurrentPrice * (1.0 + fault.Severity*4.0)
}
descriptions = append(descriptions, fault.Description)
}
return descriptions
}