Pass profile timezone through DashboardData so the frontend can compute the current hour and highlight it on the heatmap (white ring + orange triangle marker). Only activates when the dashboard date matches today in the profile timezone. In cold weather (peak < 22°C, risk low), the risk card now shows a teal "Comfortable" presentation with a checkmark icon instead of the generic green "Low" shield.
564 lines
17 KiB
Go
564 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")
|
|
}
|
|
}
|
|
|
|
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_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))
|
|
}
|
|
}
|