- 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.
199 lines
5.2 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|