Files
HeatGuard/internal/compute/compute_test.go
vikingowl 84d645ff21 feat: fix cold-weather thermal logic, add comfort mode, and dashboard forecast refresh
- Fix ComputeRoomBudget: no-AC rooms check if open-window ventilation
  can offset gains instead of defaulting to Overloaded. Net-cooling
  rooms are now Comfortable; ventilation-solvable rooms are Marginal.
- Add "comfort" cool mode for hours where outdoor is >5°C below indoor
  and budget is not overloaded (winter/cold scenarios).
- Reorder determineCoolMode: sealed now before overloaded, fixing
  humid+cold+no-AC giving "overloaded" instead of "sealed".
- Update LLM prompts: document comfort coolMode, add cold-weather
  guidance for summary and actions generation.
- Add dashboard forecast refresh button: fetches fresh forecast +
  warnings, then re-runs compute and LLM pipelines.
- Extract forecast fetch into shared fetchForecastForProfile() in db.js,
  deduplicating logic between setup.js and dashboard.js.
- Add indoor humidity support, pressure display, and cool mode sealed
  integration test.
2026-02-10 04:26:53 +01:00

560 lines
17 KiB
Go

package compute
import (
"testing"
"time"
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
)
func ptr(f float64) *float64 { return &f }
func makeForecasts(baseTime time.Time, temps []float64) []Forecast {
var forecasts []Forecast
for i, t := range temps {
ts := baseTime.Add(time.Duration(i) * time.Hour)
temp := t
humid := 50.0
cloud := 50.0
sun := 30.0
apparent := t
forecasts = append(forecasts, Forecast{
Timestamp: ts,
TemperatureC: &temp,
HumidityPct: &humid,
CloudCoverPct: &cloud,
SunshineMin: &sun,
ApparentTempC: &apparent,
})
}
return forecasts
}
func TestBuildDashboard_NoForecasts(t *testing.T) {
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "Europe/Berlin"},
Date: "2025-07-15",
}
_, err := BuildDashboard(req)
if err == nil {
t.Fatal("expected error for empty forecasts")
}
}
func TestBuildDashboard_BasicComputation(t *testing.T) {
loc, _ := time.LoadLocation("Europe/Berlin")
base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc)
// 24 hours: mild night, hot afternoon
temps := make([]float64, 24)
for i := range temps {
switch {
case i < 6:
temps[i] = 18
case i < 10:
temps[i] = 22 + float64(i-6)*2
case i < 16:
temps[i] = 30 + float64(i-10)*1.5
case i < 20:
temps[i] = 37 - float64(i-16)*2
default:
temps[i] = 22 - float64(i-20)
}
}
req := ComputeRequest{
Profile: Profile{Name: "Berlin", Timezone: "Europe/Berlin"},
Forecasts: makeForecasts(base, temps),
Rooms: []Room{{
ID: 1, ProfileID: 1, Name: "Office",
AreaSqm: 20, CeilingHeightM: 2.5, Orientation: "S",
ShadingFactor: 0.8, VentilationACH: 0.5,
WindowFraction: 0.15, SHGC: 0.6,
}},
Devices: []Device{{
ID: 1, RoomID: 1, Name: "PC",
WattsIdle: 50, WattsTypical: 200, WattsPeak: 400, DutyCycle: 1.0,
}},
Occupants: []Occupant{{
ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary",
}},
ACUnits: []ACUnit{{
ID: 1, ProfileID: 1, Name: "Portable AC",
CapacityBTU: 8000, EfficiencyEER: 10,
}},
ACAssignments: []ACAssignment{{ACID: 1, RoomID: 1}},
Toggles: map[string]bool{},
Date: "2025-07-15",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if data.ProfileName != "Berlin" {
t.Errorf("got profile name %q, want %q", data.ProfileName, "Berlin")
}
if data.Date != "2025-07-15" {
t.Errorf("got date %q, want %q", data.Date, "2025-07-15")
}
if len(data.Timeline) != 24 {
t.Errorf("got %d timeline slots, want 24", len(data.Timeline))
}
if data.PeakTempC == 0 {
t.Error("peak temp should be > 0")
}
if data.RiskLevel == "" {
t.Error("risk level should not be empty")
}
if len(data.RoomBudgets) == 0 {
t.Error("room budgets should not be empty")
}
}
func TestBuildDashboard_VulnerableOccupants(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc)
temps := make([]float64, 24)
for i := range temps {
temps[i] = 32
}
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "UTC"},
Forecasts: makeForecasts(base, temps),
Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6}},
Occupants: []Occupant{
{ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary", Vulnerable: true},
},
Toggles: map[string]bool{},
Date: "2025-07-15",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(data.CareChecklist) == 0 {
t.Error("expected care checklist for vulnerable occupant")
}
}
func TestBuildDashboard_GamingToggle(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc)
temps := make([]float64, 24)
for i := range temps {
temps[i] = 35
}
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "UTC"},
Forecasts: makeForecasts(base, temps),
Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "S", ShadingFactor: 0.8, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6}},
Devices: []Device{{
ID: 1, RoomID: 1, Name: "PC", WattsIdle: 50, WattsTypical: 200, WattsPeak: 400, DutyCycle: 1.0,
}},
Occupants: []Occupant{{ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary"}},
Toggles: map[string]bool{"gaming": true},
Date: "2025-07-15",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Gaming mode should increase heat load vs non-gaming
if len(data.RoomBudgets) == 0 {
t.Error("expected room budgets")
}
}
func TestBuildDashboard_Warnings(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc)
temps := make([]float64, 24)
for i := range temps {
temps[i] = 30
}
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "UTC"},
Forecasts: makeForecasts(base, temps),
Warnings: []Warning{{
Headline: "Heat Warning",
Severity: "Severe",
Description: "Extreme heat expected",
Instruction: "Stay hydrated",
Onset: "2025-07-15 06:00",
Expires: "2025-07-15 22:00",
}},
Toggles: map[string]bool{},
Date: "2025-07-15",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(data.Warnings) != 1 {
t.Errorf("got %d warnings, want 1", len(data.Warnings))
}
if data.Warnings[0].Headline != "Heat Warning" {
t.Errorf("got headline %q, want %q", data.Warnings[0].Headline, "Heat Warning")
}
}
func TestBuildDashboard_InvalidDate(t *testing.T) {
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "UTC"},
Forecasts: []Forecast{{Timestamp: time.Now()}},
Date: "not-a-date",
}
_, err := BuildDashboard(req)
if err == nil {
t.Fatal("expected error for invalid date")
}
}
func TestBuildDashboard_CoolModeVentilate(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc)
// All hours at 20°C (below default indoor 25°C) → ventilate
temps := make([]float64, 24)
for i := range temps {
temps[i] = 20
}
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "UTC"},
Forecasts: makeForecasts(base, temps),
Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 25}},
Toggles: map[string]bool{},
Date: "2025-07-15",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, slot := range data.Timeline {
if slot.CoolMode != "ventilate" {
t.Errorf("hour %d: got CoolMode %q, want %q", slot.Hour, slot.CoolMode, "ventilate")
}
}
if data.IndoorTempC != 25 {
t.Errorf("got IndoorTempC %v, want 25", data.IndoorTempC)
}
}
func TestBuildDashboard_CoolModeAC(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc)
// All hours at 30°C (above indoor 25°C), with enough AC → "ac"
temps := make([]float64, 24)
for i := range temps {
temps[i] = 30
}
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "UTC"},
Forecasts: makeForecasts(base, temps),
Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 25}},
ACUnits: []ACUnit{{ID: 1, ProfileID: 1, Name: "AC", CapacityBTU: 20000}},
ACAssignments: []ACAssignment{{ACID: 1, RoomID: 1}},
Toggles: map[string]bool{},
Date: "2025-07-15",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, slot := range data.Timeline {
if slot.CoolMode != "ac" {
t.Errorf("hour %d: got CoolMode %q, want %q", slot.Hour, slot.CoolMode, "ac")
}
}
}
func TestBuildDashboard_CoolModeOverloaded(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc)
// Hot temps, no AC → overloaded (heat gains exceed 0 AC capacity)
temps := make([]float64, 24)
for i := range temps {
temps[i] = 38
}
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "UTC"},
Forecasts: makeForecasts(base, temps),
Rooms: []Room{{
ID: 1, Name: "Room", AreaSqm: 20, CeilingHeightM: 2.5,
Orientation: "S", ShadingFactor: 1, VentilationACH: 2.0,
WindowFraction: 0.3, SHGC: 0.8, IndoorTempC: 25,
}},
Devices: []Device{{
ID: 1, RoomID: 1, Name: "PC",
WattsIdle: 100, WattsTypical: 400, WattsPeak: 600, DutyCycle: 1.0,
}},
Occupants: []Occupant{{ID: 1, RoomID: 1, Count: 3, ActivityLevel: "moderate"}},
Toggles: map[string]bool{},
Date: "2025-07-15",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
hasOverloaded := false
for _, slot := range data.Timeline {
if slot.CoolMode == "overloaded" {
hasOverloaded = true
break
}
}
if !hasOverloaded {
t.Error("expected at least one hour with CoolMode 'overloaded' (no AC, high gains)")
}
}
func TestDetermineCoolMode(t *testing.T) {
tests := []struct {
name string
outdoorTempC float64
indoorTempC float64
outdoorHumidityPct float64
worstStatus heat.BudgetStatus
want string
}{
{"cool and dry → ventilate", 20, 25, 50, heat.Comfortable, "ventilate"},
{"cool and humid → sealed", 20, 25, 90, heat.Comfortable, "sealed"},
{"hot and overloaded → overloaded", 38, 25, 50, heat.Overloaded, "overloaded"},
{"hot and comfortable → ac", 30, 25, 50, heat.Comfortable, "ac"},
{"humidity boundary 79.9 → ventilate", 20, 25, 79.9, heat.Comfortable, "ventilate"},
{"humidity boundary 80.0 → sealed", 20, 25, 80.0, heat.Comfortable, "sealed"},
{"cold and dry → comfort", 5, 25, 50, heat.Comfortable, "comfort"},
{"cold and humid → comfort", 5, 25, 90, heat.Marginal, "comfort"},
{"warm but not cold enough → ventilate", 21, 25, 50, heat.Comfortable, "ventilate"},
{"warm and humid, marginal → sealed", 21, 25, 85, heat.Marginal, "sealed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := determineCoolMode(tt.outdoorTempC, tt.indoorTempC, tt.outdoorHumidityPct, tt.worstStatus)
if got != tt.want {
t.Errorf("determineCoolMode(%v, %v, %v, %v) = %q, want %q",
tt.outdoorTempC, tt.indoorTempC, tt.outdoorHumidityPct, tt.worstStatus, got, tt.want)
}
})
}
}
func TestBuildDashboard_IndoorHumidityDefault(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc)
temps := make([]float64, 24)
for i := range temps {
temps[i] = 25
}
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "UTC"},
Forecasts: makeForecasts(base, temps),
Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6}},
Toggles: map[string]bool{},
Date: "2025-07-15",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if data.IndoorHumidityPct != 50.0 {
t.Errorf("got IndoorHumidityPct %v, want 50.0", data.IndoorHumidityPct)
}
}
func TestBuildDashboard_IndoorHumidityFromRooms(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc)
temps := make([]float64, 24)
for i := range temps {
temps[i] = 25
}
h60 := 60.0
h40 := 40.0
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "UTC"},
Forecasts: makeForecasts(base, temps),
Rooms: []Room{
{ID: 1, Name: "Room1", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorHumidityPct: &h60},
{ID: 2, Name: "Room2", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "S", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorHumidityPct: &h40},
},
Toggles: map[string]bool{},
Date: "2025-07-15",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if data.IndoorHumidityPct != 50.0 {
t.Errorf("got IndoorHumidityPct %v, want 50.0", data.IndoorHumidityPct)
}
}
func TestBuildDashboard_CoolModeSealed(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc)
// 20°C outdoor, 90% RH, indoor 25°C → sealed
forecasts := make([]Forecast, 24)
for i := range forecasts {
ts := base.Add(time.Duration(i) * time.Hour)
temp := 20.0
humid := 90.0
cloud := 50.0
sun := 30.0
apparent := 20.0
forecasts[i] = Forecast{
Timestamp: ts,
TemperatureC: &temp,
HumidityPct: &humid,
CloudCoverPct: &cloud,
SunshineMin: &sun,
ApparentTempC: &apparent,
}
}
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "UTC"},
Forecasts: forecasts,
Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 25}},
ACUnits: []ACUnit{{ID: 1, ProfileID: 1, Name: "AC", CapacityBTU: 20000}},
ACAssignments: []ACAssignment{{ACID: 1, RoomID: 1}},
Toggles: map[string]bool{},
Date: "2025-07-15",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, slot := range data.Timeline {
if slot.CoolMode != "sealed" {
t.Errorf("hour %d: got CoolMode %q, want %q", slot.Hour, slot.CoolMode, "sealed")
}
}
}
func TestBuildDashboard_CoolModeComfort(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 2, 10, 0, 0, 0, 0, loc)
// Winter day: -4°C to 6°C, no AC, indoor 23°C
forecasts := make([]Forecast, 24)
for i := range forecasts {
ts := base.Add(time.Duration(i) * time.Hour)
temp := -4.0 + float64(i)*0.4 // -4 to ~5.6
humid := 50.0
cloud := 80.0
sun := 0.0
apparent := temp - 2
forecasts[i] = Forecast{
Timestamp: ts,
TemperatureC: &temp,
HumidityPct: &humid,
CloudCoverPct: &cloud,
SunshineMin: &sun,
ApparentTempC: &apparent,
}
}
req := ComputeRequest{
Profile: Profile{Name: "Winter", Timezone: "UTC"},
Forecasts: forecasts,
Rooms: []Room{{
ID: 1, Name: "Office", AreaSqm: 20, CeilingHeightM: 2.5,
Orientation: "S", ShadingFactor: 0.8, VentilationACH: 0.5,
WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 23,
}},
Devices: []Device{{
ID: 1, RoomID: 1, Name: "PC",
WattsIdle: 50, WattsTypical: 200, WattsPeak: 400, DutyCycle: 1.0,
}},
Occupants: []Occupant{{ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary"}},
Toggles: map[string]bool{},
Date: "2025-02-10",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, slot := range data.Timeline {
if slot.CoolMode != "comfort" {
t.Errorf("hour %d (%.1f°C): got CoolMode %q, want %q",
slot.Hour, slot.TempC, slot.CoolMode, "comfort")
}
}
// Room budget should be marginal (not overloaded) — no AC but ventilation can solve
if len(data.RoomBudgets) == 0 {
t.Fatal("expected room budgets")
}
if data.RoomBudgets[0].Status != "marginal" {
t.Errorf("got budget status %q, want %q", data.RoomBudgets[0].Status, "marginal")
}
}
func TestBuildDashboard_MultipleRooms(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc)
temps := make([]float64, 24)
for i := range temps {
temps[i] = 35
}
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "UTC"},
Forecasts: makeForecasts(base, temps),
Rooms: []Room{
{ID: 1, Name: "Office", AreaSqm: 20, CeilingHeightM: 2.5, Orientation: "S", ShadingFactor: 0.8, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6},
{ID: 2, Name: "Bedroom", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1.0, VentilationACH: 0.3, WindowFraction: 0.1, SHGC: 0.4},
},
Devices: []Device{
{ID: 1, RoomID: 1, Name: "PC", WattsIdle: 50, WattsTypical: 200, WattsPeak: 400, DutyCycle: 1.0},
{ID: 2, RoomID: 2, Name: "Lamp", WattsIdle: 10, WattsTypical: 60, WattsPeak: 60, DutyCycle: 0.5},
},
Occupants: []Occupant{
{ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary"},
{ID: 2, RoomID: 2, Count: 2, ActivityLevel: "sleeping"},
},
ACUnits: []ACUnit{
{ID: 1, ProfileID: 1, Name: "AC1", CapacityBTU: 8000},
},
ACAssignments: []ACAssignment{
{ACID: 1, RoomID: 1},
{ACID: 1, RoomID: 2},
},
Toggles: map[string]bool{},
Date: "2025-07-15",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(data.RoomBudgets) != 2 {
t.Errorf("got %d room budgets, want 2", len(data.RoomBudgets))
}
}