Files
HeatGuard/internal/compute/compute_test.go
vikingowl 21154d5d7f feat: add heating support with heat pump modeling and cold risk detection
Model heating mode when rooms have net heat loss in cold weather (<10°C).
AC units with heat pump capability (canHeat) provide heating capacity,
with the same 20% headroom threshold used for cooling. Adds cold risk
detection, cold-weather actions, and full frontend support including
heating mode timeline colors, room budget heating display, and i18n.
2026-02-11 00:00:43 +01:00

853 lines
25 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
worstMode heat.ThermalMode
want string
}{
{"cool and dry → ventilate", 20, 25, 50, heat.Comfortable, heat.Cooling, "ventilate"},
{"cool and humid → sealed", 20, 25, 90, heat.Comfortable, heat.Cooling, "sealed"},
{"hot and overloaded → overloaded", 38, 25, 50, heat.Overloaded, heat.Cooling, "overloaded"},
{"hot and comfortable → ac", 30, 25, 50, heat.Comfortable, heat.Cooling, "ac"},
{"humidity boundary 79.9 → ventilate", 20, 25, 79.9, heat.Comfortable, heat.Cooling, "ventilate"},
{"humidity boundary 80.0 → sealed", 20, 25, 80.0, heat.Comfortable, heat.Cooling, "sealed"},
{"cold and dry → comfort", 5, 25, 50, heat.Comfortable, heat.Cooling, "comfort"},
{"cold and humid → comfort", 5, 25, 90, heat.Marginal, heat.Cooling, "comfort"},
{"warm but not cold enough → ventilate", 21, 25, 50, heat.Comfortable, heat.Cooling, "ventilate"},
{"warm and humid, marginal → sealed", 21, 25, 85, heat.Marginal, heat.Cooling, "sealed"},
{"heating mode comfortable → heating", -5, 23, 50, heat.Comfortable, heat.Heating, "heating"},
{"heating mode marginal → heating", -5, 23, 50, heat.Marginal, heat.Heating, "heating"},
{"heating mode overloaded → heat_insufficient", -10, 23, 50, heat.Overloaded, heat.Heating, "heat_insufficient"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := determineCoolMode(tt.outdoorTempC, tt.indoorTempC, tt.outdoorHumidityPct, tt.worstStatus, tt.worstMode)
if got != tt.want {
t.Errorf("determineCoolMode(%v, %v, %v, %v, %v) = %q, want %q",
tt.outdoorTempC, tt.indoorTempC, tt.outdoorHumidityPct, tt.worstStatus, tt.worstMode, 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")
}
}
if data.Timezone != "UTC" {
t.Errorf("got Timezone %q, want %q", data.Timezone, "UTC")
}
// 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_WindowsOverrideLegacy(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc)
// Peak at hour 13 (midday sun) so orientation factor is non-zero
temps := make([]float64, 24)
for i := range temps {
temps[i] = 25 + float64(i)*0.5
if i > 13 {
temps[i] = 25 + float64(24-i)*0.5
}
}
temps[13] = 38 // explicit peak
// Room with explicit windows — should use MultiWindowSolarGain
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "UTC"},
Forecasts: makeForecasts(base, temps),
Rooms: []Room{{
ID: 1, Name: "MultiWindow", AreaSqm: 20, CeilingHeightM: 2.5,
Orientation: "S", ShadingFactor: 0.8, VentilationACH: 0.5,
WindowFraction: 0.15, SHGC: 0.6,
Windows: []Window{
{ID: 1, RoomID: 1, Orientation: "S", AreaSqm: 2.0, SHGC: 0.6, ShadingFactor: 1.0},
{ID: 2, RoomID: 1, Orientation: "E", AreaSqm: 1.5, SHGC: 0.5, ShadingFactor: 0.8},
},
}},
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)
}
if len(data.RoomBudgets) == 0 {
t.Fatal("expected room budgets")
}
if data.RoomBudgets[0].SolarGainW <= 0 {
t.Error("expected positive solar gain from multi-window calculation")
}
}
func TestBuildDashboard_NoWindowsFallback(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc)
// Peak at hour 13 (midday sun)
temps := make([]float64, 24)
for i := range temps {
temps[i] = 25 + float64(i)*0.5
if i > 13 {
temps[i] = 25 + float64(24-i)*0.5
}
}
temps[13] = 38
// Room without windows — should use legacy path
req := ComputeRequest{
Profile: Profile{Name: "Test", Timezone: "UTC"},
Forecasts: makeForecasts(base, temps),
Rooms: []Room{{
ID: 1, Name: "LegacyRoom", AreaSqm: 20, CeilingHeightM: 2.5,
Orientation: "S", ShadingFactor: 0.8, VentilationACH: 0.5,
WindowFraction: 0.15, SHGC: 0.6,
}},
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)
}
if len(data.RoomBudgets) == 0 {
t.Fatal("expected room budgets")
}
if data.RoomBudgets[0].SolarGainW <= 0 {
t.Error("expected positive solar gain from legacy calculation")
}
}
func TestBuildDashboard_MixedRoomsWindowsAndLegacy(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: "WithWindows", AreaSqm: 20, CeilingHeightM: 2.5,
Orientation: "S", ShadingFactor: 0.8, VentilationACH: 0.5,
WindowFraction: 0.15, SHGC: 0.6,
Windows: []Window{
{ID: 1, RoomID: 1, Orientation: "W", AreaSqm: 3.0, SHGC: 0.6, ShadingFactor: 1.0},
},
},
{
ID: 2, Name: "Legacy", AreaSqm: 15, CeilingHeightM: 2.5,
Orientation: "N", ShadingFactor: 1.0, VentilationACH: 0.5,
WindowFraction: 0.1, SHGC: 0.4,
},
},
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))
}
}
func TestRoomHeatingCapacity(t *testing.T) {
units := []ACUnit{
{ID: 1, CanHeat: true, HeatingCapacityBTU: 10000, CapacityBTU: 8000},
{ID: 2, CanHeat: true, HeatingCapacityBTU: 0, CapacityBTU: 6000}, // fallback to cooling cap
{ID: 3, CanHeat: false, HeatingCapacityBTU: 5000, CapacityBTU: 5000}, // not a heat pump
}
assignments := []ACAssignment{
{ACID: 1, RoomID: 1},
{ACID: 2, RoomID: 1},
{ACID: 3, RoomID: 1},
}
got := roomHeatingCapacity(units, assignments, 1)
// unit 1: 10000 (explicit heating cap)
// unit 2: 6000 (fallback to CapacityBTU since heating=0)
// unit 3: skipped (CanHeat=false)
want := 16000.0
if got != want {
t.Errorf("roomHeatingCapacity = %v, want %v", got, want)
}
}
func TestRoomHeatingCapacity_NoHeatPumps(t *testing.T) {
units := []ACUnit{
{ID: 1, CanHeat: false, CapacityBTU: 8000},
}
assignments := []ACAssignment{{ACID: 1, RoomID: 1}}
got := roomHeatingCapacity(units, assignments, 1)
if got != 0 {
t.Errorf("roomHeatingCapacity = %v, want 0", got)
}
}
func TestBuildDashboard_WinterHeating(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 1, 15, 0, 0, 0, 0, loc)
// Winter day: -8°C to ~-1°C, high ACH to ensure net heat loss
forecasts := make([]Forecast, 24)
for i := range forecasts {
ts := base.Add(time.Duration(i) * time.Hour)
temp := -8.0 + float64(i)*0.3
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: 1.5,
WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 23,
}},
Occupants: []Occupant{{ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary"}},
ACUnits: []ACUnit{{
ID: 1, ProfileID: 1, Name: "Heat Pump",
CapacityBTU: 12000, CanHeat: true, HeatingCapacityBTU: 10000,
}},
ACAssignments: []ACAssignment{{ACID: 1, RoomID: 1}},
Toggles: map[string]bool{},
Date: "2025-01-15",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
hasHeating := false
for _, slot := range data.Timeline {
if slot.CoolMode == "heating" {
hasHeating = true
break
}
}
if !hasHeating {
modes := make(map[string]int)
for _, s := range data.Timeline {
modes[s.CoolMode]++
}
t.Errorf("expected at least one hour with CoolMode 'heating', got: %v", modes)
}
// Room budgets are computed at peak temp hour; peak is ~-1°C which may
// still produce heating mode with high ACH and low internal gains.
if len(data.RoomBudgets) > 0 && data.RoomBudgets[0].ThermalMode == "heating" {
if data.RoomBudgets[0].HeatingCapBTUH != 10000 {
t.Errorf("got HeatingCapBTUH %v, want 10000", data.RoomBudgets[0].HeatingCapBTUH)
}
}
}
func TestBuildDashboard_WinterNoHeating(t *testing.T) {
loc, _ := time.LoadLocation("UTC")
base := time.Date(2025, 1, 15, 0, 0, 0, 0, loc)
// Winter day: -8°C, high ACH, no heating → heat_insufficient
forecasts := make([]Forecast, 24)
for i := range forecasts {
ts := base.Add(time.Duration(i) * time.Hour)
temp := -8.0 + float64(i)*0.3
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: 1.5,
WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 23,
}},
Occupants: []Occupant{{ID: 1, RoomID: 1, Count: 1, ActivityLevel: "sedentary"}},
Toggles: map[string]bool{},
Date: "2025-01-15",
}
data, err := BuildDashboard(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
hasInsufficient := false
for _, slot := range data.Timeline {
if slot.CoolMode == "heat_insufficient" {
hasInsufficient = true
break
}
}
if !hasInsufficient {
modes := make(map[string]int)
for _, s := range data.Timeline {
modes[s.CoolMode]++
}
t.Errorf("expected at least one hour with CoolMode 'heat_insufficient', got: %v", modes)
}
}
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))
}
}