Files
HeatGuard/internal/compute/compute.go
vikingowl 5e6696aa42 feat: add AI-powered actions endpoint and timeline annotations
Add LLM actions endpoint that generates hour-specific heat
management recommendations. Replace static action engine with
AI-driven approach. Add cool mode logic (ventilate/ac/overloaded),
indoor temperature tracking, and timeline legend with annotations.
2026-02-10 03:54:09 +01:00

321 lines
7.9 KiB
Go

package compute
import (
"fmt"
"time"
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
)
// BuildDashboard computes all dashboard data from the provided request.
// All data is passed in-memory — no DB calls.
func BuildDashboard(req ComputeRequest) (DashboardData, error) {
if len(req.Forecasts) == 0 {
return DashboardData{}, fmt.Errorf("no forecast data for %s", req.Date)
}
date, err := time.Parse("2006-01-02", req.Date)
if err != nil {
return DashboardData{}, fmt.Errorf("invalid date: %s", req.Date)
}
loc, err := time.LoadLocation(req.Profile.Timezone)
if err != nil {
loc = time.UTC
}
from := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc)
to := from.Add(24 * time.Hour)
// Filter forecasts for target date
var dayForecasts []Forecast
for _, f := range req.Forecasts {
if !f.Timestamp.Before(from) && f.Timestamp.Before(to) {
dayForecasts = append(dayForecasts, f)
}
}
if len(dayForecasts) == 0 {
dayForecasts = req.Forecasts
}
// Build risk analysis
hourlyData := buildHourlyData(dayForecasts, loc)
th := risk.DefaultThresholds()
dayRisk := risk.AnalyzeDay(hourlyData, th)
toggles := req.Toggles
if toggles == nil {
toggles = map[string]bool{}
}
// Compute representative indoor temperature (average across rooms)
indoorTempC := 25.0
if len(req.Rooms) > 0 {
sum := 0.0
for _, r := range req.Rooms {
t := r.IndoorTempC
if t == 0 {
t = 25.0
}
sum += t
}
indoorTempC = sum / float64(len(req.Rooms))
}
data := DashboardData{
GeneratedAt: time.Now(),
ProfileName: req.Profile.Name,
Date: req.Date,
RiskLevel: dayRisk.Level.String(),
PeakTempC: dayRisk.PeakTempC,
MinNightTempC: dayRisk.MinNightTempC,
PoorNightCool: dayRisk.PoorNightCool,
IndoorTempC: indoorTempC,
}
// Warnings (pass-through from client)
for _, w := range req.Warnings {
data.Warnings = append(data.Warnings, WarningData{
Headline: w.Headline,
Severity: w.Severity,
Description: w.Description,
Instruction: w.Instruction,
Onset: w.Onset,
Expires: w.Expires,
})
}
// Risk windows
for _, w := range dayRisk.Windows {
data.RiskWindows = append(data.RiskWindows, RiskWindowData{
StartHour: w.StartHour,
EndHour: w.EndHour,
PeakTempC: w.PeakTempC,
Level: w.Level.String(),
Reason: w.Reason,
})
}
// Timeline + room budget computation
var peakBudgets []roomBudgetResult
for i, h := range hourlyData {
cloudPct := 50.0
sunMin := 0.0
if i < len(dayForecasts) {
if dayForecasts[i].CloudCoverPct != nil {
cloudPct = *dayForecasts[i].CloudCoverPct
}
if dayForecasts[i].SunshineMin != nil {
sunMin = *dayForecasts[i].SunshineMin
}
}
budgets, worstStatus := computeRoomBudgets(req, h.Hour, h.TempC, cloudPct, sunMin, toggles)
coolMode := "ac"
if h.TempC < indoorTempC {
coolMode = "ventilate"
} else if worstStatus == heat.Overloaded {
coolMode = "overloaded"
}
slot := TimelineSlotData{
Hour: h.Hour,
HourStr: fmt.Sprintf("%02d:00", h.Hour),
TempC: h.TempC,
HumidityPct: h.HumidityPct,
RiskLevel: dayRisk.Level.String(),
BudgetStatus: worstStatus.String(),
IndoorTempC: indoorTempC,
CoolMode: coolMode,
}
data.Timeline = append(data.Timeline, slot)
if h.TempC == dayRisk.PeakTempC && len(budgets) > 0 {
peakBudgets = budgets
}
}
// Room budgets (computed at peak temp hour)
for _, rb := range peakBudgets {
data.RoomBudgets = append(data.RoomBudgets, RoomBudgetData{
RoomName: rb.RoomName,
InternalGainsW: rb.Result.InternalGainsW,
SolarGainW: rb.Result.SolarGainW,
VentGainW: rb.Result.VentilationGainW,
TotalGainW: rb.Result.TotalGainW,
TotalGainBTUH: rb.Result.TotalGainBTUH,
ACCapacityBTUH: rb.Result.ACCapacityBTUH,
HeadroomBTUH: rb.Result.HeadroomBTUH,
Status: rb.Result.Status.String(),
})
}
// Care checklist
for _, o := range req.Occupants {
if o.Vulnerable {
roomName := roomNameByID(req.Rooms, o.RoomID)
data.CareChecklist = append(data.CareChecklist, fmt.Sprintf("Check vulnerable occupant in %s at 10:00, 14:00, 18:00", roomName))
}
}
return data, nil
}
func buildHourlyData(forecasts []Forecast, loc *time.Location) []risk.HourlyData {
var data []risk.HourlyData
for _, f := range forecasts {
tempC := 0.0
if f.TemperatureC != nil {
tempC = *f.TemperatureC
}
apparentC := tempC
if f.ApparentTempC != nil {
apparentC = *f.ApparentTempC
}
humPct := 50.0
if f.HumidityPct != nil {
humPct = *f.HumidityPct
}
h := f.Timestamp.In(loc).Hour()
data = append(data, risk.HourlyData{
Hour: h,
TempC: tempC,
ApparentC: apparentC,
HumidityPct: humPct,
IsDay: h >= 6 && h < 21,
})
}
return data
}
type roomBudgetResult struct {
RoomName string
RoomID int64
Result heat.BudgetResult
}
func computeRoomBudgets(req ComputeRequest, hour int, tempC, cloudPct, sunMin float64, toggles map[string]bool) ([]roomBudgetResult, heat.BudgetStatus) {
if len(req.Rooms) == 0 {
return nil, heat.Comfortable
}
var results []roomBudgetResult
worstStatus := heat.Comfortable
for _, room := range req.Rooms {
budget := computeSingleRoomBudget(req, room, hour, tempC, cloudPct, sunMin, toggles)
results = append(results, roomBudgetResult{
RoomName: room.Name,
RoomID: room.ID,
Result: budget,
})
if budget.Status > worstStatus {
worstStatus = budget.Status
}
}
return results, worstStatus
}
func computeSingleRoomBudget(req ComputeRequest, room Room, hour int, tempC, cloudPct, sunMin float64, toggles map[string]bool) heat.BudgetResult {
indoorTempC := room.IndoorTempC
if indoorTempC == 0 {
indoorTempC = 25.0
}
// Filter devices for this room
var heatDevices []heat.Device
for _, d := range req.Devices {
if d.RoomID == room.ID {
heatDevices = append(heatDevices, heat.Device{
WattsIdle: d.WattsIdle,
WattsTypical: d.WattsTypical,
WattsPeak: d.WattsPeak,
DutyCycle: d.DutyCycle,
})
}
}
mode := heat.ModeTypical
if toggles["gaming"] {
mode = heat.ModePeak
}
// Filter occupants for this room
var heatOccupants []heat.Occupant
for _, o := range req.Occupants {
if o.RoomID == room.ID {
heatOccupants = append(heatOccupants, heat.Occupant{
Count: o.Count,
Activity: heat.ParseActivityLevel(o.ActivityLevel),
})
}
}
// AC capacity for this room
acCap := roomACCapacity(req.ACUnits, req.ACAssignments, room.ID)
// Solar params
cloudFactor := 1.0 - (cloudPct / 100.0)
sunshineFraction := 0.0
if sunMin > 0 {
sunshineFraction = sunMin / 60.0
if sunshineFraction > 1.0 {
sunshineFraction = 1.0
}
}
solar := heat.SolarParams{
AreaSqm: room.AreaSqm,
WindowFraction: room.WindowFraction,
SHGC: room.SHGC,
ShadingFactor: room.ShadingFactor,
OrientationFactor: heat.OrientationFactor(room.Orientation, hour),
CloudFactor: cloudFactor,
SunshineFraction: sunshineFraction,
PeakIrradiance: 800,
}
volume := room.AreaSqm * room.CeilingHeightM
vent := heat.VentilationParams{
ACH: room.VentilationACH,
VolumeCubicM: volume,
OutdoorTempC: tempC,
IndoorTempC: indoorTempC,
RhoCp: 1.2,
}
return heat.ComputeRoomBudget(heat.BudgetInput{
Devices: heatDevices,
DeviceMode: mode,
Occupants: heatOccupants,
Solar: solar,
Ventilation: vent,
ACCapacityBTUH: acCap,
})
}
func roomACCapacity(units []ACUnit, assignments []ACAssignment, roomID int64) float64 {
var total float64
for _, a := range assignments {
if a.RoomID == roomID {
for _, u := range units {
if u.ID == a.ACID {
total += u.CapacityBTU
}
}
}
}
return total
}
func roomNameByID(rooms []Room, id int64) string {
for _, r := range rooms {
if r.ID == id {
return r.Name
}
}
return fmt.Sprintf("room %d", id)
}