Files
HeatGuard/internal/compute/compute.go
vikingowl 201e5441cb feat: add OWM alerts, UV index support, and provider info UI
- Parse OWM One Call 3.0 weather alerts and map to Warning structs
- Map hourly UVI from OWM response to HourlyForecast.UVIndex
- Add severity helper mapping OWM alert tags to severity levels
- Extract UVIndex through compute layer to timeline slots
- Smart warnings: use provider-supplied alerts, fall back to DWD
- Show UV index in dashboard timeline tooltips
- Add provider description below forecast dropdown with i18n
2026-02-11 01:56:09 +01:00

424 lines
11 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))
}
// Compute representative indoor humidity (average of rooms that have it)
indoorHumidityPct := 50.0
if len(req.Rooms) > 0 {
sum, count := 0.0, 0
for _, r := range req.Rooms {
if r.IndoorHumidityPct != nil && *r.IndoorHumidityPct > 0 {
sum += *r.IndoorHumidityPct
count++
}
}
if count > 0 {
indoorHumidityPct = sum / float64(count)
}
}
data := DashboardData{
GeneratedAt: time.Now(),
ProfileName: req.Profile.Name,
Date: req.Date,
Timezone: req.Profile.Timezone,
RiskLevel: dayRisk.Level.String(),
PeakTempC: dayRisk.PeakTempC,
MinNightTempC: dayRisk.MinNightTempC,
PoorNightCool: dayRisk.PoorNightCool,
IndoorTempC: indoorTempC,
IndoorHumidityPct: indoorHumidityPct,
MinTempC: dayRisk.MinTempC,
ColdRisk: dayRisk.ColdRisk,
}
// 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
pressureHpa := 0.0
uvIndex := 0.0
if i < len(dayForecasts) {
if dayForecasts[i].CloudCoverPct != nil {
cloudPct = *dayForecasts[i].CloudCoverPct
}
if dayForecasts[i].SunshineMin != nil {
sunMin = *dayForecasts[i].SunshineMin
}
if dayForecasts[i].PressureHpa != nil {
pressureHpa = *dayForecasts[i].PressureHpa
}
if dayForecasts[i].UVIndex != nil {
uvIndex = *dayForecasts[i].UVIndex
}
}
budgets, worstStatus, worstMode := computeRoomBudgets(req, h.Hour, h.TempC, cloudPct, sunMin, toggles)
coolMode := determineCoolMode(h.TempC, indoorTempC, h.HumidityPct, worstStatus, worstMode)
slot := TimelineSlotData{
Hour: h.Hour,
HourStr: fmt.Sprintf("%02d:00", h.Hour),
TempC: h.TempC,
HumidityPct: h.HumidityPct,
PressureHpa: pressureHpa,
UVIndex: uvIndex,
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(),
ThermalMode: rb.Result.Mode.String(),
HeatDeficitBTUH: rb.Result.HeatDeficitBTUH,
HeatingCapBTUH: rb.Result.HeatingCapBTUH,
HeatingHeadroom: rb.Result.HeatingHeadroom,
})
}
// 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 determineCoolMode(outdoorTempC, indoorTempC, outdoorHumidityPct float64, worstStatus heat.BudgetStatus, worstMode heat.ThermalMode) string {
if worstMode == heat.Heating {
if worstStatus == heat.Overloaded {
return "heat_insufficient"
}
return "heating"
}
return determineCoolModeCooling(outdoorTempC, indoorTempC, outdoorHumidityPct, worstStatus)
}
func determineCoolModeCooling(outdoorTempC, indoorTempC, outdoorHumidityPct float64, worstStatus heat.BudgetStatus) string {
const humidityThreshold = 80.0
const comfortDelta = 5.0
outdoorCooler := outdoorTempC < indoorTempC
tooHumid := outdoorHumidityPct >= humidityThreshold
// Cold enough outside that building envelope handles any internal gains
if worstStatus != heat.Overloaded && outdoorTempC < (indoorTempC-comfortDelta) {
return "comfort"
}
switch {
case outdoorCooler && !tooHumid:
return "ventilate"
case outdoorCooler && tooHumid:
return "sealed"
case worstStatus == heat.Overloaded:
return "overloaded"
default:
return "ac"
}
}
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, heat.ThermalMode) {
if len(req.Rooms) == 0 {
return nil, heat.Comfortable, heat.Cooling
}
var results []roomBudgetResult
worstStatus := heat.Comfortable
worstMode := heat.Cooling
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
}
if budget.Mode == heat.Heating {
worstMode = heat.Heating
}
}
return results, worstStatus, worstMode
}
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)
heatCap := roomHeatingCapacity(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
}
}
var precomputedSolar *float64
if len(room.Windows) > 0 {
var wps []heat.WindowParams
for _, w := range room.Windows {
wps = append(wps, heat.WindowParams{
AreaSqm: w.AreaSqm,
SHGC: w.SHGC,
ShadingFactor: w.ShadingFactor,
Orientation: w.Orientation,
})
}
sg := heat.MultiWindowSolarGain(wps, hour, cloudFactor, sunshineFraction, 800)
precomputedSolar = &sg
}
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,
HeatingCapacityBTUH: heatCap,
PrecomputedSolarGainW: precomputedSolar,
})
}
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 roomHeatingCapacity(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 && u.CanHeat {
if u.HeatingCapacityBTU > 0 {
total += u.HeatingCapacityBTU
} else {
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)
}