diff --git a/internal/compute/compute.go b/internal/compute/compute.go index 641f54d..0a953f9 100644 --- a/internal/compute/compute.go +++ b/internal/compute/compute.go @@ -4,7 +4,6 @@ import ( "fmt" "time" - "github.com/cnachtigall/heatwave-autopilot/internal/action" "github.com/cnachtigall/heatwave-autopilot/internal/heat" "github.com/cnachtigall/heatwave-autopilot/internal/risk" ) @@ -45,14 +44,25 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { th := risk.DefaultThresholds() dayRisk := risk.AnalyzeDay(hourlyData, th) - // Load actions - actions, _ := action.LoadDefaultActions() - toggles := req.Toggles if toggles == nil { toggles = map[string]bool{} } + // Compute representative indoor temperature (average across rooms) + indoorTempC := 25.0 + if len(req.Rooms) > 0 { + sum := 0.0 + for _, r := range req.Rooms { + t := r.IndoorTempC + if t == 0 { + t = 25.0 + } + sum += t + } + indoorTempC = sum / float64(len(req.Rooms)) + } + data := DashboardData{ GeneratedAt: time.Now(), ProfileName: req.Profile.Name, @@ -61,6 +71,7 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { PeakTempC: dayRisk.PeakTempC, MinNightTempC: dayRisk.MinNightTempC, PoorNightCool: dayRisk.PoorNightCool, + IndoorTempC: indoorTempC, } // Warnings (pass-through from client) @@ -102,16 +113,12 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { budgets, worstStatus := computeRoomBudgets(req, h.Hour, h.TempC, cloudPct, sunMin, toggles) - ctx := action.HourContext{ - Hour: h.Hour, - TempC: h.TempC, - HumidityPct: h.HumidityPct, - IsDay: h.IsDay, - RiskLevel: dayRisk.Level, - BudgetStatus: worstStatus, - ActiveToggles: toggles, + coolMode := "ac" + if h.TempC < indoorTempC { + coolMode = "ventilate" + } else if worstStatus == heat.Overloaded { + coolMode = "overloaded" } - matched := action.SelectActions(actions, ctx) slot := TimelineSlotData{ Hour: h.Hour, @@ -120,15 +127,8 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { HumidityPct: h.HumidityPct, RiskLevel: dayRisk.Level.String(), BudgetStatus: worstStatus.String(), - } - for _, a := range matched { - slot.Actions = append(slot.Actions, ActionData{ - Name: a.Name, - Category: string(a.Category), - Effort: string(a.Effort), - Impact: string(a.Impact), - Description: a.Description, - }) + IndoorTempC: indoorTempC, + CoolMode: coolMode, } data.Timeline = append(data.Timeline, slot) diff --git a/internal/compute/compute_test.go b/internal/compute/compute_test.go index bb442fa..14928a2 100644 --- a/internal/compute/compute_test.go +++ b/internal/compute/compute_test.go @@ -218,6 +218,113 @@ func TestBuildDashboard_InvalidDate(t *testing.T) { } } +func TestBuildDashboard_CoolModeVentilate(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + + // All hours at 20°C (below default indoor 25°C) → ventilate + temps := make([]float64, 24) + for i := range temps { + temps[i] = 20 + } + + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "UTC"}, + Forecasts: makeForecasts(base, temps), + Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 25}}, + Toggles: map[string]bool{}, + Date: "2025-07-15", + } + + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, slot := range data.Timeline { + if slot.CoolMode != "ventilate" { + t.Errorf("hour %d: got CoolMode %q, want %q", slot.Hour, slot.CoolMode, "ventilate") + } + } + if data.IndoorTempC != 25 { + t.Errorf("got IndoorTempC %v, want 25", data.IndoorTempC) + } +} + +func TestBuildDashboard_CoolModeAC(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + + // All hours at 30°C (above indoor 25°C), with enough AC → "ac" + temps := make([]float64, 24) + for i := range temps { + temps[i] = 30 + } + + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "UTC"}, + Forecasts: makeForecasts(base, temps), + Rooms: []Room{{ID: 1, Name: "Room", AreaSqm: 15, CeilingHeightM: 2.5, Orientation: "N", ShadingFactor: 1, VentilationACH: 0.5, WindowFraction: 0.15, SHGC: 0.6, IndoorTempC: 25}}, + ACUnits: []ACUnit{{ID: 1, ProfileID: 1, Name: "AC", CapacityBTU: 20000}}, + ACAssignments: []ACAssignment{{ACID: 1, RoomID: 1}}, + Toggles: map[string]bool{}, + Date: "2025-07-15", + } + + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, slot := range data.Timeline { + if slot.CoolMode != "ac" { + t.Errorf("hour %d: got CoolMode %q, want %q", slot.Hour, slot.CoolMode, "ac") + } + } +} + +func TestBuildDashboard_CoolModeOverloaded(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + + // Hot temps, no AC → overloaded (heat gains exceed 0 AC capacity) + temps := make([]float64, 24) + for i := range temps { + temps[i] = 38 + } + + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "UTC"}, + Forecasts: makeForecasts(base, temps), + Rooms: []Room{{ + ID: 1, Name: "Room", AreaSqm: 20, CeilingHeightM: 2.5, + Orientation: "S", ShadingFactor: 1, VentilationACH: 2.0, + WindowFraction: 0.3, SHGC: 0.8, IndoorTempC: 25, + }}, + Devices: []Device{{ + ID: 1, RoomID: 1, Name: "PC", + WattsIdle: 100, WattsTypical: 400, WattsPeak: 600, DutyCycle: 1.0, + }}, + Occupants: []Occupant{{ID: 1, RoomID: 1, Count: 3, ActivityLevel: "moderate"}}, + Toggles: map[string]bool{}, + Date: "2025-07-15", + } + + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + hasOverloaded := false + for _, slot := range data.Timeline { + if slot.CoolMode == "overloaded" { + hasOverloaded = true + break + } + } + if !hasOverloaded { + t.Error("expected at least one hour with CoolMode 'overloaded' (no AC, high gains)") + } +} + func TestBuildDashboard_MultipleRooms(t *testing.T) { loc, _ := time.LoadLocation("UTC") base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) diff --git a/internal/compute/types.go b/internal/compute/types.go index c1ae51c..fdf13ae 100644 --- a/internal/compute/types.go +++ b/internal/compute/types.go @@ -111,6 +111,7 @@ type DashboardData struct { PeakTempC float64 `json:"peakTempC"` MinNightTempC float64 `json:"minNightTempC"` PoorNightCool bool `json:"poorNightCool"` + IndoorTempC float64 `json:"indoorTempC"` Warnings []WarningData `json:"warnings"` RiskWindows []RiskWindowData `json:"riskWindows"` Timeline []TimelineSlotData `json:"timeline"` @@ -145,6 +146,8 @@ type TimelineSlotData struct { HumidityPct float64 `json:"humidityPct"` RiskLevel string `json:"riskLevel"` BudgetStatus string `json:"budgetStatus"` + IndoorTempC float64 `json:"indoorTempC"` + CoolMode string `json:"coolMode"` Actions []ActionData `json:"actions"` } diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go index bf385fd..8a89953 100644 --- a/internal/llm/anthropic.go +++ b/internal/llm/anthropic.go @@ -104,3 +104,7 @@ func (a *Anthropic) RewriteAction(ctx context.Context, input ActionInput) (strin func (a *Anthropic) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) { return a.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input), 2000) } + +func (a *Anthropic) GenerateActions(ctx context.Context, input ActionsInput) (string, error) { + return a.call(ctx, GenerateActionsSystemPrompt(), BuildActionsPrompt(input), 1500) +} diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index 8b5cb1d..cec8da5 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -107,3 +107,7 @@ func (g *Gemini) RewriteAction(ctx context.Context, input ActionInput) (string, func (g *Gemini) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) { return g.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input)) } + +func (g *Gemini) GenerateActions(ctx context.Context, input ActionsInput) (string, error) { + return g.call(ctx, GenerateActionsSystemPrompt(), BuildActionsPrompt(input)) +} diff --git a/internal/llm/llm_test.go b/internal/llm/llm_test.go index c6ad98a..810e777 100644 --- a/internal/llm/llm_test.go +++ b/internal/llm/llm_test.go @@ -25,6 +25,10 @@ func TestNoopProvider(t *testing.T) { if err != nil || h != "" { t.Errorf("GenerateHeatPlan = (%q, %v), want empty", h, err) } + a, err := n.GenerateActions(context.Background(), ActionsInput{}) + if err != nil || a != "" { + t.Errorf("GenerateActions = (%q, %v), want empty", a, err) + } } func TestAnthropicProvider_MockServer(t *testing.T) { diff --git a/internal/llm/noop.go b/internal/llm/noop.go index ae1111a..7863329 100644 --- a/internal/llm/noop.go +++ b/internal/llm/noop.go @@ -9,4 +9,5 @@ func NewNoop() *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 } +func (n *Noop) GenerateHeatPlan(_ context.Context, _ HeatPlanInput) (string, error) { return "", nil } +func (n *Noop) GenerateActions(_ context.Context, _ ActionsInput) (string, error) { return "", nil } diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go index 4bc13e9..fa55b03 100644 --- a/internal/llm/ollama.go +++ b/internal/llm/ollama.go @@ -97,3 +97,7 @@ func (o *Ollama) RewriteAction(ctx context.Context, input ActionInput) (string, func (o *Ollama) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) { return o.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input)) } + +func (o *Ollama) GenerateActions(ctx context.Context, input ActionsInput) (string, error) { + return o.call(ctx, GenerateActionsSystemPrompt(), BuildActionsPrompt(input)) +} diff --git a/internal/llm/openai.go b/internal/llm/openai.go index cb10698..96559c0 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -101,3 +101,7 @@ func (o *OpenAI) RewriteAction(ctx context.Context, input ActionInput) (string, func (o *OpenAI) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) { return o.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input)) } + +func (o *OpenAI) GenerateActions(ctx context.Context, input ActionsInput) (string, error) { + return o.call(ctx, GenerateActionsSystemPrompt(), BuildActionsPrompt(input)) +} diff --git a/internal/llm/provider.go b/internal/llm/provider.go index c131d14..79d1db1 100644 --- a/internal/llm/provider.go +++ b/internal/llm/provider.go @@ -7,6 +7,7 @@ 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) + GenerateActions(ctx context.Context, input ActionsInput) (string, error) Name() string } @@ -70,3 +71,35 @@ type HeatPlanInput struct { Actions []ActionSummary CareChecklist []string } + +// ActionsInput holds context for AI-generated actions. +type ActionsInput struct { + Date string + Language string + IndoorTempC float64 + PeakTempC float64 + MinNightTempC float64 + PoorNightCool bool + RiskLevel string + RiskWindows []RiskWindowSummary + Timeline []ActionsTimelineSlot + Rooms []ActionsRoom +} + +// ActionsTimelineSlot is one hour's data for AI action generation. +type ActionsTimelineSlot struct { + Hour int + TempC float64 + HumidityPct float64 + BudgetStatus string + CoolMode string + GainsW float64 +} + +// ActionsRoom describes a room for AI action generation. +type ActionsRoom struct { + Name string + Orientation string + ShadingType string + HasAC bool +} diff --git a/internal/server/api.go b/internal/server/api.go index f08c50e..ad518db 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -142,6 +142,161 @@ func (s *Server) handleLLMSummarize(w http.ResponseWriter, r *http.Request) { jsonResponse(w, map[string]string{"summary": summary}) } +type actionsRequest struct { + // Thermal context + Date string `json:"date"` + IndoorTempC float64 `json:"indoorTempC"` + PeakTempC float64 `json:"peakTempC"` + MinNightTempC float64 `json:"minNightTempC"` + PoorNightCool bool `json:"poorNightCool"` + RiskLevel string `json:"riskLevel"` + RiskWindows []struct { + StartHour int `json:"startHour"` + EndHour int `json:"endHour"` + PeakTempC float64 `json:"peakTempC"` + Level string `json:"level"` + } `json:"riskWindows"` + Timeline []struct { + Hour int `json:"hour"` + TempC float64 `json:"tempC"` + HumidityPct float64 `json:"humidityPct"` + BudgetStatus string `json:"budgetStatus"` + CoolMode string `json:"coolMode"` + } `json:"timeline"` + RoomBudgets []struct { + TotalGainW float64 `json:"totalGainW"` + } `json:"roomBudgets"` + // Room metadata + Rooms []struct { + Name string `json:"name"` + Orientation string `json:"orientation"` + ShadingType string `json:"shadingType"` + HasAC bool `json:"hasAC"` + } `json:"rooms"` + // LLM credentials + Provider string `json:"provider,omitempty"` + APIKey string `json:"apiKey,omitempty"` + Model string `json:"model,omitempty"` + Language string `json:"language,omitempty"` +} + +func (s *Server) handleLLMActions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req actionsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid request body", http.StatusBadRequest) + return + } + + provider := s.llmProvider + if req.Provider != "" && req.APIKey != "" { + switch req.Provider { + case "anthropic": + provider = llm.NewAnthropic(req.APIKey, req.Model, nil) + case "openai": + provider = llm.NewOpenAI(req.APIKey, req.Model, nil) + case "gemini": + provider = llm.NewGemini(req.APIKey, req.Model, nil) + } + } + + if provider.Name() == "none" { + jsonResponse(w, map[string]any{"actions": []any{}}) + return + } + + // Build ActionsInput + input := llm.ActionsInput{ + Date: req.Date, + Language: req.Language, + IndoorTempC: req.IndoorTempC, + PeakTempC: req.PeakTempC, + MinNightTempC: req.MinNightTempC, + PoorNightCool: req.PoorNightCool, + RiskLevel: req.RiskLevel, + } + for _, rw := range req.RiskWindows { + input.RiskWindows = append(input.RiskWindows, llm.RiskWindowSummary{ + StartHour: rw.StartHour, + EndHour: rw.EndHour, + PeakTempC: rw.PeakTempC, + Level: rw.Level, + }) + } + for i, s := range req.Timeline { + gainsW := 0.0 + if i < len(req.RoomBudgets) { + gainsW = req.RoomBudgets[i].TotalGainW + } + input.Timeline = append(input.Timeline, llm.ActionsTimelineSlot{ + Hour: s.Hour, + TempC: s.TempC, + HumidityPct: s.HumidityPct, + BudgetStatus: s.BudgetStatus, + CoolMode: s.CoolMode, + GainsW: gainsW, + }) + } + for _, rm := range req.Rooms { + input.Rooms = append(input.Rooms, llm.ActionsRoom{ + Name: rm.Name, + Orientation: rm.Orientation, + ShadingType: rm.ShadingType, + HasAC: rm.HasAC, + }) + } + + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + raw, err := provider.GenerateActions(ctx, input) + if err != nil { + jsonError(w, err.Error(), http.StatusBadGateway) + return + } + + // Parse JSON response defensively + var actions []json.RawMessage + if err := json.Unmarshal([]byte(raw), &actions); err != nil { + // Try extracting from ```json ... ``` fences + trimmed := raw + if start := findJSONStart(trimmed); start >= 0 { + trimmed = trimmed[start:] + } + if end := findJSONEnd(trimmed); end >= 0 { + trimmed = trimmed[:end+1] + } + if err2 := json.Unmarshal([]byte(trimmed), &actions); err2 != nil { + jsonError(w, "failed to parse AI actions response", http.StatusBadGateway) + return + } + } + + jsonResponse(w, map[string]any{"actions": actions}) +} + +func findJSONStart(s string) int { + for i, c := range s { + if c == '[' { + return i + } + } + return -1 +} + +func findJSONEnd(s string) int { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == ']' { + return i + } + } + return -1 +} + func (s *Server) handleLLMConfig(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) diff --git a/internal/server/server.go b/internal/server/server.go index ec32b2c..bb8ad7e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -87,6 +87,7 @@ func New(opts Options) (*Server, error) { s.mux.HandleFunc("/api/weather/forecast", s.handleWeatherForecast) s.mux.HandleFunc("/api/weather/warnings", s.handleWeatherWarnings) s.mux.HandleFunc("/api/llm/summarize", s.handleLLMSummarize) + s.mux.HandleFunc("/api/llm/actions", s.handleLLMActions) s.mux.HandleFunc("/api/llm/config", s.handleLLMConfig) return s, nil diff --git a/web/i18n/de.json b/web/i18n/de.json index bcaba5c..4b2113d 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -187,6 +187,9 @@ "coolAC": "Klimaanlage", "coolOverloaded": "Klima überlastet", "aiActions": "KI-empfohlene Maßnahmen", + "legendTemp": "Temperatur", + "legendCooling": "Kühlung", + "legendAI": "KI-Maßnahmen", "category": { "shading": "Verschattung", "ventilation": "Lüftung", diff --git a/web/i18n/en.json b/web/i18n/en.json index b46aea8..5b1a546 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -187,6 +187,9 @@ "coolAC": "AC cooling", "coolOverloaded": "AC overloaded", "aiActions": "AI-recommended actions", + "legendTemp": "Temperature", + "legendCooling": "Cooling", + "legendAI": "AI Actions", "category": { "shading": "Shading", "ventilation": "Ventilation", diff --git a/web/js/dashboard.js b/web/js/dashboard.js index 4c94c96..db72922 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -5,6 +5,9 @@ const $ = (id) => document.getElementById(id); const t = () => window.HG.t || {}; + let _hourActionMap = null; + let _currentTimeline = null; + function show(id) { $(id).classList.remove("hidden"); } function hide(id) { $(id).classList.add("hidden"); } @@ -202,6 +205,11 @@ const actData = await actResp.json(); if (actData.actions && actData.actions.length > 0) { renderAIActions(actData.actions); + _hourActionMap = buildHourActionMap(actData.actions); + if (_currentTimeline) { + renderTimelineAnnotations(_currentTimeline, _hourActionMap); + renderTimelineLegend(_currentTimeline, _hourActionMap); + } } else { hide("actions-loading"); } @@ -461,6 +469,15 @@ overloaded: "#f87171", }; + const categoryColors = { + shading: "#f59e0b", + ventilation: "#38bdf8", + internal_gains: "#a78bfa", + ac_strategy: "#4ade80", + hydration: "#60a5fa", + care: "#fb7185", + }; + const coolModeLabels = () => ({ ventilate: t().coolVentilate || "Open windows", ac: t().coolAC || "AC cooling", @@ -468,6 +485,7 @@ }); function renderTimelineHeatmap(timeline) { + _currentTimeline = timeline; const container = $("timeline-chart"); const tooltip = $("timeline-tooltip"); const labels = coolModeLabels(); @@ -511,11 +529,28 @@ const idx = parseInt(cell.dataset.idx); const slot = timeline[idx]; const modeLabel = labels[slot.coolMode] || slot.coolMode || ""; - tooltip.innerHTML = ` + let tooltipHtml = `