feat: add AI-powered actions endpoint and timeline annotations
Add LLM actions endpoint that generates hour-specific heat management recommendations. Replace static action engine with AI-driven approach. Add cool mode logic (ventilate/ac/overloaded), indoor temperature tracking, and timeline legend with annotations.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = `
|
||||
<div class="font-medium mb-1">${slot.hourStr}</div>
|
||||
<div>${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH</div>
|
||||
<div class="capitalize">${slot.budgetStatus} \u00b7 ${esc(modeLabel)}</div>
|
||||
`;
|
||||
const hourActions = _hourActionMap && _hourActionMap[slot.hour];
|
||||
if (hourActions && hourActions.length > 0) {
|
||||
const catLabels = (t().category) || {};
|
||||
const maxShow = 4;
|
||||
const shown = hourActions.slice(0, maxShow);
|
||||
const remaining = hourActions.length - maxShow;
|
||||
tooltipHtml += `<div class="border-t border-gray-600 mt-1.5 pt-1.5">`;
|
||||
shown.forEach(a => {
|
||||
const color = categoryColors[a.category] || "#9ca3af";
|
||||
tooltipHtml += `<div class="flex items-center gap-1.5"><span class="inline-block rounded-full flex-shrink-0" style="width:5px;height:5px;background:${color}"></span><span>${esc(a.name)}</span></div>`;
|
||||
});
|
||||
if (remaining > 0) {
|
||||
tooltipHtml += `<div class="text-gray-400">+${remaining} more</div>`;
|
||||
}
|
||||
tooltipHtml += `</div>`;
|
||||
}
|
||||
tooltip.innerHTML = tooltipHtml;
|
||||
const rect = cell.getBoundingClientRect();
|
||||
const parentRect = tooltip.parentElement.getBoundingClientRect();
|
||||
tooltip.style.left = (rect.left - parentRect.left + rect.width / 2 - 60) + "px";
|
||||
@@ -527,16 +562,111 @@
|
||||
});
|
||||
container.addEventListener("mouseleave", () => tooltip.classList.add("hidden"));
|
||||
|
||||
// Legend
|
||||
const usedModes = [...new Set(timeline.map(s => s.coolMode || "ac"))];
|
||||
// Legend (initial render without AI data)
|
||||
renderTimelineLegend(timeline, _hourActionMap);
|
||||
}
|
||||
|
||||
// ========== AI Timeline Annotations ==========
|
||||
function buildHourActionMap(actions) {
|
||||
const map = {};
|
||||
(actions || []).forEach(a => {
|
||||
(a.hours || []).forEach(h => {
|
||||
if (!map[h]) map[h] = [];
|
||||
map[h].push(a);
|
||||
});
|
||||
});
|
||||
return Object.keys(map).length > 0 ? map : null;
|
||||
}
|
||||
|
||||
function renderTimelineLegend(timeline, hourActionMap) {
|
||||
const legend = $("cooling-legend");
|
||||
if (legend) {
|
||||
legend.innerHTML = usedModes.map(mode => {
|
||||
const color = coolModeColors[mode] || "#d1d5db";
|
||||
const lbl = labels[mode] || mode;
|
||||
return `<span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full" style="background:${color}"></span>${esc(lbl)}</span>`;
|
||||
}).join("");
|
||||
if (!legend) return;
|
||||
|
||||
const labels = coolModeLabels();
|
||||
const ts = t();
|
||||
|
||||
// Temperature scale
|
||||
const tempSteps = [
|
||||
{ label: "<20", color: "#bfdbfe" },
|
||||
{ label: "20", color: "#bbf7d0" },
|
||||
{ label: "25", color: "#fde68a" },
|
||||
{ label: "30", color: "#facc15" },
|
||||
{ label: "35", color: "#f97316" },
|
||||
{ label: "40+", color: "#dc2626" },
|
||||
];
|
||||
let html = `<div class="flex flex-wrap items-center gap-x-3 gap-y-1">`;
|
||||
html += `<span class="font-medium text-gray-500 dark:text-gray-300">${esc(ts.legendTemp || "Temperature")}</span>`;
|
||||
html += tempSteps.map(s =>
|
||||
`<span class="flex items-center gap-1"><span class="inline-block w-3 h-3 rounded-sm" style="background:${s.color}"></span>${s.label}</span>`
|
||||
).join("");
|
||||
html += `</div>`;
|
||||
|
||||
// Cooling modes
|
||||
const usedModes = [...new Set((timeline || []).map(s => s.coolMode || "ac"))];
|
||||
html += `<div class="flex flex-wrap items-center gap-x-3 gap-y-1">`;
|
||||
html += `<span class="font-medium text-gray-500 dark:text-gray-300">${esc(ts.legendCooling || "Cooling")}</span>`;
|
||||
html += usedModes.map(mode => {
|
||||
const color = coolModeColors[mode] || "#d1d5db";
|
||||
const lbl = labels[mode] || mode;
|
||||
return `<span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full" style="background:${color}"></span>${esc(lbl)}</span>`;
|
||||
}).join("");
|
||||
html += `</div>`;
|
||||
|
||||
// AI categories (only when hourActionMap has entries)
|
||||
if (hourActionMap) {
|
||||
const usedCats = new Set();
|
||||
Object.values(hourActionMap).forEach(actions => {
|
||||
actions.forEach(a => { if (a.category) usedCats.add(a.category); });
|
||||
});
|
||||
if (usedCats.size > 0) {
|
||||
const catLabels = ts.category || {};
|
||||
html += `<div class="flex flex-wrap items-center gap-x-3 gap-y-1">`;
|
||||
html += `<span class="font-medium text-gray-500 dark:text-gray-300">${esc(ts.legendAI || "AI Actions")}</span>`;
|
||||
const categoryOrder = ["shading", "ventilation", "internal_gains", "ac_strategy", "hydration", "care"];
|
||||
const sorted = [...usedCats].sort((a, b) => {
|
||||
const ia = categoryOrder.indexOf(a);
|
||||
const ib = categoryOrder.indexOf(b);
|
||||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
||||
});
|
||||
html += sorted.map(cat => {
|
||||
const color = categoryColors[cat] || "#9ca3af";
|
||||
const lbl = catLabels[cat] || cat;
|
||||
return `<span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full" style="background:${color}"></span>${esc(lbl)}</span>`;
|
||||
}).join("");
|
||||
html += `</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
legend.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderTimelineAnnotations(timeline, hourActionMap) {
|
||||
if (!hourActionMap || !timeline) return;
|
||||
const container = $("timeline-chart");
|
||||
if (!container) return;
|
||||
|
||||
// Remove previous annotation row if any
|
||||
const prev = container.querySelector(".hm-ai-row");
|
||||
if (prev) prev.remove();
|
||||
|
||||
const cellsHtml = timeline.map(s => {
|
||||
const actions = hourActionMap[s.hour];
|
||||
if (!actions || actions.length === 0) {
|
||||
return `<div style="height:18px"></div>`;
|
||||
}
|
||||
const cats = [...new Set(actions.map(a => a.category).filter(Boolean))];
|
||||
const dots = cats.map(cat => {
|
||||
const color = categoryColors[cat] || "#9ca3af";
|
||||
return `<span class="inline-block rounded-full" style="width:5px;height:5px;background:${color}"></span>`;
|
||||
}).join("");
|
||||
return `<div class="flex items-center justify-center gap-px flex-wrap" style="height:18px">${dots}</div>`;
|
||||
}).join("");
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "hm-ai-row grid gap-px mt-px";
|
||||
row.style.gridTemplateColumns = `repeat(${timeline.length},minmax(0,1fr))`;
|
||||
row.innerHTML = cellsHtml;
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
<div class="relative">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.timeline"}}</h2>
|
||||
<div id="timeline-chart" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm"></div>
|
||||
<div id="cooling-legend" class="flex gap-4 text-xs text-gray-400 mt-1"></div>
|
||||
<div id="cooling-legend" class="flex flex-col gap-1 text-xs text-gray-400 mt-2"></div>
|
||||
<div id="timeline-tooltip" class="hidden absolute z-50 bg-gray-800 text-white text-xs rounded-lg p-3 shadow-lg max-w-xs pointer-events-none"></div>
|
||||
</div>
|
||||
|
||||
@@ -156,6 +156,9 @@
|
||||
coolAC: "{{t "dashboard.coolAC"}}",
|
||||
coolOverloaded: "{{t "dashboard.coolOverloaded"}}",
|
||||
aiActions: "{{t "dashboard.aiActions"}}",
|
||||
legendTemp: "{{t "dashboard.legendTemp"}}",
|
||||
legendCooling: "{{t "dashboard.legendCooling"}}",
|
||||
legendAI: "{{t "dashboard.legendAI"}}",
|
||||
category: {
|
||||
shading: "{{t "dashboard.category.shading"}}",
|
||||
ventilation: "{{t "dashboard.category.ventilation"}}",
|
||||
|
||||
Reference in New Issue
Block a user