Files
HeatGuard/internal/compute/compute.go
vikingowl d5452409b6 feat: rewrite to stateless web app with IndexedDB frontend
Replace CLI + SQLite architecture with a Go web server + vanilla JS
frontend using IndexedDB for all client-side data storage.

- Remove: cli, store, report, static packages
- Add: compute engine (BuildDashboard), server package, web UI
- Add: setup page with CRUD for profiles, rooms, devices, occupants, AC
- Add: dashboard with SVG temperature timeline, risk analysis, care checklist
- Add: i18n support (English/German) with server-side Go templates
- Add: LLM provider selection UI with client-side API key storage
- Add: per-room indoor temperature, edit buttons, language-aware AI summary
2026-02-09 13:31:38 +01:00

321 lines
8.0 KiB
Go

package compute
import (
"fmt"
"time"
"github.com/cnachtigall/heatwave-autopilot/internal/action"
"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)
// Load actions
actions, _ := action.LoadDefaultActions()
toggles := req.Toggles
if toggles == nil {
toggles = map[string]bool{}
}
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,
}
// 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)
ctx := action.HourContext{
Hour: h.Hour,
TempC: h.TempC,
HumidityPct: h.HumidityPct,
IsDay: h.IsDay,
RiskLevel: dayRisk.Level,
BudgetStatus: worstStatus,
ActiveToggles: toggles,
}
matched := action.SelectActions(actions, ctx)
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(),
}
for _, a := range matched {
slot.Actions = append(slot.Actions, ActionData{
Name: a.Name,
Category: string(a.Category),
Effort: string(a.Effort),
Impact: string(a.Impact),
Description: a.Description,
})
}
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)
}