feat: add web UI with full CRUD setup page
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
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
bin/
|
||||
node_modules/
|
||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -0,0 +1,16 @@
|
||||
.PHONY: build test clean css
|
||||
|
||||
BINARY := heatwave
|
||||
BUILD_DIR := bin
|
||||
|
||||
build: css
|
||||
go build -o $(BUILD_DIR)/$(BINARY) ./cmd/heatwave
|
||||
|
||||
test:
|
||||
go test -race ./...
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
css:
|
||||
npx @tailwindcss/cli -i tailwind/input.css -o internal/static/tailwind.css --minify
|
||||
13
cmd/heatwave/main.go
Normal file
13
cmd/heatwave/main.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cli.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
24
go.mod
Normal file
24
go.mod
Normal file
@@ -0,0 +1,24 @@
|
||||
module github.com/cnachtigall/heatwave-autopilot
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.44.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
66
go.sum
Normal file
66
go.sum
Normal file
@@ -0,0 +1,66 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
68
internal/action/action.go
Normal file
68
internal/action/action.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
|
||||
)
|
||||
|
||||
type Category string
|
||||
|
||||
const (
|
||||
Shading Category = "shading"
|
||||
Ventilation Category = "ventilation"
|
||||
InternalGains Category = "internal_gains"
|
||||
ACStrategy Category = "ac_strategy"
|
||||
Hydration Category = "hydration"
|
||||
Care Category = "care"
|
||||
)
|
||||
|
||||
type Effort string
|
||||
|
||||
const (
|
||||
EffortNone Effort = "none"
|
||||
EffortLow Effort = "low"
|
||||
EffortMedium Effort = "medium"
|
||||
EffortHigh Effort = "high"
|
||||
)
|
||||
|
||||
type Impact string
|
||||
|
||||
const (
|
||||
ImpactLow Impact = "low"
|
||||
ImpactMedium Impact = "medium"
|
||||
ImpactHigh Impact = "high"
|
||||
)
|
||||
|
||||
type TimeCondition struct {
|
||||
HourFrom int `yaml:"hour_from"`
|
||||
HourTo int `yaml:"hour_to"`
|
||||
MinTempC float64 `yaml:"min_temp_c"`
|
||||
MaxTempC float64 `yaml:"max_temp_c"`
|
||||
MinRisk string `yaml:"min_risk"`
|
||||
BudgetStatus string `yaml:"budget_status"`
|
||||
NightOnly bool `yaml:"night_only"`
|
||||
HighHumidity bool `yaml:"high_humidity"`
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Category Category `yaml:"category"`
|
||||
Effort Effort `yaml:"effort"`
|
||||
Impact Impact `yaml:"impact"`
|
||||
When TimeCondition `yaml:"when"`
|
||||
DependsOn []string `yaml:"depends_on"`
|
||||
Toggles []string `yaml:"toggles"`
|
||||
}
|
||||
|
||||
// HourContext holds the context for a specific hour used for action matching.
|
||||
type HourContext struct {
|
||||
Hour int
|
||||
TempC float64
|
||||
HumidityPct float64
|
||||
IsDay bool
|
||||
RiskLevel risk.RiskLevel
|
||||
BudgetStatus heat.BudgetStatus
|
||||
ActiveToggles map[string]bool
|
||||
}
|
||||
23
internal/action/library.go
Normal file
23
internal/action/library.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed templates/actions.yaml
|
||||
var defaultActionsYAML []byte
|
||||
|
||||
type actionFile struct {
|
||||
Actions []Action `yaml:"actions"`
|
||||
}
|
||||
|
||||
// LoadDefaultActions loads the embedded default action templates.
|
||||
func LoadDefaultActions() ([]Action, error) {
|
||||
var f actionFile
|
||||
if err := yaml.Unmarshal(defaultActionsYAML, &f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.Actions, nil
|
||||
}
|
||||
41
internal/action/library_test.go
Normal file
41
internal/action/library_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package action
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadDefaultActions(t *testing.T) {
|
||||
actions, err := LoadDefaultActions()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDefaultActions: %v", err)
|
||||
}
|
||||
if len(actions) != 10 {
|
||||
t.Errorf("len = %d, want 10", len(actions))
|
||||
}
|
||||
for _, a := range actions {
|
||||
if a.ID == "" {
|
||||
t.Error("action has empty ID")
|
||||
}
|
||||
if a.Name == "" {
|
||||
t.Errorf("action %s has empty Name", a.ID)
|
||||
}
|
||||
if a.Category == "" {
|
||||
t.Errorf("action %s has empty Category", a.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultActions_Categories(t *testing.T) {
|
||||
actions, _ := LoadDefaultActions()
|
||||
categories := make(map[Category]int)
|
||||
for _, a := range actions {
|
||||
categories[a.Category]++
|
||||
}
|
||||
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[Care] != 1 {
|
||||
t.Errorf("Care actions = %d, want 1", categories[Care])
|
||||
}
|
||||
}
|
||||
127
internal/action/selector.go
Normal file
127
internal/action/selector.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
|
||||
)
|
||||
|
||||
// Matches checks if an action's conditions are met for a given hour context.
|
||||
func Matches(a Action, ctx HourContext) bool {
|
||||
w := a.When
|
||||
|
||||
// Hour range check (only if at least one is non-zero)
|
||||
if w.HourFrom != 0 || w.HourTo != 0 {
|
||||
if ctx.Hour < w.HourFrom || ctx.Hour > w.HourTo {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Temperature threshold
|
||||
if w.MinTempC > 0 && ctx.TempC < w.MinTempC {
|
||||
return false
|
||||
}
|
||||
if w.MaxTempC > 0 && ctx.TempC > w.MaxTempC {
|
||||
return false
|
||||
}
|
||||
|
||||
// Night only
|
||||
if w.NightOnly && ctx.IsDay {
|
||||
return false
|
||||
}
|
||||
|
||||
// Risk level
|
||||
if w.MinRisk != "" {
|
||||
required := parseRiskLevel(w.MinRisk)
|
||||
if ctx.RiskLevel < required {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Budget status
|
||||
if w.BudgetStatus != "" {
|
||||
required := parseBudgetStatus(w.BudgetStatus)
|
||||
if ctx.BudgetStatus < required {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// High humidity
|
||||
if w.HighHumidity && ctx.HumidityPct <= 70 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// SelectActions returns all matching actions for a given hour, sorted by priority.
|
||||
func SelectActions(actions []Action, ctx HourContext) []Action {
|
||||
var matched []Action
|
||||
for _, a := range actions {
|
||||
if Matches(a, ctx) {
|
||||
matched = append(matched, a)
|
||||
}
|
||||
}
|
||||
sort.Slice(matched, func(i, j int) bool {
|
||||
return priority(matched[i]) > priority(matched[j])
|
||||
})
|
||||
return matched
|
||||
}
|
||||
|
||||
// priority scores an action: impact * 10 + (4 - effort)
|
||||
func priority(a Action) int {
|
||||
return impactScore(a.Impact)*10 + (4 - effortScore(a.Effort))
|
||||
}
|
||||
|
||||
func impactScore(i Impact) int {
|
||||
switch i {
|
||||
case ImpactHigh:
|
||||
return 3
|
||||
case ImpactMedium:
|
||||
return 2
|
||||
case ImpactLow:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func effortScore(e Effort) int {
|
||||
switch e {
|
||||
case EffortNone:
|
||||
return 0
|
||||
case EffortLow:
|
||||
return 1
|
||||
case EffortMedium:
|
||||
return 2
|
||||
case EffortHigh:
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func parseRiskLevel(s string) risk.RiskLevel {
|
||||
switch s {
|
||||
case "moderate":
|
||||
return risk.Moderate
|
||||
case "high":
|
||||
return risk.High
|
||||
case "extreme":
|
||||
return risk.Extreme
|
||||
default:
|
||||
return risk.Low
|
||||
}
|
||||
}
|
||||
|
||||
func parseBudgetStatus(s string) heat.BudgetStatus {
|
||||
switch s {
|
||||
case "marginal":
|
||||
return heat.Marginal
|
||||
case "overloaded":
|
||||
return heat.Overloaded
|
||||
default:
|
||||
return heat.Comfortable
|
||||
}
|
||||
}
|
||||
100
internal/action/selector_test.go
Normal file
100
internal/action/selector_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
|
||||
)
|
||||
|
||||
func TestMatches_TempThreshold(t *testing.T) {
|
||||
a := Action{When: TimeCondition{MinTempC: 30}}
|
||||
if Matches(a, HourContext{TempC: 35}) != true {
|
||||
t.Error("should match at 35C")
|
||||
}
|
||||
if Matches(a, HourContext{TempC: 25}) != false {
|
||||
t.Error("should not match at 25C")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatches_HourRange(t *testing.T) {
|
||||
a := Action{When: TimeCondition{HourFrom: 6, HourTo: 9}}
|
||||
if Matches(a, HourContext{Hour: 7}) != true {
|
||||
t.Error("should match at hour 7")
|
||||
}
|
||||
if Matches(a, HourContext{Hour: 12}) != false {
|
||||
t.Error("should not match at hour 12")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatches_NightOnly(t *testing.T) {
|
||||
a := Action{When: TimeCondition{NightOnly: true}}
|
||||
if Matches(a, HourContext{IsDay: false}) != true {
|
||||
t.Error("should match at night")
|
||||
}
|
||||
if Matches(a, HourContext{IsDay: true}) != false {
|
||||
t.Error("should not match during day")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatches_MinRisk(t *testing.T) {
|
||||
a := Action{When: TimeCondition{MinRisk: "high"}}
|
||||
if Matches(a, HourContext{RiskLevel: risk.High}) != true {
|
||||
t.Error("should match High")
|
||||
}
|
||||
if Matches(a, HourContext{RiskLevel: risk.Extreme}) != true {
|
||||
t.Error("should match Extreme")
|
||||
}
|
||||
if Matches(a, HourContext{RiskLevel: risk.Moderate}) != false {
|
||||
t.Error("should not match Moderate")
|
||||
}
|
||||
if Matches(a, HourContext{RiskLevel: risk.Low}) != false {
|
||||
t.Error("should not match Low")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatches_BudgetStatus(t *testing.T) {
|
||||
a := Action{When: TimeCondition{BudgetStatus: "marginal"}}
|
||||
if Matches(a, HourContext{BudgetStatus: heat.Marginal}) != true {
|
||||
t.Error("should match Marginal")
|
||||
}
|
||||
if Matches(a, HourContext{BudgetStatus: heat.Overloaded}) != true {
|
||||
t.Error("should match Overloaded")
|
||||
}
|
||||
if Matches(a, HourContext{BudgetStatus: heat.Comfortable}) != false {
|
||||
t.Error("should not match Comfortable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatches_HighHumidity(t *testing.T) {
|
||||
a := Action{When: TimeCondition{HighHumidity: true, MinTempC: 26}}
|
||||
if Matches(a, HourContext{HumidityPct: 80, TempC: 30}) != true {
|
||||
t.Error("should match at 80% humidity")
|
||||
}
|
||||
if Matches(a, HourContext{HumidityPct: 50, TempC: 30}) != false {
|
||||
t.Error("should not match at 50% humidity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectActions_SortedByPriority(t *testing.T) {
|
||||
actions := []Action{
|
||||
{ID: "low_impact_high_effort", Impact: ImpactLow, Effort: EffortHigh},
|
||||
{ID: "high_impact_no_effort", Impact: ImpactHigh, Effort: EffortNone},
|
||||
{ID: "med_impact_low_effort", Impact: ImpactMedium, Effort: EffortLow},
|
||||
}
|
||||
ctx := HourContext{TempC: 35, Hour: 12, IsDay: true}
|
||||
|
||||
result := SelectActions(actions, ctx)
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(result))
|
||||
}
|
||||
if result[0].ID != "high_impact_no_effort" {
|
||||
t.Errorf("first = %s, want high_impact_no_effort", result[0].ID)
|
||||
}
|
||||
if result[1].ID != "med_impact_low_effort" {
|
||||
t.Errorf("second = %s, want med_impact_low_effort", result[1].ID)
|
||||
}
|
||||
if result[2].ID != "low_impact_high_effort" {
|
||||
t.Errorf("third = %s, want low_impact_high_effort", result[2].ID)
|
||||
}
|
||||
}
|
||||
97
internal/action/templates/actions.yaml
Normal file
97
internal/action/templates/actions.yaml
Normal file
@@ -0,0 +1,97 @@
|
||||
actions:
|
||||
- id: close_south_shutters
|
||||
name: "Close south-facing shutters"
|
||||
description: "Close shutters/blinds on south-facing windows to block direct sun"
|
||||
category: shading
|
||||
effort: low
|
||||
impact: high
|
||||
when:
|
||||
hour_from: 0
|
||||
hour_to: 10
|
||||
min_temp_c: 25
|
||||
- id: close_west_shutters
|
||||
name: "Close west-facing shutters"
|
||||
description: "Close shutters/blinds on west-facing windows before afternoon sun"
|
||||
category: shading
|
||||
effort: low
|
||||
impact: high
|
||||
when:
|
||||
hour_from: 0
|
||||
hour_to: 14
|
||||
min_temp_c: 25
|
||||
- id: night_ventilation
|
||||
name: "Open windows for night ventilation"
|
||||
description: "Open windows when outdoor temp drops below indoor temp for passive cooling"
|
||||
category: ventilation
|
||||
effort: low
|
||||
impact: high
|
||||
when:
|
||||
night_only: true
|
||||
- id: close_windows_morning
|
||||
name: "Close windows in the morning"
|
||||
description: "Seal the house before outdoor temps rise above indoor"
|
||||
category: ventilation
|
||||
effort: none
|
||||
impact: high
|
||||
when:
|
||||
hour_from: 7
|
||||
hour_to: 8
|
||||
min_temp_c: 22
|
||||
- id: gaming_mode_off
|
||||
name: "Postpone gaming sessions"
|
||||
description: "Gaming PCs add significant heat; postpone to cooler hours"
|
||||
category: internal_gains
|
||||
effort: medium
|
||||
impact: medium
|
||||
when:
|
||||
min_temp_c: 30
|
||||
budget_status: marginal
|
||||
toggles: ["gaming"]
|
||||
- id: defer_cooking
|
||||
name: "Defer cooking to evening"
|
||||
description: "Oven and stove add substantial heat; cook in cooler evening hours"
|
||||
category: internal_gains
|
||||
effort: medium
|
||||
impact: medium
|
||||
when:
|
||||
hour_from: 11
|
||||
hour_to: 16
|
||||
min_temp_c: 30
|
||||
toggles: ["cooking"]
|
||||
- id: ac_precool
|
||||
name: "Pre-cool rooms with AC"
|
||||
description: "Run AC in early morning to build thermal buffer before peak heat"
|
||||
category: ac_strategy
|
||||
effort: none
|
||||
impact: high
|
||||
when:
|
||||
hour_from: 6
|
||||
hour_to: 9
|
||||
min_risk: high
|
||||
- id: ac_dehumidify
|
||||
name: "Use AC dehumidify mode"
|
||||
description: "Switch AC to dehumidify mode when humidity is high"
|
||||
category: ac_strategy
|
||||
effort: none
|
||||
impact: medium
|
||||
when:
|
||||
min_temp_c: 26
|
||||
high_humidity: true
|
||||
- id: hydration_reminder
|
||||
name: "Hydration reminder"
|
||||
description: "Drink water regularly; increase intake during heat"
|
||||
category: hydration
|
||||
effort: none
|
||||
impact: medium
|
||||
when:
|
||||
min_temp_c: 30
|
||||
- id: check_vulnerable
|
||||
name: "Check on vulnerable occupants"
|
||||
description: "Check on elderly, children, or ill household members"
|
||||
category: care
|
||||
effort: low
|
||||
impact: high
|
||||
when:
|
||||
min_temp_c: 30
|
||||
hour_from: 10
|
||||
hour_to: 18
|
||||
30
internal/action/timeline.go
Normal file
30
internal/action/timeline.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
|
||||
)
|
||||
|
||||
// TimelineSlot represents one hour in a 24-hour plan.
|
||||
type TimelineSlot struct {
|
||||
Hour int
|
||||
Actions []Action
|
||||
TempC float64
|
||||
RiskLevel risk.RiskLevel
|
||||
BudgetStatus heat.BudgetStatus
|
||||
}
|
||||
|
||||
// BuildTimeline creates a 24-slot timeline from hourly contexts and available actions.
|
||||
func BuildTimeline(contexts []HourContext, actions []Action) []TimelineSlot {
|
||||
slots := make([]TimelineSlot, len(contexts))
|
||||
for i, ctx := range contexts {
|
||||
slots[i] = TimelineSlot{
|
||||
Hour: ctx.Hour,
|
||||
TempC: ctx.TempC,
|
||||
RiskLevel: ctx.RiskLevel,
|
||||
BudgetStatus: ctx.BudgetStatus,
|
||||
Actions: SelectActions(actions, ctx),
|
||||
}
|
||||
}
|
||||
return slots
|
||||
}
|
||||
50
internal/action/timeline_test.go
Normal file
50
internal/action/timeline_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
|
||||
)
|
||||
|
||||
func TestBuildTimeline_24Slots(t *testing.T) {
|
||||
contexts := make([]HourContext, 24)
|
||||
for i := range contexts {
|
||||
contexts[i] = HourContext{
|
||||
Hour: i,
|
||||
TempC: 20 + float64(i),
|
||||
RiskLevel: risk.Low,
|
||||
BudgetStatus: heat.Comfortable,
|
||||
IsDay: i >= 6 && i < 21,
|
||||
}
|
||||
}
|
||||
|
||||
actions := []Action{
|
||||
{ID: "test_action", When: TimeCondition{MinTempC: 30}, Impact: ImpactHigh, Effort: EffortNone},
|
||||
}
|
||||
|
||||
slots := BuildTimeline(contexts, actions)
|
||||
if len(slots) != 24 {
|
||||
t.Fatalf("slots = %d, want 24", len(slots))
|
||||
}
|
||||
|
||||
// Check hour assignment
|
||||
for i, s := range slots {
|
||||
if s.Hour != i {
|
||||
t.Errorf("slot[%d].Hour = %d, want %d", i, s.Hour, i)
|
||||
}
|
||||
if s.TempC != 20+float64(i) {
|
||||
t.Errorf("slot[%d].TempC = %v, want %v", i, s.TempC, 20+float64(i))
|
||||
}
|
||||
}
|
||||
|
||||
// test_action matches hours with temp >= 30 (hours 10-23)
|
||||
for i, s := range slots {
|
||||
if i >= 10 && len(s.Actions) == 0 {
|
||||
t.Errorf("slot[%d] should have actions (temp=%v)", i, s.TempC)
|
||||
}
|
||||
if i < 10 && len(s.Actions) > 0 {
|
||||
t.Errorf("slot[%d] should not have actions (temp=%v)", i, s.TempC)
|
||||
}
|
||||
}
|
||||
}
|
||||
110
internal/cli/ac.go
Normal file
110
internal/cli/ac.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
acType string
|
||||
acBTU float64
|
||||
acRooms []int64
|
||||
acDehumidify bool
|
||||
acEER float64
|
||||
)
|
||||
|
||||
func init() {
|
||||
acCmd := &cobra.Command{
|
||||
Use: "ac",
|
||||
Short: "Manage AC units",
|
||||
}
|
||||
|
||||
addCmd := &cobra.Command{
|
||||
Use: "add <name>",
|
||||
Short: "Add an AC unit",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ac, err := db.CreateACUnit(p.ID, args[0], acType, acBTU, acDehumidify, acEER)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, roomID := range acRooms {
|
||||
if err := db.AssignACToRoom(ac.ID, roomID); err != nil {
|
||||
return fmt.Errorf("assign room %d: %w", roomID, err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("AC unit added: %s (ID: %d, %.0f BTU/h, type: %s)\n", ac.Name, ac.ID, ac.CapacityBTU, ac.ACType)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
addCmd.Flags().StringVar(&acType, "type", "portable", "AC type (portable, split, window, central)")
|
||||
addCmd.Flags().Float64Var(&acBTU, "btu", 0, "cooling capacity in BTU/h")
|
||||
addCmd.Flags().Int64SliceVar(&acRooms, "rooms", nil, "room IDs to assign")
|
||||
addCmd.Flags().BoolVar(&acDehumidify, "dehumidify", false, "has dehumidify mode")
|
||||
addCmd.Flags().Float64Var(&acEER, "eer", 10.0, "energy efficiency ratio")
|
||||
addCmd.MarkFlagRequired("btu")
|
||||
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List AC units",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
units, err := db.ListACUnits(p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(units) == 0 {
|
||||
fmt.Println("No AC units found")
|
||||
return nil
|
||||
}
|
||||
for _, u := range units {
|
||||
rooms, _ := db.GetACRoomAssignments(u.ID)
|
||||
dehumid := ""
|
||||
if u.HasDehumidify {
|
||||
dehumid = " +dehumidify"
|
||||
}
|
||||
fmt.Printf(" [%d] %s — %.0f BTU/h, type: %s, EER: %.1f%s, rooms: %v\n",
|
||||
u.ID, u.Name, u.CapacityBTU, u.ACType, u.EfficiencyEER, dehumid, rooms)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
editCmd := &cobra.Command{
|
||||
Use: "edit <id> <field> <value>",
|
||||
Short: "Edit an AC unit field",
|
||||
Args: cobra.ExactArgs(3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid AC unit ID: %s", args[0])
|
||||
}
|
||||
return db.UpdateACUnit(id, args[1], args[2])
|
||||
},
|
||||
}
|
||||
|
||||
removeCmd := &cobra.Command{
|
||||
Use: "remove <id>",
|
||||
Short: "Remove an AC unit",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid AC unit ID: %s", args[0])
|
||||
}
|
||||
return db.DeleteACUnit(id)
|
||||
},
|
||||
}
|
||||
|
||||
acCmd.AddCommand(addCmd, listCmd, editCmd, removeCmd)
|
||||
rootCmd.AddCommand(acCmd)
|
||||
}
|
||||
120
internal/cli/budget.go
Normal file
120
internal/cli/budget.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/store"
|
||||
)
|
||||
|
||||
// hourWeather holds weather data for a single hour, used for budget computation.
|
||||
type hourWeather struct {
|
||||
Hour int
|
||||
TempC float64
|
||||
CloudCoverPct float64
|
||||
SunshineMin float64
|
||||
}
|
||||
|
||||
// roomBudgetResult holds the budget result for a single room in a single hour.
|
||||
type roomBudgetResult struct {
|
||||
RoomName string
|
||||
RoomID int64
|
||||
Result heat.BudgetResult
|
||||
}
|
||||
|
||||
// computeRoomBudgets computes heat budgets for all rooms in a profile for a given hour.
|
||||
// It returns per-room results and the worst-case BudgetStatus.
|
||||
func computeRoomBudgets(profileID int64, w hourWeather, toggles map[string]bool, indoorTempC float64) ([]roomBudgetResult, heat.BudgetStatus) {
|
||||
rooms, err := db.ListRooms(profileID)
|
||||
if err != nil || len(rooms) == 0 {
|
||||
return nil, heat.Comfortable
|
||||
}
|
||||
|
||||
var results []roomBudgetResult
|
||||
worstStatus := heat.Comfortable
|
||||
|
||||
for _, room := range rooms {
|
||||
budget := computeSingleRoomBudget(room, w, toggles, indoorTempC)
|
||||
results = append(results, roomBudgetResult{
|
||||
RoomName: room.Name,
|
||||
RoomID: room.ID,
|
||||
Result: budget,
|
||||
})
|
||||
if budget.Status > worstStatus {
|
||||
worstStatus = budget.Status
|
||||
}
|
||||
}
|
||||
|
||||
return results, worstStatus
|
||||
}
|
||||
|
||||
func computeSingleRoomBudget(room store.Room, w hourWeather, toggles map[string]bool, indoorTempC float64) heat.BudgetResult {
|
||||
// Devices
|
||||
devices, _ := db.ListDevices(room.ID)
|
||||
var heatDevices []heat.Device
|
||||
for _, d := range devices {
|
||||
heatDevices = append(heatDevices, heat.Device{
|
||||
WattsIdle: d.WattsIdle,
|
||||
WattsTypical: d.WattsTypical,
|
||||
WattsPeak: d.WattsPeak,
|
||||
DutyCycle: d.DutyCycle,
|
||||
})
|
||||
}
|
||||
|
||||
// Determine device mode from toggles
|
||||
mode := heat.ModeTypical
|
||||
if toggles["gaming"] {
|
||||
mode = heat.ModePeak
|
||||
}
|
||||
|
||||
// Occupants
|
||||
occupants, _ := db.ListOccupants(room.ID)
|
||||
var heatOccupants []heat.Occupant
|
||||
for _, o := range occupants {
|
||||
heatOccupants = append(heatOccupants, heat.Occupant{
|
||||
Count: o.Count,
|
||||
Activity: heat.ParseActivityLevel(o.ActivityLevel),
|
||||
})
|
||||
}
|
||||
|
||||
// AC capacity
|
||||
acCap, _ := db.GetRoomACCapacity(room.ID)
|
||||
|
||||
// Solar params
|
||||
cloudFactor := 1.0 - (w.CloudCoverPct / 100.0)
|
||||
sunshineFraction := 0.0
|
||||
if w.SunshineMin > 0 {
|
||||
sunshineFraction = w.SunshineMin / 60.0
|
||||
if sunshineFraction > 1.0 {
|
||||
sunshineFraction = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
solar := heat.SolarParams{
|
||||
AreaSqm: room.AreaSqm,
|
||||
WindowFraction: room.WindowFraction,
|
||||
SHGC: room.SHGC,
|
||||
ShadingFactor: room.ShadingFactor,
|
||||
OrientationFactor: heat.OrientationFactor(room.Orientation, w.Hour),
|
||||
CloudFactor: cloudFactor,
|
||||
SunshineFraction: sunshineFraction,
|
||||
PeakIrradiance: 800, // W/m² typical clear-sky peak
|
||||
}
|
||||
|
||||
// Ventilation params
|
||||
volume := room.AreaSqm * room.CeilingHeightM
|
||||
vent := heat.VentilationParams{
|
||||
ACH: room.VentilationACH,
|
||||
VolumeCubicM: volume,
|
||||
OutdoorTempC: w.TempC,
|
||||
IndoorTempC: indoorTempC,
|
||||
RhoCp: 1.2, // kJ/(m³·K) — standard air at sea level
|
||||
}
|
||||
|
||||
return heat.ComputeRoomBudget(heat.BudgetInput{
|
||||
Devices: heatDevices,
|
||||
DeviceMode: mode,
|
||||
Occupants: heatOccupants,
|
||||
Solar: solar,
|
||||
Ventilation: vent,
|
||||
ACCapacityBTUH: acCap,
|
||||
})
|
||||
}
|
||||
106
internal/cli/device.go
Normal file
106
internal/cli/device.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
deviceRoom int64
|
||||
deviceType string
|
||||
deviceIdle float64
|
||||
deviceTypical float64
|
||||
devicePeak float64
|
||||
deviceDuty float64
|
||||
)
|
||||
|
||||
func init() {
|
||||
deviceCmd := &cobra.Command{
|
||||
Use: "device",
|
||||
Short: "Manage heat-producing devices",
|
||||
}
|
||||
|
||||
addCmd := &cobra.Command{
|
||||
Use: "add <name>",
|
||||
Short: "Add a device",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
d, err := db.CreateDevice(deviceRoom, args[0], deviceType, deviceIdle, deviceTypical, devicePeak, deviceDuty)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Device added: %s (ID: %d, typical: %.0fW)\n", d.Name, d.ID, d.WattsTypical)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
addCmd.Flags().Int64Var(&deviceRoom, "room", 0, "room ID")
|
||||
addCmd.Flags().StringVar(&deviceType, "type", "other", "device type (pc, monitor, appliance, other)")
|
||||
addCmd.Flags().Float64Var(&deviceIdle, "watts-idle", 0, "idle power draw (W)")
|
||||
addCmd.Flags().Float64Var(&deviceTypical, "watts-typical", 0, "typical power draw (W)")
|
||||
addCmd.Flags().Float64Var(&devicePeak, "watts-peak", 0, "peak power draw (W)")
|
||||
addCmd.Flags().Float64Var(&deviceDuty, "duty-cycle", 1.0, "duty cycle (0.0-1.0)")
|
||||
addCmd.MarkFlagRequired("room")
|
||||
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List devices",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
roomFilter, _ := cmd.Flags().GetInt64("room")
|
||||
var devices []store.Device
|
||||
if roomFilter > 0 {
|
||||
devices, err = db.ListDevices(roomFilter)
|
||||
} else {
|
||||
devices, err = db.ListAllDevices(p.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
fmt.Println("No devices found")
|
||||
return nil
|
||||
}
|
||||
for _, d := range devices {
|
||||
fmt.Printf(" [%d] %s — type: %s, idle: %.0fW, typical: %.0fW, peak: %.0fW, duty: %.0f%%\n",
|
||||
d.ID, d.Name, d.DeviceType, d.WattsIdle, d.WattsTypical, d.WattsPeak, d.DutyCycle*100)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
listCmd.Flags().Int64("room", 0, "filter by room ID")
|
||||
|
||||
editCmd := &cobra.Command{
|
||||
Use: "edit <id> <field> <value>",
|
||||
Short: "Edit a device field",
|
||||
Args: cobra.ExactArgs(3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid device ID: %s", args[0])
|
||||
}
|
||||
return db.UpdateDevice(id, args[1], args[2])
|
||||
},
|
||||
}
|
||||
|
||||
removeCmd := &cobra.Command{
|
||||
Use: "remove <id>",
|
||||
Short: "Remove a device",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid device ID: %s", args[0])
|
||||
}
|
||||
return db.DeleteDevice(id)
|
||||
},
|
||||
}
|
||||
|
||||
deviceCmd.AddCommand(addCmd, listCmd, editCmd, removeCmd)
|
||||
rootCmd.AddCommand(deviceCmd)
|
||||
}
|
||||
165
internal/cli/forecast.go
Normal file
165
internal/cli/forecast.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/store"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/weather"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
forecastForce bool
|
||||
forecastHours int
|
||||
)
|
||||
|
||||
func init() {
|
||||
forecastCmd := &cobra.Command{
|
||||
Use: "forecast",
|
||||
Short: "Manage weather forecasts",
|
||||
}
|
||||
|
||||
fetchCmd := &cobra.Command{
|
||||
Use: "fetch",
|
||||
Short: "Fetch weather forecast and warnings",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !forecastForce {
|
||||
lastFetch, err := db.GetLastFetchTime(p.ID, "openmeteo")
|
||||
if err == nil && time.Since(lastFetch) < time.Hour {
|
||||
fmt.Printf("Forecast is fresh (last fetched %s). Use --force to refetch.\n", lastFetch.Format("15:04"))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fetchForecastForProfile(p)
|
||||
},
|
||||
}
|
||||
fetchCmd.Flags().BoolVar(&forecastForce, "force", false, "force refetch even if recent data exists")
|
||||
|
||||
showCmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show forecast data",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
from := now
|
||||
to := now.Add(time.Duration(forecastHours) * time.Hour)
|
||||
forecasts, err := db.GetForecasts(p.ID, from, to, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(forecasts) == 0 {
|
||||
fmt.Println("No forecast data. Run: heatwave forecast fetch")
|
||||
return nil
|
||||
}
|
||||
for _, f := range forecasts {
|
||||
temp := "n/a"
|
||||
if f.TemperatureC != nil {
|
||||
temp = fmt.Sprintf("%.1f°C", *f.TemperatureC)
|
||||
}
|
||||
hum := "n/a"
|
||||
if f.HumidityPct != nil {
|
||||
hum = fmt.Sprintf("%.0f%%", *f.HumidityPct)
|
||||
}
|
||||
fmt.Printf(" %s %s %s %s\n", f.Timestamp.Format("02 Jan 15:04"), temp, hum, f.Source)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
showCmd.Flags().IntVar(&forecastHours, "hours", 48, "hours to display")
|
||||
|
||||
risksCmd := &cobra.Command{
|
||||
Use: "risks",
|
||||
Short: "Show identified risk windows",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Risk analysis requires forecast data. Run: heatwave forecast fetch")
|
||||
fmt.Println("Then use: heatwave plan today")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
forecastCmd.AddCommand(fetchCmd, showCmd, risksCmd)
|
||||
rootCmd.AddCommand(forecastCmd)
|
||||
}
|
||||
|
||||
// fetchForecastForProfile fetches forecast and warnings for the given profile.
|
||||
func fetchForecastForProfile(p *store.Profile) error {
|
||||
ctx := context.Background()
|
||||
|
||||
om := weather.NewOpenMeteo(nil)
|
||||
resp, err := om.FetchForecast(ctx, p.Latitude, p.Longitude, p.Timezone)
|
||||
if err != nil {
|
||||
fmt.Printf("Open-Meteo failed: %v, trying Bright Sky...\n", err)
|
||||
bs := weather.NewBrightSky(nil)
|
||||
resp, err = bs.FetchForecast(ctx, p.Latitude, p.Longitude, p.Timezone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("all forecast providers failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, h := range resp.Hourly {
|
||||
f := &store.Forecast{
|
||||
ProfileID: p.ID,
|
||||
Timestamp: h.Timestamp,
|
||||
TemperatureC: &h.TemperatureC,
|
||||
HumidityPct: &h.HumidityPct,
|
||||
WindSpeedMs: &h.WindSpeedMs,
|
||||
CloudCoverPct: &h.CloudCoverPct,
|
||||
PrecipitationMm: &h.PrecipitationMm,
|
||||
SunshineMin: &h.SunshineMin,
|
||||
PressureHpa: &h.PressureHpa,
|
||||
DewPointC: &h.DewPointC,
|
||||
ApparentTempC: &h.ApparentTempC,
|
||||
Condition: h.Condition,
|
||||
Source: resp.Source,
|
||||
}
|
||||
if err := db.UpsertForecast(f); err != nil {
|
||||
return fmt.Errorf("store forecast: %w", err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
fmt.Printf("Stored %d hourly forecasts from %s\n", count, resp.Source)
|
||||
|
||||
dwd := weather.NewDWDWFS(nil)
|
||||
warnings, err := dwd.FetchWarnings(ctx, p.Latitude, p.Longitude)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning fetch failed: %v\n", err)
|
||||
} else {
|
||||
for _, w := range warnings {
|
||||
sw := &store.Warning{
|
||||
ProfileID: p.ID,
|
||||
WarningID: w.ID,
|
||||
EventType: w.EventType,
|
||||
Severity: w.Severity,
|
||||
Headline: w.Headline,
|
||||
Description: w.Description,
|
||||
Instruction: w.Instruction,
|
||||
Onset: w.Onset,
|
||||
Expires: w.Expires,
|
||||
}
|
||||
if err := db.UpsertWarning(sw); err != nil {
|
||||
return fmt.Errorf("store warning: %w", err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("Stored %d heat warnings\n", len(warnings))
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-7 * 24 * time.Hour)
|
||||
deleted, _ := db.CleanupOldForecasts(cutoff)
|
||||
if deleted > 0 && verbose {
|
||||
fmt.Printf("Cleaned up %d old forecasts\n", deleted)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
158
internal/cli/heatplan.go
Normal file
158
internal/cli/heatplan.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/action"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
heatplanOutput string
|
||||
)
|
||||
|
||||
func init() {
|
||||
heatplanCmd := &cobra.Command{
|
||||
Use: "heatplan",
|
||||
Short: "Generate a 1-page plain-language heat plan",
|
||||
}
|
||||
|
||||
generateCmd := &cobra.Command{
|
||||
Use: "generate",
|
||||
Short: "Generate a heat plan document",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
provider := getLLMProvider()
|
||||
if provider.Name() == "none" {
|
||||
return fmt.Errorf("LLM not configured. Set llm.provider in config or use --llm flag")
|
||||
}
|
||||
|
||||
dateStr := time.Now().Format("2006-01-02")
|
||||
date, _ := time.Parse("2006-01-02", dateStr)
|
||||
loc, _ := time.LoadLocation(p.Timezone)
|
||||
from := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc)
|
||||
to := from.Add(24 * time.Hour)
|
||||
|
||||
forecasts, err := db.GetForecasts(p.ID, from, to, "")
|
||||
if err != nil || len(forecasts) == 0 {
|
||||
return fmt.Errorf("no forecast data. Run: heatwave forecast fetch")
|
||||
}
|
||||
|
||||
hourlyData := buildHourlyData(forecasts, loc)
|
||||
dayRisk := risk.AnalyzeDay(hourlyData, risk.DefaultThresholds())
|
||||
|
||||
devices, _ := db.ListAllDevices(p.ID)
|
||||
var sources []llm.HeatSource
|
||||
for _, d := range devices {
|
||||
sources = append(sources, llm.HeatSource{Name: d.Name, Watts: d.WattsTypical * d.DutyCycle})
|
||||
}
|
||||
|
||||
acUnits, _ := db.ListACUnits(p.ID)
|
||||
var totalACBTU float64
|
||||
for _, ac := range acUnits {
|
||||
totalACBTU += ac.CapacityBTU
|
||||
}
|
||||
var totalGainW float64
|
||||
for _, s := range sources {
|
||||
totalGainW += s.Watts
|
||||
}
|
||||
|
||||
warnings, _ := db.GetActiveWarnings(p.ID, time.Now())
|
||||
var warningStrs []string
|
||||
for _, w := range warnings {
|
||||
warningStrs = append(warningStrs, w.Headline)
|
||||
}
|
||||
|
||||
var riskWindows []llm.RiskWindowSummary
|
||||
for _, w := range dayRisk.Windows {
|
||||
riskWindows = append(riskWindows, llm.RiskWindowSummary{
|
||||
StartHour: w.StartHour, EndHour: w.EndHour, PeakTempC: w.PeakTempC, Level: w.Level.String(),
|
||||
})
|
||||
}
|
||||
|
||||
summaryInput := llm.SummaryInput{
|
||||
Date: dateStr,
|
||||
PeakTempC: dayRisk.PeakTempC,
|
||||
MinNightTempC: dayRisk.MinNightTempC,
|
||||
RiskLevel: dayRisk.Level.String(),
|
||||
TopHeatSources: sources,
|
||||
ACHeadroomBTUH: totalACBTU - heat.WattsToBTUH(totalGainW),
|
||||
BudgetStatus: heat.Comfortable.String(),
|
||||
ActiveWarnings: warningStrs,
|
||||
RiskWindows: riskWindows,
|
||||
}
|
||||
|
||||
// Build timeline
|
||||
actions, _ := action.LoadDefaultActions()
|
||||
toggles, _ := db.GetActiveToggleNames(p.ID)
|
||||
var timelineSlots []llm.TimelineSlotSummary
|
||||
var actionSummaries []llm.ActionSummary
|
||||
|
||||
for _, h := range hourlyData {
|
||||
ctx := action.HourContext{
|
||||
Hour: h.Hour, TempC: h.TempC, HumidityPct: h.HumidityPct,
|
||||
IsDay: h.IsDay, RiskLevel: dayRisk.Level,
|
||||
BudgetStatus: heat.Comfortable, ActiveToggles: toggles,
|
||||
}
|
||||
matched := action.SelectActions(actions, ctx)
|
||||
var actionNames []string
|
||||
for _, a := range matched {
|
||||
actionNames = append(actionNames, a.Name)
|
||||
actionSummaries = append(actionSummaries, llm.ActionSummary{
|
||||
Name: a.Name, Category: string(a.Category), Impact: string(a.Impact), Hour: h.Hour,
|
||||
})
|
||||
}
|
||||
timelineSlots = append(timelineSlots, llm.TimelineSlotSummary{
|
||||
Hour: h.Hour, TempC: h.TempC, RiskLevel: dayRisk.Level.String(),
|
||||
BudgetStatus: heat.Comfortable.String(), Actions: actionNames,
|
||||
})
|
||||
}
|
||||
|
||||
// Care checklist
|
||||
occupants, _ := db.ListAllOccupants(p.ID)
|
||||
var careList []string
|
||||
for _, o := range occupants {
|
||||
if o.Vulnerable {
|
||||
careList = append(careList, fmt.Sprintf("Check vulnerable occupant (room %d) at 10:00, 14:00, 18:00", o.RoomID))
|
||||
}
|
||||
}
|
||||
|
||||
input := llm.HeatPlanInput{
|
||||
Summary: summaryInput,
|
||||
Timeline: timelineSlots,
|
||||
Actions: actionSummaries,
|
||||
CareChecklist: careList,
|
||||
}
|
||||
|
||||
result, err := provider.GenerateHeatPlan(context.Background(), input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LLM call failed: %w", err)
|
||||
}
|
||||
|
||||
if heatplanOutput != "" {
|
||||
if err := os.WriteFile(heatplanOutput, []byte(result), 0o644); err != nil {
|
||||
return fmt.Errorf("write output: %w", err)
|
||||
}
|
||||
fmt.Printf("Heat plan written to %s\n", heatplanOutput)
|
||||
} else {
|
||||
fmt.Println(result)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
generateCmd.Flags().StringVar(&heatplanOutput, "output", "", "output file path")
|
||||
|
||||
heatplanCmd.AddCommand(generateCmd)
|
||||
rootCmd.AddCommand(heatplanCmd)
|
||||
}
|
||||
97
internal/cli/occupant.go
Normal file
97
internal/cli/occupant.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
occRoom int64
|
||||
occCount int
|
||||
occActivity string
|
||||
occVuln bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
occCmd := &cobra.Command{
|
||||
Use: "occupant",
|
||||
Short: "Manage room occupants",
|
||||
}
|
||||
|
||||
addCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add occupants to a room",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
o, err := db.CreateOccupant(occRoom, occCount, occActivity, occVuln)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Occupant added (ID: %d, room: %d, count: %d, activity: %s, vulnerable: %v)\n",
|
||||
o.ID, o.RoomID, o.Count, o.ActivityLevel, o.Vulnerable)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
addCmd.Flags().Int64Var(&occRoom, "room", 0, "room ID")
|
||||
addCmd.Flags().IntVar(&occCount, "count", 1, "number of occupants")
|
||||
addCmd.Flags().StringVar(&occActivity, "activity", "sedentary", "activity level (sleeping, sedentary, light, moderate, heavy)")
|
||||
addCmd.Flags().BoolVar(&occVuln, "vulnerable", false, "vulnerable occupant (elderly, child, ill)")
|
||||
addCmd.MarkFlagRequired("room")
|
||||
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List occupants",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
occupants, err := db.ListAllOccupants(p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(occupants) == 0 {
|
||||
fmt.Println("No occupants found")
|
||||
return nil
|
||||
}
|
||||
for _, o := range occupants {
|
||||
vuln := ""
|
||||
if o.Vulnerable {
|
||||
vuln = " [VULNERABLE]"
|
||||
}
|
||||
fmt.Printf(" [%d] room %d — %d person(s), %s%s\n", o.ID, o.RoomID, o.Count, o.ActivityLevel, vuln)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
editCmd := &cobra.Command{
|
||||
Use: "edit <id> <field> <value>",
|
||||
Short: "Edit an occupant field",
|
||||
Args: cobra.ExactArgs(3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid occupant ID: %s", args[0])
|
||||
}
|
||||
return db.UpdateOccupant(id, args[1], args[2])
|
||||
},
|
||||
}
|
||||
|
||||
removeCmd := &cobra.Command{
|
||||
Use: "remove <id>",
|
||||
Short: "Remove an occupant entry",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid occupant ID: %s", args[0])
|
||||
}
|
||||
return db.DeleteOccupant(id)
|
||||
},
|
||||
}
|
||||
|
||||
occCmd.AddCommand(addCmd, listCmd, editCmd, removeCmd)
|
||||
rootCmd.AddCommand(occCmd)
|
||||
}
|
||||
165
internal/cli/plan.go
Normal file
165
internal/cli/plan.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/action"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var planDate string
|
||||
|
||||
func init() {
|
||||
planCmd := &cobra.Command{
|
||||
Use: "plan",
|
||||
Short: "Generate action plans",
|
||||
}
|
||||
|
||||
todayCmd := &cobra.Command{
|
||||
Use: "today",
|
||||
Short: "Generate today's action plan",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPlan(time.Now().Format("2006-01-02"))
|
||||
},
|
||||
}
|
||||
|
||||
generateCmd := &cobra.Command{
|
||||
Use: "generate",
|
||||
Short: "Generate plan for a specific date",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if planDate == "" {
|
||||
planDate = time.Now().Format("2006-01-02")
|
||||
}
|
||||
return runPlan(planDate)
|
||||
},
|
||||
}
|
||||
generateCmd.Flags().StringVar(&planDate, "date", "", "date (YYYY-MM-DD)")
|
||||
|
||||
planCmd.AddCommand(todayCmd, generateCmd)
|
||||
rootCmd.AddCommand(planCmd)
|
||||
}
|
||||
|
||||
func runPlan(dateStr string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
date, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid date: %s", dateStr)
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(p.Timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
from := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc)
|
||||
to := from.Add(24 * time.Hour)
|
||||
|
||||
forecasts, err := db.GetForecasts(p.ID, from, to, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(forecasts) == 0 {
|
||||
return fmt.Errorf("no forecast data for %s. Run: heatwave forecast fetch", dateStr)
|
||||
}
|
||||
|
||||
// Build hourly risk data
|
||||
hourlyData := make([]risk.HourlyData, 0, 24)
|
||||
for _, f := range forecasts {
|
||||
tempC := 0.0
|
||||
if f.TemperatureC != nil {
|
||||
tempC = *f.TemperatureC
|
||||
}
|
||||
apparentC := tempC
|
||||
if f.ApparentTempC != nil {
|
||||
apparentC = *f.ApparentTempC
|
||||
}
|
||||
humPct := 50.0
|
||||
if f.HumidityPct != nil {
|
||||
humPct = *f.HumidityPct
|
||||
}
|
||||
h := f.Timestamp.In(loc).Hour()
|
||||
hourlyData = append(hourlyData, risk.HourlyData{
|
||||
Hour: h,
|
||||
TempC: tempC,
|
||||
ApparentC: apparentC,
|
||||
HumidityPct: humPct,
|
||||
IsDay: h >= 6 && h < 21,
|
||||
})
|
||||
}
|
||||
|
||||
// Analyze risks
|
||||
th := risk.DefaultThresholds()
|
||||
dayRisk := risk.AnalyzeDay(hourlyData, th)
|
||||
|
||||
fmt.Printf("=== Heat Plan for %s ===\n", dateStr)
|
||||
fmt.Printf("Risk Level: %s | Peak: %.1f°C | Night Min: %.1f°C\n", dayRisk.Level, dayRisk.PeakTempC, dayRisk.MinNightTempC)
|
||||
if dayRisk.PoorNightCool {
|
||||
fmt.Println("Warning: Poor nighttime cooling expected")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if len(dayRisk.Windows) > 0 {
|
||||
fmt.Println("Risk Windows:")
|
||||
for _, w := range dayRisk.Windows {
|
||||
fmt.Printf(" %02d:00–%02d:00 | Peak %.1f°C | %s — %s\n", w.StartHour, w.EndHour, w.PeakTempC, w.Level, w.Reason)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Load actions and build timeline
|
||||
actions, err := action.LoadDefaultActions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load actions: %w", err)
|
||||
}
|
||||
|
||||
// Get toggles
|
||||
toggles, _ := db.GetActiveToggleNames(p.ID)
|
||||
|
||||
// Build hour contexts with real budget computation
|
||||
contexts := make([]action.HourContext, 0, len(hourlyData))
|
||||
for i, h := range hourlyData {
|
||||
cloudPct := 50.0
|
||||
sunMin := 0.0
|
||||
if i < len(forecasts) {
|
||||
if forecasts[i].CloudCoverPct != nil {
|
||||
cloudPct = *forecasts[i].CloudCoverPct
|
||||
}
|
||||
if forecasts[i].SunshineMin != nil {
|
||||
sunMin = *forecasts[i].SunshineMin
|
||||
}
|
||||
}
|
||||
w := hourWeather{Hour: h.Hour, TempC: h.TempC, CloudCoverPct: cloudPct, SunshineMin: sunMin}
|
||||
_, worstStatus := computeRoomBudgets(p.ID, w, toggles, 25.0)
|
||||
|
||||
contexts = append(contexts, action.HourContext{
|
||||
Hour: h.Hour,
|
||||
TempC: h.TempC,
|
||||
HumidityPct: h.HumidityPct,
|
||||
IsDay: h.IsDay,
|
||||
RiskLevel: dayRisk.Level,
|
||||
BudgetStatus: worstStatus,
|
||||
ActiveToggles: toggles,
|
||||
})
|
||||
}
|
||||
|
||||
timeline := action.BuildTimeline(contexts, actions)
|
||||
|
||||
fmt.Println("Hour-by-Hour Plan:")
|
||||
for _, slot := range timeline {
|
||||
if len(slot.Actions) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" %02d:00 (%.1f°C):\n", slot.Hour, slot.TempC)
|
||||
for _, a := range slot.Actions {
|
||||
fmt.Printf(" - %s [%s, effort: %s]\n", a.Name, a.Impact, a.Effort)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
106
internal/cli/profile.go
Normal file
106
internal/cli/profile.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
profileLat float64
|
||||
profileLon float64
|
||||
profileTZ string
|
||||
)
|
||||
|
||||
func init() {
|
||||
profileCmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "Manage location profiles",
|
||||
}
|
||||
|
||||
createCmd := &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Create a new location profile",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := db.CreateProfile(args[0], profileLat, profileLon, profileTZ)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Profile created: %s (ID: %d, %.4f, %.4f)\n", p.Name, p.ID, p.Latitude, p.Longitude)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
createCmd.Flags().Float64Var(&profileLat, "lat", 0, "latitude")
|
||||
createCmd.Flags().Float64Var(&profileLon, "lon", 0, "longitude")
|
||||
createCmd.Flags().StringVar(&profileTZ, "tz", "Europe/Berlin", "timezone")
|
||||
createCmd.MarkFlagRequired("lat")
|
||||
createCmd.MarkFlagRequired("lon")
|
||||
|
||||
showCmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Display current profile",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Profile: %s (ID: %d)\n", p.Name, p.ID)
|
||||
fmt.Printf(" Location: %.4f, %.4f\n", p.Latitude, p.Longitude)
|
||||
fmt.Printf(" Timezone: %s\n", p.Timezone)
|
||||
fmt.Printf(" Created: %s\n", p.CreatedAt.Format("2006-01-02 15:04"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
editCmd := &cobra.Command{
|
||||
Use: "edit <field> <value>",
|
||||
Short: "Edit a profile field",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.UpdateProfile(p.ID, args[0], args[1])
|
||||
},
|
||||
}
|
||||
|
||||
deleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "Delete current profile",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DeleteProfile(p.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Profile %q deleted\n", p.Name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all profiles",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
profiles, err := db.ListProfiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(profiles) == 0 {
|
||||
fmt.Println("No profiles found")
|
||||
return nil
|
||||
}
|
||||
for _, p := range profiles {
|
||||
fmt.Printf(" [%d] %s (%.4f, %.4f, %s)\n", p.ID, p.Name, p.Latitude, p.Longitude, p.Timezone)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
profileCmd.AddCommand(createCmd, showCmd, editCmd, deleteCmd, listCmd)
|
||||
rootCmd.AddCommand(profileCmd)
|
||||
}
|
||||
331
internal/cli/report.go
Normal file
331
internal/cli/report.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/action"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/report"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
reportOutput string
|
||||
reportDate string
|
||||
servePort int
|
||||
serveDate string
|
||||
serveOpen bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
reportCmd := &cobra.Command{
|
||||
Use: "report",
|
||||
Short: "Generate and serve HTML reports",
|
||||
}
|
||||
|
||||
generateCmd := &cobra.Command{
|
||||
Use: "generate",
|
||||
Short: "Generate an HTML heat report",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dateStr := reportDate
|
||||
if dateStr == "" {
|
||||
dateStr = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
data, err := buildReportData(dateStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output := reportOutput
|
||||
if output == "" {
|
||||
output = fmt.Sprintf("heatwave-report-%s.html", dateStr)
|
||||
}
|
||||
|
||||
f, err := os.Create(output)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create output file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := report.Generate(f, data); err != nil {
|
||||
return fmt.Errorf("generate report: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Report generated: %s\n", output)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
generateCmd.Flags().StringVar(&reportOutput, "output", "", "output file path (default: heatwave-report-DATE.html)")
|
||||
generateCmd.Flags().StringVar(&reportDate, "date", "", "date (YYYY-MM-DD, default: today)")
|
||||
|
||||
serveCmd := &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Serve the HTML report via a local HTTP server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dateStr := serveDate
|
||||
if dateStr == "" {
|
||||
dateStr = time.Now().Format("2006-01-02")
|
||||
}
|
||||
return runWebServer(dateStr, servePort, serveOpen)
|
||||
},
|
||||
}
|
||||
serveCmd.Flags().IntVar(&servePort, "port", 8080, "HTTP port to serve on")
|
||||
serveCmd.Flags().StringVar(&serveDate, "date", "", "date (YYYY-MM-DD, default: today)")
|
||||
serveCmd.Flags().BoolVar(&serveOpen, "open", true, "open browser automatically")
|
||||
|
||||
reportCmd.AddCommand(generateCmd, serveCmd)
|
||||
rootCmd.AddCommand(reportCmd)
|
||||
}
|
||||
|
||||
// buildReportData constructs the full DashboardData from DB + forecast + budget + LLM.
|
||||
func buildReportData(dateStr string) (report.DashboardData, error) {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return report.DashboardData{}, err
|
||||
}
|
||||
|
||||
date, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return report.DashboardData{}, fmt.Errorf("invalid date: %s", dateStr)
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(p.Timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
from := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc)
|
||||
to := from.Add(24 * time.Hour)
|
||||
|
||||
forecasts, err := db.GetForecasts(p.ID, from, to, "")
|
||||
if err != nil {
|
||||
return report.DashboardData{}, err
|
||||
}
|
||||
if len(forecasts) == 0 {
|
||||
return report.DashboardData{}, fmt.Errorf("no forecast data for %s. Run: heatwave forecast fetch", dateStr)
|
||||
}
|
||||
|
||||
// Build risk analysis
|
||||
hourlyData := buildHourlyData(forecasts, loc)
|
||||
th := risk.DefaultThresholds()
|
||||
dayRisk := risk.AnalyzeDay(hourlyData, th)
|
||||
|
||||
// Build actions timeline
|
||||
actions, _ := action.LoadDefaultActions()
|
||||
toggles, _ := db.GetActiveToggleNames(p.ID)
|
||||
|
||||
// Active warnings
|
||||
warnings, _ := db.GetActiveWarnings(p.ID, time.Now())
|
||||
|
||||
data := report.DashboardData{
|
||||
GeneratedAt: time.Now(),
|
||||
ProfileName: p.Name,
|
||||
Date: dateStr,
|
||||
RiskLevel: dayRisk.Level.String(),
|
||||
PeakTempC: dayRisk.PeakTempC,
|
||||
MinNightTempC: dayRisk.MinNightTempC,
|
||||
PoorNightCool: dayRisk.PoorNightCool,
|
||||
}
|
||||
|
||||
// Warnings
|
||||
for _, w := range warnings {
|
||||
data.Warnings = append(data.Warnings, report.WarningData{
|
||||
Headline: w.Headline,
|
||||
Severity: w.Severity,
|
||||
Description: w.Description,
|
||||
Instruction: w.Instruction,
|
||||
Onset: w.Onset.Format("2006-01-02 15:04"),
|
||||
Expires: w.Expires.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
|
||||
// Risk windows
|
||||
for _, w := range dayRisk.Windows {
|
||||
data.RiskWindows = append(data.RiskWindows, report.RiskWindowData{
|
||||
StartHour: w.StartHour,
|
||||
EndHour: w.EndHour,
|
||||
PeakTempC: w.PeakTempC,
|
||||
Level: w.Level.String(),
|
||||
Reason: w.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
// Timeline + room budget computation
|
||||
var peakBudgets []roomBudgetResult
|
||||
for i, h := range hourlyData {
|
||||
cloudPct := 50.0
|
||||
sunMin := 0.0
|
||||
if i < len(forecasts) {
|
||||
if forecasts[i].CloudCoverPct != nil {
|
||||
cloudPct = *forecasts[i].CloudCoverPct
|
||||
}
|
||||
if forecasts[i].SunshineMin != nil {
|
||||
sunMin = *forecasts[i].SunshineMin
|
||||
}
|
||||
}
|
||||
w := hourWeather{Hour: h.Hour, TempC: h.TempC, CloudCoverPct: cloudPct, SunshineMin: sunMin}
|
||||
budgets, worstStatus := computeRoomBudgets(p.ID, w, toggles, 25.0)
|
||||
|
||||
ctx := action.HourContext{
|
||||
Hour: h.Hour,
|
||||
TempC: h.TempC,
|
||||
HumidityPct: h.HumidityPct,
|
||||
IsDay: h.IsDay,
|
||||
RiskLevel: dayRisk.Level,
|
||||
BudgetStatus: worstStatus,
|
||||
ActiveToggles: toggles,
|
||||
}
|
||||
matched := action.SelectActions(actions, ctx)
|
||||
slot := report.TimelineSlotData{
|
||||
Hour: h.Hour,
|
||||
HourStr: fmt.Sprintf("%02d:00", h.Hour),
|
||||
TempC: h.TempC,
|
||||
RiskLevel: dayRisk.Level.String(),
|
||||
BudgetStatus: worstStatus.String(),
|
||||
}
|
||||
for _, a := range matched {
|
||||
slot.Actions = append(slot.Actions, report.ActionData{
|
||||
Name: a.Name,
|
||||
Category: string(a.Category),
|
||||
Effort: string(a.Effort),
|
||||
Impact: string(a.Impact),
|
||||
Description: a.Description,
|
||||
})
|
||||
}
|
||||
data.Timeline = append(data.Timeline, slot)
|
||||
|
||||
// Track peak hour budgets for room budget section
|
||||
if h.TempC == dayRisk.PeakTempC && len(budgets) > 0 {
|
||||
peakBudgets = budgets
|
||||
}
|
||||
}
|
||||
|
||||
// Room budgets (computed at peak temp hour)
|
||||
for _, rb := range peakBudgets {
|
||||
data.RoomBudgets = append(data.RoomBudgets, report.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(),
|
||||
})
|
||||
}
|
||||
|
||||
// Care checklist
|
||||
occupants, _ := db.ListAllOccupants(p.ID)
|
||||
for _, o := range occupants {
|
||||
if o.Vulnerable {
|
||||
data.CareChecklist = append(data.CareChecklist, fmt.Sprintf("Check vulnerable occupant (room %d) at 10:00, 14:00, 18:00", o.RoomID))
|
||||
}
|
||||
}
|
||||
|
||||
// LLM summary (optional — gracefully degrades)
|
||||
provider := getLLMProvider()
|
||||
if provider.Name() != "noop" {
|
||||
var warningHeadlines []string
|
||||
for _, w := range warnings {
|
||||
warningHeadlines = append(warningHeadlines, w.Headline)
|
||||
}
|
||||
var riskWindowSummaries []llm.RiskWindowSummary
|
||||
for _, rw := range dayRisk.Windows {
|
||||
riskWindowSummaries = append(riskWindowSummaries, llm.RiskWindowSummary{
|
||||
StartHour: rw.StartHour,
|
||||
EndHour: rw.EndHour,
|
||||
PeakTempC: rw.PeakTempC,
|
||||
Level: rw.Level.String(),
|
||||
})
|
||||
}
|
||||
summaryBudgetStatus := "comfortable"
|
||||
var summaryHeadroom float64
|
||||
if len(peakBudgets) > 0 {
|
||||
summaryBudgetStatus = peakBudgets[0].Result.Status.String()
|
||||
summaryHeadroom = peakBudgets[0].Result.HeadroomBTUH
|
||||
for _, rb := range peakBudgets[1:] {
|
||||
if rb.Result.Status > peakBudgets[0].Result.Status {
|
||||
summaryBudgetStatus = rb.Result.Status.String()
|
||||
}
|
||||
if rb.Result.HeadroomBTUH < summaryHeadroom {
|
||||
summaryHeadroom = rb.Result.HeadroomBTUH
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summaryInput := llm.SummaryInput{
|
||||
Date: dateStr,
|
||||
PeakTempC: dayRisk.PeakTempC,
|
||||
MinNightTempC: dayRisk.MinNightTempC,
|
||||
RiskLevel: dayRisk.Level.String(),
|
||||
ACHeadroomBTUH: summaryHeadroom,
|
||||
BudgetStatus: summaryBudgetStatus,
|
||||
ActiveWarnings: warningHeadlines,
|
||||
RiskWindows: riskWindowSummaries,
|
||||
}
|
||||
|
||||
bgCtx := context.Background()
|
||||
summary, err := provider.Summarize(bgCtx, summaryInput)
|
||||
if err != nil {
|
||||
if verbose {
|
||||
fmt.Fprintf(os.Stderr, "LLM summary failed: %v\n", err)
|
||||
}
|
||||
} else if summary != "" {
|
||||
data.LLMSummary = summary
|
||||
data.LLMDisclaimer = "AI-generated summary. Not a substitute for professional advice."
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func buildHourlyData(forecasts []store.Forecast, loc *time.Location) []risk.HourlyData {
|
||||
var data []risk.HourlyData
|
||||
for _, f := range forecasts {
|
||||
tempC := 0.0
|
||||
if f.TemperatureC != nil {
|
||||
tempC = *f.TemperatureC
|
||||
}
|
||||
apparentC := tempC
|
||||
if f.ApparentTempC != nil {
|
||||
apparentC = *f.ApparentTempC
|
||||
}
|
||||
humPct := 50.0
|
||||
if f.HumidityPct != nil {
|
||||
humPct = *f.HumidityPct
|
||||
}
|
||||
h := f.Timestamp.In(loc).Hour()
|
||||
data = append(data, risk.HourlyData{
|
||||
Hour: h,
|
||||
TempC: tempC,
|
||||
ApparentC: apparentC,
|
||||
HumidityPct: humPct,
|
||||
IsDay: h >= 6 && h < 21,
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func openBrowser(url string) {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||
default:
|
||||
return
|
||||
}
|
||||
cmd.Start()
|
||||
}
|
||||
119
internal/cli/room.go
Normal file
119
internal/cli/room.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
roomSqm float64
|
||||
roomCeiling float64
|
||||
roomFloor int
|
||||
roomOrient string
|
||||
roomShading string
|
||||
roomShadeFact float64
|
||||
roomVent string
|
||||
roomVentACH float64
|
||||
roomWinFrac float64
|
||||
roomSHGC float64
|
||||
roomInsulation string
|
||||
)
|
||||
|
||||
func init() {
|
||||
roomCmd := &cobra.Command{
|
||||
Use: "room",
|
||||
Short: "Manage rooms",
|
||||
}
|
||||
|
||||
addCmd := &cobra.Command{
|
||||
Use: "add <name>",
|
||||
Short: "Add a room",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := store.RoomParams{
|
||||
CeilingHeightM: roomCeiling,
|
||||
VentilationACH: roomVentACH,
|
||||
WindowFraction: roomWinFrac,
|
||||
SHGC: roomSHGC,
|
||||
}
|
||||
r, err := db.CreateRoom(p.ID, args[0], roomSqm, roomFloor, roomOrient, roomShading, roomShadeFact, roomVent, roomInsulation, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Room added: %s (ID: %d, %.0f m², %s-facing)\n", r.Name, r.ID, r.AreaSqm, r.Orientation)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
addCmd.Flags().Float64Var(&roomSqm, "sqm", 0, "area in square meters")
|
||||
addCmd.Flags().Float64Var(&roomCeiling, "ceiling", 2.5, "ceiling height in meters")
|
||||
addCmd.Flags().IntVar(&roomFloor, "floor", 0, "floor number")
|
||||
addCmd.Flags().StringVar(&roomOrient, "orientation", "N", "orientation (N/S/E/W/NE/NW/SE/SW)")
|
||||
addCmd.Flags().StringVar(&roomShading, "shading", "none", "shading type")
|
||||
addCmd.Flags().Float64Var(&roomShadeFact, "shading-factor", 1.0, "shading factor (0.0-1.0)")
|
||||
addCmd.Flags().StringVar(&roomVent, "ventilation", "natural", "ventilation type")
|
||||
addCmd.Flags().Float64Var(&roomVentACH, "ach", 0.5, "air changes per hour")
|
||||
addCmd.Flags().Float64Var(&roomWinFrac, "window-fraction", 0.15, "window area as fraction of floor area")
|
||||
addCmd.Flags().Float64Var(&roomSHGC, "shgc", 0.6, "solar heat gain coefficient of glazing")
|
||||
addCmd.Flags().StringVar(&roomInsulation, "insulation", "average", "insulation level")
|
||||
addCmd.MarkFlagRequired("sqm")
|
||||
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List rooms",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rooms, err := db.ListRooms(p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rooms) == 0 {
|
||||
fmt.Println("No rooms found")
|
||||
return nil
|
||||
}
|
||||
for _, r := range rooms {
|
||||
fmt.Printf(" [%d] %s — %.0f m², floor %d, %s-facing, shading: %s (%.1f)\n",
|
||||
r.ID, r.Name, r.AreaSqm, r.Floor, r.Orientation, r.ShadingType, r.ShadingFactor)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
editCmd := &cobra.Command{
|
||||
Use: "edit <id> <field> <value>",
|
||||
Short: "Edit a room field",
|
||||
Args: cobra.ExactArgs(3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid room ID: %s", args[0])
|
||||
}
|
||||
return db.UpdateRoom(id, args[1], args[2])
|
||||
},
|
||||
}
|
||||
|
||||
removeCmd := &cobra.Command{
|
||||
Use: "remove <id>",
|
||||
Short: "Remove a room",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid room ID: %s", args[0])
|
||||
}
|
||||
return db.DeleteRoom(id)
|
||||
},
|
||||
}
|
||||
|
||||
roomCmd.AddCommand(addCmd, listCmd, editCmd, removeCmd)
|
||||
rootCmd.AddCommand(roomCmd)
|
||||
}
|
||||
113
internal/cli/root.go
Normal file
113
internal/cli/root.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/config"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
dbPath string
|
||||
verbose bool
|
||||
profileName string
|
||||
llmFlag string
|
||||
|
||||
db *store.Store
|
||||
cfg config.Config
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "heatwave",
|
||||
Short: "Heatwave Autopilot — personalized heat preparedness",
|
||||
Long: "A CLI tool that ingests weather forecasts, computes personalized heat budgets, and generates actionable hour-by-hour plans.",
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg = config.Load()
|
||||
|
||||
if llmFlag != "" {
|
||||
cfg.LLM.Provider = llmFlag
|
||||
}
|
||||
|
||||
if cmd.Name() == "version" {
|
||||
return nil
|
||||
}
|
||||
|
||||
path := dbPath
|
||||
if path == "" {
|
||||
path = config.DefaultDBPath()
|
||||
}
|
||||
if err := os.MkdirAll(config.DataDir(), 0o755); err != nil {
|
||||
return fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
|
||||
var err error
|
||||
db, err = store.New(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
PersistentPostRun: func(cmd *cobra.Command, args []string) {
|
||||
if db != nil {
|
||||
db.Close()
|
||||
}
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Heatwave Autopilot — use --help for available commands")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "path to SQLite database")
|
||||
rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "enable verbose output")
|
||||
rootCmd.PersistentFlags().StringVar(&profileName, "profile", "", "profile name to use")
|
||||
rootCmd.PersistentFlags().StringVar(&llmFlag, "llm", "", "LLM provider (anthropic, openai, ollama, none)")
|
||||
}
|
||||
|
||||
// Execute runs the root command.
|
||||
func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
// getActiveProfile resolves the current profile from --profile flag or first available.
|
||||
func getActiveProfile() (*store.Profile, error) {
|
||||
if profileName != "" {
|
||||
return db.GetProfileByName(profileName)
|
||||
}
|
||||
profiles, err := db.ListProfiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(profiles) == 0 {
|
||||
return nil, fmt.Errorf("no profiles found — create one with: heatwave profile create <name> --lat <lat> --lon <lon>")
|
||||
}
|
||||
return &profiles[0], nil
|
||||
}
|
||||
|
||||
// getLLMProvider creates an LLM provider based on config.
|
||||
func getLLMProvider() llm.Provider {
|
||||
switch cfg.LLM.Provider {
|
||||
case "anthropic":
|
||||
key := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if key == "" {
|
||||
fmt.Fprintln(os.Stderr, "Warning: ANTHROPIC_API_KEY not set, LLM features disabled")
|
||||
return llm.NewNoop()
|
||||
}
|
||||
return llm.NewAnthropic(key, cfg.LLM.Model, nil)
|
||||
case "openai":
|
||||
key := os.Getenv("OPENAI_API_KEY")
|
||||
if key == "" {
|
||||
fmt.Fprintln(os.Stderr, "Warning: OPENAI_API_KEY not set, LLM features disabled")
|
||||
return llm.NewNoop()
|
||||
}
|
||||
return llm.NewOpenAI(key, cfg.LLM.Model, nil)
|
||||
case "ollama":
|
||||
return llm.NewOllama(cfg.LLM.Model, cfg.LLM.Endpoint, nil)
|
||||
default:
|
||||
return llm.NewNoop()
|
||||
}
|
||||
}
|
||||
117
internal/cli/summary.go
Normal file
117
internal/cli/summary.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var summaryDate string
|
||||
|
||||
func init() {
|
||||
summaryCmd := &cobra.Command{
|
||||
Use: "summary",
|
||||
Short: "Generate a 3-bullet AI summary of the heat model",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
provider := getLLMProvider()
|
||||
if provider.Name() == "none" {
|
||||
return fmt.Errorf("LLM not configured. Set llm.provider in config or use --llm flag")
|
||||
}
|
||||
|
||||
dateStr := summaryDate
|
||||
if dateStr == "" {
|
||||
dateStr = time.Now().Format("2006-01-02")
|
||||
}
|
||||
date, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid date: %s", dateStr)
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation(p.Timezone)
|
||||
from := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc)
|
||||
to := from.Add(24 * time.Hour)
|
||||
|
||||
forecasts, err := db.GetForecasts(p.ID, from, to, "")
|
||||
if err != nil || len(forecasts) == 0 {
|
||||
return fmt.Errorf("no forecast data for %s", dateStr)
|
||||
}
|
||||
|
||||
hourlyData := buildHourlyData(forecasts, loc)
|
||||
dayRisk := risk.AnalyzeDay(hourlyData, risk.DefaultThresholds())
|
||||
|
||||
// Build heat sources from devices
|
||||
devices, _ := db.ListAllDevices(p.ID)
|
||||
var sources []llm.HeatSource
|
||||
for _, d := range devices {
|
||||
sources = append(sources, llm.HeatSource{
|
||||
Name: d.Name,
|
||||
Watts: d.WattsTypical * d.DutyCycle,
|
||||
})
|
||||
}
|
||||
|
||||
// AC headroom (simplified — sum all AC units)
|
||||
acUnits, _ := db.ListACUnits(p.ID)
|
||||
var totalACBTU float64
|
||||
for _, ac := range acUnits {
|
||||
totalACBTU += ac.CapacityBTU
|
||||
}
|
||||
var totalGainW float64
|
||||
for _, s := range sources {
|
||||
totalGainW += s.Watts
|
||||
}
|
||||
headroom := totalACBTU - heat.WattsToBTUH(totalGainW)
|
||||
|
||||
// Warnings
|
||||
warnings, _ := db.GetActiveWarnings(p.ID, time.Now())
|
||||
var warningStrs []string
|
||||
for _, w := range warnings {
|
||||
warningStrs = append(warningStrs, w.Headline)
|
||||
}
|
||||
|
||||
// Risk windows
|
||||
var riskWindows []llm.RiskWindowSummary
|
||||
for _, w := range dayRisk.Windows {
|
||||
riskWindows = append(riskWindows, llm.RiskWindowSummary{
|
||||
StartHour: w.StartHour,
|
||||
EndHour: w.EndHour,
|
||||
PeakTempC: w.PeakTempC,
|
||||
Level: w.Level.String(),
|
||||
})
|
||||
}
|
||||
|
||||
input := llm.SummaryInput{
|
||||
Date: dateStr,
|
||||
PeakTempC: dayRisk.PeakTempC,
|
||||
MinNightTempC: dayRisk.MinNightTempC,
|
||||
RiskLevel: dayRisk.Level.String(),
|
||||
TopHeatSources: sources,
|
||||
ACHeadroomBTUH: headroom,
|
||||
BudgetStatus: heat.Comfortable.String(),
|
||||
ActiveWarnings: warningStrs,
|
||||
RiskWindows: riskWindows,
|
||||
}
|
||||
|
||||
result, err := provider.Summarize(context.Background(), input)
|
||||
if err != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "LLM call failed: %v\nFalling back to raw data:\n", err)
|
||||
fmt.Printf("Peak: %.1f°C | Night min: %.1f°C | Risk: %s\n", dayRisk.PeakTempC, dayRisk.MinNightTempC, dayRisk.Level)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println(result)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
summaryCmd.Flags().StringVar(&summaryDate, "date", "", "date (YYYY-MM-DD)")
|
||||
rootCmd.AddCommand(summaryCmd)
|
||||
}
|
||||
542
internal/cli/templates/setup.html.tmpl
Normal file
542
internal/cli/templates/setup.html.tmpl
Normal file
@@ -0,0 +1,542 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Heatwave Setup</title>
|
||||
<style>{{.CSS}}</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<nav class="bg-white dark:bg-gray-800 shadow mb-4">
|
||||
<div class="container mx-auto flex items-center gap-6 px-4 py-3">
|
||||
<span class="font-bold text-lg">Heatwave</span>
|
||||
<a href="/" class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Dashboard</a>
|
||||
<a href="/setup" class="font-medium text-blue-600 dark:text-blue-400 underline">Setup</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mx-auto py-4 px-4 max-w-4xl">
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="mb-4 p-3 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded">
|
||||
{{.Flash}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h1 class="text-3xl font-bold mb-2">Setup</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Active profile: <strong>{{if .Profile}}{{.Profile.Name}}{{else}}(none){{end}}</strong>
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-8 text-sm">
|
||||
<a href="#profiles" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Profiles</a>
|
||||
<a href="#rooms" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Rooms</a>
|
||||
<a href="#devices" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Devices</a>
|
||||
<a href="#occupants" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Occupants</a>
|
||||
<a href="#ac" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">AC Units</a>
|
||||
<a href="#toggles" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Toggles</a>
|
||||
<a href="#forecast" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Forecast</a>
|
||||
</div>
|
||||
|
||||
{{template "profiles" .}}
|
||||
{{template "rooms" .}}
|
||||
{{template "devices" .}}
|
||||
{{template "occupants" .}}
|
||||
{{template "ac_units" .}}
|
||||
{{template "toggles" .}}
|
||||
{{template "forecast" .}}
|
||||
|
||||
<footer class="mt-8 text-center text-xs text-gray-500 dark:text-gray-500 py-4">
|
||||
<p>Heatwave Autopilot</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{{define "profiles"}}
|
||||
<section id="profiles" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">Profiles</h2>
|
||||
{{if .Profiles}}
|
||||
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700 mb-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="dark:bg-gray-800">
|
||||
<th>Name</th>
|
||||
<th>Latitude</th>
|
||||
<th>Longitude</th>
|
||||
<th>Timezone</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Profiles}}
|
||||
<tr class="bg-white dark:bg-gray-800">
|
||||
<td class="font-medium">{{.Name}}</td>
|
||||
<td>{{printf "%.4f" .Latitude}}</td>
|
||||
<td>{{printf "%.4f" .Longitude}}</td>
|
||||
<td>{{.Timezone}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/setup/profiles/{{.ID}}/delete" class="inline">
|
||||
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete profile?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No profiles yet.</p>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/setup/profiles/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<h3 class="font-medium mb-3">Add Profile</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Latitude</label>
|
||||
<input type="number" step="any" name="latitude" value="52.52" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Longitude</label>
|
||||
<input type="number" step="any" name="longitude" value="13.405" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Timezone</label>
|
||||
<input type="text" name="timezone" value="Europe/Berlin" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add Profile</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "rooms"}}
|
||||
<section id="rooms" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">Rooms</h2>
|
||||
{{if not .Profile}}
|
||||
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
|
||||
{{else}}
|
||||
{{if .Rooms}}
|
||||
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700 mb-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="dark:bg-gray-800">
|
||||
<th>Name</th>
|
||||
<th>Area (m²)</th>
|
||||
<th>Floor</th>
|
||||
<th>Orientation</th>
|
||||
<th>Ceiling (m)</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Rooms}}
|
||||
<tr class="bg-white dark:bg-gray-800">
|
||||
<td class="font-medium">{{.Name}}</td>
|
||||
<td>{{printf "%.1f" .AreaSqm}}</td>
|
||||
<td>{{.Floor}}</td>
|
||||
<td>{{.Orientation}}</td>
|
||||
<td>{{printf "%.2f" .CeilingHeightM}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/setup/rooms/{{.ID}}/delete" class="inline">
|
||||
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete room?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No rooms yet.</p>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/setup/rooms/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<h3 class="font-medium mb-3">Add Room</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Area (m²)</label>
|
||||
<input type="number" step="any" name="area_sqm" value="20" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Floor</label>
|
||||
<input type="number" name="floor" value="0" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Orientation</label>
|
||||
<select name="orientation" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="south">South</option>
|
||||
<option value="west">West</option>
|
||||
<option value="east">East</option>
|
||||
<option value="north">North</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Ceiling Height (m)</label>
|
||||
<input type="number" step="any" name="ceiling_height_m" value="2.50" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Window Fraction</label>
|
||||
<input type="number" step="any" name="window_fraction" value="0.30" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">SHGC</label>
|
||||
<input type="number" step="any" name="shgc" value="0.60" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Shading Type</label>
|
||||
<select name="shading_type" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="none">None</option>
|
||||
<option value="blinds">Blinds</option>
|
||||
<option value="shutters">Shutters</option>
|
||||
<option value="awning">Awning</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Shading Factor</label>
|
||||
<input type="number" step="any" name="shading_factor" value="1.0" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Ventilation</label>
|
||||
<select name="ventilation" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="natural">Natural</option>
|
||||
<option value="mechanical">Mechanical</option>
|
||||
<option value="sealed">Sealed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Ventilation ACH</label>
|
||||
<input type="number" step="any" name="ventilation_ach" value="1.5" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Insulation</label>
|
||||
<select name="insulation" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="standard">Standard</option>
|
||||
<option value="poor">Poor</option>
|
||||
<option value="good">Good</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add Room</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "devices"}}
|
||||
<section id="devices" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">Devices</h2>
|
||||
{{if not .Profile}}
|
||||
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
|
||||
{{else}}
|
||||
{{if .Devices}}
|
||||
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700 mb-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="dark:bg-gray-800">
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Room</th>
|
||||
<th>Idle (W)</th>
|
||||
<th>Typical (W)</th>
|
||||
<th>Peak (W)</th>
|
||||
<th>Duty</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Devices}}
|
||||
<tr class="bg-white dark:bg-gray-800">
|
||||
<td class="font-medium">{{.Name}}</td>
|
||||
<td>{{.DeviceType}}</td>
|
||||
<td>{{.RoomID}}</td>
|
||||
<td>{{printf "%.0f" .WattsIdle}}</td>
|
||||
<td>{{printf "%.0f" .WattsTypical}}</td>
|
||||
<td>{{printf "%.0f" .WattsPeak}}</td>
|
||||
<td>{{printf "%.0f%%" (mul .DutyCycle 100)}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/setup/devices/{{.ID}}/delete" class="inline">
|
||||
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete device?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No devices yet.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Rooms}}
|
||||
<form method="POST" action="/setup/devices/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<h3 class="font-medium mb-3">Add Device</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Room</label>
|
||||
<select name="room_id" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
{{range .Rooms}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Type</label>
|
||||
<select name="device_type" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="computer">Computer</option>
|
||||
<option value="monitor">Monitor</option>
|
||||
<option value="tv">TV</option>
|
||||
<option value="console">Console</option>
|
||||
<option value="lighting">Lighting</option>
|
||||
<option value="appliance">Appliance</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Watts Idle</label>
|
||||
<input type="number" step="any" name="watts_idle" value="10" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Watts Typical</label>
|
||||
<input type="number" step="any" name="watts_typical" value="80" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Watts Peak</label>
|
||||
<input type="number" step="any" name="watts_peak" value="200" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Duty Cycle (0–1)</label>
|
||||
<input type="number" step="any" name="duty_cycle" value="0.5" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add Device</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Add a room first to create devices.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "occupants"}}
|
||||
<section id="occupants" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">Occupants</h2>
|
||||
{{if not .Profile}}
|
||||
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
|
||||
{{else}}
|
||||
{{if .Occupants}}
|
||||
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700 mb-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="dark:bg-gray-800">
|
||||
<th>Room</th>
|
||||
<th>Count</th>
|
||||
<th>Activity</th>
|
||||
<th>Vulnerable</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Occupants}}
|
||||
<tr class="bg-white dark:bg-gray-800">
|
||||
<td>{{.RoomID}}</td>
|
||||
<td>{{.Count}}</td>
|
||||
<td>{{.ActivityLevel}}</td>
|
||||
<td>{{if .Vulnerable}}Yes{{else}}No{{end}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/setup/occupants/{{.ID}}/delete" class="inline">
|
||||
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete occupant?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No occupants yet.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Rooms}}
|
||||
<form method="POST" action="/setup/occupants/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<h3 class="font-medium mb-3">Add Occupant</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Room</label>
|
||||
<select name="room_id" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
{{range .Rooms}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Count</label>
|
||||
<input type="number" name="count" value="1" min="1" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Activity Level</label>
|
||||
<select name="activity_level" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="sedentary">Sedentary</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="moderate">Moderate</option>
|
||||
<option value="active">Active</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 pb-2">
|
||||
<input type="checkbox" name="vulnerable" value="true" class="rounded border-gray-300 dark:border-gray-600">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Vulnerable</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add Occupant</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Add a room first to create occupants.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "ac_units"}}
|
||||
<section id="ac" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">AC Units</h2>
|
||||
{{if not .Profile}}
|
||||
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
|
||||
{{else}}
|
||||
{{if .ACUnits}}
|
||||
<div class="space-y-3 mb-4">
|
||||
{{range .ACUnits}}
|
||||
{{$acID := .ID}}
|
||||
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<span class="font-medium">{{.Name}}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 ml-2">{{.ACType}} — {{printf "%.0f" .CapacityBTU}} BTU/h — EER {{printf "%.1f" .EfficiencyEER}}</span>
|
||||
{{if .HasDehumidify}}<span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded ml-1">Dehumidify</span>{{end}}
|
||||
</div>
|
||||
<form method="POST" action="/setup/ac/{{.ID}}/delete" class="inline">
|
||||
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete AC unit?')">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{{if $.Rooms}}
|
||||
<form method="POST" action="/setup/ac/{{.ID}}/assign" class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Assign to:</span>
|
||||
<select name="room_id" class="px-2 py-1 border dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-sm">
|
||||
{{range $.Rooms}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
<button type="submit" class="px-2 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700">Assign</button>
|
||||
</form>
|
||||
{{if .AssignedRoomIDs}}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{{range .AssignedRoomIDs}}
|
||||
<form method="POST" action="/setup/ac/{{$acID}}/unassign" class="inline">
|
||||
<input type="hidden" name="room_id" value="{{.RoomID}}">
|
||||
<button type="submit" class="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-sm">
|
||||
{{.RoomName}} <span class="text-red-500">×</span>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No AC units yet.</p>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/setup/ac/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<h3 class="font-medium mb-3">Add AC Unit</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Type</label>
|
||||
<select name="ac_type" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
<option value="portable">Portable</option>
|
||||
<option value="split">Split</option>
|
||||
<option value="window">Window</option>
|
||||
<option value="central">Central</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Capacity (BTU/h)</label>
|
||||
<input type="number" step="any" name="capacity_btu" value="12000" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Efficiency (EER)</label>
|
||||
<input type="number" step="any" name="efficiency_eer" value="10.0" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 pb-2">
|
||||
<input type="checkbox" name="has_dehumidify" value="true" class="rounded border-gray-300 dark:border-gray-600">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Has Dehumidify</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add AC Unit</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "toggles"}}
|
||||
<section id="toggles" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">Scenario Toggles</h2>
|
||||
{{if not .Profile}}
|
||||
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
|
||||
{{else}}
|
||||
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{{range $name, $active := .Toggles}}
|
||||
<form method="POST" action="/setup/toggles/set" class="flex items-center justify-between p-2 rounded border dark:border-gray-600">
|
||||
<input type="hidden" name="name" value="{{$name}}">
|
||||
<input type="hidden" name="active" value="{{if $active}}false{{else}}true{{end}}">
|
||||
<span class="font-medium">{{$name}}</span>
|
||||
<button type="submit" class="px-3 py-1 rounded text-sm text-white {{if $active}}bg-green-600 hover:bg-red-600{{else}}bg-gray-400 hover:bg-green-600{{end}}">
|
||||
{{if $active}}ON{{else}}OFF{{end}}
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="POST" action="/setup/toggles/set" class="flex items-center gap-2 p-2 rounded border dark:border-gray-600">
|
||||
<input type="text" name="name" placeholder="new toggle" required class="flex-1 px-2 py-1 border dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-sm">
|
||||
<input type="hidden" name="active" value="true">
|
||||
<button type="submit" class="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "forecast"}}
|
||||
<section id="forecast" class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-3">Forecast</h2>
|
||||
{{if not .Profile}}
|
||||
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
|
||||
{{else}}
|
||||
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Last fetched: <strong>{{if .LastFetch}}{{.LastFetch}}{{else}}never{{end}}</strong>
|
||||
</p>
|
||||
<form method="POST" action="/setup/forecast/fetch">
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Fetch Forecast Now</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
34
internal/cli/toggle.go
Normal file
34
internal/cli/toggle.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
toggleCmd := &cobra.Command{
|
||||
Use: "toggle <scenario>",
|
||||
Short: "Toggle a scenario (gaming, cooking, ac-off)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := args[0]
|
||||
active, _ := db.GetActiveToggleNames(p.ID)
|
||||
newState := !active[name]
|
||||
if err := db.SetToggle(p.ID, name, newState); err != nil {
|
||||
return err
|
||||
}
|
||||
state := "OFF"
|
||||
if newState {
|
||||
state = "ON"
|
||||
}
|
||||
fmt.Printf("Toggle %q: %s\n", name, state)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
rootCmd.AddCommand(toggleCmd)
|
||||
}
|
||||
20
internal/cli/version.go
Normal file
20
internal/cli/version.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const version = "0.1.0"
|
||||
|
||||
func init() {
|
||||
versionCmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("heatwave-autopilot v%s\n", version)
|
||||
},
|
||||
}
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
57
internal/cli/web.go
Normal file
57
internal/cli/web.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
webPort int
|
||||
webDate string
|
||||
webOpen bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
webCmd := &cobra.Command{
|
||||
Use: "web",
|
||||
Short: "Start local web server with the heat dashboard",
|
||||
Long: "Generates the heat report and serves it via a local HTTP server. Shortcut for 'heatwave report serve'.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dateStr := webDate
|
||||
if dateStr == "" {
|
||||
dateStr = time.Now().Format("2006-01-02")
|
||||
}
|
||||
return runWebServer(dateStr, webPort, webOpen)
|
||||
},
|
||||
}
|
||||
webCmd.Flags().IntVar(&webPort, "port", 8080, "HTTP port to serve on")
|
||||
webCmd.Flags().StringVar(&webDate, "date", "", "date (YYYY-MM-DD, default: today)")
|
||||
webCmd.Flags().BoolVar(&webOpen, "open", true, "open browser automatically")
|
||||
|
||||
rootCmd.AddCommand(webCmd)
|
||||
}
|
||||
|
||||
// runWebServer starts the web server with dashboard + setup UI.
|
||||
func runWebServer(dateStr string, port int, open bool) error {
|
||||
mux := http.NewServeMux()
|
||||
registerSetupRoutes(mux, dateStr)
|
||||
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %w", addr, err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://localhost:%d", port)
|
||||
fmt.Printf("Serving heat dashboard at %s (Ctrl+C to stop)\n", url)
|
||||
|
||||
if open {
|
||||
openBrowser(url)
|
||||
}
|
||||
|
||||
return http.Serve(ln, mux)
|
||||
}
|
||||
473
internal/cli/web_handlers.go
Normal file
473
internal/cli/web_handlers.go
Normal file
@@ -0,0 +1,473 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/report"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/static"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/store"
|
||||
)
|
||||
|
||||
//go:embed templates/setup.html.tmpl
|
||||
var setupTmplStr string
|
||||
|
||||
var setupTmpl *template.Template
|
||||
|
||||
func init() {
|
||||
funcs := template.FuncMap{
|
||||
"mul": func(a, b float64) float64 { return a * b },
|
||||
}
|
||||
setupTmpl = template.Must(template.New("setup").Funcs(funcs).Parse(setupTmplStr))
|
||||
}
|
||||
|
||||
// acRoomAssignment pairs a room ID with its name for display.
|
||||
type acRoomAssignment struct {
|
||||
RoomID int64
|
||||
RoomName string
|
||||
}
|
||||
|
||||
// acUnitView wraps an AC unit with its room assignments for the template.
|
||||
type acUnitView struct {
|
||||
store.ACUnit
|
||||
AssignedRoomIDs []acRoomAssignment
|
||||
}
|
||||
|
||||
// setupData holds all data for the setup page template.
|
||||
type setupData struct {
|
||||
CSS template.CSS
|
||||
Flash string
|
||||
Profile *store.Profile
|
||||
Profiles []store.Profile
|
||||
Rooms []store.Room
|
||||
Devices []store.Device
|
||||
Occupants []store.Occupant
|
||||
ACUnits []acUnitView
|
||||
Toggles map[string]bool
|
||||
LastFetch string
|
||||
}
|
||||
|
||||
func loadSetupData(w http.ResponseWriter, r *http.Request) setupData {
|
||||
sd := setupData{
|
||||
CSS: template.CSS(static.TailwindCSS),
|
||||
}
|
||||
sd.Flash = getFlash(w, r)
|
||||
sd.Profiles, _ = db.ListProfiles()
|
||||
|
||||
p, err := getActiveProfile()
|
||||
if err != nil || p == nil {
|
||||
return sd
|
||||
}
|
||||
sd.Profile = p
|
||||
sd.Rooms, _ = db.ListRooms(p.ID)
|
||||
sd.Devices, _ = db.ListAllDevices(p.ID)
|
||||
sd.Occupants, _ = db.ListAllOccupants(p.ID)
|
||||
|
||||
// Build room name lookup
|
||||
roomNames := make(map[int64]string)
|
||||
for _, r := range sd.Rooms {
|
||||
roomNames[r.ID] = r.Name
|
||||
}
|
||||
|
||||
acUnits, _ := db.ListACUnits(p.ID)
|
||||
for _, ac := range acUnits {
|
||||
view := acUnitView{ACUnit: ac}
|
||||
roomIDs, _ := db.GetACRoomAssignments(ac.ID)
|
||||
for _, rid := range roomIDs {
|
||||
view.AssignedRoomIDs = append(view.AssignedRoomIDs, acRoomAssignment{
|
||||
RoomID: rid,
|
||||
RoomName: roomNames[rid],
|
||||
})
|
||||
}
|
||||
sd.ACUnits = append(sd.ACUnits, view)
|
||||
}
|
||||
|
||||
toggles, _ := db.GetToggles(p.ID)
|
||||
sd.Toggles = make(map[string]bool)
|
||||
for _, t := range toggles {
|
||||
sd.Toggles[t.Name] = t.Active
|
||||
}
|
||||
|
||||
lastFetch, err := db.GetLastFetchTime(p.ID, "openmeteo")
|
||||
if err == nil {
|
||||
sd.LastFetch = lastFetch.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
return sd
|
||||
}
|
||||
|
||||
func setupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sd := loadSetupData(w, r)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := setupTmpl.Execute(w, sd); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func dashboardHandler(dateStr string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := buildReportData(dateStr)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><style>%s</style></head>
|
||||
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
<nav class="bg-white dark:bg-gray-800 shadow mb-4">
|
||||
<div class="container mx-auto flex items-center gap-6 px-4 py-3">
|
||||
<span class="font-bold text-lg">Heatwave</span>
|
||||
<a href="/" class="font-medium text-blue-600 dark:text-blue-400 underline">Dashboard</a>
|
||||
<a href="/setup" class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Setup</a>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container mx-auto py-4 px-4">
|
||||
<h1 class="text-2xl font-bold mb-4">Dashboard</h1>
|
||||
<div class="p-4 bg-yellow-50 dark:bg-yellow-950 border-l-4 border-yellow-400 rounded">
|
||||
<p class="font-medium">Cannot load dashboard</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">%s</p>
|
||||
<p class="mt-3"><a href="/setup" class="text-blue-600 dark:text-blue-400 underline">Go to Setup</a> to configure your profile and fetch forecast data.</p>
|
||||
</div>
|
||||
</div></body></html>`, static.TailwindCSS, err.Error())
|
||||
return
|
||||
}
|
||||
data.ShowNav = true
|
||||
html, err := report.GenerateString(data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
}
|
||||
|
||||
// --- CRUD Handlers ---
|
||||
|
||||
func profileAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
name := r.FormValue("name")
|
||||
lat := parseFloatOr(r.FormValue("latitude"), 0)
|
||||
lon := parseFloatOr(r.FormValue("longitude"), 0)
|
||||
tz := r.FormValue("timezone")
|
||||
if tz == "" {
|
||||
tz = "Europe/Berlin"
|
||||
}
|
||||
_, err := db.CreateProfile(name, lat, lon, tz)
|
||||
if err != nil {
|
||||
setFlash(w, "Error creating profile: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "Profile "+name+" created.")
|
||||
}
|
||||
http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func profileDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
id := parseIntOr(r.PathValue("id"), 0)
|
||||
if id == 0 {
|
||||
setFlash(w, "Invalid profile ID.")
|
||||
http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := db.DeleteProfile(id); err != nil {
|
||||
setFlash(w, "Error deleting profile: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "Profile deleted.")
|
||||
}
|
||||
http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func roomAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
setFlash(w, "No active profile.")
|
||||
http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
areaSqm := parseFloatOr(r.FormValue("area_sqm"), 20)
|
||||
floor := int(parseIntOr(r.FormValue("floor"), 0))
|
||||
orientation := r.FormValue("orientation")
|
||||
shadingType := r.FormValue("shading_type")
|
||||
shadingFactor := parseFloatOr(r.FormValue("shading_factor"), 1.0)
|
||||
ventilation := r.FormValue("ventilation")
|
||||
insulation := r.FormValue("insulation")
|
||||
params := store.RoomParams{
|
||||
CeilingHeightM: parseFloatOr(r.FormValue("ceiling_height_m"), 2.50),
|
||||
VentilationACH: parseFloatOr(r.FormValue("ventilation_ach"), 1.5),
|
||||
WindowFraction: parseFloatOr(r.FormValue("window_fraction"), 0.30),
|
||||
SHGC: parseFloatOr(r.FormValue("shgc"), 0.60),
|
||||
}
|
||||
_, err = db.CreateRoom(p.ID, name, areaSqm, floor, orientation, shadingType, shadingFactor, ventilation, insulation, params)
|
||||
if err != nil {
|
||||
setFlash(w, "Error creating room: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "Room "+name+" created.")
|
||||
}
|
||||
http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func roomDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
id := parseIntOr(r.PathValue("id"), 0)
|
||||
if id == 0 {
|
||||
setFlash(w, "Invalid room ID.")
|
||||
http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := db.DeleteRoom(id); err != nil {
|
||||
setFlash(w, "Error deleting room: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "Room deleted.")
|
||||
}
|
||||
http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func deviceAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
roomID := parseIntOr(r.FormValue("room_id"), 0)
|
||||
name := r.FormValue("name")
|
||||
deviceType := r.FormValue("device_type")
|
||||
wattsIdle := parseFloatOr(r.FormValue("watts_idle"), 10)
|
||||
wattsTypical := parseFloatOr(r.FormValue("watts_typical"), 80)
|
||||
wattsPeak := parseFloatOr(r.FormValue("watts_peak"), 200)
|
||||
dutyCycle := parseFloatOr(r.FormValue("duty_cycle"), 0.5)
|
||||
_, err := db.CreateDevice(roomID, name, deviceType, wattsIdle, wattsTypical, wattsPeak, dutyCycle)
|
||||
if err != nil {
|
||||
setFlash(w, "Error creating device: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "Device "+name+" created.")
|
||||
}
|
||||
http.Redirect(w, r, "/setup#devices", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func deviceDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
id := parseIntOr(r.PathValue("id"), 0)
|
||||
if id == 0 {
|
||||
setFlash(w, "Invalid device ID.")
|
||||
http.Redirect(w, r, "/setup#devices", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := db.DeleteDevice(id); err != nil {
|
||||
setFlash(w, "Error deleting device: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "Device deleted.")
|
||||
}
|
||||
http.Redirect(w, r, "/setup#devices", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func occupantAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
roomID := parseIntOr(r.FormValue("room_id"), 0)
|
||||
count := int(parseIntOr(r.FormValue("count"), 1))
|
||||
activityLevel := r.FormValue("activity_level")
|
||||
vulnerable := r.FormValue("vulnerable") == "true"
|
||||
_, err := db.CreateOccupant(roomID, count, activityLevel, vulnerable)
|
||||
if err != nil {
|
||||
setFlash(w, "Error creating occupant: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "Occupant added.")
|
||||
}
|
||||
http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func occupantDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
id := parseIntOr(r.PathValue("id"), 0)
|
||||
if id == 0 {
|
||||
setFlash(w, "Invalid occupant ID.")
|
||||
http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := db.DeleteOccupant(id); err != nil {
|
||||
setFlash(w, "Error deleting occupant: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "Occupant deleted.")
|
||||
}
|
||||
http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func acAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
setFlash(w, "No active profile.")
|
||||
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
acType := r.FormValue("ac_type")
|
||||
capacityBTU := parseFloatOr(r.FormValue("capacity_btu"), 12000)
|
||||
efficiencyEER := parseFloatOr(r.FormValue("efficiency_eer"), 10.0)
|
||||
hasDehumidify := r.FormValue("has_dehumidify") == "true"
|
||||
_, err = db.CreateACUnit(p.ID, name, acType, capacityBTU, hasDehumidify, efficiencyEER)
|
||||
if err != nil {
|
||||
setFlash(w, "Error creating AC unit: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "AC unit "+name+" created.")
|
||||
}
|
||||
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func acDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
id := parseIntOr(r.PathValue("id"), 0)
|
||||
if id == 0 {
|
||||
setFlash(w, "Invalid AC unit ID.")
|
||||
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := db.DeleteACUnit(id); err != nil {
|
||||
setFlash(w, "Error deleting AC unit: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "AC unit deleted.")
|
||||
}
|
||||
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func acAssignHandler(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
acID := parseIntOr(r.PathValue("id"), 0)
|
||||
roomID := parseIntOr(r.FormValue("room_id"), 0)
|
||||
if acID == 0 || roomID == 0 {
|
||||
setFlash(w, "Invalid AC or room ID.")
|
||||
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := db.AssignACToRoom(acID, roomID); err != nil {
|
||||
setFlash(w, "Error assigning AC: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "AC unit assigned to room.")
|
||||
}
|
||||
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func acUnassignHandler(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
acID := parseIntOr(r.PathValue("id"), 0)
|
||||
roomID := parseIntOr(r.FormValue("room_id"), 0)
|
||||
if acID == 0 || roomID == 0 {
|
||||
setFlash(w, "Invalid AC or room ID.")
|
||||
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := db.UnassignACFromRoom(acID, roomID); err != nil {
|
||||
setFlash(w, "Error unassigning AC: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "AC unit unassigned from room.")
|
||||
}
|
||||
http.Redirect(w, r, "/setup#ac", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func toggleSetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
setFlash(w, "No active profile.")
|
||||
http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
active := r.FormValue("active") == "true"
|
||||
if name == "" {
|
||||
setFlash(w, "Toggle name is required.")
|
||||
http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := db.SetToggle(p.ID, name, active); err != nil {
|
||||
setFlash(w, "Error setting toggle: "+err.Error())
|
||||
} else {
|
||||
state := "OFF"
|
||||
if active {
|
||||
state = "ON"
|
||||
}
|
||||
setFlash(w, fmt.Sprintf("Toggle %q set to %s.", name, state))
|
||||
}
|
||||
http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func forecastFetchHandler(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := getActiveProfile()
|
||||
if err != nil {
|
||||
setFlash(w, "No active profile.")
|
||||
http.Redirect(w, r, "/setup#forecast", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := fetchForecastForProfile(p); err != nil {
|
||||
setFlash(w, "Forecast fetch failed: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "Forecast fetched successfully.")
|
||||
}
|
||||
http.Redirect(w, r, "/setup#forecast", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- Flash helpers ---
|
||||
|
||||
const flashCookieName = "heatwave_flash"
|
||||
|
||||
func setFlash(w http.ResponseWriter, msg string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: flashCookieName,
|
||||
Value: msg,
|
||||
Path: "/",
|
||||
MaxAge: 10,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func getFlash(w http.ResponseWriter, r *http.Request) string {
|
||||
c, err := r.Cookie(flashCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// Clear the flash cookie immediately
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: flashCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
})
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// --- Parse helpers ---
|
||||
|
||||
func parseFloatOr(s string, def float64) float64 {
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseIntOr(s string, def int64) int64 {
|
||||
v, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// registerSetupRoutes registers all setup-related routes on the mux.
|
||||
func registerSetupRoutes(mux *http.ServeMux, dateStr string) {
|
||||
mux.HandleFunc("GET /{$}", dashboardHandler(dateStr))
|
||||
mux.HandleFunc("GET /setup", setupHandler)
|
||||
|
||||
mux.HandleFunc("POST /setup/profiles/add", profileAddHandler)
|
||||
mux.HandleFunc("POST /setup/profiles/{id}/delete", profileDeleteHandler)
|
||||
|
||||
mux.HandleFunc("POST /setup/rooms/add", roomAddHandler)
|
||||
mux.HandleFunc("POST /setup/rooms/{id}/delete", roomDeleteHandler)
|
||||
|
||||
mux.HandleFunc("POST /setup/devices/add", deviceAddHandler)
|
||||
mux.HandleFunc("POST /setup/devices/{id}/delete", deviceDeleteHandler)
|
||||
|
||||
mux.HandleFunc("POST /setup/occupants/add", occupantAddHandler)
|
||||
mux.HandleFunc("POST /setup/occupants/{id}/delete", occupantDeleteHandler)
|
||||
|
||||
mux.HandleFunc("POST /setup/ac/add", acAddHandler)
|
||||
mux.HandleFunc("POST /setup/ac/{id}/delete", acDeleteHandler)
|
||||
mux.HandleFunc("POST /setup/ac/{id}/assign", acAssignHandler)
|
||||
mux.HandleFunc("POST /setup/ac/{id}/unassign", acUnassignHandler)
|
||||
|
||||
mux.HandleFunc("POST /setup/toggles/set", toggleSetHandler)
|
||||
mux.HandleFunc("POST /setup/forecast/fetch", forecastFetchHandler)
|
||||
}
|
||||
62
internal/config/config.go
Normal file
62
internal/config/config.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config holds the application configuration.
|
||||
type Config struct {
|
||||
LLM LLMConfig `yaml:"llm"`
|
||||
}
|
||||
|
||||
// LLMConfig holds LLM provider settings.
|
||||
type LLMConfig struct {
|
||||
Provider string `yaml:"provider"` // anthropic, openai, ollama, none
|
||||
Model string `yaml:"model"`
|
||||
Endpoint string `yaml:"endpoint"` // for ollama
|
||||
}
|
||||
|
||||
// DefaultConfig returns a Config with sensible defaults.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
LLM: LLMConfig{Provider: "none"},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigDir returns the XDG config directory for heatwave.
|
||||
func ConfigDir() string {
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "heatwave")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "heatwave")
|
||||
}
|
||||
|
||||
// DataDir returns the XDG data directory for heatwave.
|
||||
func DataDir() string {
|
||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "heatwave")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".local", "share", "heatwave")
|
||||
}
|
||||
|
||||
// DefaultDBPath returns the default SQLite database path.
|
||||
func DefaultDBPath() string {
|
||||
return filepath.Join(DataDir(), "heatwave.db")
|
||||
}
|
||||
|
||||
// Load reads the config file from the config directory.
|
||||
func Load() Config {
|
||||
cfg := DefaultConfig()
|
||||
path := filepath.Join(ConfigDir(), "config.yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cfg
|
||||
}
|
||||
_ = yaml.Unmarshal(data, &cfg)
|
||||
return cfg
|
||||
}
|
||||
78
internal/heat/budget.go
Normal file
78
internal/heat/budget.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package heat
|
||||
|
||||
// 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)
|
||||
)
|
||||
|
||||
func (s BudgetStatus) String() string {
|
||||
switch s {
|
||||
case Comfortable:
|
||||
return "comfortable"
|
||||
case Marginal:
|
||||
return "marginal"
|
||||
case Overloaded:
|
||||
return "overloaded"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// BudgetResult holds the computed heat budget for a room.
|
||||
type BudgetResult struct {
|
||||
InternalGainsW float64
|
||||
SolarGainW float64
|
||||
VentilationGainW float64
|
||||
TotalGainW float64
|
||||
TotalGainBTUH float64
|
||||
ACCapacityBTUH float64
|
||||
HeadroomBTUH float64
|
||||
Status BudgetStatus
|
||||
}
|
||||
|
||||
// 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)
|
||||
ventilation := VentilationGain(in.Ventilation)
|
||||
|
||||
totalW := internal + solar + ventilation
|
||||
totalBTUH := WattsToBTUH(totalW)
|
||||
headroom := in.ACCapacityBTUH - totalBTUH
|
||||
|
||||
status := Overloaded
|
||||
if in.ACCapacityBTUH > 0 {
|
||||
ratio := headroom / in.ACCapacityBTUH
|
||||
switch {
|
||||
case ratio > 0.2:
|
||||
status = Comfortable
|
||||
case ratio >= 0:
|
||||
status = Marginal
|
||||
}
|
||||
}
|
||||
|
||||
return BudgetResult{
|
||||
InternalGainsW: internal,
|
||||
SolarGainW: solar,
|
||||
VentilationGainW: ventilation,
|
||||
TotalGainW: totalW,
|
||||
TotalGainBTUH: totalBTUH,
|
||||
ACCapacityBTUH: in.ACCapacityBTUH,
|
||||
HeadroomBTUH: headroom,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
129
internal/heat/budget_test.go
Normal file
129
internal/heat/budget_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
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",
|
||||
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},
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
103
internal/heat/external_gains.go
Normal file
103
internal/heat/external_gains.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package heat
|
||||
|
||||
// SolarParams holds inputs for simplified solar gain calculation.
|
||||
type SolarParams struct {
|
||||
AreaSqm float64 // room floor area in m²
|
||||
WindowFraction float64 // fraction of wall area that is window (e.g., 0.15)
|
||||
SHGC float64 // solar heat gain coefficient of glazing (e.g., 0.6)
|
||||
ShadingFactor float64 // 0.0 (fully shaded) to 1.0 (no shading)
|
||||
OrientationFactor float64 // 0.0–1.0, varies by hour and wall orientation
|
||||
CloudFactor float64 // 0.0 (overcast) to 1.0 (clear)
|
||||
SunshineFraction float64 // fraction of hour with sunshine (0.0–1.0)
|
||||
PeakIrradiance float64 // W/m² on the surface (e.g., 800 for direct sun)
|
||||
}
|
||||
|
||||
// OrientationFactor returns a solar exposure factor (0.0–1.0) based on
|
||||
// wall orientation and hour of day. This is a simplified proxy.
|
||||
func OrientationFactor(orientation string, hour int) float64 {
|
||||
switch orientation {
|
||||
case "S":
|
||||
if hour >= 10 && hour <= 16 {
|
||||
return 1.0
|
||||
}
|
||||
if hour >= 8 && hour < 10 || hour > 16 && hour <= 18 {
|
||||
return 0.5
|
||||
}
|
||||
return 0.0
|
||||
case "E":
|
||||
if hour >= 6 && hour <= 11 {
|
||||
return 0.9
|
||||
}
|
||||
if hour > 11 && hour <= 14 {
|
||||
return 0.3
|
||||
}
|
||||
return 0.0
|
||||
case "W":
|
||||
if hour >= 14 && hour <= 20 {
|
||||
return 0.9
|
||||
}
|
||||
if hour >= 11 && hour < 14 {
|
||||
return 0.3
|
||||
}
|
||||
return 0.0
|
||||
case "SE":
|
||||
if hour >= 7 && hour <= 13 {
|
||||
return 0.9
|
||||
}
|
||||
if hour > 13 && hour <= 16 {
|
||||
return 0.4
|
||||
}
|
||||
return 0.0
|
||||
case "SW":
|
||||
if hour >= 12 && hour <= 19 {
|
||||
return 0.9
|
||||
}
|
||||
if hour >= 9 && hour < 12 {
|
||||
return 0.4
|
||||
}
|
||||
return 0.0
|
||||
case "NE":
|
||||
if hour >= 5 && hour <= 9 {
|
||||
return 0.5
|
||||
}
|
||||
return 0.1
|
||||
case "NW":
|
||||
if hour >= 17 && hour <= 21 {
|
||||
return 0.5
|
||||
}
|
||||
return 0.1
|
||||
case "N":
|
||||
return 0.1 // minimal direct sun
|
||||
default:
|
||||
return 0.5
|
||||
}
|
||||
}
|
||||
|
||||
// SolarGain returns estimated solar heat gain in watts.
|
||||
// Formula: irradiance * orientationFactor * windowArea * SHGC * shadingFactor * cloudFactor * sunshineFraction
|
||||
func SolarGain(p SolarParams) float64 {
|
||||
windowArea := p.AreaSqm * p.WindowFraction
|
||||
return p.PeakIrradiance * p.OrientationFactor * windowArea * p.SHGC * p.ShadingFactor * p.CloudFactor * p.SunshineFraction
|
||||
}
|
||||
|
||||
// DefaultRhoCp is the volumetric heat capacity of air in J/(m³·K).
|
||||
// Approximately 1200 J/(m³·K) at sea level.
|
||||
const DefaultRhoCp = 1200.0
|
||||
|
||||
// VentilationParams holds inputs for ventilation heat gain/loss calculation.
|
||||
type VentilationParams struct {
|
||||
ACH float64 // air changes per hour
|
||||
VolumeCubicM float64 // room volume in m³
|
||||
OutdoorTempC float64 // outdoor temperature in °C
|
||||
IndoorTempC float64 // indoor temperature in °C
|
||||
RhoCp float64 // volumetric heat capacity in kJ/(m³·K); use value such that kJ * 1000 = J, or pass as J directly
|
||||
}
|
||||
|
||||
// VentilationGain returns ventilation heat gain in watts.
|
||||
// Positive = heating (outdoor hotter), negative = cooling (outdoor cooler).
|
||||
// Formula: ACH * volume * rhoCp_J * deltaT / 3600
|
||||
func VentilationGain(p VentilationParams) float64 {
|
||||
rhoCpJ := p.RhoCp * 1000 // convert kJ/(m³·K) to J/(m³·K)
|
||||
deltaT := p.OutdoorTempC - p.IndoorTempC
|
||||
return p.ACH * p.VolumeCubicM * rhoCpJ * deltaT / 3600
|
||||
}
|
||||
130
internal/heat/external_gains_test.go
Normal file
130
internal/heat/external_gains_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package heat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSolarGain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
p SolarParams
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "midday south-facing room, clear sky, no shading",
|
||||
p: SolarParams{
|
||||
AreaSqm: 20,
|
||||
WindowFraction: 0.15,
|
||||
SHGC: 0.6,
|
||||
ShadingFactor: 1.0,
|
||||
OrientationFactor: 1.0,
|
||||
CloudFactor: 1.0,
|
||||
SunshineFraction: 1.0,
|
||||
PeakIrradiance: 800,
|
||||
},
|
||||
// 800 * 1.0 * (20*0.15) * 0.6 * 1.0 * 1.0 * 1.0 = 800 * 3 * 0.6 = 1440
|
||||
want: 1440,
|
||||
},
|
||||
{
|
||||
name: "overcast, shutters closed",
|
||||
p: SolarParams{
|
||||
AreaSqm: 20,
|
||||
WindowFraction: 0.15,
|
||||
SHGC: 0.6,
|
||||
ShadingFactor: 0.2,
|
||||
OrientationFactor: 1.0,
|
||||
CloudFactor: 0.3,
|
||||
SunshineFraction: 0.2,
|
||||
PeakIrradiance: 800,
|
||||
},
|
||||
// 800 * 1.0 * 3 * 0.6 * 0.2 * 0.3 * 0.2 = 1440 * 0.012 = 17.28
|
||||
want: 17.28,
|
||||
},
|
||||
{
|
||||
name: "night time (zero irradiance)",
|
||||
p: SolarParams{
|
||||
AreaSqm: 20,
|
||||
WindowFraction: 0.15,
|
||||
SHGC: 0.6,
|
||||
ShadingFactor: 1.0,
|
||||
OrientationFactor: 1.0,
|
||||
CloudFactor: 1.0,
|
||||
SunshineFraction: 0.0,
|
||||
PeakIrradiance: 0,
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SolarGain(tt.p)
|
||||
if !almostEqual(got, tt.want, tolerance) {
|
||||
t.Errorf("SolarGain() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVentilationGain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
p VentilationParams
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "outdoor hotter than indoor, windows open",
|
||||
p: VentilationParams{
|
||||
ACH: 2.0,
|
||||
VolumeCubicM: 45, // 15sqm * 3m ceiling
|
||||
OutdoorTempC: 35,
|
||||
IndoorTempC: 25,
|
||||
RhoCp: 1.2, // kg/m³ * kJ/(kg·K) → ~1.2 kJ/(m³·K) = 1200 J/(m³·K)
|
||||
},
|
||||
// ACH * vol * rhoCp * deltaT / 3600
|
||||
// 2 * 45 * 1200 * 10 / 3600 = 300
|
||||
want: 300,
|
||||
},
|
||||
{
|
||||
name: "outdoor cooler than indoor (negative gain = cooling)",
|
||||
p: VentilationParams{
|
||||
ACH: 2.0,
|
||||
VolumeCubicM: 45,
|
||||
OutdoorTempC: 18,
|
||||
IndoorTempC: 25,
|
||||
RhoCp: 1.2,
|
||||
},
|
||||
// 2 * 45 * 1200 * (-7) / 3600 = -210
|
||||
want: -210,
|
||||
},
|
||||
{
|
||||
name: "equal temperatures",
|
||||
p: VentilationParams{
|
||||
ACH: 2.0,
|
||||
VolumeCubicM: 45,
|
||||
OutdoorTempC: 25,
|
||||
IndoorTempC: 25,
|
||||
RhoCp: 1.2,
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "windows closed (ACH=0)",
|
||||
p: VentilationParams{
|
||||
ACH: 0,
|
||||
VolumeCubicM: 45,
|
||||
OutdoorTempC: 35,
|
||||
IndoorTempC: 25,
|
||||
RhoCp: 1.2,
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := VentilationGain(tt.p)
|
||||
if !almostEqual(got, tt.want, tolerance) {
|
||||
t.Errorf("VentilationGain() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
93
internal/heat/internal_gains.go
Normal file
93
internal/heat/internal_gains.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package heat
|
||||
|
||||
// DeviceMode selects which power draw to use for heat gain calculation.
|
||||
type DeviceMode int
|
||||
|
||||
const (
|
||||
ModeIdle DeviceMode = iota
|
||||
ModeTypical
|
||||
ModePeak
|
||||
)
|
||||
|
||||
// Device represents a heat-producing device in a room.
|
||||
type Device struct {
|
||||
WattsIdle float64
|
||||
WattsTypical float64
|
||||
WattsPeak float64
|
||||
DutyCycle float64 // 0.0–1.0
|
||||
}
|
||||
|
||||
// ActivityLevel represents metabolic activity of room occupants.
|
||||
type ActivityLevel int
|
||||
|
||||
const (
|
||||
Sleeping ActivityLevel = iota
|
||||
Sedentary
|
||||
LightActivity
|
||||
ModerateActivity
|
||||
HeavyActivity
|
||||
)
|
||||
|
||||
// metabolicWatts maps activity level to per-person heat output in watts.
|
||||
var metabolicWatts = map[ActivityLevel]float64{
|
||||
Sleeping: 70,
|
||||
Sedentary: 100,
|
||||
LightActivity: 130,
|
||||
ModerateActivity: 200,
|
||||
HeavyActivity: 300,
|
||||
}
|
||||
|
||||
// Occupant represents people in a room.
|
||||
type Occupant struct {
|
||||
Count int
|
||||
Activity ActivityLevel
|
||||
}
|
||||
|
||||
// ParseActivityLevel converts a string to ActivityLevel.
|
||||
func ParseActivityLevel(s string) ActivityLevel {
|
||||
switch s {
|
||||
case "sleeping":
|
||||
return Sleeping
|
||||
case "sedentary":
|
||||
return Sedentary
|
||||
case "light":
|
||||
return LightActivity
|
||||
case "moderate":
|
||||
return ModerateActivity
|
||||
case "heavy":
|
||||
return HeavyActivity
|
||||
default:
|
||||
return Sedentary
|
||||
}
|
||||
}
|
||||
|
||||
// DeviceHeatGain returns heat output in watts for a device in the given mode.
|
||||
func DeviceHeatGain(d Device, mode DeviceMode) float64 {
|
||||
var base float64
|
||||
switch mode {
|
||||
case ModeIdle:
|
||||
base = d.WattsIdle
|
||||
case ModeTypical:
|
||||
base = d.WattsTypical
|
||||
case ModePeak:
|
||||
base = d.WattsPeak
|
||||
}
|
||||
return base * d.DutyCycle
|
||||
}
|
||||
|
||||
// OccupantHeatGain returns total metabolic heat output in watts.
|
||||
func OccupantHeatGain(count int, activity ActivityLevel) float64 {
|
||||
return float64(count) * metabolicWatts[activity]
|
||||
}
|
||||
|
||||
// TotalInternalGains sums device and occupant heat gains in watts.
|
||||
func TotalInternalGains(devices []Device, mode DeviceMode, occupants []Occupant) float64 {
|
||||
var total float64
|
||||
for _, d := range devices {
|
||||
total += DeviceHeatGain(d, mode)
|
||||
}
|
||||
for _, o := range occupants {
|
||||
total += OccupantHeatGain(o.Count, o.Activity)
|
||||
}
|
||||
return total
|
||||
}
|
||||
96
internal/heat/internal_gains_test.go
Normal file
96
internal/heat/internal_gains_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package heat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeviceHeatGain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dev Device
|
||||
mode DeviceMode
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "idle mode uses idle watts",
|
||||
dev: Device{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 1.0},
|
||||
mode: ModeIdle,
|
||||
want: 65,
|
||||
},
|
||||
{
|
||||
name: "typical mode with full duty cycle",
|
||||
dev: Device{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 1.0},
|
||||
mode: ModeTypical,
|
||||
want: 200,
|
||||
},
|
||||
{
|
||||
name: "typical mode with 50% duty cycle",
|
||||
dev: Device{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 0.5},
|
||||
mode: ModeTypical,
|
||||
want: 100,
|
||||
},
|
||||
{
|
||||
name: "peak mode",
|
||||
dev: Device{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 1.0},
|
||||
mode: ModePeak,
|
||||
want: 450,
|
||||
},
|
||||
{
|
||||
name: "zero duty cycle",
|
||||
dev: Device{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 0.0},
|
||||
mode: ModeTypical,
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := DeviceHeatGain(tt.dev, tt.mode)
|
||||
if !almostEqual(got, tt.want, tolerance) {
|
||||
t.Errorf("DeviceHeatGain() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccupantHeatGain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
count int
|
||||
activity ActivityLevel
|
||||
want float64
|
||||
}{
|
||||
{"one sleeping person", 1, Sleeping, 70},
|
||||
{"one sedentary person", 1, Sedentary, 100},
|
||||
{"two sedentary people", 2, Sedentary, 200},
|
||||
{"one light activity", 1, LightActivity, 130},
|
||||
{"one moderate activity", 1, ModerateActivity, 200},
|
||||
{"one heavy activity", 1, HeavyActivity, 300},
|
||||
{"zero people", 0, Sedentary, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := OccupantHeatGain(tt.count, tt.activity)
|
||||
if !almostEqual(got, tt.want, tolerance) {
|
||||
t.Errorf("OccupantHeatGain(%d, %v) = %v, want %v", tt.count, tt.activity, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalInternalGains(t *testing.T) {
|
||||
devices := []Device{
|
||||
{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 1.0},
|
||||
{WattsIdle: 30, WattsTypical: 80, WattsPeak: 120, DutyCycle: 0.5},
|
||||
}
|
||||
occupants := []Occupant{
|
||||
{Count: 1, Activity: Sedentary},
|
||||
{Count: 2, Activity: LightActivity},
|
||||
}
|
||||
|
||||
// 200 + 40 + 100 + 260 = 600
|
||||
got := TotalInternalGains(devices, ModeTypical, occupants)
|
||||
want := 600.0
|
||||
if !almostEqual(got, want, tolerance) {
|
||||
t.Errorf("TotalInternalGains() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
14
internal/heat/units.go
Normal file
14
internal/heat/units.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package heat
|
||||
|
||||
// 1 Watt = 3.41214 BTU/h
|
||||
const wattToBTUH = 3.41214
|
||||
|
||||
// WattsToBTUH converts watts to BTU per hour.
|
||||
func WattsToBTUH(watts float64) float64 {
|
||||
return watts * wattToBTUH
|
||||
}
|
||||
|
||||
// BTUHToWatts converts BTU per hour to watts.
|
||||
func BTUHToWatts(btuh float64) float64 {
|
||||
return btuh / wattToBTUH
|
||||
}
|
||||
62
internal/heat/units_test.go
Normal file
62
internal/heat/units_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package heat
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const tolerance = 0.01
|
||||
|
||||
func almostEqual(a, b, tol float64) bool {
|
||||
return math.Abs(a-b) < tol
|
||||
}
|
||||
|
||||
func TestWattsToBTUH(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
watts float64
|
||||
want float64
|
||||
}{
|
||||
{"zero", 0, 0},
|
||||
{"one watt", 1, 3.41214},
|
||||
{"1000 watts", 1000, 3412.14},
|
||||
{"negative", -100, -341.214},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := WattsToBTUH(tt.watts)
|
||||
if !almostEqual(got, tt.want, tolerance) {
|
||||
t.Errorf("WattsToBTUH(%v) = %v, want %v", tt.watts, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTUHToWatts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
btuh float64
|
||||
want float64
|
||||
}{
|
||||
{"zero", 0, 0},
|
||||
{"one BTU/h", 1, 0.29307},
|
||||
{"8000 BTU/h", 8000, 2344.57},
|
||||
{"negative", -3412.14, -1000.0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := BTUHToWatts(tt.btuh)
|
||||
if !almostEqual(got, tt.want, tolerance) {
|
||||
t.Errorf("BTUHToWatts(%v) = %v, want %v", tt.btuh, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
original := 500.0
|
||||
got := BTUHToWatts(WattsToBTUH(original))
|
||||
if !almostEqual(got, original, tolerance) {
|
||||
t.Errorf("round-trip failed: %v -> %v", original, got)
|
||||
}
|
||||
}
|
||||
106
internal/llm/anthropic.go
Normal file
106
internal/llm/anthropic.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Anthropic implements Provider using the Anthropic Messages API.
|
||||
type Anthropic struct {
|
||||
apiKey string
|
||||
model string
|
||||
client *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewAnthropic creates a new Anthropic provider.
|
||||
func NewAnthropic(apiKey, model string, client *http.Client) *Anthropic {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 60 * time.Second}
|
||||
}
|
||||
if model == "" {
|
||||
model = "claude-sonnet-4-5-20250929"
|
||||
}
|
||||
return &Anthropic{apiKey: apiKey, model: model, client: client, baseURL: "https://api.anthropic.com"}
|
||||
}
|
||||
|
||||
func (a *Anthropic) Name() string { return "anthropic" }
|
||||
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System string `json:"system"`
|
||||
Messages []anthropicMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type anthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type anthropicResponse struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func (a *Anthropic) call(ctx context.Context, systemPrompt, userMessage string, maxTokens int) (string, error) {
|
||||
reqBody := anthropicRequest{
|
||||
Model: a.model,
|
||||
MaxTokens: maxTokens,
|
||||
System: systemPrompt,
|
||||
Messages: []anthropicMessage{
|
||||
{Role: "user", Content: userMessage},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/v1/messages", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-api-key", a.apiKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("anthropic call: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result anthropicResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if result.Error != nil {
|
||||
return "", fmt.Errorf("anthropic error: %s", result.Error.Message)
|
||||
}
|
||||
if len(result.Content) == 0 {
|
||||
return "", fmt.Errorf("empty response from anthropic")
|
||||
}
|
||||
return result.Content[0].Text, nil
|
||||
}
|
||||
|
||||
func (a *Anthropic) Summarize(ctx context.Context, input SummaryInput) (string, error) {
|
||||
return a.call(ctx, SummarizeSystemPrompt(), BuildSummaryPrompt(input), 300)
|
||||
}
|
||||
|
||||
func (a *Anthropic) RewriteAction(ctx context.Context, input ActionInput) (string, error) {
|
||||
return a.call(ctx, RewriteActionSystemPrompt(), BuildRewriteActionPrompt(input), 100)
|
||||
}
|
||||
|
||||
func (a *Anthropic) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) {
|
||||
return a.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input), 2000)
|
||||
}
|
||||
121
internal/llm/llm_test.go
Normal file
121
internal/llm/llm_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNoopProvider(t *testing.T) {
|
||||
n := NewNoop()
|
||||
if n.Name() != "none" {
|
||||
t.Errorf("Name = %s, want none", n.Name())
|
||||
}
|
||||
s, err := n.Summarize(context.Background(), SummaryInput{})
|
||||
if err != nil || s != "" {
|
||||
t.Errorf("Summarize = (%q, %v), want empty", s, err)
|
||||
}
|
||||
r, err := n.RewriteAction(context.Background(), ActionInput{})
|
||||
if err != nil || r != "" {
|
||||
t.Errorf("RewriteAction = (%q, %v), want empty", r, err)
|
||||
}
|
||||
h, err := n.GenerateHeatPlan(context.Background(), HeatPlanInput{})
|
||||
if err != nil || h != "" {
|
||||
t.Errorf("GenerateHeatPlan = (%q, %v), want empty", h, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicProvider_MockServer(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-api-key") != "test-key" {
|
||||
t.Error("missing api key header")
|
||||
}
|
||||
if r.Header.Get("anthropic-version") != "2023-06-01" {
|
||||
t.Error("missing anthropic-version header")
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"content": []map[string]string{
|
||||
{"type": "text", "text": "- Bullet one\n- Bullet two\n- Bullet three"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := NewAnthropic("test-key", "test-model", srv.Client())
|
||||
a.baseURL = srv.URL
|
||||
|
||||
result, err := a.Summarize(context.Background(), testSummaryInput())
|
||||
if err != nil {
|
||||
t.Fatalf("Summarize: %v", err)
|
||||
}
|
||||
if result == "" {
|
||||
t.Error("empty result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicProvider_Error(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]string{"message": "invalid api key"},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := NewAnthropic("bad-key", "", srv.Client())
|
||||
a.baseURL = srv.URL
|
||||
|
||||
_, err := a.Summarize(context.Background(), SummaryInput{})
|
||||
if err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_MockServer(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !containsBearer(r.Header.Get("Authorization")) {
|
||||
t.Error("missing bearer token")
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{"message": map[string]string{"content": "Test summary"}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
o := NewOpenAI("test-key", "test-model", srv.Client())
|
||||
o.baseURL = srv.URL
|
||||
|
||||
result, err := o.Summarize(context.Background(), testSummaryInput())
|
||||
if err != nil {
|
||||
t.Fatalf("Summarize: %v", err)
|
||||
}
|
||||
if result != "Test summary" {
|
||||
t.Errorf("result = %q, want 'Test summary'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaProvider_MockServer(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"message": map[string]string{"content": "Ollama summary"},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
o := NewOllama("test-model", srv.URL, srv.Client())
|
||||
|
||||
result, err := o.Summarize(context.Background(), testSummaryInput())
|
||||
if err != nil {
|
||||
t.Fatalf("Summarize: %v", err)
|
||||
}
|
||||
if result != "Ollama summary" {
|
||||
t.Errorf("result = %q, want 'Ollama summary'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func containsBearer(s string) bool {
|
||||
return len(s) > 7 && s[:7] == "Bearer "
|
||||
}
|
||||
12
internal/llm/noop.go
Normal file
12
internal/llm/noop.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package llm
|
||||
|
||||
import "context"
|
||||
|
||||
// Noop is a no-op LLM provider that returns empty strings.
|
||||
type Noop struct{}
|
||||
|
||||
func NewNoop() *Noop { return &Noop{} }
|
||||
func (n *Noop) Name() string { return "none" }
|
||||
func (n *Noop) Summarize(_ context.Context, _ SummaryInput) (string, error) { return "", nil }
|
||||
func (n *Noop) RewriteAction(_ context.Context, _ ActionInput) (string, error) { return "", nil }
|
||||
func (n *Noop) GenerateHeatPlan(_ context.Context, _ HeatPlanInput) (string, error) { return "", nil }
|
||||
99
internal/llm/ollama.go
Normal file
99
internal/llm/ollama.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ollama implements Provider using a local Ollama instance.
|
||||
type Ollama struct {
|
||||
model string
|
||||
endpoint string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewOllama creates a new Ollama provider.
|
||||
func NewOllama(model, endpoint string, client *http.Client) *Ollama {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 120 * time.Second}
|
||||
}
|
||||
if model == "" {
|
||||
model = "llama3.2"
|
||||
}
|
||||
if endpoint == "" {
|
||||
endpoint = "http://localhost:11434"
|
||||
}
|
||||
return &Ollama{model: model, endpoint: endpoint, client: client}
|
||||
}
|
||||
|
||||
func (o *Ollama) Name() string { return "ollama" }
|
||||
|
||||
type ollamaRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []ollamaMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type ollamaMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type ollamaResponse struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (o *Ollama) call(ctx context.Context, systemPrompt, userMessage string) (string, error) {
|
||||
reqBody := ollamaRequest{
|
||||
Model: o.model,
|
||||
Messages: []ollamaMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userMessage},
|
||||
},
|
||||
Stream: false,
|
||||
}
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.endpoint+"/api/chat", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := o.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ollama call: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result ollamaResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if result.Error != "" {
|
||||
return "", fmt.Errorf("ollama error: %s", result.Error)
|
||||
}
|
||||
return result.Message.Content, nil
|
||||
}
|
||||
|
||||
func (o *Ollama) Summarize(ctx context.Context, input SummaryInput) (string, error) {
|
||||
return o.call(ctx, SummarizeSystemPrompt(), BuildSummaryPrompt(input))
|
||||
}
|
||||
|
||||
func (o *Ollama) RewriteAction(ctx context.Context, input ActionInput) (string, error) {
|
||||
return o.call(ctx, RewriteActionSystemPrompt(), BuildRewriteActionPrompt(input))
|
||||
}
|
||||
|
||||
func (o *Ollama) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) {
|
||||
return o.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input))
|
||||
}
|
||||
103
internal/llm/openai.go
Normal file
103
internal/llm/openai.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OpenAI implements Provider using the OpenAI Chat Completions API.
|
||||
type OpenAI struct {
|
||||
apiKey string
|
||||
model string
|
||||
client *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewOpenAI creates a new OpenAI provider.
|
||||
func NewOpenAI(apiKey, model string, client *http.Client) *OpenAI {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 60 * time.Second}
|
||||
}
|
||||
if model == "" {
|
||||
model = "gpt-4o"
|
||||
}
|
||||
return &OpenAI{apiKey: apiKey, model: model, client: client, baseURL: "https://api.openai.com"}
|
||||
}
|
||||
|
||||
func (o *OpenAI) Name() string { return "openai" }
|
||||
|
||||
type openAIRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []openAIMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type openAIMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type openAIResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func (o *OpenAI) call(ctx context.Context, systemPrompt, userMessage string) (string, error) {
|
||||
reqBody := openAIRequest{
|
||||
Model: o.model,
|
||||
Messages: []openAIMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userMessage},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.baseURL+"/v1/chat/completions", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+o.apiKey)
|
||||
|
||||
resp, err := o.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("openai call: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result openAIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if result.Error != nil {
|
||||
return "", fmt.Errorf("openai error: %s", result.Error.Message)
|
||||
}
|
||||
if len(result.Choices) == 0 {
|
||||
return "", fmt.Errorf("empty response from openai")
|
||||
}
|
||||
return result.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
func (o *OpenAI) Summarize(ctx context.Context, input SummaryInput) (string, error) {
|
||||
return o.call(ctx, SummarizeSystemPrompt(), BuildSummaryPrompt(input))
|
||||
}
|
||||
|
||||
func (o *OpenAI) RewriteAction(ctx context.Context, input ActionInput) (string, error) {
|
||||
return o.call(ctx, RewriteActionSystemPrompt(), BuildRewriteActionPrompt(input))
|
||||
}
|
||||
|
||||
func (o *OpenAI) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) {
|
||||
return o.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input))
|
||||
}
|
||||
98
internal/llm/prompt.go
Normal file
98
internal/llm/prompt.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const summarizeSystemPrompt = `You are a heat preparedness assistant. You receive computed heat model data for a specific day.
|
||||
Generate exactly 3 concise bullet points summarizing the key drivers and risks.
|
||||
Rules:
|
||||
- Reference ONLY the data provided below. Do not invent or assume additional information.
|
||||
- Use preparedness language (comfort, planning). Never give medical advice or diagnoses.
|
||||
- Each bullet: max 20 words, plain language, actionable insight.
|
||||
- Format: "- [bullet text]" (markdown list)`
|
||||
|
||||
const rewriteActionSystemPrompt = `You are a heat preparedness assistant. Rewrite the given technical action into a clear, friendly, plain-language instruction.
|
||||
Rules:
|
||||
- One sentence, max 25 words.
|
||||
- Reference the provided temperature and time context.
|
||||
- Use preparedness language. Never give medical advice.
|
||||
- Return only the rewritten sentence, nothing else.`
|
||||
|
||||
const heatPlanSystemPrompt = `You are a heat preparedness assistant. Generate a 1-page plain-language heat plan document.
|
||||
Rules:
|
||||
- Reference ONLY the data provided below. Do not invent information.
|
||||
- Use preparedness language (comfort, planning). Never give medical advice or diagnoses.
|
||||
- Structure: Brief overview (2-3 sentences), then hour-by-hour key actions, then care reminders.
|
||||
- Use simple language anyone can understand.
|
||||
- Keep total length under 500 words.
|
||||
- Format as markdown with headers.`
|
||||
|
||||
// BuildSummaryPrompt constructs the user message for Summarize.
|
||||
func BuildSummaryPrompt(input SummaryInput) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Date: %s\n", input.Date)
|
||||
fmt.Fprintf(&b, "Peak temperature: %.1f°C\n", input.PeakTempC)
|
||||
fmt.Fprintf(&b, "Minimum night temperature: %.1f°C\n", input.MinNightTempC)
|
||||
fmt.Fprintf(&b, "Overall risk level: %s\n", input.RiskLevel)
|
||||
fmt.Fprintf(&b, "AC headroom: %.0f BTU/h\n", input.ACHeadroomBTUH)
|
||||
fmt.Fprintf(&b, "Budget status: %s\n", input.BudgetStatus)
|
||||
|
||||
if len(input.TopHeatSources) > 0 {
|
||||
b.WriteString("Top heat sources:\n")
|
||||
for _, s := range input.TopHeatSources {
|
||||
fmt.Fprintf(&b, " - %s: %.0fW\n", s.Name, s.Watts)
|
||||
}
|
||||
}
|
||||
if len(input.ActiveWarnings) > 0 {
|
||||
b.WriteString("Active warnings:\n")
|
||||
for _, w := range input.ActiveWarnings {
|
||||
fmt.Fprintf(&b, " - %s\n", w)
|
||||
}
|
||||
}
|
||||
if len(input.RiskWindows) > 0 {
|
||||
b.WriteString("Risk windows:\n")
|
||||
for _, rw := range input.RiskWindows {
|
||||
fmt.Fprintf(&b, " - %02d:00–%02d:00, peak %.1f°C, level: %s\n", rw.StartHour, rw.EndHour, rw.PeakTempC, rw.Level)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// BuildRewriteActionPrompt constructs the user message for RewriteAction.
|
||||
func BuildRewriteActionPrompt(input ActionInput) string {
|
||||
return fmt.Sprintf("Action: %s\nDescription: %s\nCurrent temperature: %.1f°C\nHour: %02d:00",
|
||||
input.ActionName, input.Description, input.TempC, input.Hour)
|
||||
}
|
||||
|
||||
// BuildHeatPlanPrompt constructs the user message for GenerateHeatPlan.
|
||||
func BuildHeatPlanPrompt(input HeatPlanInput) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(BuildSummaryPrompt(input.Summary))
|
||||
b.WriteString("\nTimeline:\n")
|
||||
for _, s := range input.Timeline {
|
||||
actions := "none"
|
||||
if len(s.Actions) > 0 {
|
||||
actions = strings.Join(s.Actions, ", ")
|
||||
}
|
||||
fmt.Fprintf(&b, " %02d:00 | %.1f°C | risk: %s | budget: %s | actions: %s\n",
|
||||
s.Hour, s.TempC, s.RiskLevel, s.BudgetStatus, actions)
|
||||
}
|
||||
if len(input.CareChecklist) > 0 {
|
||||
b.WriteString("\nCare checklist:\n")
|
||||
for _, c := range input.CareChecklist {
|
||||
fmt.Fprintf(&b, " - %s\n", c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SummarizeSystemPrompt returns the system prompt for Summarize.
|
||||
func SummarizeSystemPrompt() string { return summarizeSystemPrompt }
|
||||
|
||||
// RewriteActionSystemPrompt returns the system prompt for RewriteAction.
|
||||
func RewriteActionSystemPrompt() string { return rewriteActionSystemPrompt }
|
||||
|
||||
// HeatPlanSystemPrompt returns the system prompt for GenerateHeatPlan.
|
||||
func HeatPlanSystemPrompt() string { return heatPlanSystemPrompt }
|
||||
93
internal/llm/prompt_test.go
Normal file
93
internal/llm/prompt_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testSummaryInput() SummaryInput {
|
||||
return SummaryInput{
|
||||
Date: "2025-07-15",
|
||||
PeakTempC: 37.2,
|
||||
MinNightTempC: 22.5,
|
||||
RiskLevel: "high",
|
||||
TopHeatSources: []HeatSource{{Name: "Gaming PC", Watts: 200}, {Name: "Monitor", Watts: 80}},
|
||||
ACHeadroomBTUH: 4500,
|
||||
BudgetStatus: "marginal",
|
||||
ActiveWarnings: []string{"DWD: Amtliche WARNUNG vor HITZE"},
|
||||
RiskWindows: []RiskWindowSummary{{StartHour: 11, EndHour: 18, PeakTempC: 37.2, Level: "high"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSummaryPrompt_ContainsAllFields(t *testing.T) {
|
||||
p := BuildSummaryPrompt(testSummaryInput())
|
||||
|
||||
checks := []string{
|
||||
"2025-07-15",
|
||||
"37.2",
|
||||
"22.5",
|
||||
"high",
|
||||
"Gaming PC",
|
||||
"200W",
|
||||
"4500 BTU/h",
|
||||
"marginal",
|
||||
"WARNUNG",
|
||||
"11:00",
|
||||
"18:00",
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(p, c) {
|
||||
t.Errorf("prompt missing %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRewriteActionPrompt(t *testing.T) {
|
||||
p := BuildRewriteActionPrompt(ActionInput{
|
||||
ActionName: "Close south-facing shutters",
|
||||
Description: "Block direct sun",
|
||||
TempC: 34.5,
|
||||
Hour: 9,
|
||||
})
|
||||
if !strings.Contains(p, "Close south-facing shutters") {
|
||||
t.Error("missing action name")
|
||||
}
|
||||
if !strings.Contains(p, "34.5") {
|
||||
t.Error("missing temperature")
|
||||
}
|
||||
if !strings.Contains(p, "09:00") {
|
||||
t.Error("missing hour")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHeatPlanPrompt(t *testing.T) {
|
||||
input := HeatPlanInput{
|
||||
Summary: testSummaryInput(),
|
||||
Timeline: []TimelineSlotSummary{
|
||||
{Hour: 12, TempC: 35, RiskLevel: "high", BudgetStatus: "marginal", Actions: []string{"Hydration"}},
|
||||
},
|
||||
CareChecklist: []string{"Check elderly occupants at 14:00"},
|
||||
}
|
||||
p := BuildHeatPlanPrompt(input)
|
||||
if !strings.Contains(p, "Timeline:") {
|
||||
t.Error("missing timeline section")
|
||||
}
|
||||
if !strings.Contains(p, "Hydration") {
|
||||
t.Error("missing actions")
|
||||
}
|
||||
if !strings.Contains(p, "Care checklist:") {
|
||||
t.Error("missing care checklist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPrompts_NotEmpty(t *testing.T) {
|
||||
if SummarizeSystemPrompt() == "" {
|
||||
t.Error("empty summarize system prompt")
|
||||
}
|
||||
if RewriteActionSystemPrompt() == "" {
|
||||
t.Error("empty rewrite system prompt")
|
||||
}
|
||||
if HeatPlanSystemPrompt() == "" {
|
||||
t.Error("empty heatplan system prompt")
|
||||
}
|
||||
}
|
||||
71
internal/llm/provider.go
Normal file
71
internal/llm/provider.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package llm
|
||||
|
||||
import "context"
|
||||
|
||||
// Provider is the interface for LLM backends.
|
||||
type Provider interface {
|
||||
Summarize(ctx context.Context, input SummaryInput) (string, error)
|
||||
RewriteAction(ctx context.Context, action ActionInput) (string, error)
|
||||
GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error)
|
||||
Name() string
|
||||
}
|
||||
|
||||
// HeatSource represents a ranked heat source for summary.
|
||||
type HeatSource struct {
|
||||
Name string
|
||||
Watts float64
|
||||
}
|
||||
|
||||
// RiskWindowSummary is a simplified risk window for LLM input.
|
||||
type RiskWindowSummary struct {
|
||||
StartHour int
|
||||
EndHour int
|
||||
PeakTempC float64
|
||||
Level string
|
||||
}
|
||||
|
||||
// SummaryInput holds computed data for the 3-bullet summary.
|
||||
type SummaryInput struct {
|
||||
Date string
|
||||
PeakTempC float64
|
||||
MinNightTempC float64
|
||||
RiskLevel string
|
||||
TopHeatSources []HeatSource
|
||||
ACHeadroomBTUH float64
|
||||
BudgetStatus string
|
||||
ActiveWarnings []string
|
||||
RiskWindows []RiskWindowSummary
|
||||
}
|
||||
|
||||
// ActionInput holds data for rewriting a technical action.
|
||||
type ActionInput struct {
|
||||
ActionName string
|
||||
Description string
|
||||
TempC float64
|
||||
Hour int
|
||||
}
|
||||
|
||||
// TimelineSlotSummary is a simplified timeline slot for LLM input.
|
||||
type TimelineSlotSummary struct {
|
||||
Hour int
|
||||
TempC float64
|
||||
RiskLevel string
|
||||
BudgetStatus string
|
||||
Actions []string
|
||||
}
|
||||
|
||||
// ActionSummary is a simplified action for LLM input.
|
||||
type ActionSummary struct {
|
||||
Name string
|
||||
Category string
|
||||
Impact string
|
||||
Hour int
|
||||
}
|
||||
|
||||
// HeatPlanInput holds full day data for the 1-page plan.
|
||||
type HeatPlanInput struct {
|
||||
Summary SummaryInput
|
||||
Timeline []TimelineSlotSummary
|
||||
Actions []ActionSummary
|
||||
CareChecklist []string
|
||||
}
|
||||
73
internal/report/data.go
Normal file
73
internal/report/data.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package report
|
||||
|
||||
import "time"
|
||||
|
||||
// DashboardData holds all data needed to render the HTML report.
|
||||
type DashboardData struct {
|
||||
GeneratedAt time.Time
|
||||
ProfileName string
|
||||
Date string
|
||||
ShowNav bool
|
||||
Warnings []WarningData
|
||||
RiskLevel string
|
||||
PeakTempC float64
|
||||
MinNightTempC float64
|
||||
PoorNightCool bool
|
||||
RiskWindows []RiskWindowData
|
||||
Timeline []TimelineSlotData
|
||||
RoomBudgets []RoomBudgetData
|
||||
CareChecklist []string
|
||||
LLMSummary string
|
||||
LLMDisclaimer string
|
||||
}
|
||||
|
||||
// WarningData holds a weather warning for display.
|
||||
type WarningData struct {
|
||||
Headline string
|
||||
Severity string
|
||||
Description string
|
||||
Instruction string
|
||||
Onset string
|
||||
Expires string
|
||||
}
|
||||
|
||||
// RiskWindowData holds a risk window for display.
|
||||
type RiskWindowData struct {
|
||||
StartHour int
|
||||
EndHour int
|
||||
PeakTempC float64
|
||||
Level string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// TimelineSlotData holds one hour's data for the timeline.
|
||||
type TimelineSlotData struct {
|
||||
Hour int
|
||||
HourStr string
|
||||
TempC float64
|
||||
RiskLevel string
|
||||
BudgetStatus string
|
||||
Actions []ActionData
|
||||
}
|
||||
|
||||
// ActionData holds a single action for display.
|
||||
type ActionData struct {
|
||||
Name string
|
||||
Category string
|
||||
Effort string
|
||||
Impact string
|
||||
Description string
|
||||
}
|
||||
|
||||
// RoomBudgetData holds a room's heat budget for display.
|
||||
type RoomBudgetData struct {
|
||||
RoomName string
|
||||
InternalGainsW float64
|
||||
SolarGainW float64
|
||||
VentGainW float64
|
||||
TotalGainW float64
|
||||
TotalGainBTUH float64
|
||||
ACCapacityBTUH float64
|
||||
HeadroomBTUH float64
|
||||
Status string
|
||||
}
|
||||
155
internal/report/generator.go
Normal file
155
internal/report/generator.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/static"
|
||||
)
|
||||
|
||||
//go:embed templates/dashboard.html.tmpl
|
||||
var dashboardTmpl string
|
||||
|
||||
// templateData extends DashboardData with CSS for the template.
|
||||
type templateData struct {
|
||||
DashboardData
|
||||
CSS template.CSS
|
||||
}
|
||||
|
||||
var funcMap = template.FuncMap{
|
||||
"formatTemp": formatTemp,
|
||||
"formatWatts": formatWatts,
|
||||
"formatBTU": formatBTU,
|
||||
"tempColor": tempColor,
|
||||
"riskBadge": riskBadge,
|
||||
"riskBg": riskBg,
|
||||
"riskBadgeBg": riskBadgeBg,
|
||||
"statusBadge": statusBadge,
|
||||
"statusColor": statusColor,
|
||||
"statusBadgeBg": statusBadgeBg,
|
||||
}
|
||||
|
||||
// Generate renders the dashboard HTML to the given writer.
|
||||
func Generate(w io.Writer, data DashboardData) error {
|
||||
tmpl, err := template.New("dashboard").Funcs(funcMap).Parse(dashboardTmpl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse template: %w", err)
|
||||
}
|
||||
|
||||
td := templateData{
|
||||
DashboardData: data,
|
||||
CSS: template.CSS(static.TailwindCSS),
|
||||
}
|
||||
return tmpl.Execute(w, td)
|
||||
}
|
||||
|
||||
// GenerateString renders the dashboard HTML to a string.
|
||||
func GenerateString(data DashboardData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := Generate(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func formatTemp(c float64) string {
|
||||
return fmt.Sprintf("%.1f°C", c)
|
||||
}
|
||||
|
||||
func formatWatts(w float64) string {
|
||||
return fmt.Sprintf("%.0f W", w)
|
||||
}
|
||||
|
||||
func formatBTU(b float64) string {
|
||||
return fmt.Sprintf("%.0f BTU/h", b)
|
||||
}
|
||||
|
||||
func tempColor(c float64) string {
|
||||
switch {
|
||||
case c >= 40:
|
||||
return "text-red-700 dark:text-red-400"
|
||||
case c >= 35:
|
||||
return "text-red-600 dark:text-red-400"
|
||||
case c >= 30:
|
||||
return "text-orange-600 dark:text-orange-400"
|
||||
case c >= 25:
|
||||
return "text-yellow-600 dark:text-yellow-400"
|
||||
default:
|
||||
return "text-green-600 dark:text-green-400"
|
||||
}
|
||||
}
|
||||
|
||||
func riskBadge(level string) string {
|
||||
switch level {
|
||||
case "extreme":
|
||||
return "EXTREME"
|
||||
case "high":
|
||||
return "HIGH"
|
||||
case "moderate":
|
||||
return "MODERATE"
|
||||
default:
|
||||
return "LOW"
|
||||
}
|
||||
}
|
||||
|
||||
func riskBg(level string) string {
|
||||
switch level {
|
||||
case "extreme":
|
||||
return "bg-red-50 dark:bg-red-950"
|
||||
case "high":
|
||||
return "bg-orange-50 dark:bg-orange-950"
|
||||
case "moderate":
|
||||
return "bg-yellow-50 dark:bg-yellow-950"
|
||||
default:
|
||||
return "bg-white dark:bg-gray-800"
|
||||
}
|
||||
}
|
||||
|
||||
func riskBadgeBg(level string) string {
|
||||
switch level {
|
||||
case "extreme":
|
||||
return "bg-red-600"
|
||||
case "high":
|
||||
return "bg-orange-600"
|
||||
case "moderate":
|
||||
return "bg-yellow-600"
|
||||
default:
|
||||
return "bg-green-600"
|
||||
}
|
||||
}
|
||||
|
||||
func statusBadge(status string) string {
|
||||
switch status {
|
||||
case "overloaded":
|
||||
return "OVERLOADED"
|
||||
case "marginal":
|
||||
return "MARGINAL"
|
||||
default:
|
||||
return "OK"
|
||||
}
|
||||
}
|
||||
|
||||
func statusColor(status string) string {
|
||||
switch status {
|
||||
case "overloaded":
|
||||
return "text-red-600 dark:text-red-400"
|
||||
case "marginal":
|
||||
return "text-orange-600 dark:text-orange-400"
|
||||
default:
|
||||
return "text-green-600 dark:text-green-400"
|
||||
}
|
||||
}
|
||||
|
||||
func statusBadgeBg(status string) string {
|
||||
switch status {
|
||||
case "overloaded":
|
||||
return "bg-red-600"
|
||||
case "marginal":
|
||||
return "bg-orange-600"
|
||||
default:
|
||||
return "bg-green-600"
|
||||
}
|
||||
}
|
||||
176
internal/report/generator_test.go
Normal file
176
internal/report/generator_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testDashboardData() DashboardData {
|
||||
return DashboardData{
|
||||
GeneratedAt: time.Date(2025, 7, 15, 10, 0, 0, 0, time.UTC),
|
||||
ProfileName: "home",
|
||||
Date: "2025-07-15",
|
||||
RiskLevel: "high",
|
||||
PeakTempC: 37.2,
|
||||
MinNightTempC: 22.5,
|
||||
PoorNightCool: true,
|
||||
Warnings: []WarningData{
|
||||
{
|
||||
Headline: "Heat warning Berlin",
|
||||
Severity: "Severe",
|
||||
Description: "Temperatures up to 37C",
|
||||
Instruction: "Stay hydrated",
|
||||
Onset: "2025-07-15 11:00",
|
||||
Expires: "2025-07-16 19:00",
|
||||
},
|
||||
},
|
||||
RiskWindows: []RiskWindowData{
|
||||
{StartHour: 11, EndHour: 18, PeakTempC: 37.2, Level: "high", Reason: "very hot"},
|
||||
},
|
||||
Timeline: []TimelineSlotData{
|
||||
{
|
||||
Hour: 12, HourStr: "12:00", TempC: 35.5, RiskLevel: "high", BudgetStatus: "marginal",
|
||||
Actions: []ActionData{
|
||||
{Name: "Hydration reminder", Category: "hydration", Impact: "medium"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Hour: 0, HourStr: "00:00", TempC: 22, RiskLevel: "low", BudgetStatus: "comfortable",
|
||||
},
|
||||
},
|
||||
RoomBudgets: []RoomBudgetData{
|
||||
{
|
||||
RoomName: "Office",
|
||||
InternalGainsW: 300,
|
||||
SolarGainW: 600,
|
||||
VentGainW: 150,
|
||||
TotalGainW: 1050,
|
||||
TotalGainBTUH: 3583,
|
||||
ACCapacityBTUH: 8000,
|
||||
HeadroomBTUH: 4417,
|
||||
Status: "comfortable",
|
||||
},
|
||||
},
|
||||
CareChecklist: []string{"Check elderly at 14:00"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_ProducesValidHTML(t *testing.T) {
|
||||
html, err := GenerateString(testDashboardData())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateString: %v", err)
|
||||
}
|
||||
if !strings.Contains(html, "<!DOCTYPE html>") {
|
||||
t.Error("missing DOCTYPE")
|
||||
}
|
||||
if !strings.Contains(html, "</html>") {
|
||||
t.Error("missing closing html tag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_ContainsProfileAndDate(t *testing.T) {
|
||||
html, err := GenerateString(testDashboardData())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(html, "home") {
|
||||
t.Error("missing profile name")
|
||||
}
|
||||
if !strings.Contains(html, "2025-07-15") {
|
||||
t.Error("missing date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_ContainsWarning(t *testing.T) {
|
||||
html, err := GenerateString(testDashboardData())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(html, "Heat warning Berlin") {
|
||||
t.Error("missing warning headline")
|
||||
}
|
||||
if !strings.Contains(html, "Stay hydrated") {
|
||||
t.Error("missing warning instruction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_ContainsTimeline(t *testing.T) {
|
||||
html, err := GenerateString(testDashboardData())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(html, "12:00") {
|
||||
t.Error("missing timeline hour")
|
||||
}
|
||||
if !strings.Contains(html, "Hydration reminder") {
|
||||
t.Error("missing timeline action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_ContainsRoomBudget(t *testing.T) {
|
||||
html, err := GenerateString(testDashboardData())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(html, "Office") {
|
||||
t.Error("missing room name")
|
||||
}
|
||||
if !strings.Contains(html, "8000 BTU/h") {
|
||||
t.Error("missing AC capacity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_ContainsCareChecklist(t *testing.T) {
|
||||
html, err := GenerateString(testDashboardData())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(html, "Check elderly at 14:00") {
|
||||
t.Error("missing care checklist item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_ContainsCSS(t *testing.T) {
|
||||
html, err := GenerateString(testDashboardData())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(html, "box-sizing") {
|
||||
t.Error("missing inlined CSS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_EmptyData(t *testing.T) {
|
||||
_, err := GenerateString(DashboardData{
|
||||
GeneratedAt: time.Now(),
|
||||
ProfileName: "test",
|
||||
Date: "2025-07-15",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("should handle empty data: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_WriteFile(t *testing.T) {
|
||||
path := t.TempDir() + "/report.html"
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := Generate(f, testDashboardData()); err != nil {
|
||||
t.Fatalf("Generate to file: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Size() < 1000 {
|
||||
t.Errorf("report file too small: %d bytes", info.Size())
|
||||
}
|
||||
}
|
||||
102
internal/report/sample_test.go
Normal file
102
internal/report/sample_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateSampleReport(t *testing.T) {
|
||||
if os.Getenv("WRITE_SAMPLE") == "" {
|
||||
t.Skip("set WRITE_SAMPLE=1 to generate sample report")
|
||||
}
|
||||
|
||||
temps := []float64{22, 21, 20.5, 20, 19.5, 19.5, 20, 22, 24, 27, 30, 32, 34, 35.5, 37.2, 36.8, 35, 33, 31, 28, 26, 24.5, 23.5, 22.5}
|
||||
|
||||
data := DashboardData{
|
||||
GeneratedAt: time.Now(),
|
||||
ProfileName: "home — Berlin",
|
||||
Date: "2025-07-15",
|
||||
RiskLevel: "high",
|
||||
PeakTempC: 37.2,
|
||||
MinNightTempC: 19.5,
|
||||
PoorNightCool: true,
|
||||
Warnings: []WarningData{
|
||||
{
|
||||
Headline: "Amtliche WARNUNG vor HITZE",
|
||||
Severity: "Severe",
|
||||
Description: "Es tritt eine starke Wärmebelastung auf. Temperaturen bis 37°C erwartet.",
|
||||
Instruction: "Trinken Sie ausreichend. Vermeiden Sie direkte Sonneneinstrahlung.",
|
||||
Onset: "2025-07-15 11:00",
|
||||
Expires: "2025-07-16 19:00",
|
||||
},
|
||||
},
|
||||
RiskWindows: []RiskWindowData{
|
||||
{StartHour: 10, EndHour: 18, PeakTempC: 37.2, Level: "high", Reason: "very hot daytime temperatures"},
|
||||
},
|
||||
RoomBudgets: []RoomBudgetData{
|
||||
{
|
||||
RoomName: "Office", InternalGainsW: 300, SolarGainW: 622, VentGainW: 150,
|
||||
TotalGainW: 1072, TotalGainBTUH: 3658, ACCapacityBTUH: 8000, HeadroomBTUH: 4342, Status: "comfortable",
|
||||
},
|
||||
{
|
||||
RoomName: "Bedroom", InternalGainsW: 100, SolarGainW: 200, VentGainW: 80,
|
||||
TotalGainW: 380, TotalGainBTUH: 1297, ACCapacityBTUH: 0, HeadroomBTUH: -1297, Status: "overloaded",
|
||||
},
|
||||
},
|
||||
CareChecklist: []string{
|
||||
"Check elderly occupant (bedroom) at 10:00, 14:00, 18:00",
|
||||
"Ensure water bottles are filled and accessible",
|
||||
"Verify medication storage temperature",
|
||||
},
|
||||
}
|
||||
|
||||
for i, temp := range temps {
|
||||
slot := TimelineSlotData{
|
||||
Hour: i,
|
||||
HourStr: fmt.Sprintf("%02d:00", i),
|
||||
TempC: temp,
|
||||
}
|
||||
switch {
|
||||
case temp >= 35:
|
||||
slot.RiskLevel = "high"
|
||||
slot.BudgetStatus = "marginal"
|
||||
case temp >= 30:
|
||||
slot.RiskLevel = "moderate"
|
||||
slot.BudgetStatus = "comfortable"
|
||||
default:
|
||||
slot.RiskLevel = "low"
|
||||
slot.BudgetStatus = "comfortable"
|
||||
}
|
||||
|
||||
isDay := i >= 6 && i < 21
|
||||
if i >= 11 && i <= 18 && temp >= 30 {
|
||||
slot.Actions = append(slot.Actions, ActionData{Name: "Hydration reminder", Category: "hydration", Impact: "medium"})
|
||||
}
|
||||
if i >= 10 && i <= 18 && temp >= 30 {
|
||||
slot.Actions = append(slot.Actions, ActionData{Name: "Check vulnerable occupants", Category: "care", Impact: "high"})
|
||||
}
|
||||
if i <= 10 && temp >= 25 {
|
||||
slot.Actions = append(slot.Actions, ActionData{Name: "Close south-facing shutters", Category: "shading", Impact: "high"})
|
||||
}
|
||||
if i >= 6 && i <= 9 {
|
||||
slot.Actions = append(slot.Actions, ActionData{Name: "Pre-cool rooms with AC", Category: "ac_strategy", Impact: "high"})
|
||||
}
|
||||
if !isDay && temp < 25 {
|
||||
slot.Actions = append(slot.Actions, ActionData{Name: "Night ventilation", Category: "ventilation", Impact: "high"})
|
||||
}
|
||||
data.Timeline = append(data.Timeline, slot)
|
||||
}
|
||||
|
||||
f, err := os.Create("/tmp/heatwave-sample-report.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := Generate(f, data); err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
t.Log("Sample report written to /tmp/heatwave-sample-report.html")
|
||||
}
|
||||
166
internal/report/templates/dashboard.html.tmpl
Normal file
166
internal/report/templates/dashboard.html.tmpl
Normal file
@@ -0,0 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Heatwave Report — {{.ProfileName}} — {{.Date}}</title>
|
||||
<style>{{.CSS}}</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
{{if .ShowNav}}
|
||||
<nav class="bg-white dark:bg-gray-800 shadow mb-4">
|
||||
<div class="container mx-auto flex items-center gap-6 px-4 py-3">
|
||||
<span class="font-bold text-lg">Heatwave</span>
|
||||
<a href="/" class="font-medium text-blue-600 dark:text-blue-400 underline">Dashboard</a>
|
||||
<a href="/setup" class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Setup</a>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
<div class="container mx-auto py-4">
|
||||
|
||||
<header class="mb-6">
|
||||
<h1 class="text-3xl font-bold">Heatwave Report</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{.ProfileName}} — {{.Date}}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500">Generated {{.GeneratedAt.Format "2006-01-02 15:04"}}</p>
|
||||
</header>
|
||||
|
||||
{{template "warnings" .}}
|
||||
{{template "risk_summary" .}}
|
||||
{{template "timeline" .}}
|
||||
{{template "heatbudget" .}}
|
||||
{{template "checklist" .}}
|
||||
|
||||
{{if .LLMSummary}}
|
||||
<section class="mt-8 p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
|
||||
<h2 class="text-xl font-semibold mb-2">AI Summary</h2>
|
||||
{{if .LLMDisclaimer}}<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">{{.LLMDisclaimer}}</p>{{end}}
|
||||
<div>{{.LLMSummary}}</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<footer class="mt-8 text-center text-xs text-gray-500 dark:text-gray-500 py-4">
|
||||
<p>Heatwave Autopilot — This report is for planning purposes only. It does not constitute medical advice.</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{{define "warnings"}}
|
||||
{{if .Warnings}}
|
||||
<section class="mb-6">
|
||||
{{range .Warnings}}
|
||||
<div class="p-4 mb-2 border-l-4 {{if eq .Severity "Extreme"}}border-red-400 bg-red-50 dark:bg-red-950{{else}}border-orange-400 bg-orange-50 dark:bg-orange-950{{end}} rounded">
|
||||
<p class="font-bold {{if eq .Severity "Extreme"}}text-red-700 dark:text-red-400{{else}}text-orange-600 dark:text-orange-400{{end}}">{{.Headline}}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{.Description}}</p>
|
||||
{{if .Instruction}}<p class="text-sm font-medium mt-1">{{.Instruction}}</p>{{end}}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{.Onset}} — {{.Expires}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "risk_summary"}}
|
||||
<section class="mb-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="p-4 rounded-lg shadow dark:shadow-gray-700 {{riskBg .RiskLevel}}">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Risk Level</p>
|
||||
<p class="text-2xl font-bold">{{riskBadge .RiskLevel}}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Peak Temperature</p>
|
||||
<p class="text-2xl font-bold {{tempColor .PeakTempC}}">{{formatTemp .PeakTempC}}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Min Night Temp</p>
|
||||
<p class="text-2xl font-bold">{{formatTemp .MinNightTempC}}</p>
|
||||
{{if .PoorNightCool}}<p class="text-xs text-red-600 dark:text-red-400 font-medium">Poor night cooling</p>{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .RiskWindows}}
|
||||
<section class="mb-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Risk Windows</h2>
|
||||
<div class="space-y-2">
|
||||
{{range .RiskWindows}}
|
||||
<div class="p-3 rounded {{riskBg .Level}} flex justify-between items-center">
|
||||
<span class="font-medium">{{printf "%02d:00" .StartHour}} — {{printf "%02d:00" .EndHour}}</span>
|
||||
<span>Peak {{formatTemp .PeakTempC}}</span>
|
||||
<span class="px-2 py-1 rounded-full text-xs font-semibold text-white {{riskBadgeBg .Level}}">{{.Level}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "timeline"}}
|
||||
<section class="mb-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Hour-by-Hour Timeline</h2>
|
||||
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700">
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="dark:bg-gray-800">
|
||||
<th>Hour</th>
|
||||
<th>Temp</th>
|
||||
<th>Risk</th>
|
||||
<th>Budget</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Timeline}}
|
||||
<tr class="{{riskBg .RiskLevel}}">
|
||||
<td class="font-medium">{{.HourStr}}</td>
|
||||
<td class="{{tempColor .TempC}}">{{formatTemp .TempC}}</td>
|
||||
<td>{{riskBadge .RiskLevel}}</td>
|
||||
<td>{{statusBadge .BudgetStatus}}</td>
|
||||
<td>
|
||||
{{range .Actions}}
|
||||
<span class="inline-block px-2 py-1 text-xs rounded bg-blue-100 dark:bg-blue-900 dark:text-blue-200 mb-1">{{.Name}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "heatbudget"}}
|
||||
{{if .RoomBudgets}}
|
||||
<section class="mb-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Room Heat Budgets</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{{range .RoomBudgets}}
|
||||
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
<h3 class="font-semibold text-lg mb-2">{{.RoomName}}</h3>
|
||||
<table class="text-sm">
|
||||
<tr><td class="text-gray-600 dark:text-gray-400">Internal gains</td><td class="font-medium">{{formatWatts .InternalGainsW}}</td></tr>
|
||||
<tr><td class="text-gray-600 dark:text-gray-400">Solar gain</td><td class="font-medium">{{formatWatts .SolarGainW}}</td></tr>
|
||||
<tr><td class="text-gray-600 dark:text-gray-400">Ventilation</td><td class="font-medium">{{formatWatts .VentGainW}}</td></tr>
|
||||
<tr class="font-bold"><td>Total heat load</td><td>{{formatWatts .TotalGainW}} ({{formatBTU .TotalGainBTUH}})</td></tr>
|
||||
<tr><td class="text-gray-600 dark:text-gray-400">AC capacity</td><td>{{formatBTU .ACCapacityBTUH}}</td></tr>
|
||||
<tr class="font-bold"><td>Headroom</td><td class="{{statusColor .Status}}">{{formatBTU .HeadroomBTUH}}</td></tr>
|
||||
</table>
|
||||
<p class="mt-2 px-3 py-1 rounded-full inline-block text-xs font-semibold text-white {{statusBadgeBg .Status}}">{{.Status}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "checklist"}}
|
||||
{{if .CareChecklist}}
|
||||
<section class="mb-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Care Checklist</h2>
|
||||
<ul class="list-disc list-inside space-y-2 p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
|
||||
{{range .CareChecklist}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
167
internal/risk/analyzer.go
Normal file
167
internal/risk/analyzer.go
Normal file
@@ -0,0 +1,167 @@
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
166
internal/risk/analyzer_test.go
Normal file
166
internal/risk/analyzer_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package risk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func makeHours(temps []float64) []HourlyData {
|
||||
hours := make([]HourlyData, len(temps))
|
||||
for i, t := range temps {
|
||||
hours[i] = HourlyData{
|
||||
Hour: i,
|
||||
TempC: t,
|
||||
ApparentC: t,
|
||||
HumidityPct: 50,
|
||||
IsDay: i >= 6 && i < 21,
|
||||
}
|
||||
}
|
||||
return hours
|
||||
}
|
||||
|
||||
func TestAnalyzeDay_CoolDay(t *testing.T) {
|
||||
temps := make([]float64, 24)
|
||||
for i := range temps {
|
||||
temps[i] = 20 + float64(i%5) // 20-24C
|
||||
}
|
||||
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
|
||||
if result.Level != Low {
|
||||
t.Errorf("Level = %v, want Low", result.Level)
|
||||
}
|
||||
if len(result.Windows) != 0 {
|
||||
t.Errorf("Windows = %d, want 0", len(result.Windows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDay_ModeratelyHot(t *testing.T) {
|
||||
temps := make([]float64, 24)
|
||||
for i := range temps {
|
||||
temps[i] = 18 // base below poor night cooling threshold
|
||||
}
|
||||
// Hot window 11-15
|
||||
for i := 11; i <= 15; i++ {
|
||||
temps[i] = 32
|
||||
}
|
||||
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
|
||||
if result.Level != Moderate {
|
||||
t.Errorf("Level = %v, want Moderate", result.Level)
|
||||
}
|
||||
if len(result.Windows) != 1 {
|
||||
t.Fatalf("Windows = %d, want 1", len(result.Windows))
|
||||
}
|
||||
w := result.Windows[0]
|
||||
if w.StartHour != 11 || w.EndHour != 15 {
|
||||
t.Errorf("Window = %d-%d, want 11-15", w.StartHour, w.EndHour)
|
||||
}
|
||||
if w.PeakTempC != 32 {
|
||||
t.Errorf("PeakTempC = %v, want 32", w.PeakTempC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDay_VeryHot(t *testing.T) {
|
||||
temps := make([]float64, 24)
|
||||
for i := range temps {
|
||||
temps[i] = 18 // below poor night cooling threshold
|
||||
}
|
||||
for i := 10; i <= 17; i++ {
|
||||
temps[i] = 37
|
||||
}
|
||||
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
|
||||
if result.Level != High {
|
||||
t.Errorf("Level = %v, want High", result.Level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDay_Extreme(t *testing.T) {
|
||||
temps := make([]float64, 24)
|
||||
for i := range temps {
|
||||
temps[i] = 25
|
||||
}
|
||||
for i := 12; i <= 16; i++ {
|
||||
temps[i] = 41
|
||||
}
|
||||
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
|
||||
if result.Level != Extreme {
|
||||
t.Errorf("Level = %v, want Extreme", result.Level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDay_PoorNightCoolingElevates(t *testing.T) {
|
||||
temps := make([]float64, 24)
|
||||
for i := range temps {
|
||||
temps[i] = 22
|
||||
}
|
||||
// Hot day window
|
||||
for i := 11; i <= 15; i++ {
|
||||
temps[i] = 32
|
||||
}
|
||||
// Poor night cooling (hour 0-6 and 21-23 above 20C)
|
||||
for i := 0; i <= 6; i++ {
|
||||
temps[i] = 22
|
||||
}
|
||||
for i := 21; i < 24; i++ {
|
||||
temps[i] = 22
|
||||
}
|
||||
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
|
||||
if !result.PoorNightCool {
|
||||
t.Error("expected PoorNightCool = true")
|
||||
}
|
||||
// Base Moderate + poor night = High
|
||||
if result.Level != High {
|
||||
t.Errorf("Level = %v, want High (elevated from Moderate)", result.Level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDay_GoodNightCooling(t *testing.T) {
|
||||
temps := make([]float64, 24)
|
||||
for i := range temps {
|
||||
temps[i] = 18 // cool nights
|
||||
}
|
||||
for i := 11; i <= 15; i++ {
|
||||
temps[i] = 32
|
||||
}
|
||||
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
|
||||
if result.PoorNightCool {
|
||||
t.Error("expected PoorNightCool = false")
|
||||
}
|
||||
if result.Level != Moderate {
|
||||
t.Errorf("Level = %v, want Moderate (no elevation)", result.Level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDay_Empty(t *testing.T) {
|
||||
result := AnalyzeDay(nil, DefaultThresholds())
|
||||
if result.Level != Low {
|
||||
t.Errorf("Level = %v, want Low", result.Level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDay_MinNightTemp(t *testing.T) {
|
||||
temps := make([]float64, 24)
|
||||
for i := range temps {
|
||||
temps[i] = 25
|
||||
}
|
||||
temps[3] = 16.5 // coldest night hour
|
||||
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
|
||||
if result.MinNightTempC != 16.5 {
|
||||
t.Errorf("MinNightTempC = %v, want 16.5", result.MinNightTempC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskLevelString(t *testing.T) {
|
||||
tests := []struct {
|
||||
level RiskLevel
|
||||
want string
|
||||
}{
|
||||
{Low, "low"},
|
||||
{Moderate, "moderate"},
|
||||
{High, "high"},
|
||||
{Extreme, "extreme"},
|
||||
{RiskLevel(99), "unknown"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.level.String(); got != tt.want {
|
||||
t.Errorf("RiskLevel(%d).String() = %s, want %s", tt.level, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
21
internal/risk/thresholds.go
Normal file
21
internal/risk/thresholds.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package risk
|
||||
|
||||
// Thresholds holds configurable temperature thresholds for risk analysis.
|
||||
type Thresholds struct {
|
||||
HotDayC float64 // daytime temp considered "hot" (default 30)
|
||||
VeryHotDayC float64 // daytime temp considered "very hot" (default 35)
|
||||
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)
|
||||
}
|
||||
|
||||
// DefaultThresholds returns the default temperature thresholds.
|
||||
func DefaultThresholds() Thresholds {
|
||||
return Thresholds{
|
||||
HotDayC: 30,
|
||||
VeryHotDayC: 35,
|
||||
ExtremeDayC: 40,
|
||||
PoorNightCoolingC: 20,
|
||||
ComfortMaxC: 26,
|
||||
}
|
||||
}
|
||||
6
internal/static/embed.go
Normal file
6
internal/static/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package static
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed tailwind.css
|
||||
var TailwindCSS string
|
||||
2
internal/static/tailwind.css
Normal file
2
internal/static/tailwind.css
Normal file
File diff suppressed because one or more lines are too long
133
internal/store/ac.go
Normal file
133
internal/store/ac.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ACUnit struct {
|
||||
ID int64
|
||||
ProfileID int64
|
||||
Name string
|
||||
ACType string
|
||||
CapacityBTU float64
|
||||
HasDehumidify bool
|
||||
EfficiencyEER float64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) CreateACUnit(profileID int64, name, acType string, capacityBTU float64, hasDehumidify bool, efficiencyEER float64) (*ACUnit, error) {
|
||||
if acType == "" {
|
||||
acType = "portable"
|
||||
}
|
||||
if efficiencyEER == 0 {
|
||||
efficiencyEER = 10.0
|
||||
}
|
||||
dehumid := 0
|
||||
if hasDehumidify {
|
||||
dehumid = 1
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO ac_units (profile_id, name, ac_type, capacity_btu, has_dehumidify, efficiency_eer) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
profileID, name, acType, capacityBTU, dehumid, efficiencyEER,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create ac unit: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetACUnit(id)
|
||||
}
|
||||
|
||||
func (s *Store) GetACUnit(id int64) (*ACUnit, error) {
|
||||
a := &ACUnit{}
|
||||
var created string
|
||||
var dehumid int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, profile_id, name, ac_type, capacity_btu, has_dehumidify, efficiency_eer, created_at FROM ac_units WHERE id = ?`, id,
|
||||
).Scan(&a.ID, &a.ProfileID, &a.Name, &a.ACType, &a.CapacityBTU, &dehumid, &a.EfficiencyEER, &created)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("ac unit not found: %d", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ac unit: %w", err)
|
||||
}
|
||||
a.HasDehumidify = dehumid != 0
|
||||
a.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListACUnits(profileID int64) ([]ACUnit, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, profile_id, name, ac_type, capacity_btu, has_dehumidify, efficiency_eer, created_at FROM ac_units WHERE profile_id = ? ORDER BY name`, profileID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var units []ACUnit
|
||||
for rows.Next() {
|
||||
var a ACUnit
|
||||
var created string
|
||||
var dehumid int
|
||||
if err := rows.Scan(&a.ID, &a.ProfileID, &a.Name, &a.ACType, &a.CapacityBTU, &dehumid, &a.EfficiencyEER, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.HasDehumidify = dehumid != 0
|
||||
a.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||
units = append(units, a)
|
||||
}
|
||||
return units, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateACUnit(id int64, field, value string) error {
|
||||
allowed := map[string]bool{
|
||||
"name": true, "ac_type": true, "capacity_btu": true,
|
||||
"has_dehumidify": true, "efficiency_eer": true,
|
||||
}
|
||||
if !allowed[field] {
|
||||
return fmt.Errorf("invalid field: %s", field)
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
fmt.Sprintf(`UPDATE ac_units SET %s = ? WHERE id = ?`, field), value, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteACUnit(id int64) error {
|
||||
_, err := s.db.Exec(`DELETE FROM ac_units WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AssignACToRoom(acID, roomID int64) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO ac_room_assignments (ac_id, room_id) VALUES (?, ?)`,
|
||||
acID, roomID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UnassignACFromRoom(acID, roomID int64) error {
|
||||
_, err := s.db.Exec(
|
||||
`DELETE FROM ac_room_assignments WHERE ac_id = ? AND room_id = ?`,
|
||||
acID, roomID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetACRoomAssignments(acID int64) ([]int64, error) {
|
||||
rows, err := s.db.Query(`SELECT room_id FROM ac_room_assignments WHERE ac_id = ?`, acID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var roomIDs []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roomIDs = append(roomIDs, id)
|
||||
}
|
||||
return roomIDs, rows.Err()
|
||||
}
|
||||
113
internal/store/device.go
Normal file
113
internal/store/device.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Device struct {
|
||||
ID int64
|
||||
RoomID int64
|
||||
Name string
|
||||
DeviceType string
|
||||
WattsIdle float64
|
||||
WattsTypical float64
|
||||
WattsPeak float64
|
||||
DutyCycle float64
|
||||
Schedule string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) CreateDevice(roomID int64, name, deviceType string, wattsIdle, wattsTypical, wattsPeak, dutyCycle float64) (*Device, error) {
|
||||
if dutyCycle == 0 {
|
||||
dutyCycle = 1.0
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO devices (room_id, name, device_type, watts_idle, watts_typical, watts_peak, duty_cycle) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
roomID, name, deviceType, wattsIdle, wattsTypical, wattsPeak, dutyCycle,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create device: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetDevice(id)
|
||||
}
|
||||
|
||||
func (s *Store) GetDevice(id int64) (*Device, error) {
|
||||
d := &Device{}
|
||||
var created string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, room_id, name, device_type, watts_idle, watts_typical, watts_peak, duty_cycle, schedule, created_at FROM devices WHERE id = ?`, id,
|
||||
).Scan(&d.ID, &d.RoomID, &d.Name, &d.DeviceType, &d.WattsIdle, &d.WattsTypical, &d.WattsPeak, &d.DutyCycle, &d.Schedule, &created)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("device not found: %d", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get device: %w", err)
|
||||
}
|
||||
d.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListDevices(roomID int64) ([]Device, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, room_id, name, device_type, watts_idle, watts_typical, watts_peak, duty_cycle, schedule, created_at FROM devices WHERE room_id = ? ORDER BY name`, roomID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var devices []Device
|
||||
for rows.Next() {
|
||||
var d Device
|
||||
var created string
|
||||
if err := rows.Scan(&d.ID, &d.RoomID, &d.Name, &d.DeviceType, &d.WattsIdle, &d.WattsTypical, &d.WattsPeak, &d.DutyCycle, &d.Schedule, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||
devices = append(devices, d)
|
||||
}
|
||||
return devices, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListAllDevices(profileID int64) ([]Device, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT d.id, d.room_id, d.name, d.device_type, d.watts_idle, d.watts_typical, d.watts_peak, d.duty_cycle, d.schedule, d.created_at
|
||||
FROM devices d JOIN rooms r ON d.room_id = r.id WHERE r.profile_id = ? ORDER BY d.name`, profileID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var devices []Device
|
||||
for rows.Next() {
|
||||
var d Device
|
||||
var created string
|
||||
if err := rows.Scan(&d.ID, &d.RoomID, &d.Name, &d.DeviceType, &d.WattsIdle, &d.WattsTypical, &d.WattsPeak, &d.DutyCycle, &d.Schedule, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||
devices = append(devices, d)
|
||||
}
|
||||
return devices, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateDevice(id int64, field, value string) error {
|
||||
allowed := map[string]bool{
|
||||
"name": true, "device_type": true, "watts_idle": true, "watts_typical": true,
|
||||
"watts_peak": true, "duty_cycle": true, "schedule": true,
|
||||
}
|
||||
if !allowed[field] {
|
||||
return fmt.Errorf("invalid field: %s", field)
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
fmt.Sprintf(`UPDATE devices SET %s = ? WHERE id = ?`, field), value, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDevice(id int64) error {
|
||||
_, err := s.db.Exec(`DELETE FROM devices WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
96
internal/store/forecast.go
Normal file
96
internal/store/forecast.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Forecast struct {
|
||||
ID int64
|
||||
ProfileID int64
|
||||
Timestamp time.Time
|
||||
TemperatureC *float64
|
||||
HumidityPct *float64
|
||||
WindSpeedMs *float64
|
||||
CloudCoverPct *float64
|
||||
PrecipitationMm *float64
|
||||
SunshineMin *float64
|
||||
PressureHpa *float64
|
||||
DewPointC *float64
|
||||
ApparentTempC *float64
|
||||
Condition string
|
||||
Source string
|
||||
FetchedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) UpsertForecast(f *Forecast) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO forecasts (profile_id, timestamp, temperature_c, humidity_pct, wind_speed_ms, cloud_cover_pct, precipitation_mm, sunshine_min, pressure_hpa, dew_point_c, apparent_temp_c, condition, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(profile_id, timestamp, source) DO UPDATE SET
|
||||
temperature_c=excluded.temperature_c, humidity_pct=excluded.humidity_pct,
|
||||
wind_speed_ms=excluded.wind_speed_ms, cloud_cover_pct=excluded.cloud_cover_pct,
|
||||
precipitation_mm=excluded.precipitation_mm, sunshine_min=excluded.sunshine_min,
|
||||
pressure_hpa=excluded.pressure_hpa, dew_point_c=excluded.dew_point_c,
|
||||
apparent_temp_c=excluded.apparent_temp_c, condition=excluded.condition,
|
||||
fetched_at=datetime('now')`,
|
||||
f.ProfileID, f.Timestamp.Format(time.RFC3339),
|
||||
f.TemperatureC, f.HumidityPct, f.WindSpeedMs, f.CloudCoverPct,
|
||||
f.PrecipitationMm, f.SunshineMin, f.PressureHpa, f.DewPointC,
|
||||
f.ApparentTempC, f.Condition, f.Source,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetForecasts(profileID int64, from, to time.Time, source string) ([]Forecast, error) {
|
||||
query := `SELECT id, profile_id, timestamp, temperature_c, humidity_pct, wind_speed_ms, cloud_cover_pct, precipitation_mm, sunshine_min, pressure_hpa, dew_point_c, apparent_temp_c, condition, source, fetched_at
|
||||
FROM forecasts WHERE profile_id = ? AND timestamp >= ? AND timestamp <= ?`
|
||||
args := []any{profileID, from.Format(time.RFC3339), to.Format(time.RFC3339)}
|
||||
if source != "" {
|
||||
query += ` AND source = ?`
|
||||
args = append(args, source)
|
||||
}
|
||||
query += ` ORDER BY timestamp`
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var forecasts []Forecast
|
||||
for rows.Next() {
|
||||
var f Forecast
|
||||
var ts, fetched string
|
||||
if err := rows.Scan(&f.ID, &f.ProfileID, &ts, &f.TemperatureC, &f.HumidityPct, &f.WindSpeedMs, &f.CloudCoverPct, &f.PrecipitationMm, &f.SunshineMin, &f.PressureHpa, &f.DewPointC, &f.ApparentTempC, &f.Condition, &f.Source, &fetched); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
||||
f.FetchedAt, _ = time.Parse("2006-01-02 15:04:05", fetched)
|
||||
forecasts = append(forecasts, f)
|
||||
}
|
||||
return forecasts, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetLastFetchTime(profileID int64, source string) (time.Time, error) {
|
||||
var fetched string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT MAX(fetched_at) FROM forecasts WHERE profile_id = ? AND source = ?`,
|
||||
profileID, source,
|
||||
).Scan(&fetched)
|
||||
if err != nil || fetched == "" {
|
||||
return time.Time{}, fmt.Errorf("no forecasts found")
|
||||
}
|
||||
t, _ := time.Parse("2006-01-02 15:04:05", fetched)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Store) CleanupOldForecasts(olderThan time.Time) (int64, error) {
|
||||
res, err := s.db.Exec(
|
||||
`DELETE FROM forecasts WHERE timestamp < ?`,
|
||||
olderThan.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
6
internal/store/migrations.go
Normal file
6
internal/store/migrations.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package store
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed schema.sql
|
||||
var schemaSQL string
|
||||
108
internal/store/occupant.go
Normal file
108
internal/store/occupant.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Occupant struct {
|
||||
ID int64
|
||||
RoomID int64
|
||||
Count int
|
||||
ActivityLevel string
|
||||
Vulnerable bool
|
||||
}
|
||||
|
||||
func (s *Store) CreateOccupant(roomID int64, count int, activityLevel string, vulnerable bool) (*Occupant, error) {
|
||||
if activityLevel == "" {
|
||||
activityLevel = "sedentary"
|
||||
}
|
||||
vuln := 0
|
||||
if vulnerable {
|
||||
vuln = 1
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO occupants (room_id, count, activity_level, vulnerable) VALUES (?, ?, ?, ?)`,
|
||||
roomID, count, activityLevel, vuln,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create occupant: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetOccupant(id)
|
||||
}
|
||||
|
||||
func (s *Store) GetOccupant(id int64) (*Occupant, error) {
|
||||
o := &Occupant{}
|
||||
var vuln int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, room_id, count, activity_level, vulnerable FROM occupants WHERE id = ?`, id,
|
||||
).Scan(&o.ID, &o.RoomID, &o.Count, &o.ActivityLevel, &vuln)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("occupant not found: %d", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get occupant: %w", err)
|
||||
}
|
||||
o.Vulnerable = vuln != 0
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListOccupants(roomID int64) ([]Occupant, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, room_id, count, activity_level, vulnerable FROM occupants WHERE room_id = ?`, roomID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var occupants []Occupant
|
||||
for rows.Next() {
|
||||
var o Occupant
|
||||
var vuln int
|
||||
if err := rows.Scan(&o.ID, &o.RoomID, &o.Count, &o.ActivityLevel, &vuln); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.Vulnerable = vuln != 0
|
||||
occupants = append(occupants, o)
|
||||
}
|
||||
return occupants, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListAllOccupants(profileID int64) ([]Occupant, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT o.id, o.room_id, o.count, o.activity_level, o.vulnerable
|
||||
FROM occupants o JOIN rooms r ON o.room_id = r.id WHERE r.profile_id = ?`, profileID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var occupants []Occupant
|
||||
for rows.Next() {
|
||||
var o Occupant
|
||||
var vuln int
|
||||
if err := rows.Scan(&o.ID, &o.RoomID, &o.Count, &o.ActivityLevel, &vuln); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.Vulnerable = vuln != 0
|
||||
occupants = append(occupants, o)
|
||||
}
|
||||
return occupants, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateOccupant(id int64, field, value string) error {
|
||||
allowed := map[string]bool{"count": true, "activity_level": true, "vulnerable": true}
|
||||
if !allowed[field] {
|
||||
return fmt.Errorf("invalid field: %s", field)
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
fmt.Sprintf(`UPDATE occupants SET %s = ? WHERE id = ?`, field), value, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOccupant(id int64) error {
|
||||
_, err := s.db.Exec(`DELETE FROM occupants WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
103
internal/store/profile.go
Normal file
103
internal/store/profile.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
ID int64
|
||||
Name string
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
Timezone string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) CreateProfile(name string, lat, lon float64, tz string) (*Profile, error) {
|
||||
if tz == "" {
|
||||
tz = "Europe/Berlin"
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO profiles (name, latitude, longitude, timezone) VALUES (?, ?, ?, ?)`,
|
||||
name, lat, lon, tz,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create profile: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetProfile(id)
|
||||
}
|
||||
|
||||
func (s *Store) GetProfile(id int64) (*Profile, error) {
|
||||
p := &Profile{}
|
||||
var created, updated string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, latitude, longitude, timezone, created_at, updated_at FROM profiles WHERE id = ?`, id,
|
||||
).Scan(&p.ID, &p.Name, &p.Latitude, &p.Longitude, &p.Timezone, &created, &updated)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("profile not found: %d", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get profile: %w", err)
|
||||
}
|
||||
p.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||
p.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updated)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetProfileByName(name string) (*Profile, error) {
|
||||
p := &Profile{}
|
||||
var created, updated string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, latitude, longitude, timezone, created_at, updated_at FROM profiles WHERE name = ?`, name,
|
||||
).Scan(&p.ID, &p.Name, &p.Latitude, &p.Longitude, &p.Timezone, &created, &updated)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("profile not found: %s", name)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get profile by name: %w", err)
|
||||
}
|
||||
p.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||
p.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updated)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateProfile(id int64, field, value string) error {
|
||||
allowed := map[string]bool{"name": true, "latitude": true, "longitude": true, "timezone": true}
|
||||
if !allowed[field] {
|
||||
return fmt.Errorf("invalid field: %s", field)
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
fmt.Sprintf(`UPDATE profiles SET %s = ?, updated_at = datetime('now') WHERE id = ?`, field),
|
||||
value, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteProfile(id int64) error {
|
||||
_, err := s.db.Exec(`DELETE FROM profiles WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListProfiles() ([]Profile, error) {
|
||||
rows, err := s.db.Query(`SELECT id, name, latitude, longitude, timezone, created_at, updated_at FROM profiles ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var profiles []Profile
|
||||
for rows.Next() {
|
||||
var p Profile
|
||||
var created, updated string
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Latitude, &p.Longitude, &p.Timezone, &created, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||
p.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updated)
|
||||
profiles = append(profiles, p)
|
||||
}
|
||||
return profiles, rows.Err()
|
||||
}
|
||||
151
internal/store/room.go
Normal file
151
internal/store/room.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Room struct {
|
||||
ID int64
|
||||
ProfileID int64
|
||||
Name string
|
||||
AreaSqm float64
|
||||
CeilingHeightM float64
|
||||
Floor int
|
||||
Orientation string
|
||||
ShadingType string
|
||||
ShadingFactor float64
|
||||
Ventilation string
|
||||
VentilationACH float64
|
||||
WindowFraction float64
|
||||
SHGC float64
|
||||
Insulation string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// RoomParams holds optional room parameters with defaults.
|
||||
type RoomParams struct {
|
||||
CeilingHeightM float64
|
||||
VentilationACH float64
|
||||
WindowFraction float64
|
||||
SHGC float64
|
||||
}
|
||||
|
||||
// DefaultRoomParams returns sensible defaults for room physics parameters.
|
||||
func DefaultRoomParams() RoomParams {
|
||||
return RoomParams{
|
||||
CeilingHeightM: 2.5,
|
||||
VentilationACH: 0.5,
|
||||
WindowFraction: 0.15,
|
||||
SHGC: 0.6,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) CreateRoom(profileID int64, name string, areaSqm float64, floor int, orientation, shadingType string, shadingFactor float64, ventilation, insulation string, params RoomParams) (*Room, error) {
|
||||
if shadingType == "" {
|
||||
shadingType = "none"
|
||||
}
|
||||
if shadingFactor == 0 {
|
||||
shadingFactor = 1.0
|
||||
}
|
||||
if ventilation == "" {
|
||||
ventilation = "natural"
|
||||
}
|
||||
if insulation == "" {
|
||||
insulation = "average"
|
||||
}
|
||||
if params.CeilingHeightM == 0 {
|
||||
params.CeilingHeightM = 2.5
|
||||
}
|
||||
if params.VentilationACH == 0 {
|
||||
params.VentilationACH = 0.5
|
||||
}
|
||||
if params.WindowFraction == 0 {
|
||||
params.WindowFraction = 0.15
|
||||
}
|
||||
if params.SHGC == 0 {
|
||||
params.SHGC = 0.6
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO rooms (profile_id, name, area_sqm, ceiling_height_m, floor, orientation, shading_type, shading_factor, ventilation, ventilation_ach, window_fraction, shgc, insulation) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
profileID, name, areaSqm, params.CeilingHeightM, floor, orientation, shadingType, shadingFactor, ventilation, params.VentilationACH, params.WindowFraction, params.SHGC, insulation,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create room: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetRoom(id)
|
||||
}
|
||||
|
||||
func (s *Store) GetRoom(id int64) (*Room, error) {
|
||||
r := &Room{}
|
||||
var created string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, profile_id, name, area_sqm, ceiling_height_m, floor, orientation, shading_type, shading_factor, ventilation, ventilation_ach, window_fraction, shgc, insulation, created_at FROM rooms WHERE id = ?`, id,
|
||||
).Scan(&r.ID, &r.ProfileID, &r.Name, &r.AreaSqm, &r.CeilingHeightM, &r.Floor, &r.Orientation, &r.ShadingType, &r.ShadingFactor, &r.Ventilation, &r.VentilationACH, &r.WindowFraction, &r.SHGC, &r.Insulation, &created)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("room not found: %d", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get room: %w", err)
|
||||
}
|
||||
r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListRooms(profileID int64) ([]Room, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, profile_id, name, area_sqm, ceiling_height_m, floor, orientation, shading_type, shading_factor, ventilation, ventilation_ach, window_fraction, shgc, insulation, created_at FROM rooms WHERE profile_id = ? ORDER BY name`, profileID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var rooms []Room
|
||||
for rows.Next() {
|
||||
var r Room
|
||||
var created string
|
||||
if err := rows.Scan(&r.ID, &r.ProfileID, &r.Name, &r.AreaSqm, &r.CeilingHeightM, &r.Floor, &r.Orientation, &r.ShadingType, &r.ShadingFactor, &r.Ventilation, &r.VentilationACH, &r.WindowFraction, &r.SHGC, &r.Insulation, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||
rooms = append(rooms, r)
|
||||
}
|
||||
return rooms, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateRoom(id int64, field, value string) error {
|
||||
allowed := map[string]bool{
|
||||
"name": true, "area_sqm": true, "ceiling_height_m": true, "floor": true, "orientation": true,
|
||||
"shading_type": true, "shading_factor": true, "ventilation": true, "ventilation_ach": true,
|
||||
"window_fraction": true, "shgc": true, "insulation": true,
|
||||
}
|
||||
if !allowed[field] {
|
||||
return fmt.Errorf("invalid field: %s", field)
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
fmt.Sprintf(`UPDATE rooms SET %s = ? WHERE id = ?`, field), value, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteRoom(id int64) error {
|
||||
_, err := s.db.Exec(`DELETE FROM rooms WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRoomACCapacity returns total AC capacity in BTU/h assigned to a room.
|
||||
func (s *Store) GetRoomACCapacity(roomID int64) (float64, error) {
|
||||
var total sql.NullFloat64
|
||||
err := s.db.QueryRow(
|
||||
`SELECT SUM(a.capacity_btu) FROM ac_units a JOIN ac_room_assignments ar ON a.id = ar.ac_id WHERE ar.room_id = ?`, roomID,
|
||||
).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !total.Valid {
|
||||
return 0, nil
|
||||
}
|
||||
return total.Float64, nil
|
||||
}
|
||||
106
internal/store/schema.sql
Normal file
106
internal/store/schema.sql
Normal file
@@ -0,0 +1,106 @@
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
timezone TEXT NOT NULL DEFAULT 'Europe/Berlin',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
area_sqm REAL NOT NULL,
|
||||
ceiling_height_m REAL NOT NULL DEFAULT 2.5,
|
||||
floor INTEGER NOT NULL DEFAULT 0,
|
||||
orientation TEXT NOT NULL DEFAULT 'N',
|
||||
shading_type TEXT NOT NULL DEFAULT 'none',
|
||||
shading_factor REAL NOT NULL DEFAULT 1.0,
|
||||
ventilation TEXT NOT NULL DEFAULT 'natural',
|
||||
ventilation_ach REAL NOT NULL DEFAULT 0.5,
|
||||
window_fraction REAL NOT NULL DEFAULT 0.15,
|
||||
shgc REAL NOT NULL DEFAULT 0.6,
|
||||
insulation TEXT NOT NULL DEFAULT 'average',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS occupants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
activity_level TEXT NOT NULL DEFAULT 'sedentary',
|
||||
vulnerable INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
watts_idle REAL NOT NULL DEFAULT 0,
|
||||
watts_typical REAL NOT NULL DEFAULT 0,
|
||||
watts_peak REAL NOT NULL DEFAULT 0,
|
||||
duty_cycle REAL NOT NULL DEFAULT 1.0,
|
||||
schedule TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ac_units (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
ac_type TEXT NOT NULL DEFAULT 'portable',
|
||||
capacity_btu REAL NOT NULL,
|
||||
has_dehumidify INTEGER NOT NULL DEFAULT 0,
|
||||
efficiency_eer REAL NOT NULL DEFAULT 10.0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ac_room_assignments (
|
||||
ac_id INTEGER NOT NULL REFERENCES ac_units(id) ON DELETE CASCADE,
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (ac_id, room_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS forecasts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
timestamp TEXT NOT NULL,
|
||||
temperature_c REAL,
|
||||
humidity_pct REAL,
|
||||
wind_speed_ms REAL,
|
||||
cloud_cover_pct REAL,
|
||||
precipitation_mm REAL,
|
||||
sunshine_min REAL,
|
||||
pressure_hpa REAL,
|
||||
dew_point_c REAL,
|
||||
apparent_temp_c REAL,
|
||||
condition TEXT,
|
||||
source TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(profile_id, timestamp, source)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS warnings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
warning_id TEXT NOT NULL UNIQUE,
|
||||
event_type TEXT,
|
||||
severity TEXT,
|
||||
headline TEXT,
|
||||
description TEXT,
|
||||
instruction TEXT,
|
||||
onset TEXT,
|
||||
expires TEXT,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS toggles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 0,
|
||||
activated_at TEXT
|
||||
);
|
||||
45
internal/store/store.go
Normal file
45
internal/store/store.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Store wraps a SQLite database connection.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New opens (or creates) a SQLite database and runs migrations.
|
||||
func New(dsn string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("set WAL mode: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
_, err := s.db.Exec(schemaSQL)
|
||||
return err
|
||||
}
|
||||
496
internal/store/store_test.go
Normal file
496
internal/store/store_test.go
Normal file
@@ -0,0 +1,496 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestNewStore(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if s == nil {
|
||||
t.Fatal("store is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Profile CRUD ---
|
||||
|
||||
func TestProfileCRUD(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
p, err := s.CreateProfile("home", 52.52, 13.41, "Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProfile: %v", err)
|
||||
}
|
||||
if p.Name != "home" || p.Latitude != 52.52 || p.Longitude != 13.41 {
|
||||
t.Errorf("unexpected profile: %+v", p)
|
||||
}
|
||||
|
||||
got, err := s.GetProfile(p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile: %v", err)
|
||||
}
|
||||
if got.Name != "home" {
|
||||
t.Errorf("GetProfile name = %s, want home", got.Name)
|
||||
}
|
||||
|
||||
gotByName, err := s.GetProfileByName("home")
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfileByName: %v", err)
|
||||
}
|
||||
if gotByName.ID != p.ID {
|
||||
t.Errorf("GetProfileByName ID = %d, want %d", gotByName.ID, p.ID)
|
||||
}
|
||||
|
||||
if err := s.UpdateProfile(p.ID, "name", "office"); err != nil {
|
||||
t.Fatalf("UpdateProfile: %v", err)
|
||||
}
|
||||
updated, _ := s.GetProfile(p.ID)
|
||||
if updated.Name != "office" {
|
||||
t.Errorf("updated name = %s, want office", updated.Name)
|
||||
}
|
||||
|
||||
profiles, err := s.ListProfiles()
|
||||
if err != nil {
|
||||
t.Fatalf("ListProfiles: %v", err)
|
||||
}
|
||||
if len(profiles) != 1 {
|
||||
t.Errorf("ListProfiles len = %d, want 1", len(profiles))
|
||||
}
|
||||
|
||||
if err := s.DeleteProfile(p.ID); err != nil {
|
||||
t.Fatalf("DeleteProfile: %v", err)
|
||||
}
|
||||
_, err = s.GetProfile(p.ID)
|
||||
if err == nil {
|
||||
t.Error("expected error after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileDuplicateName(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
_, err := s.CreateProfile("home", 52.52, 13.41, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.CreateProfile("home", 48.13, 11.58, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for duplicate name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileInvalidField(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
||||
err := s.UpdateProfile(p.ID, "nonexistent", "value")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid field")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Room CRUD ---
|
||||
|
||||
func TestRoomCRUD(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
||||
|
||||
r, err := s.CreateRoom(p.ID, "office", 15, 3, "S", "shutters", 0.3, "natural", "average", DefaultRoomParams())
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
if r.Name != "office" || r.AreaSqm != 15 || r.Orientation != "S" || r.ShadingFactor != 0.3 {
|
||||
t.Errorf("unexpected room: %+v", r)
|
||||
}
|
||||
|
||||
rooms, err := s.ListRooms(p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRooms: %v", err)
|
||||
}
|
||||
if len(rooms) != 1 {
|
||||
t.Errorf("ListRooms len = %d, want 1", len(rooms))
|
||||
}
|
||||
|
||||
if err := s.UpdateRoom(r.ID, "name", "bedroom"); err != nil {
|
||||
t.Fatalf("UpdateRoom: %v", err)
|
||||
}
|
||||
updated, _ := s.GetRoom(r.ID)
|
||||
if updated.Name != "bedroom" {
|
||||
t.Errorf("updated name = %s, want bedroom", updated.Name)
|
||||
}
|
||||
|
||||
if err := s.DeleteRoom(r.ID); err != nil {
|
||||
t.Fatalf("DeleteRoom: %v", err)
|
||||
}
|
||||
_, err = s.GetRoom(r.ID)
|
||||
if err == nil {
|
||||
t.Error("expected error after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomPhysicsFields(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
||||
|
||||
params := RoomParams{
|
||||
CeilingHeightM: 3.0,
|
||||
VentilationACH: 1.5,
|
||||
WindowFraction: 0.20,
|
||||
SHGC: 0.45,
|
||||
}
|
||||
r, err := s.CreateRoom(p.ID, "office", 15, 3, "S", "shutters", 0.3, "natural", "average", params)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
if r.CeilingHeightM != 3.0 {
|
||||
t.Errorf("CeilingHeightM = %v, want 3.0", r.CeilingHeightM)
|
||||
}
|
||||
if r.VentilationACH != 1.5 {
|
||||
t.Errorf("VentilationACH = %v, want 1.5", r.VentilationACH)
|
||||
}
|
||||
if r.WindowFraction != 0.20 {
|
||||
t.Errorf("WindowFraction = %v, want 0.20", r.WindowFraction)
|
||||
}
|
||||
if r.SHGC != 0.45 {
|
||||
t.Errorf("SHGC = %v, want 0.45", r.SHGC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomACCapacity(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
||||
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
|
||||
|
||||
// No AC assigned yet
|
||||
cap, err := s.GetRoomACCapacity(r.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRoomACCapacity: %v", err)
|
||||
}
|
||||
if cap != 0 {
|
||||
t.Errorf("expected 0 BTU/h, got %v", cap)
|
||||
}
|
||||
|
||||
// Assign AC
|
||||
ac, _ := s.CreateACUnit(p.ID, "Portable", "portable", 8000, false, 10)
|
||||
s.AssignACToRoom(ac.ID, r.ID)
|
||||
|
||||
cap, err = s.GetRoomACCapacity(r.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRoomACCapacity: %v", err)
|
||||
}
|
||||
if cap != 8000 {
|
||||
t.Errorf("expected 8000 BTU/h, got %v", cap)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Device CRUD ---
|
||||
|
||||
func TestDeviceCRUD(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
||||
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
|
||||
|
||||
d, err := s.CreateDevice(r.ID, "Gaming PC", "pc", 65, 200, 450, 1.0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDevice: %v", err)
|
||||
}
|
||||
if d.Name != "Gaming PC" || d.WattsTypical != 200 {
|
||||
t.Errorf("unexpected device: %+v", d)
|
||||
}
|
||||
|
||||
devices, err := s.ListDevices(r.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDevices: %v", err)
|
||||
}
|
||||
if len(devices) != 1 {
|
||||
t.Errorf("ListDevices len = %d, want 1", len(devices))
|
||||
}
|
||||
|
||||
allDevices, err := s.ListAllDevices(p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllDevices: %v", err)
|
||||
}
|
||||
if len(allDevices) != 1 {
|
||||
t.Errorf("ListAllDevices len = %d, want 1", len(allDevices))
|
||||
}
|
||||
|
||||
if err := s.DeleteDevice(d.ID); err != nil {
|
||||
t.Fatalf("DeleteDevice: %v", err)
|
||||
}
|
||||
_, err = s.GetDevice(d.ID)
|
||||
if err == nil {
|
||||
t.Error("expected error after delete")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Occupant CRUD ---
|
||||
|
||||
func TestOccupantCRUD(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
||||
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
|
||||
|
||||
o, err := s.CreateOccupant(r.ID, 1, "sedentary", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOccupant: %v", err)
|
||||
}
|
||||
if o.Count != 1 || o.ActivityLevel != "sedentary" || o.Vulnerable {
|
||||
t.Errorf("unexpected occupant: %+v", o)
|
||||
}
|
||||
|
||||
oVuln, err := s.CreateOccupant(r.ID, 1, "sleeping", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !oVuln.Vulnerable {
|
||||
t.Error("expected vulnerable=true")
|
||||
}
|
||||
|
||||
occupants, err := s.ListOccupants(r.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListOccupants: %v", err)
|
||||
}
|
||||
if len(occupants) != 2 {
|
||||
t.Errorf("ListOccupants len = %d, want 2", len(occupants))
|
||||
}
|
||||
|
||||
all, err := s.ListAllOccupants(p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllOccupants: %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Errorf("ListAllOccupants len = %d, want 2", len(all))
|
||||
}
|
||||
|
||||
if err := s.DeleteOccupant(o.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.GetOccupant(o.ID)
|
||||
if err == nil {
|
||||
t.Error("expected error after delete")
|
||||
}
|
||||
}
|
||||
|
||||
// --- AC Unit CRUD ---
|
||||
|
||||
func TestACUnitCRUD(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
||||
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
|
||||
|
||||
ac, err := s.CreateACUnit(p.ID, "Portable", "portable", 8000, true, 10.5)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateACUnit: %v", err)
|
||||
}
|
||||
if ac.Name != "Portable" || ac.CapacityBTU != 8000 || !ac.HasDehumidify {
|
||||
t.Errorf("unexpected ac: %+v", ac)
|
||||
}
|
||||
|
||||
if err := s.AssignACToRoom(ac.ID, r.ID); err != nil {
|
||||
t.Fatalf("AssignACToRoom: %v", err)
|
||||
}
|
||||
rooms, err := s.GetACRoomAssignments(ac.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetACRoomAssignments: %v", err)
|
||||
}
|
||||
if len(rooms) != 1 || rooms[0] != r.ID {
|
||||
t.Errorf("unexpected room assignments: %v", rooms)
|
||||
}
|
||||
|
||||
if err := s.UnassignACFromRoom(ac.ID, r.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rooms, _ = s.GetACRoomAssignments(ac.ID)
|
||||
if len(rooms) != 0 {
|
||||
t.Error("expected empty after unassign")
|
||||
}
|
||||
|
||||
units, err := s.ListACUnits(p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(units) != 1 {
|
||||
t.Errorf("ListACUnits len = %d, want 1", len(units))
|
||||
}
|
||||
|
||||
if err := s.DeleteACUnit(ac.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Forecast CRUD ---
|
||||
|
||||
func TestForecastUpsertAndQuery(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
||||
|
||||
ts := time.Date(2025, 7, 15, 14, 0, 0, 0, time.UTC)
|
||||
temp := 35.5
|
||||
hum := 40.0
|
||||
f := &Forecast{
|
||||
ProfileID: p.ID,
|
||||
Timestamp: ts,
|
||||
TemperatureC: &temp,
|
||||
HumidityPct: &hum,
|
||||
Source: "openmeteo",
|
||||
}
|
||||
if err := s.UpsertForecast(f); err != nil {
|
||||
t.Fatalf("UpsertForecast: %v", err)
|
||||
}
|
||||
|
||||
// Upsert again (should update, not duplicate)
|
||||
temp2 := 36.0
|
||||
f.TemperatureC = &temp2
|
||||
if err := s.UpsertForecast(f); err != nil {
|
||||
t.Fatalf("UpsertForecast update: %v", err)
|
||||
}
|
||||
|
||||
from := ts.Add(-time.Hour)
|
||||
to := ts.Add(time.Hour)
|
||||
forecasts, err := s.GetForecasts(p.ID, from, to, "openmeteo")
|
||||
if err != nil {
|
||||
t.Fatalf("GetForecasts: %v", err)
|
||||
}
|
||||
if len(forecasts) != 1 {
|
||||
t.Fatalf("GetForecasts len = %d, want 1", len(forecasts))
|
||||
}
|
||||
if *forecasts[0].TemperatureC != 36.0 {
|
||||
t.Errorf("temperature = %v, want 36.0", *forecasts[0].TemperatureC)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
deleted, err := s.CleanupOldForecasts(ts.Add(time.Hour * 24))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Errorf("deleted = %d, want 1", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Warning CRUD ---
|
||||
|
||||
func TestWarningUpsertAndQuery(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
||||
|
||||
now := time.Now().UTC()
|
||||
w := &Warning{
|
||||
ProfileID: p.ID,
|
||||
WarningID: "dwd-heat-001",
|
||||
EventType: "STARKE HITZE",
|
||||
Severity: "Severe",
|
||||
Headline: "Heat warning Berlin",
|
||||
Description: "Temperatures up to 37C expected",
|
||||
Instruction: "Stay hydrated",
|
||||
Onset: now,
|
||||
Expires: now.Add(48 * time.Hour),
|
||||
}
|
||||
if err := s.UpsertWarning(w); err != nil {
|
||||
t.Fatalf("UpsertWarning: %v", err)
|
||||
}
|
||||
|
||||
// Upsert again (should update)
|
||||
w.Headline = "Updated heat warning"
|
||||
if err := s.UpsertWarning(w); err != nil {
|
||||
t.Fatalf("UpsertWarning update: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetWarning("dwd-heat-001")
|
||||
if err != nil {
|
||||
t.Fatalf("GetWarning: %v", err)
|
||||
}
|
||||
if got.Headline != "Updated heat warning" {
|
||||
t.Errorf("headline = %s, want 'Updated heat warning'", got.Headline)
|
||||
}
|
||||
|
||||
active, err := s.GetActiveWarnings(p.ID, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(active) != 1 {
|
||||
t.Errorf("active warnings = %d, want 1", len(active))
|
||||
}
|
||||
|
||||
// Check expired don't show
|
||||
expired, _ := s.GetActiveWarnings(p.ID, now.Add(72*time.Hour))
|
||||
if len(expired) != 0 {
|
||||
t.Errorf("expected 0 active warnings after expiry, got %d", len(expired))
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
deleted, err := s.CleanupExpiredWarnings(now.Add(72 * time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Errorf("deleted = %d, want 1", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Toggle ---
|
||||
|
||||
func TestToggle(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
||||
|
||||
if err := s.SetToggle(p.ID, "gaming", true); err != nil {
|
||||
t.Fatalf("SetToggle: %v", err)
|
||||
}
|
||||
|
||||
toggles, err := s.GetToggles(p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(toggles) != 1 || !toggles[0].Active || toggles[0].Name != "gaming" {
|
||||
t.Errorf("unexpected toggles: %+v", toggles)
|
||||
}
|
||||
|
||||
active, err := s.GetActiveToggleNames(p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !active["gaming"] {
|
||||
t.Error("expected gaming toggle active")
|
||||
}
|
||||
|
||||
if err := s.SetToggle(p.ID, "gaming", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, _ = s.GetActiveToggleNames(p.ID)
|
||||
if active["gaming"] {
|
||||
t.Error("expected gaming toggle inactive")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cascade Delete ---
|
||||
|
||||
func TestCascadeDeleteProfile(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
|
||||
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
|
||||
s.CreateDevice(r.ID, "PC", "pc", 65, 200, 450, 1.0)
|
||||
s.CreateOccupant(r.ID, 1, "sedentary", false)
|
||||
s.CreateACUnit(p.ID, "AC", "portable", 8000, false, 10)
|
||||
|
||||
if err := s.DeleteProfile(p.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rooms, _ := s.ListRooms(p.ID)
|
||||
if len(rooms) != 0 {
|
||||
t.Error("expected rooms deleted on cascade")
|
||||
}
|
||||
devices, _ := s.ListDevices(r.ID)
|
||||
if len(devices) != 0 {
|
||||
t.Error("expected devices deleted on cascade")
|
||||
}
|
||||
}
|
||||
78
internal/store/toggle.go
Normal file
78
internal/store/toggle.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package store
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Toggle struct {
|
||||
ID int64
|
||||
ProfileID int64
|
||||
Name string
|
||||
Active bool
|
||||
ActivatedAt string
|
||||
}
|
||||
|
||||
func (s *Store) SetToggle(profileID int64, name string, active bool) error {
|
||||
activeInt := 0
|
||||
activatedAt := ""
|
||||
if active {
|
||||
activeInt = 1
|
||||
activatedAt = "datetime('now')"
|
||||
}
|
||||
|
||||
if active {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO toggles (profile_id, name, active, activated_at) VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT DO NOTHING`,
|
||||
profileID, name, activeInt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set toggle: %w", err)
|
||||
}
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE toggles SET active = ?, activated_at = datetime('now') WHERE profile_id = ? AND name = ?`,
|
||||
activeInt, profileID, name,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
_ = activatedAt
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE toggles SET active = 0, activated_at = NULL WHERE profile_id = ? AND name = ?`,
|
||||
profileID, name,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetToggles(profileID int64) ([]Toggle, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, profile_id, name, active, COALESCE(activated_at, '') FROM toggles WHERE profile_id = ? ORDER BY name`, profileID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var toggles []Toggle
|
||||
for rows.Next() {
|
||||
var t Toggle
|
||||
var active int
|
||||
if err := rows.Scan(&t.ID, &t.ProfileID, &t.Name, &active, &t.ActivatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Active = active != 0
|
||||
toggles = append(toggles, t)
|
||||
}
|
||||
return toggles, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetActiveToggleNames(profileID int64) (map[string]bool, error) {
|
||||
toggles, err := s.GetToggles(profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[string]bool)
|
||||
for _, t := range toggles {
|
||||
if t.Active {
|
||||
m[t.Name] = true
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
91
internal/store/warning.go
Normal file
91
internal/store/warning.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Warning struct {
|
||||
ID int64
|
||||
ProfileID int64
|
||||
WarningID string
|
||||
EventType string
|
||||
Severity string
|
||||
Headline string
|
||||
Description string
|
||||
Instruction string
|
||||
Onset time.Time
|
||||
Expires time.Time
|
||||
FetchedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) UpsertWarning(w *Warning) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO warnings (profile_id, warning_id, event_type, severity, headline, description, instruction, onset, expires)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(warning_id) DO UPDATE SET
|
||||
severity=excluded.severity, headline=excluded.headline,
|
||||
description=excluded.description, instruction=excluded.instruction,
|
||||
onset=excluded.onset, expires=excluded.expires, fetched_at=datetime('now')`,
|
||||
w.ProfileID, w.WarningID, w.EventType, w.Severity, w.Headline,
|
||||
w.Description, w.Instruction,
|
||||
w.Onset.Format(time.RFC3339), w.Expires.Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetActiveWarnings(profileID int64, now time.Time) ([]Warning, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, profile_id, warning_id, event_type, severity, headline, description, instruction, onset, expires, fetched_at
|
||||
FROM warnings WHERE profile_id = ? AND expires > ? ORDER BY onset`,
|
||||
profileID, now.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var warnings []Warning
|
||||
for rows.Next() {
|
||||
var w Warning
|
||||
var onset, expires, fetched string
|
||||
if err := rows.Scan(&w.ID, &w.ProfileID, &w.WarningID, &w.EventType, &w.Severity, &w.Headline, &w.Description, &w.Instruction, &onset, &expires, &fetched); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.Onset, _ = time.Parse(time.RFC3339, onset)
|
||||
w.Expires, _ = time.Parse(time.RFC3339, expires)
|
||||
w.FetchedAt, _ = time.Parse("2006-01-02 15:04:05", fetched)
|
||||
warnings = append(warnings, w)
|
||||
}
|
||||
return warnings, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetWarning(warningID string) (*Warning, error) {
|
||||
w := &Warning{}
|
||||
var onset, expires, fetched string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, profile_id, warning_id, event_type, severity, headline, description, instruction, onset, expires, fetched_at
|
||||
FROM warnings WHERE warning_id = ?`, warningID,
|
||||
).Scan(&w.ID, &w.ProfileID, &w.WarningID, &w.EventType, &w.Severity, &w.Headline, &w.Description, &w.Instruction, &onset, &expires, &fetched)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("warning not found: %s", warningID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get warning: %w", err)
|
||||
}
|
||||
w.Onset, _ = time.Parse(time.RFC3339, onset)
|
||||
w.Expires, _ = time.Parse(time.RFC3339, expires)
|
||||
w.FetchedAt, _ = time.Parse("2006-01-02 15:04:05", fetched)
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (s *Store) CleanupExpiredWarnings(now time.Time) (int64, error) {
|
||||
res, err := s.db.Exec(
|
||||
`DELETE FROM warnings WHERE expires < ?`,
|
||||
now.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
121
internal/weather/brightsky.go
Normal file
121
internal/weather/brightsky.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const brightSkyBaseURL = "https://api.brightsky.dev/weather"
|
||||
|
||||
// BrightSky implements Provider using the Bright Sky API (DWD MOSMIX wrapper).
|
||||
type BrightSky struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewBrightSky creates a new Bright Sky provider.
|
||||
func NewBrightSky(client *http.Client) *BrightSky {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
return &BrightSky{client: client, baseURL: brightSkyBaseURL}
|
||||
}
|
||||
|
||||
func (b *BrightSky) Name() string { return "brightsky" }
|
||||
|
||||
type brightSkyResponse struct {
|
||||
Weather []struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Temperature *float64 `json:"temperature"`
|
||||
Humidity *float64 `json:"relative_humidity"`
|
||||
DewPoint *float64 `json:"dew_point"`
|
||||
CloudCover *float64 `json:"cloud_cover"`
|
||||
WindSpeed *float64 `json:"wind_speed"`
|
||||
WindDirection *float64 `json:"wind_direction"`
|
||||
Precipitation *float64 `json:"precipitation"`
|
||||
Sunshine *float64 `json:"sunshine"`
|
||||
PressureMSL *float64 `json:"pressure_msl"`
|
||||
Condition string `json:"condition"`
|
||||
} `json:"weather"`
|
||||
}
|
||||
|
||||
func (b *BrightSky) FetchForecast(ctx context.Context, lat, lon float64, timezone string) (*ForecastResponse, error) {
|
||||
now := time.Now()
|
||||
params := url.Values{
|
||||
"lat": {fmt.Sprintf("%.4f", lat)},
|
||||
"lon": {fmt.Sprintf("%.4f", lon)},
|
||||
"date": {now.Format("2006-01-02")},
|
||||
"last_date": {now.Add(72 * time.Hour).Format("2006-01-02")},
|
||||
}
|
||||
|
||||
reqURL := b.baseURL + "?" + params.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch brightsky: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("brightsky returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw brightSkyResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("decode brightsky: %w", err)
|
||||
}
|
||||
|
||||
result := &ForecastResponse{Source: "brightsky"}
|
||||
for _, w := range raw.Weather {
|
||||
t, err := time.Parse(time.RFC3339, w.Timestamp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hf := HourlyForecast{
|
||||
Timestamp: t,
|
||||
Condition: w.Condition,
|
||||
}
|
||||
if w.Temperature != nil {
|
||||
hf.TemperatureC = *w.Temperature
|
||||
}
|
||||
if w.Humidity != nil {
|
||||
hf.HumidityPct = *w.Humidity
|
||||
}
|
||||
if w.DewPoint != nil {
|
||||
hf.DewPointC = *w.DewPoint
|
||||
}
|
||||
if w.CloudCover != nil {
|
||||
hf.CloudCoverPct = *w.CloudCover
|
||||
}
|
||||
if w.WindSpeed != nil {
|
||||
hf.WindSpeedMs = *w.WindSpeed / 3.6 // km/h -> m/s
|
||||
}
|
||||
if w.WindDirection != nil {
|
||||
hf.WindDirectionDeg = *w.WindDirection
|
||||
}
|
||||
if w.Precipitation != nil {
|
||||
hf.PrecipitationMm = *w.Precipitation
|
||||
}
|
||||
if w.Sunshine != nil {
|
||||
hf.SunshineMin = *w.Sunshine
|
||||
}
|
||||
if w.PressureMSL != nil {
|
||||
hf.PressureHpa = *w.PressureMSL
|
||||
}
|
||||
// BrightSky doesn't provide IsDay — approximate from hour
|
||||
hour := t.Hour()
|
||||
hf.IsDay = hour >= 6 && hour < 21
|
||||
|
||||
result.Hourly = append(result.Hourly, hf)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
92
internal/weather/brightsky_test.go
Normal file
92
internal/weather/brightsky_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const brightSkyTestJSON = `{
|
||||
"weather": [
|
||||
{
|
||||
"timestamp": "2025-07-15T14:00:00+02:00",
|
||||
"temperature": 34.5,
|
||||
"relative_humidity": 40,
|
||||
"dew_point": 19.5,
|
||||
"cloud_cover": 15,
|
||||
"wind_speed": 10.8,
|
||||
"wind_direction": 220,
|
||||
"precipitation": 0,
|
||||
"sunshine": 55,
|
||||
"pressure_msl": 1015.2,
|
||||
"condition": "dry"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-15T15:00:00+02:00",
|
||||
"temperature": 35.1,
|
||||
"relative_humidity": 38,
|
||||
"dew_point": 19.0,
|
||||
"cloud_cover": 10,
|
||||
"wind_speed": 7.2,
|
||||
"wind_direction": 210,
|
||||
"precipitation": 0,
|
||||
"sunshine": 60,
|
||||
"pressure_msl": 1015.0,
|
||||
"condition": "dry"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestBrightSkyFetchForecast(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(brightSkyTestJSON))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
bs := NewBrightSky(srv.Client())
|
||||
bs.baseURL = srv.URL
|
||||
|
||||
resp, err := bs.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchForecast: %v", err)
|
||||
}
|
||||
|
||||
if resp.Source != "brightsky" {
|
||||
t.Errorf("Source = %s, want brightsky", resp.Source)
|
||||
}
|
||||
if len(resp.Hourly) != 2 {
|
||||
t.Fatalf("Hourly len = %d, want 2", len(resp.Hourly))
|
||||
}
|
||||
if resp.Hourly[0].TemperatureC != 34.5 {
|
||||
t.Errorf("temp = %v, want 34.5", resp.Hourly[0].TemperatureC)
|
||||
}
|
||||
// wind_speed is km/h in brightsky, converted to m/s
|
||||
expectedWindMs := 10.8 / 3.6
|
||||
if diff := resp.Hourly[0].WindSpeedMs - expectedWindMs; diff > 0.01 || diff < -0.01 {
|
||||
t.Errorf("WindSpeedMs = %v, want %v", resp.Hourly[0].WindSpeedMs, expectedWindMs)
|
||||
}
|
||||
if resp.Hourly[0].Condition != "dry" {
|
||||
t.Errorf("Condition = %s, want dry", resp.Hourly[0].Condition)
|
||||
}
|
||||
// 14:00 should be daytime
|
||||
if !resp.Hourly[0].IsDay {
|
||||
t.Error("expected IsDay=true for hour 14")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrightSkyServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
bs := NewBrightSky(srv.Client())
|
||||
bs.baseURL = srv.URL
|
||||
|
||||
_, err := bs.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
|
||||
if err == nil {
|
||||
t.Error("expected error for 502 response")
|
||||
}
|
||||
}
|
||||
116
internal/weather/dwd_wfs.go
Normal file
116
internal/weather/dwd_wfs.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dwdWFSBaseURL = "https://maps.dwd.de/geoserver/dwd/ows"
|
||||
|
||||
// DWDWFS implements WarningProvider using the DWD WFS GeoServer.
|
||||
type DWDWFS struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewDWDWFS creates a new DWD WFS warning provider.
|
||||
func NewDWDWFS(client *http.Client) *DWDWFS {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
return &DWDWFS{client: client, baseURL: dwdWFSBaseURL}
|
||||
}
|
||||
|
||||
func (d *DWDWFS) Name() string { return "dwd_wfs" }
|
||||
|
||||
type dwdGeoJSON struct {
|
||||
Features []struct {
|
||||
Properties struct {
|
||||
ECII int `json:"EC_II"`
|
||||
Severity string `json:"SEVERITY"`
|
||||
Headline string `json:"HEADLINE"`
|
||||
Description string `json:"DESCRIPTION"`
|
||||
Instruction string `json:"INSTRUCTION"`
|
||||
Onset string `json:"ONSET"`
|
||||
Expires string `json:"EXPIRES"`
|
||||
Identifier string `json:"IDENTIFIER"`
|
||||
Event string `json:"EVENT"`
|
||||
} `json:"properties"`
|
||||
} `json:"features"`
|
||||
}
|
||||
|
||||
func (d *DWDWFS) FetchWarnings(ctx context.Context, lat, lon float64) ([]Warning, error) {
|
||||
// BBOX filter: ~50km radius around user location
|
||||
const bboxBuffer = 0.5 // roughly 50km at mid-latitudes
|
||||
cql := fmt.Sprintf("EC_II IN(247,248) AND BBOX(THE_GEOM,%.4f,%.4f,%.4f,%.4f)",
|
||||
lon-bboxBuffer, lat-bboxBuffer, lon+bboxBuffer, lat+bboxBuffer)
|
||||
params := url.Values{
|
||||
"SERVICE": {"WFS"},
|
||||
"VERSION": {"2.0.0"},
|
||||
"REQUEST": {"GetFeature"},
|
||||
"typeName": {"dwd:Warnungen_Gemeinden"},
|
||||
"outputFormat": {"application/json"},
|
||||
"CQL_FILTER": {cql},
|
||||
}
|
||||
reqURL := d.baseURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch dwd wfs: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("dwd wfs returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw dwdGeoJSON
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("decode dwd wfs: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var warnings []Warning
|
||||
for _, f := range raw.Features {
|
||||
p := f.Properties
|
||||
if seen[p.Identifier] {
|
||||
continue
|
||||
}
|
||||
seen[p.Identifier] = true
|
||||
|
||||
var eventType string
|
||||
switch p.ECII {
|
||||
case 247:
|
||||
eventType = "STARKE HITZE"
|
||||
case 248:
|
||||
eventType = "EXTREME HITZE"
|
||||
default:
|
||||
eventType = p.Event
|
||||
}
|
||||
|
||||
onset, _ := time.Parse(time.RFC3339, p.Onset)
|
||||
expires, _ := time.Parse(time.RFC3339, p.Expires)
|
||||
|
||||
warnings = append(warnings, Warning{
|
||||
ID: p.Identifier,
|
||||
EventType: eventType,
|
||||
Severity: p.Severity,
|
||||
Headline: p.Headline,
|
||||
Description: p.Description,
|
||||
Instruction: p.Instruction,
|
||||
Onset: onset,
|
||||
Expires: expires,
|
||||
})
|
||||
}
|
||||
|
||||
return warnings, nil
|
||||
}
|
||||
127
internal/weather/dwd_wfs_test.go
Normal file
127
internal/weather/dwd_wfs_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const dwdWFSTestJSON = `{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"EC_II": 247,
|
||||
"SEVERITY": "Severe",
|
||||
"HEADLINE": "Amtliche WARNUNG vor HITZE",
|
||||
"DESCRIPTION": "Es tritt eine starke Wärmebelastung auf.",
|
||||
"INSTRUCTION": "Trinken Sie ausreichend.",
|
||||
"ONSET": "2025-07-15T11:00:00Z",
|
||||
"EXPIRES": "2025-07-16T19:00:00Z",
|
||||
"IDENTIFIER": "dwd-heat-247-001",
|
||||
"EVENT": "HITZE"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"EC_II": 248,
|
||||
"SEVERITY": "Extreme",
|
||||
"HEADLINE": "Amtliche WARNUNG vor EXTREMER HITZE",
|
||||
"DESCRIPTION": "Es tritt eine extreme Wärmebelastung auf.",
|
||||
"INSTRUCTION": "Vermeiden Sie körperliche Anstrengung.",
|
||||
"ONSET": "2025-07-16T11:00:00Z",
|
||||
"EXPIRES": "2025-07-17T19:00:00Z",
|
||||
"IDENTIFIER": "dwd-heat-248-001",
|
||||
"EVENT": "HITZE"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"EC_II": 247,
|
||||
"SEVERITY": "Severe",
|
||||
"HEADLINE": "Duplicate warning",
|
||||
"DESCRIPTION": "Should be deduplicated",
|
||||
"INSTRUCTION": "",
|
||||
"ONSET": "2025-07-15T11:00:00Z",
|
||||
"EXPIRES": "2025-07-16T19:00:00Z",
|
||||
"IDENTIFIER": "dwd-heat-247-001",
|
||||
"EVENT": "HITZE"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestDWDWFSFetchWarnings(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(dwdWFSTestJSON))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dwd := NewDWDWFS(srv.Client())
|
||||
dwd.baseURL = srv.URL
|
||||
|
||||
warnings, err := dwd.FetchWarnings(context.Background(), 52.52, 13.41)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchWarnings: %v", err)
|
||||
}
|
||||
|
||||
// Should deduplicate: 3 features, 2 unique identifiers
|
||||
if len(warnings) != 2 {
|
||||
t.Fatalf("warnings len = %d, want 2 (deduplicated)", len(warnings))
|
||||
}
|
||||
|
||||
w0 := warnings[0]
|
||||
if w0.ID != "dwd-heat-247-001" {
|
||||
t.Errorf("ID = %s, want dwd-heat-247-001", w0.ID)
|
||||
}
|
||||
if w0.EventType != "STARKE HITZE" {
|
||||
t.Errorf("EventType = %s, want STARKE HITZE", w0.EventType)
|
||||
}
|
||||
if w0.Severity != "Severe" {
|
||||
t.Errorf("Severity = %s, want Severe", w0.Severity)
|
||||
}
|
||||
|
||||
w1 := warnings[1]
|
||||
if w1.EventType != "EXTREME HITZE" {
|
||||
t.Errorf("EventType = %s, want EXTREME HITZE", w1.EventType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWDWFSServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dwd := NewDWDWFS(srv.Client())
|
||||
dwd.baseURL = srv.URL
|
||||
|
||||
_, err := dwd.FetchWarnings(context.Background(), 52.52, 13.41)
|
||||
if err == nil {
|
||||
t.Error("expected error for 503 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWDWFSEmptyResponse(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"type": "FeatureCollection", "features": []}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dwd := NewDWDWFS(srv.Client())
|
||||
dwd.baseURL = srv.URL
|
||||
|
||||
warnings, err := dwd.FetchWarnings(context.Background(), 52.52, 13.41)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchWarnings: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Errorf("warnings len = %d, want 0", len(warnings))
|
||||
}
|
||||
}
|
||||
167
internal/weather/openmeteo.go
Normal file
167
internal/weather/openmeteo.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const openMeteoBaseURL = "https://api.open-meteo.com/v1/forecast"
|
||||
|
||||
// OpenMeteo implements Provider using the Open-Meteo API.
|
||||
type OpenMeteo struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewOpenMeteo creates a new Open-Meteo provider.
|
||||
func NewOpenMeteo(client *http.Client) *OpenMeteo {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
return &OpenMeteo{client: client, baseURL: openMeteoBaseURL}
|
||||
}
|
||||
|
||||
func (o *OpenMeteo) Name() string { return "openmeteo" }
|
||||
|
||||
type openMeteoResponse struct {
|
||||
Hourly struct {
|
||||
Time []string `json:"time"`
|
||||
Temperature2m []float64 `json:"temperature_2m"`
|
||||
ApparentTemperature []float64 `json:"apparent_temperature"`
|
||||
RelativeHumidity2m []float64 `json:"relative_humidity_2m"`
|
||||
DewPoint2m []float64 `json:"dew_point_2m"`
|
||||
CloudCover []float64 `json:"cloud_cover"`
|
||||
WindSpeed10m []float64 `json:"wind_speed_10m"`
|
||||
WindDirection10m []float64 `json:"wind_direction_10m"`
|
||||
Precipitation []float64 `json:"precipitation"`
|
||||
SunshineDuration []float64 `json:"sunshine_duration"`
|
||||
ShortwaveRadiation []float64 `json:"shortwave_radiation"`
|
||||
SurfacePressure []float64 `json:"surface_pressure"`
|
||||
IsDay []int `json:"is_day"`
|
||||
} `json:"hourly"`
|
||||
Daily struct {
|
||||
Time []string `json:"time"`
|
||||
Temperature2mMax []float64 `json:"temperature_2m_max"`
|
||||
Temperature2mMin []float64 `json:"temperature_2m_min"`
|
||||
ApparentTemperatureMax []float64 `json:"apparent_temperature_max"`
|
||||
Sunrise []string `json:"sunrise"`
|
||||
Sunset []string `json:"sunset"`
|
||||
} `json:"daily"`
|
||||
}
|
||||
|
||||
func (o *OpenMeteo) FetchForecast(ctx context.Context, lat, lon float64, timezone string) (*ForecastResponse, error) {
|
||||
if timezone == "" {
|
||||
timezone = "Europe/Berlin"
|
||||
}
|
||||
params := url.Values{
|
||||
"latitude": {fmt.Sprintf("%.4f", lat)},
|
||||
"longitude": {fmt.Sprintf("%.4f", lon)},
|
||||
"hourly": {"temperature_2m,apparent_temperature,relative_humidity_2m,dew_point_2m,cloud_cover,wind_speed_10m,wind_direction_10m,precipitation,sunshine_duration,shortwave_radiation,surface_pressure,is_day"},
|
||||
"daily": {"temperature_2m_max,temperature_2m_min,apparent_temperature_max,sunrise,sunset"},
|
||||
"wind_speed_unit": {"ms"},
|
||||
"timezone": {timezone},
|
||||
"forecast_days": {"3"},
|
||||
}
|
||||
|
||||
reqURL := o.baseURL + "?" + params.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := o.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch openmeteo: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("openmeteo returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw openMeteoResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("decode openmeteo: %w", err)
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation(timezone)
|
||||
result := &ForecastResponse{Source: "openmeteo"}
|
||||
|
||||
for i, ts := range raw.Hourly.Time {
|
||||
t, err := time.ParseInLocation("2006-01-02T15:04", ts, loc)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hf := HourlyForecast{
|
||||
Timestamp: t,
|
||||
Condition: "",
|
||||
}
|
||||
if i < len(raw.Hourly.Temperature2m) {
|
||||
hf.TemperatureC = raw.Hourly.Temperature2m[i]
|
||||
}
|
||||
if i < len(raw.Hourly.ApparentTemperature) {
|
||||
hf.ApparentTempC = raw.Hourly.ApparentTemperature[i]
|
||||
}
|
||||
if i < len(raw.Hourly.RelativeHumidity2m) {
|
||||
hf.HumidityPct = raw.Hourly.RelativeHumidity2m[i]
|
||||
}
|
||||
if i < len(raw.Hourly.DewPoint2m) {
|
||||
hf.DewPointC = raw.Hourly.DewPoint2m[i]
|
||||
}
|
||||
if i < len(raw.Hourly.CloudCover) {
|
||||
hf.CloudCoverPct = raw.Hourly.CloudCover[i]
|
||||
}
|
||||
if i < len(raw.Hourly.WindSpeed10m) {
|
||||
hf.WindSpeedMs = raw.Hourly.WindSpeed10m[i]
|
||||
}
|
||||
if i < len(raw.Hourly.WindDirection10m) {
|
||||
hf.WindDirectionDeg = raw.Hourly.WindDirection10m[i]
|
||||
}
|
||||
if i < len(raw.Hourly.Precipitation) {
|
||||
hf.PrecipitationMm = raw.Hourly.Precipitation[i]
|
||||
}
|
||||
if i < len(raw.Hourly.SunshineDuration) {
|
||||
hf.SunshineMin = raw.Hourly.SunshineDuration[i] / 60.0 // seconds -> minutes
|
||||
}
|
||||
if i < len(raw.Hourly.ShortwaveRadiation) {
|
||||
hf.ShortwaveRadW = raw.Hourly.ShortwaveRadiation[i]
|
||||
}
|
||||
if i < len(raw.Hourly.SurfacePressure) {
|
||||
hf.PressureHpa = raw.Hourly.SurfacePressure[i]
|
||||
}
|
||||
if i < len(raw.Hourly.IsDay) {
|
||||
hf.IsDay = raw.Hourly.IsDay[i] == 1
|
||||
}
|
||||
result.Hourly = append(result.Hourly, hf)
|
||||
}
|
||||
|
||||
for i, ds := range raw.Daily.Time {
|
||||
t, err := time.ParseInLocation("2006-01-02", ds, loc)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
df := DailyForecast{Date: t}
|
||||
if i < len(raw.Daily.Temperature2mMax) {
|
||||
df.TempMaxC = raw.Daily.Temperature2mMax[i]
|
||||
}
|
||||
if i < len(raw.Daily.Temperature2mMin) {
|
||||
df.TempMinC = raw.Daily.Temperature2mMin[i]
|
||||
}
|
||||
if i < len(raw.Daily.ApparentTemperatureMax) {
|
||||
df.ApparentTempMaxC = raw.Daily.ApparentTemperatureMax[i]
|
||||
}
|
||||
if i < len(raw.Daily.Sunrise) {
|
||||
df.Sunrise, _ = time.ParseInLocation("2006-01-02T15:04", raw.Daily.Sunrise[i], loc)
|
||||
}
|
||||
if i < len(raw.Daily.Sunset) {
|
||||
df.Sunset, _ = time.ParseInLocation("2006-01-02T15:04", raw.Daily.Sunset[i], loc)
|
||||
}
|
||||
result.Daily = append(result.Daily, df)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
88
internal/weather/openmeteo_test.go
Normal file
88
internal/weather/openmeteo_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const openMeteoTestJSON = `{
|
||||
"hourly": {
|
||||
"time": ["2025-07-15T00:00", "2025-07-15T01:00", "2025-07-15T02:00"],
|
||||
"temperature_2m": [22.5, 21.8, 21.0],
|
||||
"apparent_temperature": [23.1, 22.4, 21.5],
|
||||
"relative_humidity_2m": [65, 68, 72],
|
||||
"dew_point_2m": [15.5, 15.8, 16.0],
|
||||
"cloud_cover": [20, 30, 45],
|
||||
"wind_speed_10m": [3.5, 2.8, 2.1],
|
||||
"wind_direction_10m": [180, 190, 200],
|
||||
"precipitation": [0, 0, 0],
|
||||
"sunshine_duration": [3600, 3000, 0],
|
||||
"shortwave_radiation": [0, 0, 0],
|
||||
"surface_pressure": [1013, 1013, 1012],
|
||||
"is_day": [0, 0, 0]
|
||||
},
|
||||
"daily": {
|
||||
"time": ["2025-07-15"],
|
||||
"temperature_2m_max": [35.5],
|
||||
"temperature_2m_min": [20.1],
|
||||
"apparent_temperature_max": [37.2],
|
||||
"sunrise": ["2025-07-15T05:15"],
|
||||
"sunset": ["2025-07-15T21:30"]
|
||||
}
|
||||
}`
|
||||
|
||||
func TestOpenMeteoFetchForecast(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(openMeteoTestJSON))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
om := NewOpenMeteo(srv.Client())
|
||||
om.baseURL = srv.URL
|
||||
|
||||
resp, err := om.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchForecast: %v", err)
|
||||
}
|
||||
|
||||
if resp.Source != "openmeteo" {
|
||||
t.Errorf("Source = %s, want openmeteo", resp.Source)
|
||||
}
|
||||
if len(resp.Hourly) != 3 {
|
||||
t.Fatalf("Hourly len = %d, want 3", len(resp.Hourly))
|
||||
}
|
||||
if resp.Hourly[0].TemperatureC != 22.5 {
|
||||
t.Errorf("Hourly[0].TemperatureC = %v, want 22.5", resp.Hourly[0].TemperatureC)
|
||||
}
|
||||
if resp.Hourly[0].ApparentTempC != 23.1 {
|
||||
t.Errorf("Hourly[0].ApparentTempC = %v, want 23.1", resp.Hourly[0].ApparentTempC)
|
||||
}
|
||||
if resp.Hourly[0].SunshineMin != 60.0 {
|
||||
t.Errorf("Hourly[0].SunshineMin = %v, want 60 (3600s / 60)", resp.Hourly[0].SunshineMin)
|
||||
}
|
||||
|
||||
if len(resp.Daily) != 1 {
|
||||
t.Fatalf("Daily len = %d, want 1", len(resp.Daily))
|
||||
}
|
||||
if resp.Daily[0].TempMaxC != 35.5 {
|
||||
t.Errorf("Daily[0].TempMaxC = %v, want 35.5", resp.Daily[0].TempMaxC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMeteoServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
om := NewOpenMeteo(srv.Client())
|
||||
om.baseURL = srv.URL
|
||||
|
||||
_, err := om.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
|
||||
if err == nil {
|
||||
t.Error("expected error for 500 response")
|
||||
}
|
||||
}
|
||||
22
internal/weather/provider.go
Normal file
22
internal/weather/provider.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package weather
|
||||
|
||||
import "context"
|
||||
|
||||
// Provider is the interface for weather data sources.
|
||||
type Provider interface {
|
||||
// FetchForecast retrieves hourly + daily forecasts for a location.
|
||||
// The timezone parameter (e.g. "Europe/Berlin") controls the API's time axis.
|
||||
FetchForecast(ctx context.Context, lat, lon float64, timezone string) (*ForecastResponse, error)
|
||||
|
||||
// Name returns the provider identifier (e.g., "openmeteo", "brightsky").
|
||||
Name() string
|
||||
}
|
||||
|
||||
// WarningProvider is the interface for weather warning sources.
|
||||
type WarningProvider interface {
|
||||
// FetchWarnings retrieves active heat warnings near the given location.
|
||||
FetchWarnings(ctx context.Context, lat, lon float64) ([]Warning, error)
|
||||
|
||||
// Name returns the provider identifier.
|
||||
Name() string
|
||||
}
|
||||
50
internal/weather/types.go
Normal file
50
internal/weather/types.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package weather
|
||||
|
||||
import "time"
|
||||
|
||||
// HourlyForecast represents a single hour's weather data.
|
||||
type HourlyForecast struct {
|
||||
Timestamp time.Time
|
||||
TemperatureC float64
|
||||
ApparentTempC float64
|
||||
HumidityPct float64
|
||||
DewPointC float64
|
||||
CloudCoverPct float64
|
||||
WindSpeedMs float64
|
||||
WindDirectionDeg float64
|
||||
PrecipitationMm float64
|
||||
SunshineMin float64
|
||||
ShortwaveRadW float64
|
||||
PressureHpa float64
|
||||
IsDay bool
|
||||
Condition string
|
||||
}
|
||||
|
||||
// DailyForecast represents a single day's summary data.
|
||||
type DailyForecast struct {
|
||||
Date time.Time
|
||||
TempMaxC float64
|
||||
TempMinC float64
|
||||
ApparentTempMaxC float64
|
||||
Sunrise time.Time
|
||||
Sunset time.Time
|
||||
}
|
||||
|
||||
// ForecastResponse holds the full forecast response from a provider.
|
||||
type ForecastResponse struct {
|
||||
Hourly []HourlyForecast
|
||||
Daily []DailyForecast
|
||||
Source string
|
||||
}
|
||||
|
||||
// Warning represents a weather warning.
|
||||
type Warning struct {
|
||||
ID string
|
||||
EventType string
|
||||
Severity string
|
||||
Headline string
|
||||
Description string
|
||||
Instruction string
|
||||
Onset time.Time
|
||||
Expires time.Time
|
||||
}
|
||||
1055
package-lock.json
generated
Normal file
1055
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "heatwave-autopilot",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
"tailwindcss": "^4.1.18"
|
||||
}
|
||||
}
|
||||
3
tailwind/input.css
Normal file
3
tailwind/input.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
@source "../internal/report/templates/*.tmpl";
|
||||
@source "../internal/cli/templates/*.tmpl";
|
||||
Reference in New Issue
Block a user