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:
@@ -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])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
80
web/js/db.js
80
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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"}}",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user