feat: add heating support with heat pump modeling and cold risk detection

Model heating mode when rooms have net heat loss in cold weather (<10°C).
AC units with heat pump capability (canHeat) provide heating capacity,
with the same 20% headroom threshold used for cooling. Adds cold risk
detection, cold-weather actions, and full frontend support including
heating mode timeline colors, room budget heating display, and i18n.
This commit is contained in:
2026-02-11 00:00:43 +01:00
parent 94798631bc
commit 21154d5d7f
22 changed files with 1087 additions and 118 deletions

View File

@@ -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])

View File

@@ -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
}

View File

@@ -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},

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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"`
}

View File

@@ -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 020% of AC capacity
Overloaded // headroom < 0 (AC can't keep up)
Comfortable BudgetStatus = iota // headroom > 20% of capacity
Marginal // headroom 020% 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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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) {

View File

@@ -226,6 +226,14 @@
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-blue-400"></span>{{t "dashboard.acCapacity"}}</span>
</div>
</div>
<!-- Heating mode section (hidden by default, shown by JS) -->
<div class="hidden mt-2 pt-2 border-t border-gray-200 dark:border-gray-700 space-y-0.5 text-xs" data-slot="heating-section">
<div class="flex justify-between"><span>{{t "dashboard.heatDeficit"}}</span><span data-slot="heat-deficit"></span></div>
<div class="flex justify-between"><span>{{t "dashboard.heatingCapacity"}}</span><span data-slot="heating-capacity"></span></div>
<div class="flex justify-between font-medium"><span>{{t "dashboard.heatingHeadroom"}}</span><span data-slot="heating-headroom-value"></span></div>
<div class="text-xs mt-0.5 text-green-600 dark:text-green-400" data-slot="heating-headroom-ok">{{t "dashboard.heatingHeadroomOk"}}</div>
<div class="text-xs mt-0.5 text-red-600 dark:text-red-400 hidden" data-slot="heating-headroom-bad" data-label="{{t "dashboard.heatingHeadroomInsufficient"}}"></div>
</div>
</div>
</template>
@@ -275,6 +283,8 @@
coolAC: "{{t "dashboard.coolAC"}}",
coolOverloaded: "{{t "dashboard.coolOverloaded"}}",
coolSealed: "{{t "dashboard.coolSealed"}}",
coolHeating: "{{t "dashboard.coolHeating"}}",
coolHeatInsufficient: "{{t "dashboard.coolHeatInsufficient"}}",
aiDisclaimer: "{{t "dashboard.aiDisclaimer"}}",
aiActions: "{{t "dashboard.aiActions"}}",
legendTemp: "{{t "dashboard.legendTemp"}}",

View File

@@ -53,6 +53,10 @@
<div class="space-y-6">
<h2 class="text-xl font-bold">{{t "guide.risk.title"}}</h2>
<div class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm space-y-3">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-teal-500"></span>
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.risk.comfortable"}}</p>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-green-500"></span>
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.risk.low"}}</p>

View File

@@ -292,6 +292,16 @@
{{t "setup.ac.dehumidify.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.dehumidify.tooltip"}}">?</span>
</label>
</div>
<div class="flex items-end pb-1">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="canHeat" class="rounded" id="ac-can-heat">
{{t "setup.ac.canHeat.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.canHeat.tooltip"}}">?</span>
</label>
</div>
<div>
<label class="block text-sm font-medium mb-1">{{t "setup.ac.heatingCapacity.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.heatingCapacity.tooltip"}}">?</span></label>
<input type="number" name="heatingCapacityBtu" step="100" value="0" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">{{t "setup.ac.rooms.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.rooms.tooltip"}}">?</span></label>