- 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
424 lines
11 KiB
Go
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)
|
|
}
|