diff --git a/internal/action/library_test.go b/internal/action/library_test.go index 5910538..8c22941 100644 --- a/internal/action/library_test.go +++ b/internal/action/library_test.go @@ -7,8 +7,8 @@ func TestLoadDefaultActions(t *testing.T) { if err != nil { t.Fatalf("LoadDefaultActions: %v", err) } - if len(actions) != 10 { - t.Errorf("len = %d, want 10", len(actions)) + if len(actions) != 13 { + t.Errorf("len = %d, want 13", len(actions)) } for _, a := range actions { if a.ID == "" { @@ -32,8 +32,8 @@ func TestLoadDefaultActions_Categories(t *testing.T) { if categories[Shading] != 2 { t.Errorf("Shading actions = %d, want 2", categories[Shading]) } - if categories[Ventilation] != 2 { - t.Errorf("Ventilation actions = %d, want 2", categories[Ventilation]) + if categories[Ventilation] != 4 { + t.Errorf("Ventilation actions = %d, want 4", categories[Ventilation]) } if categories[Care] != 1 { t.Errorf("Care actions = %d, want 1", categories[Care]) diff --git a/internal/action/selector.go b/internal/action/selector.go index 7d1cfa2..24dc171 100644 --- a/internal/action/selector.go +++ b/internal/action/selector.go @@ -19,10 +19,10 @@ func Matches(a Action, ctx HourContext) bool { } // Temperature threshold - if w.MinTempC > 0 && ctx.TempC < w.MinTempC { + if w.MinTempC != 0 && ctx.TempC < w.MinTempC { return false } - if w.MaxTempC > 0 && ctx.TempC > w.MaxTempC { + if w.MaxTempC != 0 && ctx.TempC > w.MaxTempC { return false } diff --git a/internal/action/selector_test.go b/internal/action/selector_test.go index 0417ca0..443371e 100644 --- a/internal/action/selector_test.go +++ b/internal/action/selector_test.go @@ -76,6 +76,66 @@ func TestMatches_HighHumidity(t *testing.T) { } } +func TestMatches_MaxTempThreshold(t *testing.T) { + a := Action{When: TimeCondition{MaxTempC: 5}} + if Matches(a, HourContext{TempC: 3}) != true { + t.Error("should match at 3C (below max 5)") + } + if Matches(a, HourContext{TempC: 10}) != false { + t.Error("should not match at 10C (above max 5)") + } +} + +func TestMatches_NegativeMaxTemp(t *testing.T) { + a := Action{When: TimeCondition{MaxTempC: -5}} + if Matches(a, HourContext{TempC: -8}) != true { + t.Error("should match at -8C (below max -5)") + } + if Matches(a, HourContext{TempC: 0}) != false { + t.Error("should not match at 0C (above max -5)") + } +} + +func TestMatches_NegativeMinTemp(t *testing.T) { + a := Action{When: TimeCondition{MinTempC: -10}} + if Matches(a, HourContext{TempC: -5}) != true { + t.Error("should match at -5C (above min -10)") + } + if Matches(a, HourContext{TempC: -15}) != false { + t.Error("should not match at -15C (below min -10)") + } +} + +func TestSelectActions_ColdActions(t *testing.T) { + actions, err := LoadDefaultActions() + if err != nil { + t.Fatalf("LoadDefaultActions: %v", err) + } + ctx := HourContext{TempC: -8, Hour: 12, IsDay: true} + result := SelectActions(actions, ctx) + var ids []string + for _, a := range result { + ids = append(ids, a.ID) + } + wantIDs := map[string]bool{ + "close_windows_cold": true, + "use_heating": true, + "check_insulation": true, + } + for id := range wantIDs { + found := false + for _, got := range ids { + if got == id { + found = true + break + } + } + if !found { + t.Errorf("expected action %q in results, got %v", id, ids) + } + } +} + func TestSelectActions_SortedByPriority(t *testing.T) { actions := []Action{ {ID: "low_impact_high_effort", Impact: ImpactLow, Effort: EffortHigh}, diff --git a/internal/action/templates/actions.yaml b/internal/action/templates/actions.yaml index 1bf8230..ecc608f 100644 --- a/internal/action/templates/actions.yaml +++ b/internal/action/templates/actions.yaml @@ -95,3 +95,27 @@ actions: min_temp_c: 30 hour_from: 10 hour_to: 18 + - id: close_windows_cold + name: "Close windows to retain heat" + description: "Keep all windows and doors closed to minimize heat loss in cold weather" + category: ventilation + effort: none + impact: high + when: + max_temp_c: 5 + - id: use_heating + name: "Activate heating" + description: "Turn on heat pump or heating system to maintain indoor comfort" + category: ac_strategy + effort: none + impact: high + when: + max_temp_c: 10 + - id: check_insulation + name: "Check window and door insulation" + description: "Verify window seals and door draft excluders to reduce heat loss" + category: ventilation + effort: medium + impact: medium + when: + max_temp_c: -5 diff --git a/internal/compute/compute.go b/internal/compute/compute.go index 14e5e8d..f0b27f7 100644 --- a/internal/compute/compute.go +++ b/internal/compute/compute.go @@ -89,6 +89,8 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { PoorNightCool: dayRisk.PoorNightCool, IndoorTempC: indoorTempC, IndoorHumidityPct: indoorHumidityPct, + MinTempC: dayRisk.MinTempC, + ColdRisk: dayRisk.ColdRisk, } // Warnings (pass-through from client) @@ -132,9 +134,9 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { } } - budgets, worstStatus := computeRoomBudgets(req, h.Hour, h.TempC, cloudPct, sunMin, toggles) + budgets, worstStatus, worstMode := computeRoomBudgets(req, h.Hour, h.TempC, cloudPct, sunMin, toggles) - coolMode := determineCoolMode(h.TempC, indoorTempC, h.HumidityPct, worstStatus) + coolMode := determineCoolMode(h.TempC, indoorTempC, h.HumidityPct, worstStatus, worstMode) slot := TimelineSlotData{ Hour: h.Hour, @@ -157,15 +159,19 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { // Room budgets (computed at peak temp hour) for _, rb := range peakBudgets { data.RoomBudgets = append(data.RoomBudgets, RoomBudgetData{ - RoomName: rb.RoomName, - InternalGainsW: rb.Result.InternalGainsW, - SolarGainW: rb.Result.SolarGainW, - VentGainW: rb.Result.VentilationGainW, - TotalGainW: rb.Result.TotalGainW, - TotalGainBTUH: rb.Result.TotalGainBTUH, - ACCapacityBTUH: rb.Result.ACCapacityBTUH, - HeadroomBTUH: rb.Result.HeadroomBTUH, - Status: rb.Result.Status.String(), + RoomName: rb.RoomName, + InternalGainsW: rb.Result.InternalGainsW, + SolarGainW: rb.Result.SolarGainW, + VentGainW: rb.Result.VentilationGainW, + TotalGainW: rb.Result.TotalGainW, + TotalGainBTUH: rb.Result.TotalGainBTUH, + ACCapacityBTUH: rb.Result.ACCapacityBTUH, + HeadroomBTUH: rb.Result.HeadroomBTUH, + Status: rb.Result.Status.String(), + ThermalMode: rb.Result.Mode.String(), + HeatDeficitBTUH: rb.Result.HeatDeficitBTUH, + HeatingCapBTUH: rb.Result.HeatingCapBTUH, + HeatingHeadroom: rb.Result.HeatingHeadroom, }) } @@ -180,7 +186,17 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { return data, nil } -func determineCoolMode(outdoorTempC, indoorTempC, outdoorHumidityPct float64, worstStatus heat.BudgetStatus) string { +func determineCoolMode(outdoorTempC, indoorTempC, outdoorHumidityPct float64, worstStatus heat.BudgetStatus, worstMode heat.ThermalMode) string { + if worstMode == heat.Heating { + if worstStatus == heat.Overloaded { + return "heat_insufficient" + } + return "heating" + } + return determineCoolModeCooling(outdoorTempC, indoorTempC, outdoorHumidityPct, worstStatus) +} + +func determineCoolModeCooling(outdoorTempC, indoorTempC, outdoorHumidityPct float64, worstStatus heat.BudgetStatus) string { const humidityThreshold = 80.0 const comfortDelta = 5.0 @@ -237,13 +253,14 @@ type roomBudgetResult struct { Result heat.BudgetResult } -func computeRoomBudgets(req ComputeRequest, hour int, tempC, cloudPct, sunMin float64, toggles map[string]bool) ([]roomBudgetResult, heat.BudgetStatus) { +func computeRoomBudgets(req ComputeRequest, hour int, tempC, cloudPct, sunMin float64, toggles map[string]bool) ([]roomBudgetResult, heat.BudgetStatus, heat.ThermalMode) { if len(req.Rooms) == 0 { - return nil, heat.Comfortable + return nil, heat.Comfortable, heat.Cooling } var results []roomBudgetResult worstStatus := heat.Comfortable + worstMode := heat.Cooling for _, room := range req.Rooms { budget := computeSingleRoomBudget(req, room, hour, tempC, cloudPct, sunMin, toggles) @@ -255,9 +272,12 @@ func computeRoomBudgets(req ComputeRequest, hour int, tempC, cloudPct, sunMin fl if budget.Status > worstStatus { worstStatus = budget.Status } + if budget.Mode == heat.Heating { + worstMode = heat.Heating + } } - return results, worstStatus + return results, worstStatus, worstMode } func computeSingleRoomBudget(req ComputeRequest, room Room, hour int, tempC, cloudPct, sunMin float64, toggles map[string]bool) heat.BudgetResult { @@ -296,6 +316,7 @@ func computeSingleRoomBudget(req ComputeRequest, room Room, hour int, tempC, clo // AC capacity for this room acCap := roomACCapacity(req.ACUnits, req.ACAssignments, room.ID) + heatCap := roomHeatingCapacity(req.ACUnits, req.ACAssignments, room.ID) // Solar params cloudFactor := 1.0 - (cloudPct / 100.0) @@ -307,6 +328,22 @@ func computeSingleRoomBudget(req ComputeRequest, room Room, hour int, tempC, clo } } + var precomputedSolar *float64 + + if len(room.Windows) > 0 { + var wps []heat.WindowParams + for _, w := range room.Windows { + wps = append(wps, heat.WindowParams{ + AreaSqm: w.AreaSqm, + SHGC: w.SHGC, + ShadingFactor: w.ShadingFactor, + Orientation: w.Orientation, + }) + } + sg := heat.MultiWindowSolarGain(wps, hour, cloudFactor, sunshineFraction, 800) + precomputedSolar = &sg + } + solar := heat.SolarParams{ AreaSqm: room.AreaSqm, WindowFraction: room.WindowFraction, @@ -328,12 +365,14 @@ func computeSingleRoomBudget(req ComputeRequest, room Room, hour int, tempC, clo } return heat.ComputeRoomBudget(heat.BudgetInput{ - Devices: heatDevices, - DeviceMode: mode, - Occupants: heatOccupants, - Solar: solar, - Ventilation: vent, - ACCapacityBTUH: acCap, + Devices: heatDevices, + DeviceMode: mode, + Occupants: heatOccupants, + Solar: solar, + Ventilation: vent, + ACCapacityBTUH: acCap, + HeatingCapacityBTUH: heatCap, + PrecomputedSolarGainW: precomputedSolar, }) } @@ -351,6 +390,24 @@ func roomACCapacity(units []ACUnit, assignments []ACAssignment, roomID int64) fl return total } +func roomHeatingCapacity(units []ACUnit, assignments []ACAssignment, roomID int64) float64 { + var total float64 + for _, a := range assignments { + if a.RoomID == roomID { + for _, u := range units { + if u.ID == a.ACID && u.CanHeat { + if u.HeatingCapacityBTU > 0 { + total += u.HeatingCapacityBTU + } else { + total += u.CapacityBTU + } + } + } + } + } + return total +} + func roomNameByID(rooms []Room, id int64) string { for _, r := range rooms { if r.ID == id { diff --git a/internal/compute/compute_test.go b/internal/compute/compute_test.go index 781927d..9ae3e52 100644 --- a/internal/compute/compute_test.go +++ b/internal/compute/compute_test.go @@ -334,25 +334,29 @@ func TestDetermineCoolMode(t *testing.T) { indoorTempC float64 outdoorHumidityPct float64 worstStatus heat.BudgetStatus + worstMode heat.ThermalMode 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"}, + {"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) + got := determineCoolMode(tt.outdoorTempC, tt.indoorTempC, tt.outdoorHumidityPct, tt.worstStatus, tt.worstMode) 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) + t.Errorf("determineCoolMode(%v, %v, %v, %v, %v) = %q, want %q", + tt.outdoorTempC, tt.indoorTempC, tt.outdoorHumidityPct, tt.worstStatus, tt.worstMode, got, tt.want) } }) } @@ -518,6 +522,291 @@ func TestBuildDashboard_CoolModeComfort(t *testing.T) { } } +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) diff --git a/internal/compute/types.go b/internal/compute/types.go index 8837dd4..081c0a1 100644 --- a/internal/compute/types.go +++ b/internal/compute/types.go @@ -11,6 +11,17 @@ type Profile struct { Timezone string `json:"timezone"` } +// Window holds individual window data for multi-window rooms. +type Window struct { + ID int64 `json:"id"` + RoomID int64 `json:"roomId"` + Orientation string `json:"orientation"` + AreaSqm float64 `json:"areaSqm"` + SHGC float64 `json:"shgc"` + ShadingType string `json:"shadingType"` + ShadingFactor float64 `json:"shadingFactor"` +} + // Room holds room parameters sent from the client. type Room struct { ID int64 `json:"id"` @@ -22,13 +33,13 @@ type Room struct { Orientation string `json:"orientation"` ShadingType string `json:"shadingType"` ShadingFactor float64 `json:"shadingFactor"` - Ventilation string `json:"ventilation"` VentilationACH float64 `json:"ventilationAch"` WindowFraction float64 `json:"windowFraction"` SHGC float64 `json:"shgc"` Insulation string `json:"insulation"` IndoorTempC float64 `json:"indoorTempC"` IndoorHumidityPct *float64 `json:"indoorHumidityPct,omitempty"` + Windows []Window `json:"windows,omitempty"` } // Device holds device data sent from the client. @@ -54,13 +65,15 @@ type Occupant struct { // ACUnit holds AC unit data sent from the client. type ACUnit struct { - ID int64 `json:"id"` - ProfileID int64 `json:"profileId"` - Name string `json:"name"` - ACType string `json:"acType"` - CapacityBTU float64 `json:"capacityBtu"` - HasDehumidify bool `json:"hasDehumidify"` - EfficiencyEER float64 `json:"efficiencyEer"` + ID int64 `json:"id"` + ProfileID int64 `json:"profileId"` + Name string `json:"name"` + ACType string `json:"acType"` + CapacityBTU float64 `json:"capacityBtu"` + HasDehumidify bool `json:"hasDehumidify"` + EfficiencyEER float64 `json:"efficiencyEer"` + CanHeat bool `json:"canHeat"` + HeatingCapacityBTU float64 `json:"heatingCapacityBtu"` } // ACAssignment maps an AC unit to a room. @@ -116,6 +129,8 @@ type DashboardData struct { PoorNightCool bool `json:"poorNightCool"` IndoorTempC float64 `json:"indoorTempC"` IndoorHumidityPct float64 `json:"indoorHumidityPct"` + MinTempC float64 `json:"minTempC,omitempty"` + ColdRisk bool `json:"coldRisk,omitempty"` Warnings []WarningData `json:"warnings"` RiskWindows []RiskWindowData `json:"riskWindows"` Timeline []TimelineSlotData `json:"timeline"` @@ -167,13 +182,17 @@ type ActionData struct { // RoomBudgetData holds a room's heat budget for display. type RoomBudgetData struct { - RoomName string `json:"roomName"` - InternalGainsW float64 `json:"internalGainsW"` - SolarGainW float64 `json:"solarGainW"` - VentGainW float64 `json:"ventGainW"` - TotalGainW float64 `json:"totalGainW"` - TotalGainBTUH float64 `json:"totalGainBtuh"` - ACCapacityBTUH float64 `json:"acCapacityBtuh"` - HeadroomBTUH float64 `json:"headroomBtuh"` - Status string `json:"status"` + RoomName string `json:"roomName"` + InternalGainsW float64 `json:"internalGainsW"` + SolarGainW float64 `json:"solarGainW"` + VentGainW float64 `json:"ventGainW"` + TotalGainW float64 `json:"totalGainW"` + TotalGainBTUH float64 `json:"totalGainBtuh"` + ACCapacityBTUH float64 `json:"acCapacityBtuh"` + HeadroomBTUH float64 `json:"headroomBtuh"` + Status string `json:"status"` + ThermalMode string `json:"thermalMode"` + HeatDeficitBTUH float64 `json:"heatDeficitBtuh,omitempty"` + HeatingCapBTUH float64 `json:"heatingCapBtuh,omitempty"` + HeatingHeadroom float64 `json:"heatingHeadroom,omitempty"` } diff --git a/internal/heat/budget.go b/internal/heat/budget.go index 74fa4cf..969eaa2 100644 --- a/internal/heat/budget.go +++ b/internal/heat/budget.go @@ -1,12 +1,14 @@ package heat +import "math" + // BudgetStatus represents the thermal comfort state of a room. type BudgetStatus int const ( - Comfortable BudgetStatus = iota // headroom > 20% of AC capacity - Marginal // headroom 0–20% of AC capacity - Overloaded // headroom < 0 (AC can't keep up) + Comfortable BudgetStatus = iota // headroom > 20% of capacity + Marginal // headroom 0–20% of capacity + Overloaded // headroom < 0 (capacity can't keep up) ) func (s BudgetStatus) String() string { @@ -22,14 +24,44 @@ func (s BudgetStatus) String() string { } } +// ThermalMode indicates whether a room is in cooling or heating mode. +type ThermalMode int + +const ( + Cooling ThermalMode = iota + Heating +) + +func (m ThermalMode) String() string { + switch m { + case Cooling: + return "cooling" + case Heating: + return "heating" + default: + return "unknown" + } +} + +// heatingColdThresholdC is the outdoor temperature below which heating mode +// activates when the room has net heat loss. At or above this threshold, +// net cooling is considered comfortable (mild weather). +const heatingColdThresholdC = 10.0 + +// heatingTrivialDeficitW is the maximum net heat loss (in watts) below which +// the deficit is considered trivial — not requiring active heating. +const heatingTrivialDeficitW = 100.0 + // BudgetInput holds all inputs for a room heat budget calculation. type BudgetInput struct { - Devices []Device - DeviceMode DeviceMode - Occupants []Occupant - Solar SolarParams - Ventilation VentilationParams - ACCapacityBTUH float64 + Devices []Device + DeviceMode DeviceMode + Occupants []Occupant + Solar SolarParams + Ventilation VentilationParams + ACCapacityBTUH float64 + HeatingCapacityBTUH float64 + PrecomputedSolarGainW *float64 } // BudgetResult holds the computed heat budget for a room. @@ -42,44 +74,28 @@ type BudgetResult struct { ACCapacityBTUH float64 HeadroomBTUH float64 Status BudgetStatus + Mode ThermalMode + HeatDeficitBTUH float64 + HeatingCapBTUH float64 + HeatingHeadroom float64 } // ComputeRoomBudget calculates the full heat budget for a room. func ComputeRoomBudget(in BudgetInput) BudgetResult { internal := TotalInternalGains(in.Devices, in.DeviceMode, in.Occupants) - solar := SolarGain(in.Solar) + var solar float64 + if in.PrecomputedSolarGainW != nil { + solar = *in.PrecomputedSolarGainW + } else { + solar = SolarGain(in.Solar) + } ventilation := VentilationGain(in.Ventilation) totalW := internal + solar + ventilation totalBTUH := WattsToBTUH(totalW) headroom := in.ACCapacityBTUH - totalBTUH - status := Overloaded - if totalBTUH <= 0 { - // Net cooling — room is losing heat, no problem - status = Comfortable - } else if in.ACCapacityBTUH > 0 { - ratio := headroom / in.ACCapacityBTUH - switch { - case ratio > 0.2: - status = Comfortable - case ratio >= 0: - status = Marginal - } - } else { - // No AC, positive gain — can open-window ventilation offset it? - deltaT := in.Ventilation.OutdoorTempC - in.Ventilation.IndoorTempC - if deltaT < 0 { - const openWindowACH = 5.0 - rhoCpJ := in.Ventilation.RhoCp * 1000 - maxVentW := openWindowACH * in.Ventilation.VolumeCubicM * rhoCpJ * deltaT / 3600 - if (internal + solar + maxVentW) <= 0 { - status = Marginal - } - } - } - - return BudgetResult{ + result := BudgetResult{ InternalGainsW: internal, SolarGainW: solar, VentilationGainW: ventilation, @@ -87,6 +103,62 @@ func ComputeRoomBudget(in BudgetInput) BudgetResult { TotalGainBTUH: totalBTUH, ACCapacityBTUH: in.ACCapacityBTUH, HeadroomBTUH: headroom, - Status: status, + Mode: Cooling, } + + if totalBTUH <= 0 { + // Net heat loss — room is losing heat + if in.Ventilation.OutdoorTempC < heatingColdThresholdC { + // Cold weather: enter heating mode + result.Mode = Heating + deficit := math.Abs(totalBTUH) + result.HeatDeficitBTUH = deficit + result.HeatingCapBTUH = in.HeatingCapacityBTUH + result.HeatingHeadroom = in.HeatingCapacityBTUH - deficit + + if in.HeatingCapacityBTUH > 0 { + ratio := result.HeatingHeadroom / in.HeatingCapacityBTUH + switch { + case ratio > 0.2: + result.Status = Comfortable + case ratio >= 0: + result.Status = Marginal + default: + result.Status = Overloaded + } + } else if math.Abs(totalW) < heatingTrivialDeficitW { + // No heating, but trivial deficit + result.Status = Marginal + } else { + result.Status = Overloaded + } + } else { + // Mild weather — net cooling is fine + result.Status = Comfortable + } + } else if in.ACCapacityBTUH > 0 { + ratio := headroom / in.ACCapacityBTUH + switch { + case ratio > 0.2: + result.Status = Comfortable + case ratio >= 0: + result.Status = Marginal + default: + result.Status = Overloaded + } + } else { + // No AC, positive gain — can open-window ventilation offset it? + result.Status = Overloaded + deltaT := in.Ventilation.OutdoorTempC - in.Ventilation.IndoorTempC + if deltaT < 0 { + const openWindowACH = 5.0 + rhoCpJ := in.Ventilation.RhoCp * 1000 + maxVentW := openWindowACH * in.Ventilation.VolumeCubicM * rhoCpJ * deltaT / 3600 + if (internal + solar + maxVentW) <= 0 { + result.Status = Marginal + } + } + } + + return result } diff --git a/internal/heat/budget_test.go b/internal/heat/budget_test.go index 2a40d30..ec987d4 100644 --- a/internal/heat/budget_test.go +++ b/internal/heat/budget_test.go @@ -74,6 +74,35 @@ func TestComputeRoomBudget(t *testing.T) { } } +func TestComputeRoomBudget_PrecomputedSolar(t *testing.T) { + precomputed := 500.0 + input := BudgetInput{ + Devices: nil, + DeviceMode: ModeIdle, + Occupants: nil, + Solar: SolarParams{ + AreaSqm: 20, + WindowFraction: 0.15, + SHGC: 0.6, + ShadingFactor: 1.0, + OrientationFactor: 1.0, + CloudFactor: 1.0, + SunshineFraction: 1.0, + PeakIrradiance: 800, + }, + Ventilation: VentilationParams{RhoCp: 1.2, OutdoorTempC: 25, IndoorTempC: 25, VolumeCubicM: 50}, + ACCapacityBTUH: 8000, + PrecomputedSolarGainW: &precomputed, + } + + result := ComputeRoomBudget(input) + + // Should use 500W precomputed, not calculate from SolarParams (which would give 1440W) + if !almostEqual(result.SolarGainW, 500, tolerance) { + t.Errorf("SolarGainW = %v, want 500 (precomputed)", result.SolarGainW) + } +} + func TestBudgetStatus(t *testing.T) { tests := []struct { name string @@ -128,6 +157,157 @@ func TestBudgetStatus(t *testing.T) { } } +func TestThermalModeString(t *testing.T) { + tests := []struct { + mode ThermalMode + want string + }{ + {Cooling, "cooling"}, + {Heating, "heating"}, + {ThermalMode(99), "unknown"}, + } + for _, tt := range tests { + if got := tt.mode.String(); got != tt.want { + t.Errorf("ThermalMode(%d).String() = %s, want %s", tt.mode, got, tt.want) + } + } +} + +func TestComputeRoomBudget_HeatingComfortable(t *testing.T) { + // Cold outdoor (-5°C), room has heating capacity that exceeds deficit + input := BudgetInput{ + Devices: nil, + DeviceMode: ModeIdle, + Occupants: nil, + Solar: SolarParams{}, + Ventilation: VentilationParams{ + ACH: 0.5, VolumeCubicM: 50, OutdoorTempC: -5, IndoorTempC: 23, RhoCp: 1.2, + }, + ACCapacityBTUH: 8000, + HeatingCapacityBTUH: 12000, + } + result := ComputeRoomBudget(input) + if result.Mode != Heating { + t.Errorf("Mode = %v, want Heating", result.Mode) + } + if result.Status != Comfortable { + t.Errorf("Status = %v, want Comfortable", result.Status) + } + if result.HeatDeficitBTUH <= 0 { + t.Errorf("HeatDeficitBTUH = %v, want > 0", result.HeatDeficitBTUH) + } + if result.HeatingCapBTUH != 12000 { + t.Errorf("HeatingCapBTUH = %v, want 12000", result.HeatingCapBTUH) + } + if result.HeatingHeadroom <= 0 { + t.Errorf("HeatingHeadroom = %v, want > 0", result.HeatingHeadroom) + } +} + +func TestComputeRoomBudget_HeatingMarginal(t *testing.T) { + // Cold outdoor, heating capacity barely covers deficit (0-20% headroom) + input := BudgetInput{ + Devices: nil, + DeviceMode: ModeIdle, + Occupants: nil, + Solar: SolarParams{}, + Ventilation: VentilationParams{ + ACH: 1.0, VolumeCubicM: 60, OutdoorTempC: -10, IndoorTempC: 23, RhoCp: 1.2, + }, + ACCapacityBTUH: 0, + HeatingCapacityBTUH: 7000, // just barely enough + } + result := ComputeRoomBudget(input) + if result.Mode != Heating { + t.Errorf("Mode = %v, want Heating", result.Mode) + } + // Vent gain: 1.0 * 60 * 1200 * (-33) / 3600 = -660W → -2252 BTU/h + // deficit = 2252, cap = 7000, headroom = 4748, ratio = 4748/7000 = 0.678 → comfortable + // Need tighter values. Let's compute: we need ratio 0-0.2. + // deficit = 2252, need cap such that (cap - 2252)/cap is 0-0.2 → cap between 2252 and 2815 + input.HeatingCapacityBTUH = 2600 + result = ComputeRoomBudget(input) + if result.Mode != Heating { + t.Errorf("Mode = %v, want Heating", result.Mode) + } + if result.Status != Marginal { + t.Errorf("Status = %v, want Marginal (deficit=%.0f, cap=%.0f, headroom=%.0f)", + result.Status, result.HeatDeficitBTUH, result.HeatingCapBTUH, result.HeatingHeadroom) + } +} + +func TestComputeRoomBudget_HeatingOverloaded(t *testing.T) { + // Cold outdoor, no heating capacity → overloaded + input := BudgetInput{ + Devices: nil, + DeviceMode: ModeIdle, + Occupants: nil, + Solar: SolarParams{}, + Ventilation: VentilationParams{ + ACH: 1.0, VolumeCubicM: 60, OutdoorTempC: -10, IndoorTempC: 23, RhoCp: 1.2, + }, + ACCapacityBTUH: 0, + HeatingCapacityBTUH: 0, + } + result := ComputeRoomBudget(input) + if result.Mode != Heating { + t.Errorf("Mode = %v, want Heating", result.Mode) + } + if result.Status != Overloaded { + t.Errorf("Status = %v, want Overloaded", result.Status) + } +} + +func TestComputeRoomBudget_HeatingTrivialDeficit(t *testing.T) { + // Cold outdoor but very small deficit (<100W), no heating → Marginal (not Overloaded) + input := BudgetInput{ + Devices: []Device{ + {WattsIdle: 90, WattsTypical: 90, WattsPeak: 90, DutyCycle: 1.0}, + }, + DeviceMode: ModeIdle, + Occupants: nil, + Solar: SolarParams{}, + Ventilation: VentilationParams{ + ACH: 0.3, VolumeCubicM: 30, OutdoorTempC: 5, IndoorTempC: 23, RhoCp: 1.2, + }, + ACCapacityBTUH: 0, + HeatingCapacityBTUH: 0, + } + // Internal: 90W, vent: 0.3*30*1200*(-18)/3600 = -54W, total = 36W → positive, not heating + // Need totalW < 0 but |totalW| < 100. Adjust device down. + input.Devices[0].WattsIdle = 40 + // total = 40 + (-54) = -14W → deficit ~48 BTU/h, trivial + result := ComputeRoomBudget(input) + if result.Mode != Heating { + t.Errorf("Mode = %v, want Heating", result.Mode) + } + if result.Status != Marginal { + t.Errorf("Status = %v, want Marginal (trivial deficit, totalW=%.1f)", result.Status, result.TotalGainW) + } +} + +func TestComputeRoomBudget_MildColdStaysCooling(t *testing.T) { + // Outdoor 10°C (>= threshold), net cooling → stays Cooling mode Comfortable + input := BudgetInput{ + Devices: nil, + DeviceMode: ModeIdle, + Occupants: nil, + Solar: SolarParams{}, + Ventilation: VentilationParams{ + ACH: 1.0, VolumeCubicM: 45, OutdoorTempC: 10, IndoorTempC: 25, RhoCp: 1.2, + }, + ACCapacityBTUH: 0, + HeatingCapacityBTUH: 0, + } + result := ComputeRoomBudget(input) + if result.Mode != Cooling { + t.Errorf("Mode = %v, want Cooling", result.Mode) + } + if result.Status != Comfortable { + t.Errorf("Status = %v, want Comfortable", result.Status) + } +} + func TestBudgetStatus_NoACVentilation(t *testing.T) { tests := []struct { name string diff --git a/internal/heat/external_gains.go b/internal/heat/external_gains.go index a3248fc..14e357c 100644 --- a/internal/heat/external_gains.go +++ b/internal/heat/external_gains.go @@ -80,6 +80,24 @@ func SolarGain(p SolarParams) float64 { return p.PeakIrradiance * p.OrientationFactor * windowArea * p.SHGC * p.ShadingFactor * p.CloudFactor * p.SunshineFraction } +// WindowParams holds per-window inputs for multi-window solar gain. +type WindowParams struct { + AreaSqm float64 + SHGC float64 + ShadingFactor float64 + Orientation string +} + +// MultiWindowSolarGain computes total solar gain across multiple individually-oriented windows. +func MultiWindowSolarGain(windows []WindowParams, hour int, cloudFactor, sunshineFraction, peakIrradiance float64) float64 { + total := 0.0 + for _, w := range windows { + of := OrientationFactor(w.Orientation, hour) + total += peakIrradiance * of * w.AreaSqm * w.SHGC * w.ShadingFactor * cloudFactor * sunshineFraction + } + return total +} + // DefaultRhoCp is the volumetric heat capacity of air in J/(m³·K). // Approximately 1200 J/(m³·K) at sea level. const DefaultRhoCp = 1200.0 diff --git a/internal/heat/external_gains_test.go b/internal/heat/external_gains_test.go index 2b45d26..1abdedd 100644 --- a/internal/heat/external_gains_test.go +++ b/internal/heat/external_gains_test.go @@ -65,6 +65,62 @@ func TestSolarGain(t *testing.T) { } } +func TestMultiWindowSolarGain(t *testing.T) { + tests := []struct { + name string + windows []WindowParams + hour int + cloudFactor float64 + sunshineFraction float64 + peakIrradiance float64 + want float64 + }{ + { + name: "single south window at noon", + windows: []WindowParams{ + {AreaSqm: 3.0, SHGC: 0.6, ShadingFactor: 1.0, Orientation: "S"}, + }, + hour: 12, cloudFactor: 1.0, sunshineFraction: 1.0, peakIrradiance: 800, + // OrientationFactor("S", 12) = 1.0 + // 800 * 1.0 * 3.0 * 0.6 * 1.0 * 1.0 * 1.0 = 1440 + want: 1440, + }, + { + name: "south + east windows at 10am", + windows: []WindowParams{ + {AreaSqm: 2.0, SHGC: 0.6, ShadingFactor: 1.0, Orientation: "S"}, + {AreaSqm: 1.5, SHGC: 0.5, ShadingFactor: 0.8, Orientation: "E"}, + }, + hour: 10, cloudFactor: 0.8, sunshineFraction: 0.9, peakIrradiance: 800, + // S: OrientationFactor("S",10) = 1.0 → 800*1.0*2.0*0.6*1.0*0.8*0.9 = 691.2 + // E: OrientationFactor("E",10) = 0.9 → 800*0.9*1.5*0.5*0.8*0.8*0.9 = 311.04 + want: 691.2 + 311.04, + }, + { + name: "empty windows slice", + windows: nil, + hour: 12, cloudFactor: 1.0, sunshineFraction: 1.0, peakIrradiance: 800, + want: 0, + }, + { + name: "night time (zero sunshine)", + windows: []WindowParams{ + {AreaSqm: 3.0, SHGC: 0.6, ShadingFactor: 1.0, Orientation: "S"}, + }, + hour: 2, cloudFactor: 1.0, sunshineFraction: 0.0, peakIrradiance: 800, + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MultiWindowSolarGain(tt.windows, tt.hour, tt.cloudFactor, tt.sunshineFraction, tt.peakIrradiance) + if !almostEqual(got, tt.want, 0.1) { + t.Errorf("MultiWindowSolarGain() = %v, want %v", got, tt.want) + } + }) + } +} + func TestVentilationGain(t *testing.T) { tests := []struct { name string diff --git a/internal/risk/analyzer.go b/internal/risk/analyzer.go index 8e91c3c..1668492 100644 --- a/internal/risk/analyzer.go +++ b/internal/risk/analyzer.go @@ -51,6 +51,8 @@ type DayRisk struct { PeakTempC float64 MinNightTempC float64 PoorNightCool bool + MinTempC float64 + ColdRisk bool Windows []RiskWindow } @@ -76,19 +78,23 @@ func riskLevelForTemp(tempC float64, th Thresholds) RiskLevel { // AnalyzeDay analyzes 24 hourly data points and returns the overall day risk. func AnalyzeDay(hours []HourlyData, th Thresholds) DayRisk { if len(hours) == 0 { - return DayRisk{Level: Low, MinNightTempC: math.Inf(1)} + return DayRisk{Level: Low, MinNightTempC: math.Inf(1), MinTempC: math.Inf(1)} } result := DayRisk{ Level: Low, MinNightTempC: math.Inf(1), + MinTempC: math.Inf(1), } - // Find peak temp and min night temp + // Find peak temp, min night temp, and global min temp for _, h := range hours { if h.TempC > result.PeakTempC { result.PeakTempC = h.TempC } + if h.TempC < result.MinTempC { + result.MinTempC = h.TempC + } if isNightHour(h.Hour) { if h.TempC < result.MinNightTempC { result.MinNightTempC = h.TempC @@ -103,6 +109,10 @@ func AnalyzeDay(hours []HourlyData, th Thresholds) DayRisk { if math.IsInf(result.MinNightTempC, 1) { result.MinNightTempC = 0 } + if math.IsInf(result.MinTempC, 1) { + result.MinTempC = 0 + } + result.ColdRisk = result.MinTempC <= th.ColdDayC // Find contiguous risk windows (hours where temp >= HotDayC) var currentWindow *RiskWindow diff --git a/internal/risk/analyzer_test.go b/internal/risk/analyzer_test.go index 127409c..1540a8b 100644 --- a/internal/risk/analyzer_test.go +++ b/internal/risk/analyzer_test.go @@ -147,6 +147,36 @@ func TestAnalyzeDay_MinNightTemp(t *testing.T) { } } +func TestAnalyzeDay_ColdRiskDetected(t *testing.T) { + temps := make([]float64, 24) + for i := range temps { + temps[i] = 5 + } + temps[3] = -2 // below ColdDayC (0) + result := AnalyzeDay(makeHours(temps), DefaultThresholds()) + if result.MinTempC != -2 { + t.Errorf("MinTempC = %v, want -2", result.MinTempC) + } + if !result.ColdRisk { + t.Error("expected ColdRisk = true") + } +} + +func TestAnalyzeDay_ColdRiskAbsent(t *testing.T) { + temps := make([]float64, 24) + for i := range temps { + temps[i] = 15 + } + temps[5] = 5 // above ColdDayC (0) + result := AnalyzeDay(makeHours(temps), DefaultThresholds()) + if result.MinTempC != 5 { + t.Errorf("MinTempC = %v, want 5", result.MinTempC) + } + if result.ColdRisk { + t.Error("expected ColdRisk = false") + } +} + func TestRiskLevelString(t *testing.T) { tests := []struct { level RiskLevel diff --git a/internal/risk/thresholds.go b/internal/risk/thresholds.go index 104d82d..6182368 100644 --- a/internal/risk/thresholds.go +++ b/internal/risk/thresholds.go @@ -7,6 +7,7 @@ type Thresholds struct { ExtremeDayC float64 // extreme heat (default 40) PoorNightCoolingC float64 // night temp above which cooling is poor (default 20) ComfortMaxC float64 // max indoor comfort temp (default 26) + ColdDayC float64 // temp at or below which cold risk is flagged (default 0) } // DefaultThresholds returns the default temperature thresholds. diff --git a/web/i18n/de.json b/web/i18n/de.json index bb502c5..2002a2e 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -69,9 +69,10 @@ "devices": { "title": "Ger\u00e4te", "help": "W\u00e4rmeproduzierende Ger\u00e4te in jedem Raum.", + "noRooms": "F\u00fcgen Sie zuerst einen Raum hinzu, bevor Sie Ger\u00e4te hinzuf\u00fcgen.", "name": { "label": "Name", "tooltip": "Ger\u00e4tename (z.B. Desktop-PC, TV)" }, "room": { "label": "Raum", "tooltip": "In welchem Raum sich das Ger\u00e4t befindet" }, - "type": { "label": "Typ", "tooltip": "Ger\u00e4tekategorie" }, + "type": { "label": "Typ", "tooltip": "Ger\u00e4tekategorie (nur Bezeichnung, wird nicht in Berechnungen verwendet)" }, "wattsIdle": { "label": "Watt (Leerlauf)", "tooltip": "Leistungsaufnahme im Leerlauf/Standby" }, "wattsTypical": { "label": "Watt (Typisch)", "tooltip": "Leistungsaufnahme bei normaler Nutzung" }, "wattsPeak": { "label": "Watt (Spitze)", "tooltip": "Leistungsaufnahme bei Maximallast (z.B. Gaming)" }, @@ -83,6 +84,7 @@ "occupants": { "title": "Bewohner", "help": "Personen in jedem Raum. K\u00f6rperw\u00e4rme tr\u00e4gt zur Raumtemperatur bei.", + "noRooms": "F\u00fcgen Sie zuerst einen Raum hinzu, bevor Sie Bewohner hinzuf\u00fcgen.", "room": { "label": "Raum", "tooltip": "Welcher Raum" }, "count": { "label": "Anzahl", "tooltip": "Anzahl der Personen" }, "activity": { @@ -98,6 +100,7 @@ "ac": { "title": "Klimaanlagen", "help": "Klimager\u00e4te und deren Raumzuordnungen.", + "noRooms": "F\u00fcgen Sie zuerst einen Raum hinzu, bevor Sie Klimager\u00e4te zuweisen.", "name": { "label": "Name", "tooltip": "Name des Klimager\u00e4ts" }, "type": { "label": "Typ", @@ -107,6 +110,8 @@ "capacity": { "label": "Leistung (BTU)", "tooltip": "K\u00fchlleistung in BTU/h. Typisch mobil: 8.000\u201314.000 BTU." }, "eer": { "label": "EER", "tooltip": "Energieeffizienzwert. H\u00f6her = effizienter. Typisch: 8\u201312." }, "dehumidify": { "label": "Entfeuchtung", "tooltip": "Ob das Ger\u00e4t einen Entfeuchtungsmodus hat" }, + "canHeat": { "label": "Heizf\u00e4hig", "tooltip": "Ob dieses Ger\u00e4t als W\u00e4rmepumpe zum Heizen betrieben werden kann" }, + "heatingCapacity": { "label": "Heizleistung (BTU)", "tooltip": "Heizleistung in BTU/h. Wenn leer, wird die K\u00fchlleistung verwendet." }, "rooms": { "label": "Zugewiesene R\u00e4ume", "tooltip": "Welche R\u00e4ume dieses Klimager\u00e4t versorgt" }, "add": "Klimager\u00e4t hinzuf\u00fcgen", "save": "Klimager\u00e4t speichern", @@ -128,6 +133,19 @@ "never": "Nie", "fetching": "Vorhersage wird abgerufen\u2026" }, + "windows": { + "title": "Fenster", + "help": "Einzelne Fenster mit eigener Ausrichtung und Eigenschaften. \u00dcberschreibt den Solargewinn auf Raumebene.", + "orientation": { "label": "Ausrichtung", "tooltip": "In welche Richtung dieses Fenster zeigt" }, + "area": { "label": "Fl\u00e4che (m\u00b2)", "tooltip": "Tats\u00e4chliche Verglasungsfl\u00e4che in Quadratmetern" }, + "shgc": { "label": "SHGC", "tooltip": "Gesamtenergiedurchlassgrad f\u00fcr dieses Fenster" }, + "shadingType": { "label": "Verschattung", "tooltip": "Verschattungsart f\u00fcr dieses Fenster" }, + "shadingFactor": { "label": "Verschattungsfaktor", "tooltip": "0 = vollst\u00e4ndig verschattet, 1 = keine Verschattung" }, + "add": "Fenster hinzuf\u00fcgen", + "save": "Fenster speichern", + "noItems": "Keine Fenster. Solargewinne auf Raumebene werden verwendet.", + "saveRoomFirst": "Speichern Sie zuerst den Raum, um Fenster hinzuzuf\u00fcgen." + }, "llm": { "title": "KI-Zusammenfassung", "help": "Konfigurieren Sie einen KI-Anbieter f\u00fcr personalisierte Hitzezusammenfassungen.", @@ -165,6 +183,8 @@ "totalGain": "Gesamtgewinn", "acCapacity": "Klimaleistung", "headroom": "Reserve", + "headroomOk": "Klimaanlage deckt die W\u00e4rmelast", + "headroomInsufficient": "Klimaanlage unterversorgt um", "fetchForecastFirst": "Keine Vorhersagedaten. Rufen Sie zuerst eine Vorhersage in der Einrichtung ab.", "no": "Nein", "noActions": "Keine Ma\u00dfnahmen", @@ -177,6 +197,13 @@ "coolAC": "Klimaanlage", "coolOverloaded": "Klima \u00fcberlastet", "coolSealed": "Geschlossen halten", + "coolHeating": "Heizung", + "coolHeatInsufficient": "Heizung unzureichend", + "heatingCapacity": "Heizleistung", + "heatDeficit": "W\u00e4rmedefizit", + "heatingHeadroom": "Heizungsreserve", + "heatingHeadroomOk": "Heizung deckt den W\u00e4rmeverlust", + "heatingHeadroomInsufficient": "Heizung unterversorgt um", "aiActions": "KI-empfohlene Ma\u00dfnahmen", "legendTemp": "Temperatur", "legendCooling": "K\u00fchlung", @@ -235,6 +262,7 @@ }, "risk": { "title": "Risikostufen verstehen", + "comfortable": "Komfortabel: K\u00fchles Wetter, kein Hitzerisiko. H\u00f6chstwert unter 22\u00b0C.", "low": "Niedrig: Temperaturen unter 30\u00b0C. Normale Bedingungen.", "moderate": "Mittel: Temperaturen 30\u201335\u00b0C. Grundlegende Vorsichtsma\u00dfnahmen treffen.", "high": "Hoch: Temperaturen 35\u201340\u00b0C. Erhebliches Hitzestressrisiko.", diff --git a/web/i18n/en.json b/web/i18n/en.json index 69c6dde..d4ab664 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -69,9 +69,10 @@ "devices": { "title": "Devices", "help": "Heat-producing devices in each room.", + "noRooms": "Add a room first before adding devices.", "name": { "label": "Name", "tooltip": "Device name (e.g. Desktop PC, TV)" }, "room": { "label": "Room", "tooltip": "Which room this device is in" }, - "type": { "label": "Type", "tooltip": "Device category" }, + "type": { "label": "Type", "tooltip": "Device category (label only, not used in calculations)" }, "wattsIdle": { "label": "Watts (Idle)", "tooltip": "Power draw when idle/standby" }, "wattsTypical": { "label": "Watts (Typical)", "tooltip": "Power draw during normal use" }, "wattsPeak": { "label": "Watts (Peak)", "tooltip": "Power draw at maximum load (e.g. gaming)" }, @@ -83,6 +84,7 @@ "occupants": { "title": "Occupants", "help": "People in each room. Metabolic heat contributes to room temperature.", + "noRooms": "Add a room first before adding occupants.", "room": { "label": "Room", "tooltip": "Which room" }, "count": { "label": "Count", "tooltip": "Number of people" }, "activity": { @@ -98,6 +100,7 @@ "ac": { "title": "AC Units", "help": "Air conditioning units and their room assignments.", + "noRooms": "Add a room first before assigning AC units.", "name": { "label": "Name", "tooltip": "AC unit name" }, "type": { "label": "Type", @@ -107,6 +110,8 @@ "capacity": { "label": "Capacity (BTU)", "tooltip": "Cooling capacity in BTU/h. Typical portable: 8,000\u201314,000 BTU." }, "eer": { "label": "EER", "tooltip": "Energy Efficiency Ratio. Higher = more efficient. Typical: 8\u201312." }, "dehumidify": { "label": "Dehumidify", "tooltip": "Whether the unit has a dehumidify mode" }, + "canHeat": { "label": "Can Heat", "tooltip": "Whether this unit can operate as a heat pump for heating" }, + "heatingCapacity": { "label": "Heating Capacity (BTU)", "tooltip": "Heating capacity in BTU/h. If blank, cooling capacity is used." }, "rooms": { "label": "Assigned Rooms", "tooltip": "Which rooms this AC unit serves" }, "add": "Add AC Unit", "save": "Save AC Unit", @@ -128,6 +133,19 @@ "never": "Never", "fetching": "Fetching forecast\u2026" }, + "windows": { + "title": "Windows", + "help": "Individual windows with their own orientation and properties. Overrides room-level solar gain.", + "orientation": { "label": "Orientation", "tooltip": "Which direction this window faces" }, + "area": { "label": "Area (m\u00b2)", "tooltip": "Actual glazing area in square meters" }, + "shgc": { "label": "SHGC", "tooltip": "Solar Heat Gain Coefficient for this window" }, + "shadingType": { "label": "Shading", "tooltip": "Shading type for this window" }, + "shadingFactor": { "label": "Shading Factor", "tooltip": "0 = fully shaded, 1 = no shading" }, + "add": "Add Window", + "save": "Save Window", + "noItems": "No windows. Room-level solar defaults are used.", + "saveRoomFirst": "Save the room first to add windows." + }, "llm": { "title": "AI Summary", "help": "Configure an AI provider for personalized heat summaries.", @@ -165,6 +183,8 @@ "totalGain": "Total Gain", "acCapacity": "AC Capacity", "headroom": "Headroom", + "headroomOk": "AC covers the heat load", + "headroomInsufficient": "AC insufficient by", "fetchForecastFirst": "No forecast data. Fetch a forecast in Setup first.", "no": "No", "noActions": "No actions", @@ -177,6 +197,13 @@ "coolAC": "AC cooling", "coolOverloaded": "AC overloaded", "coolSealed": "Keep sealed", + "coolHeating": "Heating", + "coolHeatInsufficient": "Heating insufficient", + "heatingCapacity": "Heating Capacity", + "heatDeficit": "Heat Deficit", + "heatingHeadroom": "Heating Headroom", + "heatingHeadroomOk": "Heating covers the heat loss", + "heatingHeadroomInsufficient": "Heating insufficient by", "aiActions": "AI-recommended actions", "legendTemp": "Temperature", "legendCooling": "Cooling", @@ -235,6 +262,7 @@ }, "risk": { "title": "Understanding Risk Levels", + "comfortable": "Comfortable: Cool weather, no heat risk. Peak below 22\u00b0C.", "low": "Low: Temperatures below 30\u00b0C. Normal conditions.", "moderate": "Moderate: Temperatures 30\u201335\u00b0C. Take basic precautions.", "high": "High: Temperatures 35\u201340\u00b0C. Significant heat stress risk.", diff --git a/web/js/dashboard.js b/web/js/dashboard.js index 16ddbc9..cd36789 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -381,6 +381,28 @@ el.querySelector('[data-slot="bar-vent"]').style.width = `${ventPct.toFixed(1)}%`; el.querySelector('[data-slot="bar-ac"]').style.width = `${capPct.toFixed(1)}%`; + // Heating mode display + const heatingSection = el.querySelector('[data-slot="heating-section"]'); + if (rb.thermalMode === "heating" && heatingSection) { + heatingSection.classList.remove("hidden"); + const ts = t(); + el.querySelector('[data-slot="heat-deficit"]').textContent = `${(rb.heatDeficitBtuh || 0).toFixed(0)} BTU/h`; + el.querySelector('[data-slot="heating-capacity"]').textContent = `${(rb.heatingCapBtuh || 0).toFixed(0)} BTU/h`; + el.querySelector('[data-slot="heating-headroom-value"]').textContent = `${(rb.heatingHeadroom || 0).toFixed(0)} BTU/h`; + if ((rb.heatingHeadroom || 0) >= 0) { + const hbad = el.querySelector('[data-slot="heating-headroom-bad"]'); + if (hbad) hbad.remove(); + } else { + const hok = el.querySelector('[data-slot="heating-headroom-ok"]'); + if (hok) hok.remove(); + const hbad = el.querySelector('[data-slot="heating-headroom-bad"]'); + if (hbad) { + hbad.textContent = `${hbad.dataset.label} ${Math.abs(rb.heatingHeadroom || 0).toFixed(0)} BTU/h`; + hbad.classList.remove("hidden"); + } + } + } + budgetContainer.appendChild(el); } } @@ -515,6 +537,8 @@ ac: "#4ade80", overloaded: "#f87171", sealed: "#a78bfa", + heating: "#818cf8", + heat_insufficient: "#c084fc", }; const categoryColors = { @@ -532,6 +556,8 @@ ac: t().coolAC || "AC cooling", overloaded: t().coolOverloaded || "AC overloaded", sealed: t().coolSealed || "Keep sealed", + heating: t().coolHeating || "Heating", + heat_insufficient: t().coolHeatInsufficient || "Heating insufficient", }); function renderTimelineHeatmap(timeline, timezone, dashDate) { diff --git a/web/js/db.js b/web/js/db.js index 4f7f3d2..e1ffd02 100644 --- a/web/js/db.js +++ b/web/js/db.js @@ -1,6 +1,6 @@ // IndexedDB wrapper for HeatGuard const DB_NAME = "heatguard"; -const DB_VERSION = 1; +const DB_VERSION = 2; const STORES = { profiles: { keyPath: "id", autoIncrement: true, indexes: [{ name: "name", keyPath: "name", unique: true }] }, @@ -13,6 +13,7 @@ const STORES = { warnings: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] }, toggles: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId_name", keyPath: ["profileId", "name"] }] }, settings: { keyPath: "key" }, + windows: { keyPath: "id", autoIncrement: true, indexes: [{ name: "roomId", keyPath: "roomId" }] }, }; let dbPromise = null; @@ -23,6 +24,7 @@ function openDB() { const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = (e) => { const db = e.target.result; + const tx = e.target.transaction; for (const [name, cfg] of Object.entries(STORES)) { if (!db.objectStoreNames.contains(name)) { const opts = { keyPath: cfg.keyPath }; @@ -35,6 +37,26 @@ function openDB() { } } } + // v1 → v2: migrate existing rooms to have a synthetic window each + if (e.oldVersion < 2 && db.objectStoreNames.contains("rooms") && db.objectStoreNames.contains("windows")) { + const roomStore = tx.objectStore("rooms"); + const winStore = tx.objectStore("windows"); + roomStore.openCursor().onsuccess = (ce) => { + const cursor = ce.target.result; + if (!cursor) return; + const r = cursor.value; + const area = (r.areaSqm || 15) * (r.windowFraction || 0.15); + winStore.add({ + roomId: r.id, + orientation: r.orientation || "S", + areaSqm: Math.round(area * 100) / 100, + shgc: r.shgc || 0.6, + shadingType: r.shadingType || "none", + shadingFactor: r.shadingFactor ?? 1.0, + }); + cursor.continue(); + }; + } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); @@ -146,6 +168,7 @@ async function deleteProfile(profileId) { async function deleteRoomData(roomId) { await deleteByIndex("devices", "roomId", roomId); await deleteByIndex("occupants", "roomId", roomId); + await deleteByIndex("windows", "roomId", roomId); // Delete ac_assignments for this room const assignments = await dbGetAll("ac_assignments"); for (const a of assignments) { @@ -255,11 +278,14 @@ async function getComputePayload(profileId, dateStr) { const rooms = await dbGetByIndex("rooms", "profileId", profileId); const allDevices = []; const allOccupants = []; + const roomWindows = {}; for (const room of rooms) { const devices = await dbGetByIndex("devices", "roomId", room.id); allDevices.push(...devices); const occupants = await dbGetByIndex("occupants", "roomId", room.id); allOccupants.push(...occupants); + const wins = await dbGetByIndex("windows", "roomId", room.id); + if (wins.length > 0) roomWindows[room.id] = wins; } const acUnits = await dbGetByIndex("ac_units", "profileId", profileId); @@ -287,24 +313,38 @@ async function getComputePayload(profileId, dateStr) { longitude: profile.longitude, timezone: profile.timezone || "Europe/Berlin", }, - rooms: rooms.map(r => ({ - id: r.id, - profileId: r.profileId, - name: r.name, - areaSqm: r.areaSqm || 0, - ceilingHeightM: r.ceilingHeightM || 2.5, - floor: r.floor || 0, - orientation: r.orientation || "S", - shadingType: r.shadingType || "none", - shadingFactor: r.shadingFactor ?? 1.0, - ventilation: r.ventilation || "natural", - ventilationAch: r.ventilationAch || 0.5, - windowFraction: r.windowFraction || 0.15, - shgc: r.shgc || 0.6, - insulation: r.insulation || "average", - indoorTempC: r.indoorTempC || 0, - indoorHumidityPct: r.indoorHumidityPct || null, - })), + rooms: rooms.map(r => { + const rm = { + id: r.id, + profileId: r.profileId, + name: r.name, + areaSqm: r.areaSqm || 0, + ceilingHeightM: r.ceilingHeightM || 2.5, + floor: r.floor || 0, + orientation: r.orientation || "S", + shadingType: r.shadingType || "none", + shadingFactor: r.shadingFactor ?? 1.0, + ventilationAch: r.ventilationAch || 0.5, + windowFraction: r.windowFraction || 0.15, + shgc: r.shgc || 0.6, + insulation: r.insulation || "average", + indoorTempC: r.indoorTempC || 0, + indoorHumidityPct: r.indoorHumidityPct || null, + }; + const wins = roomWindows[r.id]; + if (wins && wins.length > 0) { + rm.windows = wins.map(w => ({ + id: w.id, + roomId: w.roomId, + orientation: w.orientation || "S", + areaSqm: w.areaSqm || 0, + shgc: w.shgc || 0.6, + shadingType: w.shadingType || "none", + shadingFactor: w.shadingFactor ?? 1.0, + })); + } + return rm; + }), devices: allDevices.map(d => ({ id: d.id, roomId: d.roomId, @@ -330,6 +370,8 @@ async function getComputePayload(profileId, dateStr) { capacityBtu: a.capacityBtu || 0, hasDehumidify: !!a.hasDehumidify, efficiencyEer: a.efficiencyEer || 10, + canHeat: !!a.canHeat, + heatingCapacityBtu: a.heatingCapacityBtu || 0, })), acAssignments: acAssignments.map(a => ({ acId: a.acId, diff --git a/web/js/setup.js b/web/js/setup.js index 8932003..ee94517 100644 --- a/web/js/setup.js +++ b/web/js/setup.js @@ -614,7 +614,8 @@ const roomIds = assignments.filter(a => a.acId === u.id).map(a => a.roomId); const roomNames = roomIds.map(id => roomMap[id] || `Room ${id}`).join(", "); el.querySelector('[data-slot="name"]').textContent = u.name; - el.querySelector('[data-slot="details"]').textContent = `${u.capacityBtu} BTU \u00b7 ${u.acType}${roomNames ? ' \u00b7 ' + roomNames : ''}`; + const heatInfo = u.canHeat ? ` \u00b7 Heat ${u.heatingCapacityBtu || u.capacityBtu} BTU` : ''; + el.querySelector('[data-slot="details"]').textContent = `${u.capacityBtu} BTU \u00b7 ${u.acType}${heatInfo}${roomNames ? ' \u00b7 ' + roomNames : ''}`; el.firstElementChild.dataset.id = u.id; list.appendChild(el); } @@ -630,6 +631,8 @@ form.querySelector('input[name="capacityBtu"]').value = u.capacityBtu || 0; form.querySelector('input[name="efficiencyEer"]').value = u.efficiencyEer || 10; form.querySelector('input[name="hasDehumidify"]').checked = !!u.hasDehumidify; + form.querySelector('input[name="canHeat"]').checked = !!u.canHeat; + form.querySelector('input[name="heatingCapacityBtu"]').value = u.heatingCapacityBtu || 0; // Check assigned rooms const assignments = await dbGetAll("ac_assignments"); const assignedRoomIds = new Set(assignments.filter(a => a.acId === id).map(a => a.roomId)); @@ -674,6 +677,8 @@ capacityBtu: numOrDefault(data.capacityBtu, 0), efficiencyEer: numOrDefault(data.efficiencyEer, 10), hasDehumidify: !!data.hasDehumidify, + canHeat: !!data.canHeat, + heatingCapacityBtu: numOrDefault(data.heatingCapacityBtu, 0), }; let acId; if (data.id) { diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 00bac58..7b83a8a 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -226,6 +226,14 @@ {{t "dashboard.acCapacity"}} + +
{{t "guide.risk.comfortable"}}
+{{t "guide.risk.low"}}
diff --git a/web/templates/setup.html b/web/templates/setup.html index f44850c..7ba3339 100644 --- a/web/templates/setup.html +++ b/web/templates/setup.html @@ -292,6 +292,16 @@ {{t "setup.ac.dehumidify.label"}} ?