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 makeForecastsWithUVI(baseTime time.Time, temps []float64, uvis []float64) []Forecast { forecasts := makeForecasts(baseTime, temps) for i := range forecasts { if i < len(uvis) { uv := uvis[i] forecasts[i].UVIndex = &uv } } 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)) } } func TestBuildDashboard_UVIndex(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) temps := make([]float64, 24) uvis := make([]float64, 24) for i := range temps { temps[i] = 30 if i >= 10 && i <= 14 { uvis[i] = 8.5 } } req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "UTC"}, Forecasts: makeForecastsWithUVI(base, temps, uvis), Toggles: map[string]bool{}, Date: "2025-07-15", } data, err := BuildDashboard(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(data.Timeline) != 24 { t.Fatalf("got %d timeline slots, want 24", len(data.Timeline)) } // Hour 12 should have UVI 8.5 if data.Timeline[12].UVIndex != 8.5 { t.Errorf("Timeline[12].UVIndex = %v, want 8.5", data.Timeline[12].UVIndex) } // Hour 0 should have UVI 0 if data.Timeline[0].UVIndex != 0 { t.Errorf("Timeline[0].UVIndex = %v, want 0", data.Timeline[0].UVIndex) } }