package compute import ( "fmt" "time" "github.com/cnachtigall/heatwave-autopilot/internal/heat" "github.com/cnachtigall/heatwave-autopilot/internal/risk" ) // BuildDashboard computes all dashboard data from the provided request. // All data is passed in-memory — no DB calls. func BuildDashboard(req ComputeRequest) (DashboardData, error) { if len(req.Forecasts) == 0 { return DashboardData{}, fmt.Errorf("no forecast data for %s", req.Date) } date, err := time.Parse("2006-01-02", req.Date) if err != nil { return DashboardData{}, fmt.Errorf("invalid date: %s", req.Date) } loc, err := time.LoadLocation(req.Profile.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) // Filter forecasts for target date var dayForecasts []Forecast for _, f := range req.Forecasts { if !f.Timestamp.Before(from) && f.Timestamp.Before(to) { dayForecasts = append(dayForecasts, f) } } if len(dayForecasts) == 0 { dayForecasts = req.Forecasts } // Build risk analysis hourlyData := buildHourlyData(dayForecasts, loc) th := risk.DefaultThresholds() dayRisk := risk.AnalyzeDay(hourlyData, th) 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)) } // Compute representative indoor humidity (average of rooms that have it) indoorHumidityPct := 50.0 if len(req.Rooms) > 0 { sum, count := 0.0, 0 for _, r := range req.Rooms { if r.IndoorHumidityPct != nil && *r.IndoorHumidityPct > 0 { sum += *r.IndoorHumidityPct count++ } } if count > 0 { indoorHumidityPct = sum / float64(count) } } data := DashboardData{ GeneratedAt: time.Now(), ProfileName: req.Profile.Name, Date: req.Date, Timezone: req.Profile.Timezone, RiskLevel: dayRisk.Level.String(), PeakTempC: dayRisk.PeakTempC, MinNightTempC: dayRisk.MinNightTempC, PoorNightCool: dayRisk.PoorNightCool, IndoorTempC: indoorTempC, IndoorHumidityPct: indoorHumidityPct, } // Warnings (pass-through from client) for _, w := range req.Warnings { data.Warnings = append(data.Warnings, WarningData{ Headline: w.Headline, Severity: w.Severity, Description: w.Description, Instruction: w.Instruction, Onset: w.Onset, Expires: w.Expires, }) } // Risk windows for _, w := range dayRisk.Windows { data.RiskWindows = append(data.RiskWindows, 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 pressureHpa := 0.0 if i < len(dayForecasts) { if dayForecasts[i].CloudCoverPct != nil { cloudPct = *dayForecasts[i].CloudCoverPct } if dayForecasts[i].SunshineMin != nil { sunMin = *dayForecasts[i].SunshineMin } if dayForecasts[i].PressureHpa != nil { pressureHpa = *dayForecasts[i].PressureHpa } } budgets, worstStatus := computeRoomBudgets(req, h.Hour, h.TempC, cloudPct, sunMin, toggles) coolMode := determineCoolMode(h.TempC, indoorTempC, h.HumidityPct, worstStatus) slot := TimelineSlotData{ Hour: h.Hour, HourStr: fmt.Sprintf("%02d:00", h.Hour), TempC: h.TempC, HumidityPct: h.HumidityPct, PressureHpa: pressureHpa, RiskLevel: dayRisk.Level.String(), BudgetStatus: worstStatus.String(), IndoorTempC: indoorTempC, CoolMode: coolMode, } data.Timeline = append(data.Timeline, slot) 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, 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 for _, o := range req.Occupants { if o.Vulnerable { roomName := roomNameByID(req.Rooms, o.RoomID) data.CareChecklist = append(data.CareChecklist, fmt.Sprintf("Check vulnerable occupant in %s at 10:00, 14:00, 18:00", roomName)) } } return data, nil } func determineCoolMode(outdoorTempC, indoorTempC, outdoorHumidityPct float64, worstStatus heat.BudgetStatus) string { const humidityThreshold = 80.0 const comfortDelta = 5.0 outdoorCooler := outdoorTempC < indoorTempC tooHumid := outdoorHumidityPct >= humidityThreshold // Cold enough outside that building envelope handles any internal gains if worstStatus != heat.Overloaded && outdoorTempC < (indoorTempC-comfortDelta) { return "comfort" } switch { case outdoorCooler && !tooHumid: return "ventilate" case outdoorCooler && tooHumid: return "sealed" case worstStatus == heat.Overloaded: return "overloaded" default: return "ac" } } func buildHourlyData(forecasts []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 } type roomBudgetResult struct { RoomName string RoomID int64 Result heat.BudgetResult } func computeRoomBudgets(req ComputeRequest, hour int, tempC, cloudPct, sunMin float64, toggles map[string]bool) ([]roomBudgetResult, heat.BudgetStatus) { if len(req.Rooms) == 0 { return nil, heat.Comfortable } var results []roomBudgetResult worstStatus := heat.Comfortable for _, room := range req.Rooms { budget := computeSingleRoomBudget(req, room, hour, tempC, cloudPct, sunMin, toggles) results = append(results, roomBudgetResult{ RoomName: room.Name, RoomID: room.ID, Result: budget, }) if budget.Status > worstStatus { worstStatus = budget.Status } } return results, worstStatus } func computeSingleRoomBudget(req ComputeRequest, room Room, hour int, tempC, cloudPct, sunMin float64, toggles map[string]bool) heat.BudgetResult { indoorTempC := room.IndoorTempC if indoorTempC == 0 { indoorTempC = 25.0 } // Filter devices for this room var heatDevices []heat.Device for _, d := range req.Devices { if d.RoomID == room.ID { heatDevices = append(heatDevices, heat.Device{ WattsIdle: d.WattsIdle, WattsTypical: d.WattsTypical, WattsPeak: d.WattsPeak, DutyCycle: d.DutyCycle, }) } } mode := heat.ModeTypical if toggles["gaming"] { mode = heat.ModePeak } // Filter occupants for this room var heatOccupants []heat.Occupant for _, o := range req.Occupants { if o.RoomID == room.ID { heatOccupants = append(heatOccupants, heat.Occupant{ Count: o.Count, Activity: heat.ParseActivityLevel(o.ActivityLevel), }) } } // AC capacity for this room acCap := roomACCapacity(req.ACUnits, req.ACAssignments, room.ID) // Solar params cloudFactor := 1.0 - (cloudPct / 100.0) sunshineFraction := 0.0 if sunMin > 0 { sunshineFraction = sunMin / 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, hour), CloudFactor: cloudFactor, SunshineFraction: sunshineFraction, PeakIrradiance: 800, } volume := room.AreaSqm * room.CeilingHeightM vent := heat.VentilationParams{ ACH: room.VentilationACH, VolumeCubicM: volume, OutdoorTempC: tempC, IndoorTempC: indoorTempC, RhoCp: 1.2, } return heat.ComputeRoomBudget(heat.BudgetInput{ Devices: heatDevices, DeviceMode: mode, Occupants: heatOccupants, Solar: solar, Ventilation: vent, ACCapacityBTUH: acCap, }) } func roomACCapacity(units []ACUnit, assignments []ACAssignment, roomID int64) float64 { var total float64 for _, a := range assignments { if a.RoomID == roomID { for _, u := range units { if u.ID == a.ACID { total += u.CapacityBTU } } } } return total } func roomNameByID(rooms []Room, id int64) string { for _, r := range rooms { if r.ID == id { return r.Name } } return fmt.Sprintf("room %d", id) }