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
321 lines
8.0 KiB
Go
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)
|
|
}
|