Files
HeatGuard/internal/risk/analyzer.go
vikingowl 21154d5d7f 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.
2026-02-11 00:00:43 +01:00

178 lines
3.8 KiB
Go

package risk
import "math"
// RiskLevel represents the severity of heat risk.
type RiskLevel int
const (
Low RiskLevel = iota
Moderate
High
Extreme
)
func (r RiskLevel) String() string {
switch r {
case Low:
return "low"
case Moderate:
return "moderate"
case High:
return "high"
case Extreme:
return "extreme"
default:
return "unknown"
}
}
// HourlyData holds weather data for a single hour.
type HourlyData struct {
Hour int
TempC float64
ApparentC float64
HumidityPct float64
IsDay bool
}
// RiskWindow represents a contiguous block of hours with elevated heat risk.
type RiskWindow struct {
StartHour int
EndHour int
PeakTempC float64
Level RiskLevel
Reason string
}
// DayRisk holds the overall risk assessment for a day.
type DayRisk struct {
Level RiskLevel
PeakTempC float64
MinNightTempC float64
PoorNightCool bool
MinTempC float64
ColdRisk bool
Windows []RiskWindow
}
// isNightHour returns true for hours 21-23 and 0-6.
func isNightHour(hour int) bool {
return hour >= 21 || hour <= 6
}
// riskLevelForTemp returns the risk level based on temperature and thresholds.
func riskLevelForTemp(tempC float64, th Thresholds) RiskLevel {
switch {
case tempC >= th.ExtremeDayC:
return Extreme
case tempC >= th.VeryHotDayC:
return High
case tempC >= th.HotDayC:
return Moderate
default:
return Low
}
}
// 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), MinTempC: math.Inf(1)}
}
result := DayRisk{
Level: Low,
MinNightTempC: math.Inf(1),
MinTempC: math.Inf(1),
}
// 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
}
if h.TempC >= th.PoorNightCoolingC {
result.PoorNightCool = true
}
}
}
// If no night hours were seen, set MinNightTempC to 0
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
for _, h := range hours {
level := riskLevelForTemp(h.TempC, th)
if level >= Moderate {
if currentWindow == nil {
currentWindow = &RiskWindow{
StartHour: h.Hour,
EndHour: h.Hour,
PeakTempC: h.TempC,
Level: level,
}
} else {
currentWindow.EndHour = h.Hour
if h.TempC > currentWindow.PeakTempC {
currentWindow.PeakTempC = h.TempC
}
if level > currentWindow.Level {
currentWindow.Level = level
}
}
} else {
if currentWindow != nil {
currentWindow.Reason = reasonForLevel(currentWindow.Level)
result.Windows = append(result.Windows, *currentWindow)
currentWindow = nil
}
}
}
if currentWindow != nil {
currentWindow.Reason = reasonForLevel(currentWindow.Level)
result.Windows = append(result.Windows, *currentWindow)
}
// Overall level = max of all windows
for _, w := range result.Windows {
if w.Level > result.Level {
result.Level = w.Level
}
}
// Poor night cooling elevates by one level (capped at Extreme)
if result.PoorNightCool && result.Level > Low && result.Level < Extreme {
result.Level++
}
return result
}
func reasonForLevel(level RiskLevel) string {
switch level {
case Moderate:
return "hot daytime temperatures"
case High:
return "very hot daytime temperatures"
case Extreme:
return "extreme heat"
default:
return ""
}
}