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.
853 lines
25 KiB
Go
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))
|
|
}
|
|
}
|