Files
HeatGuard/internal/heat/budget_test.go
vikingowl 84d645ff21 feat: fix cold-weather thermal logic, add comfort mode, and dashboard forecast refresh
- Fix ComputeRoomBudget: no-AC rooms check if open-window ventilation
  can offset gains instead of defaulting to Overloaded. Net-cooling
  rooms are now Comfortable; ventilation-solvable rooms are Marginal.
- Add "comfort" cool mode for hours where outdoor is >5°C below indoor
  and budget is not overloaded (winter/cold scenarios).
- Reorder determineCoolMode: sealed now before overloaded, fixing
  humid+cold+no-AC giving "overloaded" instead of "sealed".
- Update LLM prompts: document comfort coolMode, add cold-weather
  guidance for summary and actions generation.
- Add dashboard forecast refresh button: fetches fresh forecast +
  warnings, then re-runs compute and LLM pipelines.
- Extract forecast fetch into shared fetchForecastForProfile() in db.js,
  deduplicating logic between setup.js and dashboard.js.
- Add indoor humidity support, pressure display, and cool mode sealed
  integration test.
2026-02-10 04:26:53 +01:00

199 lines
5.2 KiB
Go

package heat
import (
"testing"
)
func TestComputeRoomBudget(t *testing.T) {
input := BudgetInput{
Devices: []Device{
{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 1.0},
},
DeviceMode: ModeTypical,
Occupants: []Occupant{
{Count: 1, Activity: Sedentary},
},
Solar: SolarParams{
AreaSqm: 15,
WindowFraction: 0.15,
SHGC: 0.6,
ShadingFactor: 1.0,
OrientationFactor: 0.8,
CloudFactor: 0.9,
SunshineFraction: 0.8,
PeakIrradiance: 800,
},
Ventilation: VentilationParams{
ACH: 1.0,
VolumeCubicM: 45,
OutdoorTempC: 35,
IndoorTempC: 25,
RhoCp: 1.2,
},
ACCapacityBTUH: 8000,
}
result := ComputeRoomBudget(input)
// Internal: 200 (device) + 100 (occupant) = 300W
if !almostEqual(result.InternalGainsW, 300, tolerance) {
t.Errorf("InternalGainsW = %v, want 300", result.InternalGainsW)
}
// Solar: 800 * 0.8 * (15*0.15) * 0.6 * 1.0 * 0.9 * 0.8 = 800*0.8*2.25*0.6*0.9*0.8 = 622.08
if !almostEqual(result.SolarGainW, 622.08, 0.1) {
t.Errorf("SolarGainW = %v, want ~622.08", result.SolarGainW)
}
// Ventilation: 1 * 45 * 1200 * 10 / 3600 = 150W
if !almostEqual(result.VentilationGainW, 150, tolerance) {
t.Errorf("VentilationGainW = %v, want 150", result.VentilationGainW)
}
// Total gain = 300 + 622.08 + 150 = 1072.08W
expectedTotal := 300 + 622.08 + 150.0
if !almostEqual(result.TotalGainW, expectedTotal, 0.1) {
t.Errorf("TotalGainW = %v, want %v", result.TotalGainW, expectedTotal)
}
// TotalGainBTUH
expectedBTUH := WattsToBTUH(expectedTotal)
if !almostEqual(result.TotalGainBTUH, expectedBTUH, 1) {
t.Errorf("TotalGainBTUH = %v, want %v", result.TotalGainBTUH, expectedBTUH)
}
// Headroom = 8000 - totalGainBTUH
expectedHeadroom := 8000 - expectedBTUH
if !almostEqual(result.HeadroomBTUH, expectedHeadroom, 1) {
t.Errorf("HeadroomBTUH = %v, want %v", result.HeadroomBTUH, expectedHeadroom)
}
// Status should be comfortable (headroom > 20% of 8000 = 1600)
if result.Status != Comfortable {
t.Errorf("Status = %v, want Comfortable", result.Status)
}
}
func TestBudgetStatus(t *testing.T) {
tests := []struct {
name string
totalGainW float64
acBTUH float64
want BudgetStatus
}{
{
name: "comfortable: headroom > 20% of AC",
totalGainW: 500,
acBTUH: 8000,
want: Comfortable,
},
{
name: "marginal: headroom 0-20% of AC",
totalGainW: 2000,
acBTUH: 8000,
want: Marginal,
},
{
name: "overloaded: negative headroom",
totalGainW: 3000,
acBTUH: 8000,
want: Overloaded,
},
{
name: "no AC at all, hot outdoor",
totalGainW: 500,
acBTUH: 0,
want: Overloaded,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := BudgetInput{
Devices: nil,
DeviceMode: ModeIdle,
Occupants: nil,
Solar: SolarParams{},
Ventilation: VentilationParams{RhoCp: 1.2, OutdoorTempC: 30, IndoorTempC: 25, VolumeCubicM: 50},
ACCapacityBTUH: tt.acBTUH,
}
// Manually set gains via devices to control the total
input.Devices = []Device{
{WattsIdle: tt.totalGainW, WattsTypical: tt.totalGainW, WattsPeak: tt.totalGainW, DutyCycle: 1.0},
}
result := ComputeRoomBudget(input)
if result.Status != tt.want {
t.Errorf("Status = %v, want %v (headroom=%v)", result.Status, tt.want, result.HeadroomBTUH)
}
})
}
}
func TestBudgetStatus_NoACVentilation(t *testing.T) {
tests := []struct {
name string
devices []Device
solar SolarParams
vent VentilationParams
acBTUH float64
want BudgetStatus
}{
{
name: "no AC, net cooling via ventilation",
devices: nil,
vent: VentilationParams{
ACH: 1.0, VolumeCubicM: 45, OutdoorTempC: 10, IndoorTempC: 25, RhoCp: 1.2,
},
acBTUH: 0,
want: Comfortable,
},
{
name: "no AC, cold outdoor, ventilation can solve gains",
devices: []Device{
{WattsIdle: 500, WattsTypical: 500, WattsPeak: 500, DutyCycle: 1.0},
},
vent: VentilationParams{
ACH: 0.5, VolumeCubicM: 50, OutdoorTempC: -4, IndoorTempC: 23, RhoCp: 1.2,
},
acBTUH: 0,
want: Marginal,
},
{
name: "no AC, hot outdoor, delta >= 0",
devices: []Device{
{WattsIdle: 500, WattsTypical: 500, WattsPeak: 500, DutyCycle: 1.0},
},
vent: VentilationParams{
ACH: 0.5, VolumeCubicM: 50, OutdoorTempC: 30, IndoorTempC: 25, RhoCp: 1.2,
},
acBTUH: 0,
want: Overloaded,
},
{
name: "no AC, warm outdoor, vent cannot solve massive gains",
devices: []Device{
{WattsIdle: 3000, WattsTypical: 3000, WattsPeak: 3000, DutyCycle: 1.0},
},
vent: VentilationParams{
ACH: 0.5, VolumeCubicM: 50, OutdoorTempC: 20, IndoorTempC: 25, RhoCp: 1.2,
},
acBTUH: 0,
want: Overloaded,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ComputeRoomBudget(BudgetInput{
Devices: tt.devices,
DeviceMode: ModeIdle,
Solar: tt.solar,
Ventilation: tt.vent,
ACCapacityBTUH: tt.acBTUH,
})
if result.Status != tt.want {
t.Errorf("Status = %v, want %v (totalW=%.1f, headroom=%.1f)",
result.Status, tt.want, result.TotalGainW, result.HeadroomBTUH)
}
})
}
}