Add server-side rendered setup UI accessible via `heatwave web`. The dashboard is now re-rendered per request and includes a nav bar linking to the new /setup page. Setup provides full CRUD for profiles, rooms, devices, occupants, AC units (with room assignment), scenario toggles, and forecast fetching — all via POST/redirect/GET forms. - Add ShowNav field to DashboardData for conditional nav bar - Extract fetchForecastForProfile() for reuse by web handler - Create setup.html.tmpl with Tailwind-styled entity sections - Create web_handlers.go with 15 route handlers and flash cookies - Switch web.go from pre-rendered to per-request dashboard rendering - Graceful dashboard fallback when no forecast data exists
168 lines
3.5 KiB
Go
168 lines
3.5 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
|
|
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)}
|
|
}
|
|
|
|
result := DayRisk{
|
|
Level: Low,
|
|
MinNightTempC: math.Inf(1),
|
|
}
|
|
|
|
// Find peak temp and min night temp
|
|
for _, h := range hours {
|
|
if h.TempC > result.PeakTempC {
|
|
result.PeakTempC = 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
|
|
}
|
|
|
|
// 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 ""
|
|
}
|
|
}
|