diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 24ded5201..5a58057c7 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -71,6 +71,7 @@ byok cadata calcsize calib +calvind Cantarell cbat CDLL @@ -81,6 +82,7 @@ ceyyyy CGNAT Chae Chaf +charg chargedischarge chargeenergytotal chargefreeze @@ -113,6 +115,7 @@ creds crosscharge ctrdis ctypes +cubone customauthorizer customisation Customise @@ -213,6 +216,7 @@ etok euids EVAC EVDC +evenodd evergen evilspringfall evse @@ -359,6 +363,8 @@ libc libyaml LIFEPO linebreak +linecap +linejoin linestyle loadml loadmlpower @@ -450,6 +456,7 @@ notgithub notsn nrgheat numpy +nums OCPP octo octoplus @@ -478,6 +485,7 @@ oversizing overvoltage ownerapi parseable +partlycloudy pbat pbgw pcharging @@ -641,6 +649,7 @@ Strømligning subclassing submessage substep +subvalue sunspec sunsynk supabase @@ -681,12 +690,14 @@ tmpfs tmrw tojson topbar +TOTL tottime trapezoidally trapz Trefor treforsiphone treforsouthwell +TRGT triggerable tunables twinx @@ -727,6 +738,7 @@ Vallery varh venv Victron +VIEWBOX visibilitychange vvwwxxabcdefyyzzuu waf @@ -749,6 +761,7 @@ xaxistooltip xlabel xlim xload +Xmark xrtkq xticks xxabcdefyy diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 6aef46e77..aaeb5eee6 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -30,6 +30,7 @@ { "name": "version", "friendly_name": "Predbat Core Update", + "description": "Shows the installed Predbat version and provides the Home Assistant update entity for installing a newer core release.", "type": "update", "title": "Predbat", "installed_version": THIS_VERSION, @@ -41,18 +42,21 @@ { "name": "expert_mode", "friendly_name": "Expert Mode", + "description": "Shows advanced configuration controls that most installations do not normally need to change.", "type": "switch", "default": False, }, { "name": "performance_tweaks", "friendly_name": "Performance Tweaks", + "description": "Shows performance-related optimisation controls that can trade planning accuracy for faster calculation.", "type": "switch", "default": False, }, { "name": "active", "friendly_name": "Predbat Active", + "description": "Shows when Predbat is actively running; turning it on can also request an immediate Predbat update.", "type": "switch", "default": False, "restore": False, @@ -60,6 +64,7 @@ { "name": "compare_active", "friendly_name": "Predbat Compare Active", + "description": "Shows when Predbat is currently running a tariff or comparison calculation.", "type": "switch", "default": False, "restore": False, @@ -67,6 +72,7 @@ { "name": "pv_metric10_weight", "friendly_name": "Metric 10 Weight", + "description": "Controls how much influence the pessimistic PV10 solar forecast has on planning; higher values make the plan more cautious about solar generation.", "type": "input_number", "min": 0, "max": 1.0, @@ -78,6 +84,7 @@ { "name": "pv_scaling", "friendly_name": "PV Scaling", + "description": "Multiplies the solar forecast used by Predbat; 1.0 leaves the forecast unchanged, lower values reduce it and higher values increase it.", "type": "input_number", "min": 0, "max": 2.0, @@ -89,6 +96,7 @@ { "name": "load_scaling", "friendly_name": "Load Scaling", + "description": "Multiplies predicted household consumption; values above 1.0 make Predbat assume the house will use more energy.", "type": "input_number", "min": 0, "max": 2.0, @@ -100,6 +108,7 @@ { "name": "load_scaling10", "friendly_name": "Load Scaling PV10%", + "description": "Applies an additional pessimistic household-load scaling factor to the PV10 planning scenario.", "type": "input_number", "min": 0, "max": 2.0, @@ -111,6 +120,7 @@ { "name": "charge_scaling10", "friendly_name": "Charge Scaling PV10%", + "description": "Reduces the assumed battery charging capability in the pessimistic PV10 scenario; this affects planning only, not the inverter charge-rate setting.", "type": "input_number", "min": 0.1, "max": 1.0, @@ -123,6 +133,7 @@ { "name": "pv_metric90_weight", "friendly_name": "Metric 90 Weight", + "description": "Controls how much influence the optimistic PV90 solar forecast has on planning; higher values give more weight to better-than-expected solar.", "type": "input_number", "min": 0, "max": 1.0, @@ -135,6 +146,7 @@ { "name": "load_scaling90", "friendly_name": "Load Scaling PV90%", + "description": "Applies a lower household-load scaling factor to the optimistic PV90 planning scenario.", "type": "input_number", "min": 0, "max": 2.0, @@ -147,6 +159,7 @@ { "name": "load_scaling_saving", "friendly_name": "Load Scaling for saving sessions", + "description": "Adjusts predicted household consumption during Saving Sessions to account for deliberate demand reduction.", "type": "input_number", "min": 0, "max": 2.0, @@ -158,6 +171,7 @@ { "name": "load_scaling_free", "friendly_name": "Load Scaling for free sessions", + "description": "Adjusts predicted household consumption during free-electricity sessions to account for deliberately increased usage.", "type": "input_number", "min": 0, "max": 2.0, @@ -169,6 +183,7 @@ { "name": "battery_rate_max_scaling", "friendly_name": "Battery rate max scaling charge", + "description": "Scales the maximum battery charge rate used by Predbat when modelling the inverter; use it to correct an inaccurate nominal charge limit.", "type": "input_number", "min": 0, "max": 2.0, @@ -180,6 +195,7 @@ { "name": "battery_rate_max_scaling_discharge", "friendly_name": "Battery rate max scaling discharge", + "description": "Scales the maximum battery discharge rate used by Predbat when modelling the inverter; use it to correct an inaccurate nominal discharge limit.", "type": "input_number", "min": 0, "max": 2.0, @@ -191,6 +207,7 @@ { "name": "battery_loss", "friendly_name": "Battery loss charge ", + "description": "Models the fraction of energy lost while charging the battery.", "type": "input_number", "min": 0, "max": 1.0, @@ -202,6 +219,7 @@ { "name": "battery_loss_discharge", "friendly_name": "Battery loss discharge", + "description": "Models the fraction of stored energy lost while discharging the battery.", "type": "input_number", "min": 0, "max": 1.0, @@ -213,6 +231,7 @@ { "name": "inverter_loss", "friendly_name": "Inverter Loss", + "description": "Models energy lost through inverter AC/DC conversion.", "type": "input_number", "min": 0, "max": 1.0, @@ -224,12 +243,14 @@ { "name": "inverter_hybrid", "friendly_name": "Inverter Hybrid", + "description": "Enable for a DC-coupled hybrid inverter; disable for an AC-coupled battery so Predbat includes the appropriate conversion losses.", "type": "switch", "default": True, }, { "name": "inverter_soc_reset", "friendly_name": "Inverter SoC Reset", + "description": "Resets the inverter charge target to 100% outside charge windows where required to avoid unintentionally limiting solar charging.", "type": "switch", "enable": "expert_mode", "default": True, @@ -237,6 +258,7 @@ { "name": "inverter_set_charge_before", "friendly_name": "Inverter Set charge window before start", + "description": "Programs an upcoming charge window into the inverter before it starts instead of waiting until the start of the slot.", "type": "switch", "enable": "expert_mode", "default": True, @@ -244,6 +266,7 @@ { "name": "battery_capacity_nominal", "friendly_name": "Use the Battery Capacity Nominal size", + "description": "Uses the inverter-reported nominal battery capacity where supported instead of the normal usable-capacity value.", "type": "switch", "enable": "expert_mode", "default": False, @@ -251,6 +274,7 @@ { "name": "car_charging_energy_scale", "friendly_name": "Car charging energy scale", + "description": "Scales the energy reported by the configured EV charging sensor to correct its units or calibration.", "type": "input_number", "min": 0, "max": 1.0, @@ -262,6 +286,7 @@ { "name": "car_charging_threshold", "friendly_name": "Car charging threshold", + "description": "Power threshold above which Predbat considers the EV to be actively charging.", "type": "input_number", "min": 4, "max": 24, @@ -273,6 +298,7 @@ { "name": "car_charging_rate", "friendly_name": "Car charging rate (Car 0)", + "description": "Expected charging power for EV/charger 0, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -286,6 +312,7 @@ { "name": "car_charging_rate_1", "friendly_name": "Car charging rate (Car 1)", + "description": "Expected charging power for EV/charger 1, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -299,6 +326,7 @@ { "name": "car_charging_rate_2", "friendly_name": "Car charging rate (Car 2)", + "description": "Expected charging power for EV/charger 2, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -312,6 +340,7 @@ { "name": "car_charging_rate_3", "friendly_name": "Car charging rate (Car 3)", + "description": "Expected charging power for EV/charger 3, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -325,6 +354,7 @@ { "name": "car_charging_rate_4", "friendly_name": "Car charging rate (Car 4)", + "description": "Expected charging power for EV/charger 4, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -338,6 +368,7 @@ { "name": "car_charging_rate_5", "friendly_name": "Car charging rate (Car 5)", + "description": "Expected charging power for EV/charger 5, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -351,6 +382,7 @@ { "name": "car_charging_rate_6", "friendly_name": "Car charging rate (Car 6)", + "description": "Expected charging power for EV/charger 6, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -364,6 +396,7 @@ { "name": "car_charging_rate_7", "friendly_name": "Car charging rate (Car 7)", + "description": "Expected charging power for EV/charger 7, used when Predbat models how much energy a charging slot will add.", "type": "input_number", "min": 0.1, "max": 24, @@ -377,6 +410,7 @@ { "name": "car_charging_loss", "friendly_name": "Car charging loss", + "description": "Models charging losses between energy drawn by the EV charger and energy stored in the vehicle battery.", "type": "input_number", "min": 0, "max": 1.0, @@ -390,6 +424,7 @@ { "name": "best_soc_min", "friendly_name": "Best SoC Min", + "description": "Sets a lower battery state-of-charge target used by the optimiser when selecting charge and export targets.", "type": "input_number", "min": 0, "max": 100.0, @@ -402,6 +437,7 @@ { "name": "best_soc_max", "friendly_name": "Best SoC Max", + "description": "Caps the battery state of charge Predbat will plan to charge to; 0 disables the cap.", "type": "input_number", "min": 0, "max": 100.0, @@ -414,6 +450,7 @@ { "name": "best_soc_keep", "friendly_name": "Best SoC Keep", + "description": "Adds a soft preference to retain at least this much energy in the home battery for later use.", "type": "input_number", "min": 0, "max": 100.0, @@ -425,6 +462,7 @@ { "name": "best_soc_keep_weight", "friendly_name": "Best SoC Keep Weighting", + "description": "Controls how strongly Predbat values retaining the Best SoC Keep energy; higher values make it more reluctant to use that reserve.", "type": "input_number", "min": 0.1, "max": 5, @@ -437,6 +475,7 @@ { "name": "metric_min_improvement_plan", "friendly_name": "Metric Min Improvement Plan", + "description": "Minimum improvement required before Predbat considers a newly calculated plan sufficiently better than the previous plan.", "type": "input_number", "min": -50, "max": 250.0, @@ -449,6 +488,7 @@ { "name": "metric_min_improvement", "friendly_name": "Metric Min Improvement Charge", + "description": "Minimum financial improvement required before Predbat changes battery state of charge by adding or altering a charge action.", "type": "input_number", "min": -50, "max": 50.0, @@ -462,6 +502,7 @@ "name": "metric_min_improvement_export", "oldname": "metric_min_improvement_discharge", "friendly_name": "Metric Min Improvement Export", + "description": "Minimum financial improvement required before Predbat adds a forced battery export to the plan.", "type": "input_number", "min": -50, "max": 50.0, @@ -474,6 +515,7 @@ { "name": "metric_min_improvement_swap", "friendly_name": "Metric Min Improvement Swap", + "description": "Controls how much improvement is required before Predbat moves an export to a different time; small negative values can favour later exports.", "type": "input_number", "min": -50, "max": 50.0, @@ -486,6 +528,7 @@ { "name": "metric_min_improvement_export_freeze", "friendly_name": "Metric Min Improvement Export Freeze", + "description": "Minimum improvement required before Predbat uses an export-freeze action instead of normal operation.", "type": "input_number", "min": -50, "max": 50.0, @@ -498,6 +541,7 @@ { "name": "metric_battery_cycle", "friendly_name": "Metric Battery Cycle Cost", + "description": "Adds a virtual cost for charging and discharging the battery so Predbat avoids marginal battery cycling.", "type": "input_number", "min": -50, "max": 50.0, @@ -510,6 +554,7 @@ { "name": "metric_battery_value_scaling", "friendly_name": "Metric Battery Value Scaling", + "description": "Changes how valuable Predbat considers energy left in the battery at the end of the forecast; higher values encourage retaining more energy.", "type": "input_number", "min": 0, "max": 2.0, @@ -522,6 +567,7 @@ { "name": "metric_battery_value_export_scaling", "friendly_name": "Metric Battery Value Scaling when export is worthless", + "description": "Discounts the end-of-plan value of stored battery energy when surplus energy cannot later be exported for a useful price.", "type": "input_number", "min": 0, "max": 1.0, @@ -534,6 +580,7 @@ { "name": "metric_future_rate_offset_import", "friendly_name": "Metric Future Rate Offset Import", + "description": "Adjustment added to future import prices that have not yet been published, useful when modelling variable tariffs.", "type": "input_number", "min": -50, "max": 50.0, @@ -546,6 +593,7 @@ { "name": "metric_future_rate_offset_export", "friendly_name": "Metric Future Rate Offset Export", + "description": "Adjustment added to future export prices that have not yet been published.", "type": "input_number", "min": -50, "max": 50.0, @@ -558,6 +606,7 @@ { "name": "metric_inday_adjust_damping", "friendly_name": "In-day adjustment damping factor", + "description": "Controls how strongly Predbat applies the difference between today’s actual and forecast consumption to the remaining in-day load forecast.", "type": "input_number", "min": 0.5, "max": 2.0, @@ -570,6 +619,7 @@ { "name": "metric_cloud_enable", "friendly_name": "Enable Cloud Model", + "description": "Adds realistic short-term peaks and dips to the solar forecast to model passing clouds while keeping overall forecast energy similar.", "type": "switch", "default": True, "enable": "expert_mode", @@ -577,6 +627,7 @@ { "name": "metric_load_divergence_enable", "friendly_name": "Enable Load Divergence Model", + "description": "Adds short-term variation to predicted household demand instead of assuming perfectly smooth consumption.", "type": "switch", "default": True, "enable": "expert_mode", @@ -584,6 +635,7 @@ { "name": "metric_pv_calibration_enable", "friendly_name": "Enable use of Calibrated PV data", + "description": "Uses historical actual-versus-forecast solar production to calibrate future PV forecasts.", "type": "switch", "default": True, "enable": "expert_mode", @@ -591,12 +643,14 @@ { "name": "metric_dynamic_load_adjust", "friendly_name": "Dynamic Load Adjust", + "description": "Temporarily adjusts the current and near-term load forecast when live consumption is unusually high or low.", "type": "switch", "default": False, }, { "name": "metric_self_sufficiency", "friendly_name": "Metric Self Sufficiency", + "description": "Adds a virtual penalty to grid imports so the optimiser favours self-consumption even where importing would be slightly cheaper.", "type": "input_number", "min": 0, "max": 100, @@ -609,6 +663,7 @@ { "name": "set_reserve_min", "friendly_name": "Set Reserve Min (Inverter 1)", + "description": "Minimum battery percentage Predbat allows inverter 1 to discharge to; it protects reserve capacity but does not itself force charging to that level.", "type": "input_number", "min": 0, "max": 100, @@ -621,6 +676,7 @@ { "name": "set_reserve_min_1", "friendly_name": "Set Reserve Min (Inverter 2)", + "description": "Minimum battery percentage Predbat allows inverter 2 to discharge to; it protects reserve capacity but does not itself force charging to that level.", "type": "input_number", "min": 0, "max": 100, @@ -635,6 +691,7 @@ { "name": "set_reserve_min_2", "friendly_name": "Set Reserve Min (Inverter 3)", + "description": "Minimum battery percentage Predbat allows inverter 3 to discharge to; it protects reserve capacity but does not itself force charging to that level.", "type": "input_number", "min": 0, "max": 100, @@ -649,6 +706,7 @@ { "name": "set_reserve_min_3", "friendly_name": "Set Reserve Min (Inverter 4)", + "description": "Minimum battery percentage Predbat allows inverter 4 to discharge to; it protects reserve capacity but does not itself force charging to that level.", "type": "input_number", "min": 0, "max": 100, @@ -663,6 +721,7 @@ { "name": "rate_low_threshold", "friendly_name": "Rate Low Threshold", + "description": "Controls which import prices qualify as possible charging periods relative to future prices; 0 lets Predbat determine the threshold automatically.", "type": "input_number", "min": 0.00, "max": 2.00, @@ -675,6 +734,7 @@ { "name": "rate_high_threshold", "friendly_name": "Rate High Threshold", + "description": "Controls which export prices qualify as possible export periods; 0 lets Predbat determine the threshold automatically.", "type": "input_number", "min": 0.00, "max": 2.00, @@ -687,6 +747,7 @@ { "name": "combine_rate_threshold", "friendly_name": "Combine Rate Threshold", + "description": "Allows nearby tariff rates within this price difference to be treated as one larger rate block; 0 keeps each rate change separate.", "type": "input_number", "min": 0, "max": 1000, @@ -699,12 +760,14 @@ { "name": "car_charging_hold", "friendly_name": "Car charging hold (remove car charging energy from load data)", + "description": "Removes identified historical EV charging from household load data so it is not forecast again as ordinary house consumption.", "type": "switch", "default": True, }, { "name": "car_energy_reported_load", "friendly_name": "Car energy is reported in load data (inside CT clamp)", + "description": "Tells Predbat whether EV charging is already included in the measured household load because the charger is inside the load CT clamp.", "type": "switch", "default": True, "enable_condition": "num_cars > 0", @@ -712,6 +775,7 @@ { "name": "car_charging_manual_soc", "friendly_name": "Car charging manual SOC (Car 0)", + "description": "Uses Predbat’s manually entered EV energy value for car 0 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -720,6 +784,7 @@ { "name": "car_charging_manual_soc_kwh", "friendly_name": "Car manual SOC kWh (Car 0)", + "description": "Current energy stored in car 0, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -733,6 +798,7 @@ { "name": "car_charging_manual_soc_1", "friendly_name": "Car charging manual SOC (Car 1)", + "description": "Uses Predbat’s manually entered EV energy value for car 1 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -741,6 +807,7 @@ { "name": "car_charging_manual_soc_kwh_1", "friendly_name": "Car manual SOC kWh (Car 1)", + "description": "Current energy stored in car 1, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -754,6 +821,7 @@ { "name": "car_charging_manual_soc_2", "friendly_name": "Car charging manual SOC (Car 2)", + "description": "Uses Predbat’s manually entered EV energy value for car 2 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -762,6 +830,7 @@ { "name": "car_charging_manual_soc_kwh_2", "friendly_name": "Car manual SOC kWh (Car 2)", + "description": "Current energy stored in car 2, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -775,6 +844,7 @@ { "name": "car_charging_manual_soc_3", "friendly_name": "Car charging manual SOC (Car 3)", + "description": "Uses Predbat’s manually entered EV energy value for car 3 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -783,6 +853,7 @@ { "name": "car_charging_manual_soc_kwh_3", "friendly_name": "Car manual SOC kWh (Car 3)", + "description": "Current energy stored in car 3, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -796,6 +867,7 @@ { "name": "car_charging_manual_soc_4", "friendly_name": "Car charging manual SOC (Car 4)", + "description": "Uses Predbat’s manually entered EV energy value for car 4 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -804,6 +876,7 @@ { "name": "car_charging_manual_soc_kwh_4", "friendly_name": "Car manual SOC kWh (Car 4)", + "description": "Current energy stored in car 4, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -817,6 +890,7 @@ { "name": "car_charging_manual_soc_5", "friendly_name": "Car charging manual SOC (Car 5)", + "description": "Uses Predbat’s manually entered EV energy value for car 5 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -825,6 +899,7 @@ { "name": "car_charging_manual_soc_kwh_5", "friendly_name": "Car manual SOC kWh (Car 5)", + "description": "Current energy stored in car 5, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -838,6 +913,7 @@ { "name": "car_charging_manual_soc_6", "friendly_name": "Car charging manual SOC (Car 6)", + "description": "Uses Predbat’s manually entered EV energy value for car 6 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -846,6 +922,7 @@ { "name": "car_charging_manual_soc_kwh_6", "friendly_name": "Car manual SOC kWh (Car 6)", + "description": "Current energy stored in car 6, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -859,6 +936,7 @@ { "name": "car_charging_manual_soc_7", "friendly_name": "Car charging manual SOC (Car 7)", + "description": "Uses Predbat’s manually entered EV energy value for car 7 instead of a configured vehicle state-of-charge sensor.", "type": "switch", "default": False, "enable": "num_cars", @@ -867,6 +945,7 @@ { "name": "car_charging_manual_soc_kwh_7", "friendly_name": "Car manual SOC kWh (Car 7)", + "description": "Current energy stored in car 7, used when manual EV state of charge is enabled for that car.", "type": "input_number", "min": 0, "max": 100, @@ -880,12 +959,14 @@ { "name": "octopus_intelligent_charging", "friendly_name": "Octopus Intelligent Charging", + "description": "Uses charging slots supplied by Octopus Intelligent rather than relying only on Predbat-generated EV charging slots.", "type": "switch", "default": True, }, { "name": "octopus_saving_auto_join", "friendly_name": "Octopus Saving Session Auto Join", + "description": "Automatically joins supported Octopus Saving Sessions when Predbat discovers them.", "type": "switch", "default": True, }, @@ -904,6 +985,7 @@ { "name": "octopus_intelligent_ignore_unplugged", "friendly_name": "Ignore Intelligent slots when car is unplugged", + "description": "Ignores Intelligent Octopus charging slots when Predbat knows the car is not connected.", "type": "switch", "default": False, "enable": "expert_mode", @@ -911,6 +993,7 @@ { "name": "octopus_intelligent_consider_full", "friendly_name": "Consider car full as part of Intelligent plan", + "description": "Makes Intelligent Octopus slot modelling take the configured EV full or charge-limit state into account.", "type": "switch", "default": False, "enable": "expert_mode", @@ -932,6 +1015,7 @@ { "name": "car_charging_plan_smart", "friendly_name": "Car Charging Plan Smart", + "description": "Selects the cheapest suitable periods needed for EV charging instead of treating every eligible low-rate slot as a charging period.", "type": "switch", "default": True, "enable": "num_cars", @@ -940,6 +1024,7 @@ { "name": "car_charging_plan_max_price", "friendly_name": "Car Charging Plan max price", + "description": "Maximum electricity price Predbat may use for its planned EV charging slots.", "type": "input_number", "min": -1000, "max": 1000, @@ -953,6 +1038,7 @@ { "name": "car_charging_from_battery", "friendly_name": "Allow car to charge from battery", + "description": "Allows EV charging to use energy from the home battery; when disabled Predbat protects the home battery while the car is charging.", "type": "switch", "default": False, "reset_inverter": True, @@ -963,6 +1049,7 @@ "name": "calculate_export_oncharge", "oldname": "calculate_discharge_oncharge", "friendly_name": "Calculate Export within charge slots", + "description": "Allows the optimiser to place an export opportunity within periods that were initially considered for battery charging.", "type": "switch", "enable": "expert_mode", "default": False, @@ -970,6 +1057,7 @@ { "name": "calculate_export_on_pv", "friendly_name": "Calculate export slots during solar production", + "description": "Allows forced battery export opportunities to be considered while solar generation is expected.", "type": "switch", "enable": "expert_mode", "default": True, @@ -977,6 +1065,7 @@ { "name": "calculate_second_pass", "friendly_name": "Calculate full second pass (slower)", + "description": "Runs a slower second optimisation pass over charge and export windows to refine the plan.", "type": "switch", "enable": "performance_tweaks", "default": True, @@ -984,6 +1073,7 @@ { "name": "calculate_pv90_plan", "friendly_name": "Calculate PV90 upside plan", + "description": "Includes the optimistic PV90 solar scenario in optimisation; disabling it speeds planning but ignores that upside scenario.", "type": "switch", "enable": "performance_tweaks", "default": True, @@ -991,6 +1081,7 @@ { "name": "calculate_import_low_export", "friendly_name": "Calculate import slots on low export rate slots first", + "description": "When import prices are equal, prefers charging in slots where the opportunity cost of exporting is lowest.", "type": "switch", "enable": "expert_mode", "default": True, @@ -999,6 +1090,7 @@ { "name": "calculate_export_high_import", "friendly_name": "Calculate export slots on high import rate slots first", + "description": "When export prices are equal, uses import prices as a tie-breaker when ordering export opportunities.", "type": "switch", "enable": "expert_mode", "default": True, @@ -1006,12 +1098,14 @@ { "name": "calculate_inday_adjustment", "friendly_name": "Calculate in-day adjustment", + "description": "Compares today’s actual consumption with today’s forecast and adjusts the remaining load forecast when they diverge.", "type": "switch", "default": True, }, { "name": "calculate_plan_every", "friendly_name": "Calculate plan every N minutes", + "description": "Sets how often Predbat fully recalculates the optimised plan; inverter control can still run more frequently.", "type": "input_number", "min": 5, "max": 60, @@ -1024,6 +1118,7 @@ { "name": "combine_charge_slots", "friendly_name": "Combine Charge Slots", + "description": "Treats adjacent charge periods as larger blocks, which can speed optimisation and reduce oscillation but gives the optimiser less flexibility.", "type": "switch", "default": False, }, @@ -1031,6 +1126,7 @@ "name": "combine_export_slots", "oldname": "combine_discharge_slots", "friendly_name": "Combine Export Slots", + "description": "Treats adjacent export periods as larger blocks, which can speed optimisation but may miss a slightly better export arrangement.", "type": "switch", "enable": "expert_mode", "default": False, @@ -1038,30 +1134,35 @@ { "name": "set_status_notify", "friendly_name": "Set Status Notify", + "description": "Sends notifications when Predbat changes operating state, such as Charging, Demand or Exporting.", "type": "switch", "default": True, }, { "name": "set_inverter_notify", "friendly_name": "Set Inverter Notify", + "description": "Sends notifications when Predbat changes inverter parameters such as charge rates, targets or operating windows.", "type": "switch", "default": False, }, { "name": "set_event_notify", "friendly_name": "Set Event Notify", + "description": "Sends notifications about external energy events such as Saving Sessions or other supported events.", "type": "switch", "default": True, }, { "name": "set_system_notify", "friendly_name": "Set System Notify", + "description": "Sends notifications about Predbat itself, such as updates, restarts and settings save or restore operations.", "type": "switch", "default": True, }, { "name": "set_charge_freeze", "friendly_name": "Set Charge Freeze", + "description": "Allows Predbat to hold battery state of charge during a charge window rather than discharging the battery to supply the house.", "type": "switch", "enable": "expert_mode", "default": True, @@ -1070,6 +1171,7 @@ { "name": "set_charge_freeze_only", "friendly_name": "Set Charge Freeze Only", + "description": "Prevents grid charging during charge windows so Predbat may only hold the battery level rather than increase it.", "type": "switch", "enable": "expert_mode", "default": False, @@ -1078,6 +1180,7 @@ { "name": "set_charge_low_power", "friendly_name": "Set Charge Low Power Mode", + "description": "Uses the lowest battery charge rate Predbat believes will still reach the target on time instead of charging at full power.", "type": "switch", "default": False, "reset_inverter": True, @@ -1085,6 +1188,7 @@ { "name": "set_export_low_power", "friendly_name": "Set Export Low Power Mode", + "description": "Uses a reduced forced-discharge rate where the planned export target can still be achieved without exporting at maximum power.", "type": "switch", "default": False, "reset_inverter": True, @@ -1092,6 +1196,7 @@ { "name": "charge_low_power_margin", "friendly_name": "Low power mode margin", + "description": "Sets how many minutes early low-power charging should aim to reach its target, providing a safety margin.", "type": "input_number", "min": 0, "max": 60, @@ -1123,6 +1228,7 @@ { "name": "set_reserve_enable", "friendly_name": "Set Reserve Enable", + "description": "Allows Predbat to use the inverter reserve setting to prevent discharge below the planned minimum state of charge.", "type": "switch", "enable": "expert_mode", "default": True, @@ -1132,6 +1238,7 @@ "name": "set_export_freeze", "oldname": "set_discharge_freeze", "friendly_name": "Set Export Freeze", + "description": "Allows Predbat to prevent battery charging so surplus solar can be exported instead.", "type": "switch", "default": True, "reset_inverter": True, @@ -1140,6 +1247,7 @@ "name": "set_export_freeze_only", "oldname": "set_discharge_freeze_only", "friendly_name": "Set Export Freeze Only", + "description": "Prevents forced battery discharge; Predbat may export surplus solar but will not deliberately empty the battery into the grid.", "type": "switch", "enable": "expert_mode", "default": False, @@ -1148,12 +1256,14 @@ { "name": "set_discharge_during_charge", "friendly_name": "Set discharge During Charge", + "description": "Controls whether Predbat prevents battery discharge during charge slots, mainly to avoid cross-charging in multi-inverter systems.", "type": "switch", "default": True, }, { "name": "set_freeze_export_during_demand", "friendly_name": "Set Freeze Export during Demand", + "description": "Prevents the battery from charging during normal Demand mode while still allowing discharge, mainly as a multi-inverter cross-charging control.", "type": "switch", "enable": "expert_mode", "default": False, @@ -1162,12 +1272,14 @@ { "name": "export_more_solar", "friendly_name": "Export more solar", + "description": "Where the cost difference is small, favours exporting surplus solar rather than storing it in the battery.", "type": "switch", "default": False, }, { "name": "export_more_solar_threshold", "friendly_name": "Export more solar threshold", + "description": "Maximum extra plan cost Predbat will tolerate when favouring solar export instead of storing that energy.", "type": "input_number", "min": 0, "max": 1000, @@ -1180,6 +1292,7 @@ { "name": "set_read_only", "friendly_name": "Read Only mode", + "description": "Lets Predbat calculate its normal plan but prevents it from changing inverter settings.", "type": "switch", "default": False, "reset_inverter_force": True, @@ -1187,30 +1300,35 @@ { "name": "chat_confirm_writes", "friendly_name": "Chat confirm before changing settings", + "description": "Requires confirmation before the Predbat chat or AI feature changes configuration or applies a write action.", "type": "switch", "default": True, }, { "name": "chat_web_search", "friendly_name": "Chat web search (costs per request)", + "description": "Allows the Predbat chat or AI feature to search the wider web through its configured provider, which may incur API charges.", "type": "switch", "default": False, }, { "name": "ai_ha_state_enable", "friendly_name": "AI: allow reading Home Assistant state", + "description": "Allows the Predbat AI feature to read Home Assistant entity states and history outside Predbat itself.", "type": "switch", "default": True, }, { "name": "balance_inverters_enable", "friendly_name": "Balance Inverters Enable (Beta)", + "description": "Enables Predbat’s multi-inverter battery balancing logic.", "type": "switch", "default": False, }, { "name": "balance_inverters_charge", "friendly_name": "Balance Inverters for charging", + "description": "Adjusts individual inverter charge rates when battery states of charge diverge during charging.", "type": "switch", "enable": "balance_inverters_enable", "default": False, @@ -1218,6 +1336,7 @@ { "name": "balance_inverters_discharge", "friendly_name": "Balance Inverters for discharge", + "description": "Adjusts individual inverter discharge rates when battery states of charge diverge during discharge.", "type": "switch", "enable": "balance_inverters_enable", "default": False, @@ -1225,6 +1344,7 @@ { "name": "balance_inverters_crosscharge", "friendly_name": "Balance Inverters for cross-charging", + "description": "Prevents one inverter or battery unnecessarily charging another while the system is in normal operation.", "type": "switch", "enable": "balance_inverters_enable", "default": True, @@ -1232,6 +1352,7 @@ { "name": "balance_inverters_threshold_charge", "friendly_name": "Balance Inverters threshold charge", + "description": "Minimum state-of-charge difference between batteries before charge balancing begins.", "type": "input_number", "min": 1, "max": 20, @@ -1244,6 +1365,7 @@ { "name": "balance_inverters_threshold_discharge", "friendly_name": "Balance Inverters threshold discharge", + "description": "Minimum state-of-charge difference between batteries before discharge balancing begins.", "type": "input_number", "min": 1, "max": 20, @@ -1256,6 +1378,7 @@ { "name": "debug_enable", "friendly_name": "Debug Enable", + "description": "Enables detailed Predbat diagnostic logging and debug output; intended for troubleshooting rather than normal operation.", "type": "switch", "icon": "mdi:bug-outline", "default": False, @@ -1263,6 +1386,7 @@ { "name": "debug_history_enable", "friendly_name": "Debug history rolling capture enable", + "description": "Keeps periodic diagnostic snapshots so a problem can be investigated after it has occurred.", "type": "switch", "icon": "mdi:history", "default": True, @@ -1270,6 +1394,7 @@ { "name": "debug_history_count", "friendly_name": "Debug history snapshot count", + "description": "Number of historical diagnostic snapshots Predbat retains.", "type": "input_number", "min": 1, # The maximum only bounds what a user can opt into, the default below is what almost every @@ -1288,6 +1413,7 @@ { "name": "debug_history_interval", "friendly_name": "Debug history snapshot interval", + "description": "Number of hours between automatic diagnostic-history snapshots.", "type": "input_number", "min": 1, "max": 24, @@ -1299,6 +1425,7 @@ { "name": "debug_history_force_capture", "friendly_name": "Debug history force capture now", + "description": "Requests an immediate diagnostic-history snapshot and then returns to the off state.", "type": "switch", "icon": "mdi:camera", "default": False, @@ -1306,6 +1433,7 @@ { "name": "car_charging_plan_time", "friendly_name": "Car charging planned ready time", + "description": "Time by which Predbat should aim to have planned EV charging completed.", "type": "select", "options": OPTIONS_TIME, "icon": "mdi:clock-end", @@ -1316,6 +1444,7 @@ { "name": "mode", "friendly_name": "Predbat mode", + "description": "Chooses how much control Predbat has, from monitoring only through charge control to full charge and forced-export control.", "type": "select", "options": PREDBAT_MODE_OPTIONS, "icon": "mdi:state-machine", @@ -1325,6 +1454,7 @@ { "name": "update", "friendly_name": "Predbat update", + "description": "Selects a Predbat core update action or version when an update is available.", "type": "select", "options": PREDBAT_UPDATE_OPTIONS, "icon": "mdi:state-machine", @@ -1335,6 +1465,7 @@ { "name": "manual_charge", "friendly_name": "Manual force charge", + "description": "Forces selected future slots to be treated as battery charging periods.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1345,6 +1476,7 @@ { "name": "manual_export", "friendly_name": "Manual force export", + "description": "Forces selected future slots to discharge and export the battery; this takes priority over a manual charge in the same slot.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1355,6 +1487,7 @@ { "name": "manual_demand", "friendly_name": "Manual force demand", + "description": "Forces normal Demand operation for selected slots, with no forced charge or export.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1365,6 +1498,7 @@ { "name": "manual_import_rates", "friendly_name": "Manual import rates", + "description": "Overrides the import tariff used by Predbat for selected future slots.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1375,6 +1509,7 @@ { "name": "manual_export_rates", "friendly_name": "Manual export rates", + "description": "Overrides the export tariff used by Predbat for selected future slots.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1385,6 +1520,7 @@ { "name": "manual_load_adjust", "friendly_name": "Manual load adjustment", + "description": "Adds or removes a specified amount of energy from the predicted household load in selected future slots.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1395,6 +1531,7 @@ { "name": "manual_import_value", "friendly_name": "Manual import value", + "description": "Default import price applied when creating a Manual Import Rates override.", "type": "input_number", "min": -50, "max": 1000, @@ -1406,6 +1543,7 @@ { "name": "manual_export_value", "friendly_name": "Manual export value", + "description": "Default export price applied when creating a Manual Export Rates override.", "type": "input_number", "min": -50, "max": 1000, @@ -1417,6 +1555,7 @@ { "name": "manual_load_value", "friendly_name": "Manual load adjustment value", + "description": "Default kWh adjustment applied when creating a Manual Load Adjust override.", "type": "input_number", "min": -10, "max": 10, @@ -1428,6 +1567,7 @@ { "name": "manual_soc", "friendly_name": "Manual SOC target", + "description": "Sets a minimum battery state-of-charge target that Predbat must aim to reach by the selected time.", "type": "select", "options": ["off"], "icon": "mdi:battery-charging", @@ -1438,6 +1578,7 @@ { "name": "manual_soc_value", "friendly_name": "Manual SOC target value", + "description": "Percentage used as the target for Manual SoC overrides.", "type": "input_number", "min": 0, "max": 100, @@ -1470,6 +1611,7 @@ { "name": "manual_api", "friendly_name": "Manual API controls", + "description": "Advanced interface for temporarily overriding Predbat configuration or controls from Home Assistant automations or API actions.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1480,6 +1622,7 @@ { "name": "manual_freeze_charge", "friendly_name": "Manual force charge freeze", + "description": "Forces selected future slots to hold battery state of charge by preventing discharge.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1490,6 +1633,7 @@ { "name": "manual_freeze_export", "friendly_name": "Manual force export freeze", + "description": "Forces selected future slots to prevent battery charging so surplus solar can be exported while the battery may still supply demand.", "type": "select", "options": ["off"], "icon": "mdi:state-machine", @@ -1500,6 +1644,7 @@ { "name": "saverestore", "friendly_name": "Save/restore settings", + "description": "Saves the current Predbat settings, restores a saved set, or restores the default configuration.", "type": "select", "options": PREDBAT_SAVE_RESTORE, "icon": "mdi:state-machine", @@ -1510,12 +1655,14 @@ { "name": "auto_update", "friendly_name": "Predbat automatic update enable", + "description": "Automatically installs new Predbat releases when they become available.", "type": "switch", "default": False, }, { "name": "load_filter_modal", "friendly_name": "Apply modal filter historical load", + "description": "Filters unusually low historical-load days from the load average when enough historical days are available.", "type": "switch", "enable": "expert_mode", "default": True, @@ -1523,18 +1670,21 @@ { "name": "iboost_enable", "friendly_name": "iBoost enable", + "description": "Enables Predbat’s hot-water or solar-diverter model and planning controls.", "type": "switch", "default": False, }, { "name": "carbon_enable", "friendly_name": "Carbon enable", + "description": "Includes configured electricity carbon intensity when evaluating plans.", "type": "switch", "default": False, }, { "name": "carbon_metric", "friendly_name": "Carbon Metric", + "description": "Sets the financial-style weighting assigned to carbon emissions; higher values make lower-carbon plans more valuable to the optimiser.", "type": "input_number", "min": 0, "max": 500, @@ -1547,6 +1697,7 @@ { "name": "iboost_solar", "friendly_name": "iBoost on solar power", + "description": "Allows solar generation to be used for hot-water or diverter operation.", "type": "switch", "default": True, "enable": "iboost_enable", @@ -1554,6 +1705,7 @@ { "name": "iboost_solar_excess", "friendly_name": "iBoost solar only runs when exporting excess", + "description": "Restricts solar diversion to genuine surplus energy that would otherwise be exported.", "type": "switch", "default": False, "enable": "iboost_enable", @@ -1561,6 +1713,7 @@ { "name": "iboost_gas", "friendly_name": "iBoost when import electricity cheaper than gas", + "description": "Allows electric hot-water heating when importing electricity is cheaper than the modelled cost of gas heating.", "type": "switch", "default": False, "enable": "iboost_enable", @@ -1568,6 +1721,7 @@ { "name": "iboost_gas_export", "friendly_name": "iBoost when export electricity cheaper than gas", + "description": "Compares the value of exported electricity with gas heating before diverting surplus electricity to hot water.", "type": "switch", "default": False, "enable": "iboost_enable", @@ -1575,6 +1729,7 @@ { "name": "iboost_charging", "friendly_name": "iBoost when battery charging", + "description": "Allows the diverter to run while the home battery is charging.", "type": "switch", "default": False, "enable": "iboost_enable", @@ -1582,6 +1737,7 @@ { "name": "iboost_rate_threshold", "friendly_name": "iBoost max import boost price", + "description": "Maximum import electricity price at which Predbat may run the diverter.", "type": "input_number", "min": -10, "max": 1000, @@ -1594,6 +1750,7 @@ { "name": "iboost_rate_threshold_export", "friendly_name": "iBoost max export boost price", + "description": "Maximum export value Predbat is willing to give up by diverting energy to hot water.", "type": "input_number", "min": -10, "max": 1000, @@ -1606,6 +1763,7 @@ { "name": "iboost_smart", "friendly_name": "iBoost when import rates are lowest to hit target energy", + "description": "Chooses the cheapest import-price slots needed to meet the daily diverter energy target.", "type": "switch", "enable": "iboost_enable", "default": False, @@ -1613,6 +1771,7 @@ { "name": "iboost_smart_min_length", "friendly_name": "iBoost smart min length", + "description": "Minimum continuous duration of a price-based smart diverter boost period.", "type": "input_number", "min": 30, "max": 120, @@ -1626,6 +1785,7 @@ "name": "iboost_on_export", "oldname": "iboost_on_export", "friendly_name": "iBoost is allowed when battery is force exporting", + "description": "Allows the diverter to operate while Predbat is deliberately force-exporting the home battery.", "type": "switch", "enable": "iboost_enable", "default": False, @@ -1633,6 +1793,7 @@ { "name": "iboost_prevent_discharge", "friendly_name": "When iBoost is active prevent battery discharge", + "description": "Prevents the home battery from discharging to supply the diverter while it is active.", "type": "switch", "enable": "iboost_enable", "default": False, @@ -1640,6 +1801,7 @@ { "name": "iboost_gas_scale", "friendly_name": "iBoost gas price scaling", + "description": "Scales the gas price, for example to account for boiler efficiency, before comparing gas heating with electric diversion.", "type": "input_number", "min": 0, "max": 2.0, @@ -1652,6 +1814,7 @@ { "name": "iboost_max_energy", "friendly_name": "iBoost max energy", + "description": "Maximum amount of energy the diverter should consume in one day.", "type": "input_number", "min": 0, "max": 30, @@ -1663,6 +1826,7 @@ { "name": "iboost_today", "friendly_name": "iBoost today", + "description": "Tracks Predbat’s estimate of how much energy the diverter has used today.", "type": "input_number", "min": 0, "max": 30, @@ -1674,6 +1838,7 @@ { "name": "iboost_max_power", "friendly_name": "iBoost max power", + "description": "Maximum electrical power the modelled diverter can consume.", "type": "input_number", "min": 0, "max": 5000, @@ -1685,6 +1850,7 @@ { "name": "iboost_min_power", "friendly_name": "iBoost min power", + "description": "Minimum useful operating power for the modelled diverter.", "type": "input_number", "min": 0, "max": 5000, @@ -1696,6 +1862,7 @@ { "name": "iboost_min_soc", "friendly_name": "iBoost min soc", + "description": "Minimum home-battery state of charge required before solar energy may be diverted to hot water.", "type": "input_number", "min": 0, "max": 100, @@ -1708,6 +1875,7 @@ { "name": "iboost_value_scaling", "friendly_name": "iBoost value scaling", + "description": "Controls how valuable Predbat considers energy sent to hot water; higher values make diversion more attractive to the optimiser.", "type": "input_number", "min": 0, "max": 2.0, @@ -1720,6 +1888,7 @@ { "name": "iboost_energy_subtract", "friendly_name": "Subtract iBoost energy from load predictions", + "description": "Removes measured diverter energy from historical household load so it is not forecast again as normal house demand.", "type": "switch", "enable": "iboost_enable", "default": True, @@ -1727,6 +1896,7 @@ { "name": "holiday_days_left", "friendly_name": "Holiday days left", + "description": "Enables holiday load forecasting for this many remaining days and counts the value down as the holiday progresses.", "type": "input_number", "min": 0, "max": 28, @@ -1739,6 +1909,7 @@ { "name": "holiday_load_scaling", "friendly_name": "Holiday load scaling", + "description": "Temporary scaling applied to normal household consumption while in holiday mode until Predbat has enough holiday-use history.", "type": "input_number", "min": 0.1, "max": 1.0, @@ -1750,6 +1921,7 @@ { "name": "forecast_plan_hours", "friendly_name": "Plan forecast hours", + "description": "Sets the minimum number of future hours Predbat considers when constructing and optimising the plan.", "type": "input_number", "min": 8, "max": 96, @@ -1762,6 +1934,7 @@ { "name": "plan_debug", "friendly_name": "HTML Plan Debug", + "description": "Adds extra optimiser and diagnostic information to the generated HTML plan.", "type": "switch", "default": False, "enable": "expert_mode", @@ -1769,12 +1942,14 @@ { "name": "predheat_enable", "friendly_name": "Enable PredHeat", + "description": "Enables the optional PredHeat model so expected heating demand and temperature can be included in planning.", "type": "switch", "default": False, }, { "name": "next_volume_temp", "friendly_name": "Volume Temperature Next", + "description": "Stores the estimated heating-system or radiator-water temperature carried into the next PredHeat calculation when needed.", "type": "input_number", "min": -20, "max": 40, @@ -1786,6 +1961,7 @@ { "name": "inverter_limit_override", "friendly_name": "Inverter Limit Override", + "description": "Artificially caps the battery or inverter power limit used by Predbat where the installation should not use the reported maximum.", "type": "input_number", "min": 0, "max": 50000, @@ -2609,6 +2785,7 @@ "ha_key": {"type": "string", "empty": False}, "load_filter_threshold": {"type": "integer"}, "web_port": {"type": "integer"}, + "web_ui": {"type": "string", "allowed": ["legacy", "modern"]}, # The chat agent's LLM endpoint. Named chat_api_* rather than openrouter_* because the # endpoint no longer has to be OpenRouter: any OpenAI-compatible API works, including a local # Ollama. The openrouter_* names are still accepted so an existing apps.yaml keeps working. @@ -2725,17 +2902,69 @@ "pv_forecast_tomorrow": {"type": "sensor", "sensor_type": "float"}, "pv_forecast_d3": {"type": "sensor", "sensor_type": "float"}, "pv_forecast_d4": {"type": "sensor", "sensor_type": "float"}, - "car_charging_energy": {"type": "sensor", "sensor_type": "float", "transient_ok": True}, + "car_charging_energy": { + "type": "sensor|sensor_list", + "sensor_type": "float", + "transient_ok": True, + "description": "Home Assistant energy entity for each EV charger, used for today's charging total on Overview.", + }, "car_charging_power": {"type": "sensor|sensor_list", "sensor_type": "float", "transient_ok": True}, + "car_charging_status": { + "type": "sensor|sensor_list", + "sensor_type": "string", + "transient_ok": True, + "entries": "num_cars", + "optional_entries": True, + "description": "Home Assistant status entity for each EV charger, such as unplugged, connected or charging.", + }, "num_cars": {"type": "integer", "zero": True}, "car_charging_planned": {"type": "sensor|sensor_list", "sensor_type": "string|boolean", "entries": "num_cars"}, "car_charging_planned_response": {"type": "string_list"}, "car_charging_now": {"type": "sensor|sensor_list", "sensor_type": "string|boolean", "entries": "num_cars"}, "car_charging_now_response": {"type": "string_list"}, "car_charging_battery_size": {"type": "sensor", "zero": False, "sensor_type": "float", "entries": "num_cars"}, - "car_charging_soc": {"type": "sensor", "sensor_type": "float", "entries": "num_cars"}, + "car_charging_soc": { + "type": "sensor|sensor_list", + "sensor_type": "float", + "entries": "num_cars", + "description": "Home Assistant state-of-charge entity for each configured car.", + }, "car_charging_limit": {"type": "sensor", "sensor_type": "float", "entries": "num_cars"}, "car_charging_exclusive": {"type": "boolean_list", "entries": "num_cars"}, + "weather": { + "type": "sensor", + "sensor_type": "string", + "transient_ok": True, + "description": "Home Assistant weather entity used by Overview when PredHeat is not configured.", + }, + "ashp_enable": { + "type": "boolean", + "description": "Optional Overview ASHP-card override. PredHeat in pump mode shows the card automatically; set false to hide it or true when PredHeat is not configured.", + }, + "ashp_power": { + "type": "sensor", + "sensor_type": "float", + "transient_ok": True, + "description": "Home Assistant entity reporting the heat pump's current power draw.", + }, + "ashp_status": { + "type": "sensor", + "sensor_type": "string", + "transient_ok": True, + "description": "Home Assistant entity reporting the heat pump state, such as off, heating or hot_water.", + }, + "heat_energy": { + "type": "sensor", + "sensor_type": "float", + "transient_ok": True, + "description": "Home Assistant entity reporting today's heat-pump energy use when PredHeat is not configured.", + }, + "ashp_energy_today": { + "type": "sensor", + "sensor_type": "float", + "transient_ok": True, + "description": "Legacy alias for heat_energy, retained so existing Overview configurations continue to work.", + }, "carbon_intensity": {"type": "sensor", "sensor_type": "string"}, "carbon_postcode": {"type": "string", "empty": False}, "carbon_automatic": {"type": "boolean"}, diff --git a/apps/predbat/config/apps.yaml b/apps/predbat/config/apps.yaml index 333db4f7b..0e5b928c7 100644 --- a/apps/predbat/config/apps.yaml +++ b/apps/predbat/config/apps.yaml @@ -332,7 +332,7 @@ pred_bat: # is used for car_charging_hold feature to filter out car charging from the previous load data # Automatically set to detect Wallbox and Zappi, if it doesn't match manually enter your sensor name # Also adjust car_charging_energy_scale if it's not in kwH to fix the units - car_charging_energy: 're:(sensor.myenergi_zappi_[0-9a-z]+_charge_added_session|sensor.wallbox_portal_added_energy)' + car_charging_energy: 're:(sensor.myenergi_zappi_[0-9a-z]+_charge_added_session|sensor.[0-9a-z_]+_myenergi_zappi_[0-9a-z]+_session_energy|sensor.wallbox_portal_added_energy)' # Defines the number of cars modelled by the system, set to 0 for no car num_cars: 1 @@ -345,6 +345,10 @@ pred_bat: car_charging_planned: - 're:(sensor.wallbox_portal_status_description|sensor.myenergi_zappi_[0-9a-z]+_plug_status|sensor.tesla_wall_connector_status)' + # Optional live charger state used by the modern Overview page. + car_charging_status: + - 're:(sensor.wallbox_portal_status_description|sensor.myenergi_zappi_[0-9a-z]+_plug_status|sensor.[0-9a-z_]+_myenergi_zappi_[0-9a-z]+_plug_status|sensor.tesla_wall_connector_status)' + car_charging_planned_response: - 'yes' - 'on' diff --git a/apps/predbat/frontend.zip b/apps/predbat/frontend.zip new file mode 100644 index 000000000..363653d45 Binary files /dev/null and b/apps/predbat/frontend.zip differ diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 684a91e46..a11fb21b0 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -4185,10 +4185,16 @@ def fetch_octopus_sessions(self, axle_sessions=None): octopus_free_slot["start"] = start octopus_free_slot["end"] = end octopus_free_slot["rate"] = 0 + if event.get("event_type", None): + octopus_free_slot["event_type"] = event["event_type"] octopus_free_slots.append(octopus_free_slot) # Direct Octopus URL if "octopus_free_url" in self.args: free_online = self.download_octopus_free(self.get_arg("octopus_free_url", indirect=False)) + for slot in free_online: + # The public website feed exposes only times and a zero rate. Keep + # that provenance so the UI does not guess Power Up or Happy Hour. + slot.setdefault("event_type", "FREE_ELECTRICITY") octopus_free_slots.extend(free_online) # Load free electricity events from Octopus flexibility API @@ -4205,6 +4211,8 @@ def fetch_octopus_sessions(self, axle_sessions=None): octopus_free_slot["start"] = start octopus_free_slot["end"] = end octopus_free_slot["rate"] = 0 + if event.get("event_type", None): + octopus_free_slot["event_type"] = event["event_type"] octopus_free_slots.append(octopus_free_slot) # Octopus saving session @@ -4364,7 +4372,7 @@ def fetch_octopus_sessions(self, axle_sessions=None): continue if abs((start_time - self.now_utc).days) <= 3: self.log("Octopus: Joined Octopus Weekend Happy Hour {}-{} - treating as a free electricity session".format(start_time.strftime("%a %d/%m %H:%M"), end_time.strftime("%H:%M"))) - octopus_free_slots.append({"start": start, "end": end, "rate": 0}) + octopus_free_slots.append({"start": start, "end": end, "rate": 0, "event_type": "WEEKEND_HAPPY_HOUR"}) continue # Skip events with no rate info unless default is configured diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 77c2e397c..24bb7dcf6 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -22,7 +22,7 @@ from datetime import timedelta from predbat import THIS_VERSION_DISPLAY from const import TIME_FORMAT, PREDICT_STEP, EXPORT_LIMIT_IDLE, MINUTE_WATT, FULL_EXPORT_POWER, EXPORT_MODE_TARGET, EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE, CHARGE_STATE_PRECEDENCE, EXPORT_STATE_PRECEDENCE -from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state, find_charge_rate, export_mode_of, export_target_of, export_power_of, export_limit_sort_key, pack_export_limit, export_limit_from_stored +from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state, find_charge_rate, export_mode_of, export_target_of, export_power_of, export_limit_sort_key, pack_export_limit, export_limit_from_stored, minutes_to_time, str2time from prediction import Prediction # Per-slot plan "why" reason templates. Keyed by a stable reason code, each template is @@ -57,6 +57,41 @@ } +def event_slot_contains_minute(slot, minute, midnight_utc, start_key="start", end_key="end"): + """Return whether an event slot contains an absolute plan minute.""" + start = slot.get(start_key) + end = slot.get(end_key) + if not start or not end: + return False + try: + start_minute = minutes_to_time(str2time(start), midnight_utc) + end_minute = minutes_to_time(str2time(end), midnight_utc) + except (TypeError, ValueError): + return False + return start_minute <= minute < end_minute + + +def plan_rate_event_type(base, minute): + """Return the programme that produced a plan row's generic ``saving`` rate tag.""" + matching_free_slots = [slot for slot in base.octopus_free_slots if event_slot_contains_minute(slot, minute, base.midnight_utc)] + if any(slot.get("event_type") == "WEEKEND_HAPPY_HOUR" for slot in matching_free_slots): + return "octopus_happy_hour" + if any(slot.get("event_type") == "FREE_ELECTRICITY" for slot in matching_free_slots): + return "octopus_free_electricity" + if any("POWER_UP" in str(slot.get("event_type", "")).upper() or slot.get("event_type") == "TURN_UP" for slot in matching_free_slots): + return "octopus_power_up" + if matching_free_slots: + return "octopus_free_electricity" + for slot in base.octopus_saving_slots: + active_undated_slot = slot.get("state") and not slot.get("start") and not slot.get("end") and (base.minutes_now // 30) * 30 <= minute < (base.minutes_now // 30) * 30 + 30 + if active_undated_slot or event_slot_contains_minute(slot, minute, base.midnight_utc): + return "octopus_power_down" + for slot in base.axle_sessions: + if event_slot_contains_minute(slot, minute, base.midnight_utc, "start_time", "end_time"): + return "axle_{}".format(slot.get("import_export", "event")) + return "energy_event" + + def yesterday_slot_is_exporting(slot_status): """True when a historical ``predbat.status`` string (already lower-cased) represents export activity for the "yesterday" plan reconstruction in ``calculate_yesterday()``. @@ -1075,8 +1110,13 @@ def short_textual_plan(self, soc_min, soc_min_minute, pv_forecast_minute_step, p ) if publish: + # Existing HTML version used by the classic Predbat interface. self.text_plan = self.get_text_plan_html(sentence) + # Keep the original textual version as well so that newer + # interfaces can consume it without having to parse HTML. + self.text_plan_raw = sentence + return sentence def get_charge_rate_kw(self, charge_window_n, minute_start, minute_relative_start, pv_forecast_minute_step): @@ -1169,7 +1209,12 @@ def import_rate_color(rate): raw_plan["import_cost_threshold"] = import_cost_threshold raw_plan["export_cost_threshold"] = export_cost_threshold - raw_plan["reason_templates"] = REASON_TEMPLATES + # Reason templates are shared by the legacy and modern plan views. Replace the + # historical pence label once here so every client receives the configured minor unit. + raw_plan["reason_templates"] = {code: template.replace("p/kWh", "{}/kWh".format(self.currency_symbols[1])) for code, template in REASON_TEMPLATES.items()} + + raw_plan["description"] = [line.strip()[2:] if line.strip().startswith("- ") else line.strip() for line in self.text_plan_raw.splitlines() if line.strip()] + raw_plan["currency_symbols"] = self.currency_symbols raw_plan["soc"] = prediction.soc_kw if prediction is not None else self.soc_kw raw_plan["soc_max"] = prediction.soc_max if prediction is not None else self.soc_max @@ -1793,6 +1838,8 @@ def import_rate_color(rate): export_rate_adjust_type = self.rate_export_replicated.get(minute) if export_rate_adjust_type is not None: json_row["export_rate_adjust_type"] = export_rate_adjust_type + if import_rate_adjust_type == "saving" or export_rate_adjust_type == "saving": + json_row["rate_event_type"] = plan_rate_event_type(self, minute_start) # Add adjusted rates (always included for client-side debug toggle) json_row["import_rate_adjusted"] = dp2(rate_value_import / self.battery_loss / self.inverter_loss + self.metric_battery_cycle) json_row["export_rate_adjusted"] = dp2(rate_value_export * self.battery_loss_discharge * self.inverter_loss - self.metric_battery_cycle) @@ -2730,6 +2777,17 @@ def record_status(self, message, debug="", had_errors=False, notify=False, extra if had_errors: error_count += 1 + status_text = (message + extra).lower() + status_icon = "mdi:information" + if "hold for car" in status_text: + status_icon = "mdi:car" + elif status_text.startswith("demand"): + status_icon = "mdi:house" + elif status_text.startswith("charg"): + status_icon = "mdi:battery-charging" + elif status_text.startswith("export"): + status_icon = "mdi:transmission-tower-export" + # Home Assistant rejects entity states over 255 characters, and this message is the state # of the status sensor. Clamp what is written as the state - the full text survives in # current_status, the log line and the notification, and attributes have no such cap. @@ -2742,7 +2800,7 @@ def record_status(self, message, debug="", had_errors=False, notify=False, extra attributes={ "friendly_name": "Status", "detail": extra, - "icon": "mdi:information", + "icon": status_icon, "last_updated": self.now_utc_real.strftime(TIME_FORMAT), "debug": debug, "version": THIS_VERSION_DISPLAY, diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 93508ee50..88ebfe414 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -84,6 +84,7 @@ dp3, find_unmasked_secret_paths, is_entity_id, + normalise_entity_prefix, mask_secret_args, malloc_trim, limit_malloc_arenas, @@ -315,6 +316,7 @@ def reset(self): Init stub """ self.text_plan = "Computing please wait..." + self.text_plan_raw = "" self.prediction_cache_enable = True self.base_load = 0 self.plan_interval_minutes = self.args.get("plan_interval_minutes", 30) @@ -361,7 +363,9 @@ def reset(self): self.dashboard_index = [] self.dashboard_index_app = {} self.dashboard_values = {} - self.prefix = self.args.get("prefix", "predbat") + self.configured_prefix = self.args.get("prefix", "predbat") + self.prefix = normalise_entity_prefix(self.configured_prefix) + self.args["prefix"] = self.prefix self.current_status = None self.previous_status = None self.had_errors = False @@ -774,6 +778,7 @@ def _emit_snapshot_metrics(self): # Config validity m.config_valid.set(0 if self.arg_errors else 1) m.config_warnings.set(len(self.arg_errors) if self.arg_errors else 0) + m.config_errors = dict(self.arg_errors) # Errors if self.had_errors: @@ -1803,8 +1808,8 @@ def validate_config(self): if spec.get("modify", False): prefix = sensor.split(".")[0] if prefix not in ["switch", "select", "input_number", "number", "time", "input_number", "input_datetime"]: - if sensor.startswith("sensor.predbat_"): - # We can ignore predbat generated sensors as they are control placeholders + if sensor.startswith("sensor.{}_".format(self.prefix)): + # Generated sensor controls are placeholders implemented by the inverter adapter. pass else: self.log("Warn: Validation of apps.yaml found configuration item '{}' element {} which cannot be modified".format(name, sensor)) @@ -2029,6 +2034,8 @@ def initialize(self): try: self.reset() self.update_time(print=False) + if str(self.configured_prefix) != self.prefix: + self.log("Warn: apps.yaml prefix '{}' is not valid in a Home Assistant entity ID; using '{}' instead".format(self.configured_prefix, self.prefix)) # Start all sub-components self.components = Components(self) diff --git a/apps/predbat/predbat_metrics.py b/apps/predbat/predbat_metrics.py index 82ec964bb..bc9ce12fb 100644 --- a/apps/predbat/predbat_metrics.py +++ b/apps/predbat/predbat_metrics.py @@ -76,6 +76,7 @@ def __init__(self): self.last_update_timestamp = _gauge("predbat_last_update_timestamp", "Timestamp of last update") self.config_valid = _gauge("predbat_config_valid", "Configuration is valid (1) or has errors (0)") self.config_warnings = _gauge("predbat_config_warnings", "Number of configuration warnings") + self.config_errors = {} # -- Plan -------------------------------------------------------------- self.plan_valid = _gauge("predbat_plan_valid", "Whether the current plan is valid") @@ -202,6 +203,7 @@ def _api_services(m): "last_update_timestamp": _val(self.last_update_timestamp), "config_valid": _val(self.config_valid), "config_warnings": _val(self.config_warnings), + "config_errors": dict(self.config_errors), # Plan "plan_valid": _val(self.plan_valid), "plan_age_minutes": _val(self.plan_age_minutes), diff --git a/apps/predbat/tests/test_component_health_status.py b/apps/predbat/tests/test_component_health_status.py index c8543daa1..a4e263c67 100644 --- a/apps/predbat/tests/test_component_health_status.py +++ b/apps/predbat/tests/test_component_health_status.py @@ -84,6 +84,20 @@ def test_record_status_state_clamped(my_predbat): failed = 1 else: print("OK: state clamped to 255 characters ({}), full text kept in current_status ({})".format(len(state), len(my_predbat.current_status))) + + expected_icons = [ + ("Demand", "", "mdi:house"), + ("Charging", "", "mdi:battery-charging"), + ("Exporting", "", "mdi:transmission-tower-export"), + ("Demand", ", Hold for car", "mdi:car"), + ("Idle", "", "mdi:information"), + ] + for message, extra, expected_icon in expected_icons: + my_predbat.record_status(message, extra=extra) + icon = my_predbat.dashboard_values.get(my_predbat.prefix + ".status", {}).get("attributes", {}).get("icon") + if icon != expected_icon: + print("ERROR: Status {}{} used icon {}, expected {}".format(message, extra, icon, expected_icon)) + failed = 1 finally: my_predbat.current_status = "" diff --git a/apps/predbat/tests/test_plan_json_rate_adjust.py b/apps/predbat/tests/test_plan_json_rate_adjust.py index 8ea257df1..31dfb2c59 100644 --- a/apps/predbat/tests/test_plan_json_rate_adjust.py +++ b/apps/predbat/tests/test_plan_json_rate_adjust.py @@ -8,6 +8,8 @@ # pylint: disable=line-too-long # pylint: disable=attribute-defined-outside-init +from datetime import timedelta + from prediction import Prediction from tests.test_infra import reset_inverter, reset_rates, update_rates_import @@ -140,7 +142,45 @@ def run_test_plan_json_rate_adjust(my_predbat): my_predbat.rate_import_replicated = {} my_predbat.rate_export_replicated = {} - # --- Test 3: car rate diverging from the house rate (batpred#4646) --- + # --- Test 3: the generic saving tag retains its source event in the JSON row --- + print("Test plan JSON output identifies the source energy event") + event_start = (my_predbat.midnight_utc + timedelta(minutes=test_minute - 5)).isoformat() + event_end = (my_predbat.midnight_utc + timedelta(minutes=test_minute + 60)).isoformat() + original_saving_slots = my_predbat.octopus_saving_slots + original_free_slots = my_predbat.octopus_free_slots + original_axle_sessions = my_predbat.axle_sessions + event_cases = [ + ("octopus_power_down", [{"start": event_start, "end": event_end}], [], []), + ("octopus_free_electricity", [], [{"start": event_start, "end": event_end}], []), + ("octopus_power_up", [], [{"start": event_start, "end": event_end, "event_type": "POWER_UP"}], []), + ("octopus_free_electricity", [], [{"start": event_start, "end": event_end, "event_type": "FREE_ELECTRICITY"}], []), + ("octopus_happy_hour", [], [{"start": event_start, "end": event_end, "event_type": "WEEKEND_HAPPY_HOUR"}], []), + ( + "octopus_happy_hour", + [], + [{"start": event_start, "end": event_end}, {"start": event_start, "end": event_end, "event_type": "WEEKEND_HAPPY_HOUR"}], + [], + ), + ("axle_export", [], [], [{"start_time": event_start, "end_time": event_end, "import_export": "export"}]), + ] + try: + my_predbat.rate_import_replicated = {test_minute: "saving"} + for expected_type, saving_slots, free_slots, axle_sessions in event_cases: + my_predbat.octopus_saving_slots = saving_slots + my_predbat.octopus_free_slots = free_slots + my_predbat.axle_sessions = axle_sessions + _, event_plan = my_predbat.publish_html_plan(pv_step, pv_step, load_step, load_step, my_predbat.end_record, publish=False) + event_row = next((row for row in event_plan["rows"] if row.get("slot_minute") == test_minute), None) + if event_row is None or event_row.get("rate_event_type") != expected_type: + print("ERROR: Expected rate_event_type='{}' got '{}'".format(expected_type, event_row and event_row.get("rate_event_type"))) + failed = True + finally: + my_predbat.rate_import_replicated = {} + my_predbat.octopus_saving_slots = original_saving_slots + my_predbat.octopus_free_slots = original_free_slots + my_predbat.axle_sessions = original_axle_sessions + + # --- Test 4: car rate diverging from the house rate (batpred#4646) --- print("Test plan JSON output with a car rate that diverges from the house rate") my_predbat.num_cars = 1 car_minute = my_predbat.minutes_now diff --git a/apps/predbat/tests/test_plan_why_reason.py b/apps/predbat/tests/test_plan_why_reason.py index 08f359bdb..cbae48605 100644 --- a/apps/predbat/tests/test_plan_why_reason.py +++ b/apps/predbat/tests/test_plan_why_reason.py @@ -852,6 +852,20 @@ def render(car_hold_minutes=None): print("ERROR: expected toggleForceDropdown to null-guard document.getElementById(id) before using it") failed = True + # --- Test 17: shared reason templates use the configured currency unit --- + print("Test reason templates use the configured minor currency symbol") + original_currency_symbols = my_predbat.currency_symbols + try: + my_predbat.currency_symbols = "€c" + _, currency_plan = render() + currency_templates = currency_plan["reason_templates"] + currency_text = " ".join(currency_templates.values()) + if "c/kWh" not in currency_text or "p/kWh" in currency_text: + print("ERROR: reason templates did not replace pence with the configured cents unit: {}".format(currency_templates)) + failed = True + finally: + my_predbat.currency_symbols = original_currency_symbols + if not failed: print("All plan why-reason tests passed") return failed diff --git a/apps/predbat/tests/test_saving_session.py b/apps/predbat/tests/test_saving_session.py index f4e988fbe..a1dd4a9b8 100644 --- a/apps/predbat/tests/test_saving_session.py +++ b/apps/predbat/tests/test_saving_session.py @@ -1547,7 +1547,7 @@ def run(joined_yaml): print(" Test 1: WEEKEND_HAPPY_HOUR is free, TURN_DOWN is a saving slot") octopus_free_slots, octopus_saving_slots = run(happy_hour + "\n" + turn_down_rewarded) - expected_free = [{"start": "{}T11:00:00+{}:00".format(date_today, tz_offset), "end": "{}T12:00:00+{}:00".format(date_today, tz_offset), "rate": 0}] + expected_free = [{"start": "{}T11:00:00+{}:00".format(date_today, tz_offset), "end": "{}T12:00:00+{}:00".format(date_today, tz_offset), "rate": 0, "event_type": "WEEKEND_HAPPY_HOUR"}] expected_saving = [{"start": "{}T17:00:00+{}:00".format(date_today, tz_offset), "end": "{}T18:00:00+{}:00".format(date_today, tz_offset), "rate": 50.0, "state": False}] if json.dumps(octopus_free_slots) != json.dumps(expected_free): print("ERROR: Expecting free slots {} got {}".format(expected_free, octopus_free_slots)) diff --git a/apps/predbat/tests/test_validate_config.py b/apps/predbat/tests/test_validate_config.py index dfe329d0c..ef8a902f0 100644 --- a/apps/predbat/tests/test_validate_config.py +++ b/apps/predbat/tests/test_validate_config.py @@ -30,6 +30,8 @@ import os import tempfile +from utils import normalise_entity_prefix + def _run(my_predbat, extra_args, extra_states=None, expect_errors=(), expect_clean=()): """Inject args/states, run validate_config, assert per-field expectations. @@ -66,6 +68,10 @@ def test_validate_config(my_predbat): """Comprehensive validator tests covering every validation type in APPS_SCHEMA.""" print("**** test_validate_config ****") + print(" [prefix] invalid Home Assistant characters are normalised") + assert normalise_entity_prefix("Predbat-Dev") == "predbat_dev" + assert normalise_entity_prefix("123 test") == "predbat_123_test" + # ========================================================================== # STRING type (gateway_mqtt_host: {"type": "string", "empty": False}) # ========================================================================== @@ -367,6 +373,19 @@ def test_validate_config(my_predbat): expect_clean=["charge_rate"], ) + print(" [sensor_list modify] generated controls respect a custom Predbat prefix") + saved_prefix = my_predbat.prefix + try: + my_predbat.prefix = "battery_site" + _run( + my_predbat, + {"charge_rate": ["sensor.battery_site_GS_0_charge_rate"], "num_inverters": 1}, + extra_states={"sensor.battery_site_GS_0_charge_rate": 2.5}, + expect_clean=["charge_rate"], + ) + finally: + my_predbat.prefix = saved_prefix + print(" [sensor_list modify] select. prefix is allowed for modification") _run( my_predbat, diff --git a/apps/predbat/tests/test_web_annual.py b/apps/predbat/tests/test_web_annual.py index d39a50327..bca9bb8d7 100644 --- a/apps/predbat/tests/test_web_annual.py +++ b/apps/predbat/tests/test_web_annual.py @@ -2525,7 +2525,7 @@ def test_web_annual_post_numeric_coercion(my_predbat): def test_web_annual_routes_registered(my_predbat): - """Verify all nine Annual routes are registered, so a typo'd path cannot ship green.""" + """Verify Annual routes and the modern UI entry point are registered.""" failed = False print("**** Testing web_annual route registration ****") @@ -2539,6 +2539,7 @@ def test_web_annual_routes_registered(my_predbat): expected = { ("GET", "/annual"), + ("GET", "/legacy_annual"), ("POST", "/annual"), ("POST", "/annual_run"), ("GET", "/annual_status"), @@ -2553,6 +2554,19 @@ def test_web_annual_routes_registered(my_predbat): print(" ERROR: missing route registrations: {}".format(missing)) failed = True + print("Test: modern mode serves the React shell while retaining the legacy page for the embedded tool") + modern_interface = WebInterface(my_predbat, web_port=5057) + modern_interface.get_web_ui = lambda: "modern" + modern_app = aiohttp_web.Application() + modern_interface._register_annual_routes(modern_app) + modern_routes = {(route.method, route.resource.canonical): route.handler for route in modern_app.router.routes()} + if modern_routes[("GET", "/annual")] != modern_interface.html_modern_ui: + print(" ERROR: /annual should serve the modern UI when web_ui is modern") + failed = True + if modern_routes[("GET", "/legacy_annual")] != modern_interface.annual_page.html_annual: + print(" ERROR: /legacy_annual should retain the existing What If implementation") + failed = True + return failed diff --git a/apps/predbat/tests/test_web_charts.py b/apps/predbat/tests/test_web_charts.py index 60f97cd6c..c00580d29 100644 --- a/apps/predbat/tests/test_web_charts.py +++ b/apps/predbat/tests/test_web_charts.py @@ -105,6 +105,408 @@ def run_web_charts_tests(my_predbat): now_str = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") series_data = [{"name": "SoC", "data": {"2026-07-23T10:00:00+00:00": 45.0}, "chart_type": "line"}] + # ------------------------------------------------------------------------- + print("Test: battery chart JSON and legacy rendering share the same series source") + original_dashboard_values = my_predbat.dashboard_values + original_history = my_predbat.soc_kwh_history + original_soc_kw = my_predbat.soc_kw + original_history_with_now_attrs = web.get_history_with_now_attrs + try: + forecast_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + my_predbat.prefix + ".soc_kw_best": {"attributes": {"results": {forecast_stamp: 6.5}}}, + my_predbat.prefix + ".best_charge_limit_kw": {"attributes": {"results": {forecast_stamp: 8.0}}}, + my_predbat.prefix + ".best_export_limit_kw": {"attributes": {"results": {forecast_stamp: 2.0}}}, + } + my_predbat.soc_kwh_history = {} + my_predbat.soc_kw = 6.25 + web.get_history_with_now_attrs = lambda *args, **kwargs: [] + + battery_data = web.get_battery_chart_data() + legacy_html = web.get_chart("Battery") + + if not battery_data["ready"]: + print(" ERROR: populated optimised forecast should mark battery chart data ready") + failed += 1 + if battery_data["series"]["optimized"].get(forecast_stamp) != 6.5: + print(f" ERROR: optimised series missing from battery chart JSON: {battery_data}") + failed += 1 + if battery_data["series"]["actual"].get(now_str) != 6.25: + print(f" ERROR: current battery value missing from Actual series: {battery_data}") + failed += 1 + if "name: 'Best'" not in legacy_html or "y: 6.5" not in legacy_html: + print(" ERROR: legacy chart no longer renders the shared optimised series") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + my_predbat.soc_kwh_history = original_history + my_predbat.soc_kw = original_soc_kw + web.get_history_with_now_attrs = original_history_with_now_attrs + + # ------------------------------------------------------------------------- + print("Test: power chart JSON exposes the existing optimised power series") + original_dashboard_values = my_predbat.dashboard_values + try: + forecast_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + my_predbat.prefix + ".battery_power_best": {"attributes": {"results": {forecast_stamp: -2.5}}}, + my_predbat.prefix + ".pv_power_best": {"attributes": {"results": {forecast_stamp: 3.2}}}, + # The prediction engine stores import as positive; the modern chart + # API normalises it to Predbat's live negative-import convention. + my_predbat.prefix + ".grid_power_best": {"attributes": {"results": {forecast_stamp: 0.4}}}, + my_predbat.prefix + ".load_power_best": {"attributes": {"results": {forecast_stamp: 1.1}}}, + my_predbat.prefix + ".iboost_best": {"attributes": {"results": {forecast_stamp: 0.25}}}, + } + + power_data = web.get_power_chart_data() + + if not power_data["ready"]: + print(" ERROR: populated forecast should mark power chart data ready") + failed += 1 + if power_data["series"]["battery"].get(forecast_stamp) != -2.5: + print(f" ERROR: battery power forecast missing from chart JSON: {power_data}") + failed += 1 + if power_data["series"]["grid"].get(forecast_stamp) != -0.4: + print(f" ERROR: grid import was not normalised to negative power: {power_data}") + failed += 1 + if power_data["series"]["iboost_energy"].get(forecast_stamp) != 0.25: + print(f" ERROR: iBoost energy forecast missing from chart JSON: {power_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + + # ------------------------------------------------------------------------- + print("Test: cost chart JSON exposes actual and optimised costs with currency") + original_dashboard_values = my_predbat.dashboard_values + original_currency_symbols = my_predbat.currency_symbols + try: + forecast_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + my_predbat.prefix + ".cost_today": {"attributes": {"results": {forecast_stamp: 125.0}}}, + my_predbat.prefix + ".metric": {"attributes": {"results": {forecast_stamp: 240.0}}}, + my_predbat.prefix + ".best_metric": {"attributes": {"results": {forecast_stamp: 180.0}}}, + } + + for currency_symbols, expected_major, expected_minor in (("£p", "£", "p"), ("$c", "$", "c"), (["€", "c"], "€", "c")): + my_predbat.currency_symbols = currency_symbols + cost_data = web.get_cost_chart_data() + + if not cost_data["ready"]: + print(" ERROR: populated optimised forecast should mark cost chart data ready") + failed += 1 + if cost_data["series"]["actual"].get(forecast_stamp) != 125.0: + print(f" ERROR: actual cost missing from chart JSON: {cost_data}") + failed += 1 + if cost_data["series"]["optimized"].get(forecast_stamp) != 180.0: + print(f" ERROR: optimised cost missing from chart JSON: {cost_data}") + failed += 1 + if cost_data["currency_symbol"] != expected_major or cost_data["currency_unit"] != expected_minor: + print(f" ERROR: cost chart currency metadata is incorrect for {currency_symbols}: {cost_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + my_predbat.currency_symbols = original_currency_symbols + + # ------------------------------------------------------------------------- + print("Test: rates chart JSON exposes tariff series and configured currency") + original_dashboard_values = my_predbat.dashboard_values + original_currency_symbols = my_predbat.currency_symbols + original_history_wrapper = web.get_history_wrapper + try: + forecast_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.currency_symbols = ["€", "c"] + my_predbat.dashboard_values = { + my_predbat.prefix + ".rates": {"attributes": {"results": {forecast_stamp: 21.5}}}, + my_predbat.prefix + ".rates_export": {"attributes": {"results": {forecast_stamp: 12.0}}}, + my_predbat.prefix + ".rates_gas": {"attributes": {"results": {forecast_stamp: 7.25}}}, + } + web.get_history_wrapper = lambda *args, **kwargs: [[{"state": 18.5, "last_updated": forecast_stamp}]] + + rates_data = web.get_rates_chart_data() + + if not rates_data["ready"]: + print(" ERROR: populated tariffs should mark rates chart data ready") + failed += 1 + if rates_data["series"]["import"].get(forecast_stamp) != 21.5: + print(f" ERROR: import tariff missing from rates chart JSON: {rates_data}") + failed += 1 + if rates_data["series"]["export"].get(forecast_stamp) != 12.0: + print(f" ERROR: export tariff missing from rates chart JSON: {rates_data}") + failed += 1 + if rates_data["currency_symbol"] != "€" or rates_data["currency_unit"] != "c": + print(f" ERROR: rates chart currency metadata is incorrect: {rates_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + my_predbat.currency_symbols = original_currency_symbols + web.get_history_wrapper = original_history_wrapper + + # ------------------------------------------------------------------------- + print("Test: in-day chart JSON exposes cumulative load forecasts and adjustment history") + original_dashboard_values = my_predbat.dashboard_values + original_history_wrapper = web.get_history_wrapper + try: + forecast_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + my_predbat.prefix + ".load_energy_actual": {"attributes": {"results": {forecast_stamp: 4.2}}}, + my_predbat.prefix + ".load_energy_predicted": {"attributes": {"results": {forecast_stamp: 8.5}}}, + my_predbat.prefix + ".load_energy_adjusted": {"attributes": {"results": {forecast_stamp: 9.1}}}, + } + web.get_history_wrapper = lambda *args, **kwargs: [[{"state": 7.5, "last_updated": forecast_stamp}]] + + inday_data = web.get_inday_chart_data() + + if not inday_data["ready"]: + print(" ERROR: populated cumulative load data should mark in-day chart data ready") + failed += 1 + if inday_data["series"]["actual"].get(forecast_stamp) != 4.2: + print(f" ERROR: actual cumulative load missing from in-day chart JSON: {inday_data}") + failed += 1 + if inday_data["series"]["adjusted"].get(forecast_stamp) != 9.1: + print(f" ERROR: adjusted load forecast missing from in-day chart JSON: {inday_data}") + failed += 1 + if 7.5 not in inday_data["series"]["adjustment_factor"].values(): + print(f" ERROR: in-day adjustment history missing from chart JSON: {inday_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + web.get_history_wrapper = original_history_wrapper + + # ------------------------------------------------------------------------- + print("Test: solar chart JSON combines seven-day history with today and tomorrow forecasts") + original_dashboard_values = my_predbat.dashboard_values + original_history_wrapper = web.get_history_wrapper + try: + history_stamp = (my_predbat.now_utc - timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%S%z") + today_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + tomorrow_stamp = (my_predbat.now_utc + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + "sensor." + my_predbat.prefix + "_pv_today": {"attributes": {"detailedForecast": [{"period_start": today_stamp, "pv_estimate": 2.1, "pv_estimate10": 1.4, "pv_estimate90": 2.8, "pv_estimateCL": 2.3}]}}, + "sensor." + my_predbat.prefix + "_pv_tomorrow": {"attributes": {"detailedForecast": [{"period_start": tomorrow_stamp, "pv_estimate": 3.0, "pv_estimate10": 2.0, "pv_estimate90": 3.8, "pv_estimateCL": 3.2}]}}, + } + + def solar_history(entity, *args, **kwargs): + if entity == my_predbat.prefix + ".pv_power": + return [[{"state": 1.9, "last_updated": history_stamp}]] + if entity == my_predbat.prefix + ".pv_energy_h0": + return [[{"state": 4.6, "last_updated": history_stamp}]] + if entity == "sensor." + my_predbat.prefix + "_pv_today": + return [[{"state": 8.0, "last_updated": history_stamp, "attributes": {"totalCL": 8.0, "remainingCL": 3.1}}]] + return [[{"state": 1.8, "last_updated": history_stamp, "attributes": {"nowCL": 2.0}}]] + + web.get_history_wrapper = solar_history + solar_data = web.get_solar_chart_data() + + if not solar_data["ready"]: + print(" ERROR: populated measured and forecast solar data should mark the chart ready") + failed += 1 + if 1.9 not in solar_data["series"]["actual"].values(): + print(f" ERROR: measured solar history missing from chart JSON: {solar_data}") + failed += 1 + if 2.3 not in solar_data["series"]["forecast_calibrated"].values(): + print(f" ERROR: today's calibrated solar forecast missing from chart JSON: {solar_data}") + failed += 1 + if 3.2 not in solar_data["series"]["forecast_calibrated"].values(): + print(f" ERROR: tomorrow's calibrated solar forecast missing from chart JSON: {solar_data}") + failed += 1 + if 2.0 not in solar_data["series"]["forecast_history_calibrated"].values(): + print(f" ERROR: calibrated forecast history missing from chart JSON: {solar_data}") + failed += 1 + if 4.6 not in solar_data["series"]["energy_actual"].values() or 4.9 not in solar_data["series"]["energy_forecast"].values(): + print(f" ERROR: cumulative solar accuracy series missing from chart JSON: {solar_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + web.get_history_wrapper = original_history_wrapper + + # ------------------------------------------------------------------------- + print("Test: savings chart JSON exposes daily and cumulative values in major currency units") + original_currency_symbols = my_predbat.currency_symbols + original_history_wrapper = web.get_history_wrapper + total_entities = { + my_predbat.prefix + ".savings_total_predbat": 301, + my_predbat.prefix + ".savings_total_pvbat": 450, + } + original_total_states = {entity: my_predbat.ha_interface.dummy_items.get(entity) for entity in total_entities} + try: + history_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.currency_symbols = ["€", "c"] + web.get_history_wrapper = lambda entity, *args, **kwargs: [] if ".savings_total_" in entity else [[{"state": 125, "last_updated": history_stamp}]] + for entity, value in total_entities.items(): + my_predbat.ha_interface.dummy_items[entity] = {"state": value, "attributes": {}} + + savings_data = web.get_savings_chart_data() + + if not savings_data["ready"]: + print(" ERROR: populated savings history should mark the chart ready") + failed += 1 + if 1.25 not in savings_data["series"]["daily_predbat"].values(): + print(f" ERROR: savings chart did not convert minor units to major units: {savings_data}") + failed += 1 + if 3.01 not in savings_data["series"]["total_predbat"].values() or 4.5 not in savings_data["series"]["total_pv_battery"].values(): + print(f" ERROR: current cumulative savings are missing when recorder history is empty: {savings_data}") + failed += 1 + if savings_data["currency_symbol"] != "€": + print(f" ERROR: savings chart currency metadata is incorrect: {savings_data}") + failed += 1 + finally: + my_predbat.currency_symbols = original_currency_symbols + web.get_history_wrapper = original_history_wrapper + for entity, value in original_total_states.items(): + if value is None: + my_predbat.ha_interface.dummy_items.pop(entity, None) + else: + my_predbat.ha_interface.dummy_items[entity] = value + + # ------------------------------------------------------------------------- + print("Test: battery degradation chart JSON includes history and today's sensor values") + original_dashboard_values = my_predbat.dashboard_values + original_history_wrapper = web.get_history_wrapper + original_battery_scaling_auto = my_predbat.battery_scaling_auto + try: + sensor_id = "sensor." + my_predbat.prefix + "_soc_max_calculated" + history_stamp = (my_predbat.now_utc - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + sensor_id: { + "state": 9.2, + "attributes": {"nominal_capacity": 10.0, "degradation_percent": 8.0}, + } + } + my_predbat.battery_scaling_auto = True + web.get_history_wrapper = lambda *args, **kwargs: [ + [ + { + "state": 9.3, + "last_updated": history_stamp, + "attributes": {"nominal_capacity": 10.0, "degradation_percent": 7.0}, + } + ] + ] + + degradation_data = web.get_battery_degradation_chart_data() + inverter = degradation_data["inverters"][0] + today = my_predbat.now_utc.strftime("%Y-%m-%d") + + if not degradation_data["ready"] or inverter["calculated"].get(today) != 9.2: + print(f" ERROR: current calculated capacity missing from degradation chart JSON: {degradation_data}") + failed += 1 + if inverter["nominal"].get(today) != 10.0 or inverter["degradation"].get(today) != 8.0: + print(f" ERROR: current battery attributes missing from degradation chart JSON: {degradation_data}") + failed += 1 + if not degradation_data["automatic_scaling"]: + print(f" ERROR: battery scaling state missing from degradation chart JSON: {degradation_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + my_predbat.battery_scaling_auto = original_battery_scaling_auto + web.get_history_wrapper = original_history_wrapper + + # ------------------------------------------------------------------------- + print("Test: marginal costs chart JSON combines history and forecast") + original_dashboard_values = my_predbat.dashboard_values + original_history_with_now_attrs = web.get_history_with_now_attrs + try: + sensor_id = "sensor." + my_predbat.prefix + "_marginal_energy_costs" + time_labels = [(my_predbat.now_utc + timedelta(hours=hours)).strftime("%H:%M") for hours in range(0, 13, 2)] + my_predbat.dashboard_values = { + sensor_id: { + "attributes": { + "matrix": {kwh: {stamp: kwh * 10 + index for index, stamp in enumerate(time_labels)} for kwh in (1, 2, 4, 8)}, + "grid_import": {stamp: 20 + index for index, stamp in enumerate(time_labels)}, + "grid_export": {stamp: 5 + index for index, stamp in enumerate(time_labels)}, + "rate_now_low_consumption": 10, + "rate_now_med_consumption": 20, + "rate_now_high_consumption": 40, + "rate_now_ev_consumption": 80, + } + }, + "binary_sensor.{}_marginal_rate_now_low_is_cheap".format(my_predbat.prefix): {"state": "on"}, + } + web.get_history_with_now_attrs = lambda *args, **kwargs: [] + + marginal_data = web.get_marginal_costs_chart_data() + + if not marginal_data["ready"] or marginal_data["levels"][0]["current_cost"] != 10: + print(f" ERROR: marginal cost summary is incomplete: {marginal_data}") + failed += 1 + if len(marginal_data["levels"][0]["series"]) != 7 or len(marginal_data["grid_import"]) != 7: + print(f" ERROR: marginal forecast series is incomplete: {marginal_data}") + failed += 1 + if not marginal_data["levels"][0]["cheap"] or marginal_data["currency_unit"] != my_predbat.currency_symbols[1]: + print(f" ERROR: marginal cost metadata is incomplete: {marginal_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + web.get_history_with_now_attrs = original_history_with_now_attrs + + # ------------------------------------------------------------------------- + print("Test: carbon chart JSON exposes actual, base, optimised and intensity series") + original_dashboard_values = my_predbat.dashboard_values + original_carbon_intensity = my_predbat.carbon_intensity + try: + forecast_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + my_predbat.prefix + ".carbon_today": {"attributes": {"results": {forecast_stamp: 8000}}}, + my_predbat.prefix + ".carbon": {"attributes": {"results": {forecast_stamp: 9200}}}, + my_predbat.prefix + ".carbon_best": {"attributes": {"results": {forecast_stamp: 8700}}}, + } + my_predbat.carbon_intensity = {0: 180, 30: 150} + + carbon_data = web.get_carbon_chart_data() + + if not carbon_data["ready"] or carbon_data["series"]["actual"].get(forecast_stamp) != 8000: + print(f" ERROR: actual household carbon is missing from chart JSON: {carbon_data}") + failed += 1 + if carbon_data["series"]["base"].get(forecast_stamp) != 9200 or carbon_data["series"]["optimized"].get(forecast_stamp) != 8700: + print(f" ERROR: carbon forecasts are missing from chart JSON: {carbon_data}") + failed += 1 + if sorted(carbon_data["series"]["intensity"].values()) != [150, 180]: + print(f" ERROR: grid carbon intensity is missing from chart JSON: {carbon_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + my_predbat.carbon_intensity = original_carbon_intensity + + # ------------------------------------------------------------------------- + print("Test: load ML chart JSON exposes energy, converted power and car-adjusted load") + original_dashboard_values = my_predbat.dashboard_values + original_history_wrapper = web.get_history_wrapper + original_history_with_now_attrs = web.get_history_with_now_attrs + original_car_configured = my_predbat.car_charging_power_configured + try: + first_stamp = my_predbat.now_utc.strftime("%Y-%m-%dT%H:%M:%S%z") + second_stamp = (my_predbat.now_utc + timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%S%z") + my_predbat.dashboard_values = { + "sensor." + my_predbat.prefix + "_load_ml_forecast": {"attributes": {"results": {first_stamp: 1.0, second_stamp: 1.5}}}, + my_predbat.prefix + ".pv_power_best": {"attributes": {"results": {second_stamp: 2.5}}}, + "sensor." + my_predbat.prefix + "_temperature": {"attributes": {"results": {second_stamp: 12.0}}}, + } + stats = [[{"last_updated": first_stamp, "attributes": {"load_today": 3.0, "load_today_h1": 2.8, "load_today_h8": 2.6, "power_today": 0.8, "power_today_h1": 0.7, "power_today_h8": 0.6}}]] + web.get_history_with_now_attrs = lambda *args, **kwargs: stats + + def load_ml_history(entity, *args, **kwargs): + value = 1.2 if entity.endswith(".load_power") else 0.4 if entity.endswith(".car_charging_power") else 2.0 + return [[{"state": value, "last_updated": first_stamp}]] + + web.get_history_wrapper = load_ml_history + my_predbat.car_charging_power_configured = True + load_ml_data = web.get_load_ml_chart_data() + + if not load_ml_data["ready"] or 3.0 not in load_ml_data["series"]["energy_actual"].values(): + print(f" ERROR: learned load energy is missing from chart JSON: {load_ml_data}") + failed += 1 + if load_ml_data["series"]["power_forecast"].get(second_stamp) != 1.0: + print(f" ERROR: cumulative ML energy was not converted to interval power: {load_ml_data}") + failed += 1 + if 0.8 not in load_ml_data["series"]["power_actual_less_car"].values() or not load_ml_data["car_configured"]: + print(f" ERROR: configured car power was not removed from measured load: {load_ml_data}") + failed += 1 + finally: + my_predbat.dashboard_values = original_dashboard_values + web.get_history_wrapper = original_history_wrapper + web.get_history_with_now_attrs = original_history_with_now_attrs + my_predbat.car_charging_power_configured = original_car_configured + # ------------------------------------------------------------------------- print("Test: render_chart() targets a percent-unit tagname via getElementById, not a CSS id selector") html = web.render_chart(series_data, "%", "SoC Chart", now_str, tagname="chart_%") diff --git a/apps/predbat/tests/test_web_chat.py b/apps/predbat/tests/test_web_chat.py index 3b9032513..efc1bea16 100644 --- a/apps/predbat/tests/test_web_chat.py +++ b/apps/predbat/tests/test_web_chat.py @@ -119,6 +119,7 @@ def get_component(self, name): paths = {str(route.resource.canonical) for route in app.router.routes()} expected_routes = [ "/chat", + "/legacy_chat", "/chat/conversations", "/chat/history", "/chat/send", @@ -165,6 +166,18 @@ def get_component(self, name): if expected not in paths: print("ERROR: route {} was not registered with a chat component present, got {}".format(expected, sorted(paths))) failed = True + + modern_interface = _make_web(my_predbat) + modern_interface.get_web_ui = lambda: "modern" + modern_app = aiohttp_web.Application() + modern_interface._register_chat_routes(modern_app) + modern_routes = {(route.method, route.resource.canonical): route.handler for route in modern_app.router.routes()} + if modern_routes[("GET", "/chat")] != modern_interface.html_modern_ui: + print("ERROR: /chat should serve the modern UI when web_ui is modern") + failed = True + if modern_routes[("GET", "/legacy_chat")] != modern_interface.chat_page.html_chat: + print("ERROR: /legacy_chat should retain the complete existing Chat page") + failed = True finally: my_predbat.components = original_components diff --git a/apps/predbat/tests/test_web_discovery.py b/apps/predbat/tests/test_web_discovery.py index 4333e5308..de2fc8a34 100644 --- a/apps/predbat/tests/test_web_discovery.py +++ b/apps/predbat/tests/test_web_discovery.py @@ -23,6 +23,7 @@ """ import asyncio +import json from coordinator import Coordinator from web import WebInterface @@ -343,7 +344,7 @@ def test_discovery_page_escapes_rendered_values(my_predbat): def test_discovery_route_is_registered(my_predbat): - """/discovery is wired into the router, so the nav link actually resolves.""" + """The Discovery page and its JSON source are wired into the router.""" print("**** test_discovery_route_is_registered ****") import inspect @@ -352,11 +353,37 @@ def test_discovery_route_is_registered(my_predbat): if '"/discovery"' not in source: print(" ERROR: expected /discovery to be registered on the router") failed = True + if '"/api/discovery"' not in source: + print(" ERROR: expected /api/discovery to be registered on the router") + failed = True if not failed: print("PASS: the /discovery route is registered") return failed +def test_discovery_api_is_redacted_by_default(my_predbat): + """The modern UI API only returns identifying values when raw mode is explicit.""" + print("**** test_discovery_api_is_redacted_by_default ****") + failed = False + saved = getattr(my_predbat, "components", None) + try: + w = _make_web(my_predbat, _real_coordinator(my_predbat)) + redacted = json.loads(asyncio.run(w.html_api_discovery(FakeRequest())).text) + raw = json.loads(asyncio.run(w.html_api_discovery(FakeRequest({"raw": "1"}))).text) + if SAMPLE_MPAN in json.dumps(redacted): + print(" ERROR: the default API response exposed an MPAN") + failed = True + if SAMPLE_MPAN not in json.dumps(raw): + print(" ERROR: raw mode did not return the MPAN") + failed = True + finally: + my_predbat.components = saved + + if not failed: + print("PASS: the Discovery API is redacted by default") + return failed + + def run_web_discovery_tests(my_predbat): """Run every discovery web page test.""" failed = False @@ -368,4 +395,5 @@ def run_web_discovery_tests(my_predbat): failed |= test_discovery_page_renders_a_field_it_was_never_taught(my_predbat) failed |= test_discovery_page_escapes_rendered_values(my_predbat) failed |= test_discovery_route_is_registered(my_predbat) + failed |= test_discovery_api_is_redacted_by_default(my_predbat) return failed diff --git a/apps/predbat/tests/test_web_functions.py b/apps/predbat/tests/test_web_functions.py index 45c0a8392..14a6e930f 100644 --- a/apps/predbat/tests/test_web_functions.py +++ b/apps/predbat/tests/test_web_functions.py @@ -36,6 +36,23 @@ def run_web_functions_tests(my_predbat): web = make_web(my_predbat) prefix = my_predbat.prefix + print("Test: legacy UI is the default and the bundled modern UI is selectable") + had_web_ui = "web_ui" in my_predbat.args + original_web_ui = my_predbat.args.get("web_ui") + my_predbat.args.pop("web_ui", None) + if web.get_web_ui() != "legacy": + print(" ERROR: missing web_ui should select the legacy interface") + failed += 1 + my_predbat.args["web_ui"] = "modern" + modern_response = asyncio.run(web.html_plan(None)) + if modern_response.status != 200 or '
' not in modern_response.text: + print(" ERROR: web_ui modern did not serve the bundled React interface") + failed += 1 + if had_web_ui: + my_predbat.args["web_ui"] = original_web_ui + else: + my_predbat.args.pop("web_ui", None) + charging_entity = "binary_sensor." + prefix + "_charging" exporting_entity = "binary_sensor." + prefix + "_exporting" soc_entity = prefix + ".soc_kw" @@ -326,6 +343,10 @@ def run_compare_empty_state_tests(my_predbat, web): if "7 day rolling average chart loading (please wait)" in text: print(f" ERROR: should not show the stuck '7 day rolling average' message when nothing is configured") failed += 1 + data = web.get_compare_data() + if data["configured"] or data["ready"] or data["tariffs"]: + print(f" ERROR: modern Compare API should return a clean unconfigured state") + failed += 1 # ------------------------------------------------------------------------- print("Test: a configured but not-yet-computed compare_list keeps the genuine loading message") @@ -338,6 +359,10 @@ def run_compare_empty_state_tests(my_predbat, web): if "Loading chart (please wait)" not in text: print(f" ERROR: expected the genuine loading message when compare_list is set but not yet computed") failed += 1 + data = web.get_compare_data() + if not data["configured"] or data["ready"] or data["tariffs"][0]["name"] != "Test tariff": + print(f" ERROR: modern Compare API should expose configured tariffs before results exist") + failed += 1 my_predbat.args = original_args diff --git a/apps/predbat/tests/test_web_if.py b/apps/predbat/tests/test_web_if.py index 4c3abc360..300be86d4 100644 --- a/apps/predbat/tests/test_web_if.py +++ b/apps/predbat/tests/test_web_if.py @@ -82,6 +82,7 @@ def run_test_web_if(my_predbat): ("GET", "/entity"), ("POST", "/entity"), ("POST", "/config"), + ("POST", "/api/config"), ("GET", "/dash"), ("POST", "/dash"), ("GET", "/components"), @@ -105,15 +106,22 @@ def run_test_web_if(my_predbat): ("POST", "/inverter_refresh"), ("GET", "/api/state"), ("GET", "/api/ping"), + ("GET", "/api/apps_schema"), + ("GET", "/api/apps_yaml"), + ("POST", "/api/apps_yaml"), ("POST", "/api/state"), ("POST", "/api/service"), ("GET", "/api/log"), ("GET", "/api/entities"), + ("GET", "/api/config"), + ("GET", "/api/components"), + ("GET", "/api/browse"), ("POST", "/api/login"), ("GET", "/browse"), ("GET", "/download"), ("GET", "/internals"), ("GET", "/api/internals"), + ("GET", "/api/internals/threads"), ("GET", "/api/internals/download"), ("GET", "/api/status"), ("GET", "/metrics"), @@ -199,6 +207,72 @@ def _error_total(page): # Test POST endpoints print("\n**** Testing POST endpoints ****") + components_response = requests.get(base_url + "/api/components") + components_data = components_response.json() + component_ids = {component.get("id") for component in components_data.get("components", [])} + if components_response.status_code != 200 or not {"db", "web"}.issubset(component_ids): + print("ERROR: /api/components did not return registered component status") + failed = 1 + + config_response = requests.get(base_url + "/api/config") + config_items = config_response.json().get("items", []) + if config_response.status_code != 200 or not config_items or not all(item.get("entity") and item.get("description") for item in config_items): + print("ERROR: /api/config did not return enabled configuration entities") + failed = 1 + + browse_response = requests.get(base_url + "/api/browse") + browse_files = {item.get("name") for item in browse_response.json().get("files", [])} + if browse_response.status_code != 200 or "apps.yaml" not in browse_files: + print("ERROR: /api/browse did not return files from the Predbat working directory") + failed = 1 + if requests.get(base_url + "/api/browse", params={"path": "../"}).status_code != 403: + print("ERROR: /api/browse allowed directory traversal") + failed = 1 + + my_predbat.set_state_wrapper("update.predbat_version", "on", attributes={"in_progress": True, "latest_version": "v99.0.0"}) + update_status = requests.get(base_url + "/api/status").json() + if update_status.get("updating") is not True: + print("ERROR: /api/status did not report an in-progress Predbat update") + failed = 1 + if update_status.get("update_available") is not True or update_status.get("latest_version") != "v99.0.0": + print("ERROR: /api/status did not report the available Predbat update") + failed = 1 + my_predbat.set_state_wrapper("update.predbat_version", "on", attributes={"in_progress": False}) + + print("Test apps.yaml editor API and generated schema") + schema_response = requests.get(base_url + "/api/apps_schema") + schema = schema_response.json() + app_schema = schema.get("$defs", {}).get("predbatApp", {}) + properties = app_schema.get("properties", {}) + if schema_response.status_code != 200 or schema.get("required") != ["pred_bat"] or not {"module", "class", "load_today", "active", "grid_power"}.issubset(properties): + print("ERROR: /api/apps_schema did not combine structural, APPS_SCHEMA and CONFIG_ITEMS keys") + failed = 1 + if properties.get("prefix", {}).get("default") != "predbat" or properties.get("timezone", {}).get("default") != "Europe/London": + print("ERROR: /api/apps_schema did not include structural defaults") + failed = 1 + if properties.get("active", {}).get("default") is not False or not properties.get("load_today", {}).get("description"): + print("ERROR: /api/apps_schema did not enrich settings with defaults and descriptions") + failed = 1 + load_today_options = properties.get("load_today", {}).get("oneOf", []) + grid_power_options = properties.get("grid_power", {}).get("oneOf", []) + if not all(any(option.get("x-ha-entity") or option.get("items", {}).get("x-ha-entity") for option in options) for options in (load_today_options, grid_power_options)): + print("ERROR: /api/apps_schema did not identify Home Assistant entity fields") + failed = 1 + + yaml_response = requests.get(base_url + "/api/apps_yaml") + yaml_data = yaml_response.json() + save_response = requests.post(base_url + "/api/apps_yaml", json=yaml_data) + if yaml_response.status_code == 200 and save_response.status_code == 200 and save_response.json().get("saved"): + accessed_endpoints.add(("POST", "/api/apps_yaml")) + else: + print("ERROR: /api/apps_yaml could not round-trip apps.yaml") + failed = 1 + + stale_response = requests.post(base_url + "/api/apps_yaml", json={"content": yaml_data.get("content", ""), "checksum": "stale"}) + if stale_response.status_code != 409: + print("ERROR: /api/apps_yaml did not reject a stale editor save") + failed = 1 + # Test /compare POST print("Test POST /compare") address = base_url + "/compare" @@ -247,6 +321,13 @@ def _error_total(page): print("ERROR: Unexpected response from /config: {} - {}".format(res.status_code, res.text)) failed = 1 + res = requests.post(base_url + "/api/config", data=data) + if res.status_code in [200]: + accessed_endpoints.add(("POST", "/api/config")) + else: + print("ERROR: Unexpected response from /api/config: {} - {}".format(res.status_code, res.text)) + failed = 1 + # Test /dash POST print("Test POST /dash") address = base_url + "/dash" diff --git a/apps/predbat/tests/test_web_power_flow.py b/apps/predbat/tests/test_web_power_flow.py index 557aa3296..b86cb1edb 100644 --- a/apps/predbat/tests/test_web_power_flow.py +++ b/apps/predbat/tests/test_web_power_flow.py @@ -8,11 +8,14 @@ # pylint: disable=line-too-long # pylint: disable=attribute-defined-outside-init +import asyncio +import json import math import re +from types import SimpleNamespace from config import APPS_SCHEMA -from web import WebInterface +from web import WebInterface, build_apps_json_schema # Where each node is drawn in the diagram's SVG, keyed by the colour of the arm that reaches it HOUSE_CENTRE = (300, 200) @@ -331,6 +334,151 @@ def run_power_flow_geometry_tests(my_predbat, web): return failed +def run_overview_api_tests(my_predbat, web_interface): + """Check the live API adds complete optional Overview data without changing flow values.""" + failed = 0 + print("**** Running Overview API tests ****") + original_args = my_predbat.args.copy() + original_predheat = my_predbat.predheat + original_values = {name: getattr(my_predbat, name) for name in ("load_minutes_now", "pv_today_now", "import_today_now", "export_today_now", "cost_today_sofar")} + added_entities = [ + "weather.forecast_home", + "weather.overview_test", + "weather.predheat_test", + "sensor.predbat_pv_today", + "sensor.configured_pv_forecast_today", + "sensor.car_status", + "sensor.car_soc", + "sensor.car_energy", + "sensor.ashp_power", + "sensor.ashp_status", + "sensor.ashp_energy", + ] + original_entities = {name: my_predbat.ha_interface.dummy_items.get(name) for name in added_entities} + + try: + my_predbat.args.pop("predheat", None) + my_predbat.args.update( + { + "weather": "weather.overview_test", + "pv_forecast_today": "sensor.configured_pv_forecast_today", + "car_charging_planned": ["sensor.car_status"], + "car_charging_soc": ["sensor.car_soc"], + "car_charging_energy": "sensor.car_energy", + "ashp_enable": True, + "ashp_power": "sensor.ashp_power", + "ashp_status": "sensor.ashp_status", + "heat_energy": "sensor.ashp_energy", + } + ) + my_predbat.ha_interface.dummy_items.update( + { + "weather.forecast_home": {"state": "cloudy", "temperature": 12.0, "temperature_unit": "C"}, + "weather.overview_test": {"state": "sunny", "temperature": 17.5, "temperature_unit": "C"}, + "sensor.predbat_pv_today": {"state": 12.12, "unit_of_measurement": "kWh"}, + "sensor.configured_pv_forecast_today": {"state": 7.95, "unit_of_measurement": "kWh"}, + "sensor.car_status": {"state": "EV Connected"}, + "sensor.car_soc": {"state": 68, "unit_of_measurement": "%"}, + "sensor.car_energy": {"state": 4.2, "unit_of_measurement": "kWh"}, + "sensor.ashp_power": {"state": 1.25, "unit_of_measurement": "kW"}, + "sensor.ashp_status": {"state": "heating"}, + "sensor.ashp_energy": {"state": 5.6, "unit_of_measurement": "kWh"}, + } + ) + my_predbat.load_minutes_now = 10.1 + my_predbat.pv_today_now = 6.2 + my_predbat.import_today_now = 3.3 + my_predbat.export_today_now = 1.4 + my_predbat.cost_today_sofar = 87.5 + + response = asyncio.run(web_interface.html_api_power_flow(None)) + data = json.loads(response.text) + if data.get("weather") != {"state": "sunny", "temperature": 17.5, "temperature_unit": "C"}: + print(" ERROR: weather data was not returned from the standalone Overview weather setting: " f"{data.get('weather')!r}, configured={web_interface.get_arg('weather', default=None, indirect=False)!r}") + failed += 1 + if data.get("pv_forecast_today") != 7.95: + print(" ERROR: today's configured PV forecast was not returned") + failed += 1 + if data.get("car", {}).get("status") != "EV Connected" or data.get("car", {}).get("soc") != 68 or data.get("car", {}).get("energy_today") != 4.2: + print(" ERROR: EV status, state of charge or today's energy was not returned") + failed += 1 + if data.get("ashp") != {"power": 1250.0, "status": "heating", "energy_today": 5.6}: + print(" ERROR: the complete ASHP configuration was not returned with converted power") + failed += 1 + if data.get("totals") != {"load_today": 10.1, "pv_today": 6.2, "import_today": 3.3, "export_today": 1.4, "cost_today": 87.5}: + print(" ERROR: daily Overview totals did not match Predbat's current totals") + failed += 1 + + my_predbat.args.pop("ashp_status") + partial_data = json.loads(asyncio.run(web_interface.html_api_power_flow(None)).text) + if partial_data.get("ashp") != {"power": 1250.0, "status": None, "energy_today": 5.6}: + print(" ERROR: enabled ASHP should retain available values when an optional entity is missing") + failed += 1 + + my_predbat.args.pop("ashp_enable") + my_predbat.args.pop("weather") + my_predbat.args.pop("heat_energy") + my_predbat.args["predheat"] = { + "mode": "pump", + "weather": "weather.predheat_test", + "heating_energy": "sensor.ashp_energy", + } + my_predbat.predheat = SimpleNamespace(heat_energy_today=3.25) + my_predbat.ha_interface.dummy_items["weather.predheat_test"] = {"state": "rainy", "temperature": 9.5, "temperature_unit": "C"} + + predheat_data = json.loads(asyncio.run(web_interface.html_api_power_flow(None)).text) + if predheat_data.get("weather") != {"state": "rainy", "temperature": 9.5, "temperature_unit": "C"}: + print(" ERROR: Overview did not reuse the PredHeat weather entity") + failed += 1 + if predheat_data.get("ashp") != {"power": 1250.0, "status": None, "energy_today": 3.25}: + print(" ERROR: pump-mode PredHeat should enable ASHP and provide its calculated daily energy") + failed += 1 + + my_predbat.args["ashp_enable"] = False + disabled_data = json.loads(asyncio.run(web_interface.html_api_power_flow(None)).text) + if disabled_data.get("ashp") is not None: + print(" ERROR: ASHP should be hidden when it is disabled") + failed += 1 + finally: + my_predbat.args = original_args + my_predbat.predheat = original_predheat + for name, value in original_values.items(): + setattr(my_predbat, name, value) + for entity_id, value in original_entities.items(): + if value is None: + my_predbat.ha_interface.dummy_items.pop(entity_id, None) + else: + my_predbat.ha_interface.dummy_items[entity_id] = value + + for name in ("car_charging_energy", "car_charging_status", "car_charging_soc", "weather", "ashp_enable", "ashp_power", "ashp_status", "heat_energy", "ashp_energy_today"): + if name not in APPS_SCHEMA: + print(f" ERROR: {name} is missing from APPS_SCHEMA") + failed += 1 + + schema_properties = build_apps_json_schema()["$defs"]["predbatApp"]["properties"] + generic_description_starts = ("Predbat setting for ", "Enable or disable ", "Home Assistant entity or entities used for ") + for name, property_schema in schema_properties.items(): + description = property_schema.get("description", "") + if len(description) < 24 or description.startswith(generic_description_starts): + print(f" ERROR: {name} has an unhelpful apps.yaml schema description: {description!r}") + failed += 1 + + for name in ("car_charging_energy", "car_charging_status", "car_charging_soc", "weather", "ashp_power", "ashp_status", "heat_energy", "ashp_energy_today"): + encoded = json.dumps(schema_properties[name]) + if '"x-ha-entity": true' not in encoded: + print(f" ERROR: {name} is not marked for Home Assistant entity autocomplete") + failed += 1 + + predheat_properties = schema_properties["predheat"].get("properties", {}) + for name in ("weather", "heating_energy"): + if not predheat_properties.get(name, {}).get("x-ha-entity"): + print(f" ERROR: predheat.{name} is not marked for Home Assistant entity autocomplete") + failed += 1 + + print("**** Overview API tests completed ****") + return failed + + def run_web_power_flow_tests(my_predbat): """Car charging power input, published sensor and its arm of the power flow diagram.""" failed = 0 @@ -617,6 +765,7 @@ def run_web_power_flow_tests(my_predbat): failed += run_power_flow_colour_tests(my_predbat, web) failed += run_battery_icon_tests(my_predbat, web) failed += run_power_flow_geometry_tests(my_predbat, web) + failed += run_overview_api_tests(my_predbat, web) my_predbat.args = original_args my_predbat.load_power = original_load_power diff --git a/apps/predbat/utils.py b/apps/predbat/utils.py index 35033d4f8..190e285f7 100644 --- a/apps/predbat/utils.py +++ b/apps/predbat/utils.py @@ -891,6 +891,16 @@ def prune_today(data, now_utc, midnight_utc, prune=True, group=15, prune_future= return results +def normalise_entity_prefix(value): + """Return a Home Assistant-safe prefix for Predbat-generated entity IDs.""" + raw = str(value or "predbat").strip().lower() + prefix = re.sub(r"[^a-z0-9_]+", "_", raw) + prefix = re.sub(r"_+", "_", prefix).strip("_") + if not prefix or not prefix[0].isalpha(): + prefix = "predbat_{}".format(prefix).rstrip("_") + return prefix + + def is_entity_id(value): """ Whether a resolved apps.yaml value names a Home Assistant entity rather than being a literal. diff --git a/apps/predbat/web.py b/apps/predbat/web.py index 613c33289..dda1fece9 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -36,6 +36,9 @@ from io import StringIO import hashlib import copy +import mimetypes +import zipfile +from pathlib import PurePosixPath from ruamel.yaml import YAML from ruamel.yaml.scalarstring import DoubleQuotedScalarString @@ -95,7 +98,7 @@ from const import TIME_FORMAT, TIME_FORMAT_DAILY, TIME_FORMAT_HA, MANUAL_RATE_MAX_MINUTES, MANUAL_TIME_MAX_MINUTES from predbat import THIS_VERSION_DISPLAY from component_base import ComponentBase -from config import APPS_SCHEMA +from config import APPS_SCHEMA, CONFIG_ITEMS import debug_history from web_annual import AnnualPage from web_chat import WebChat @@ -109,6 +112,458 @@ DEBUG_HISTORY_DOWNLOAD_MAX = 16 +def _optional_float(value): + """Return a finite float for an optional Home Assistant value, otherwise None.""" + try: + result = float(value) + except (TypeError, ValueError): + return None + return result if result == result and abs(result) != float("inf") else None + + +def _apps_schema_value(spec): + """Convert one Predbat validation spec to JSON Schema.""" + scalar_types = { + "none": {"type": "null"}, + "integer": {"type": "integer"}, + "float": {"type": "number"}, + "string": {"type": "string"}, + "boolean": {"type": "boolean"}, + "dict": {"type": "object"}, + "sensor": {"type": "string", "x-ha-entity": True}, + "integer_list": {"type": "array", "items": {"type": "integer"}}, + "float_list": {"type": "array", "items": {"type": "number"}}, + "string_list": {"type": "array", "items": {"type": "string"}}, + "boolean_list": {"type": "array", "items": {"type": "boolean"}}, + "dict_list": {"type": "array", "items": {"type": "object"}}, + "sensor_list": {"type": "array", "items": {"type": ["string", "number", "boolean"], "x-ha-entity": True}}, + "int_float_dict": {"type": "object", "additionalProperties": {"type": "number"}}, + } + choices = [copy.deepcopy(scalar_types[item]) for item in spec.get("type", "string").split("|") if item in scalar_types] + if spec.get("or_auto"): + choices.append({"const": "auto"}) + schema = choices[0] if len(choices) == 1 else {"oneOf": choices} + if spec.get("allowed"): + schema = {"enum": spec["allowed"]} + return schema + + +APPS_SCHEMA_DESCRIPTIONS = { + "currency_symbols": "Currency symbols used throughout Predbat. Supply the major-unit symbol first and the minor-unit symbol second, for example ['£', 'p'] or ['$', 'c'].", + "db_enable": "Enables Predbat's local history database for longer-term statistics and charts.", + "db_days": "Number of days of Predbat history retained in the local database before older records are removed.", + "db_mirror_ha": "Copies relevant Home Assistant history into Predbat's local database so charts can use one consistent data source.", + "db_primary": "Uses Predbat's local database as the primary history source instead of Home Assistant where matching data is available.", + "threads": "Number of worker threads used to optimise the plan. Use 'auto' to match the host, 0 to disable parallel workers, or a fixed number to limit CPU use.", + "prediction_kernel_enable": "Uses Predbat's compiled prediction kernel for faster plan simulation. Disable only when diagnosing compatibility or calculation issues.", + "log_count": "Number of log files to retain, including the current predbat.log. Older rotated logs are deleted after this limit.", + "ha_url": "Base URL of the Home Assistant instance used when Predbat runs outside the add-on environment.", + "ha_key": "Home Assistant long-lived access token used to read entities and call services when Predbat connects remotely.", + "load_filter_threshold": "Maximum number of consecutive minutes with zero household load that Predbat fills from surrounding history. Set 1440 to disable gap filling.", + "web_port": "TCP port used by Predbat's built-in web server and modern dashboard.", + "web_ui": "Selects the dashboard served by Predbat: 'legacy' for the original interface or 'modern' for the new responsive interface.", + "chat": "Chat assistant configuration, including named providers, API endpoints, credentials, model selection and request timeout.", + "load_today": "One or more cumulative Home Assistant energy sensors reporting household consumption today in kWh. Multiple sensors are added together.", + "import_today": "One or more cumulative Home Assistant energy sensors reporting grid import today in kWh. Multiple sensors are added together.", + "export_today": "One or more cumulative Home Assistant energy sensors reporting grid export today in kWh. Multiple sensors are added together.", + "pv_today": "One or more cumulative Home Assistant energy sensors reporting solar generation today in kWh. Include every separate PV source that should be totalled.", + "load_forecast_only": "Uses the configured load_forecast data as the complete demand forecast instead of adding it to Predbat's historical household-load model.", + "load_forecast": "External forecast entities added to predicted household demand, such as a heat-pump forecast. Each source must expose timestamped incremental energy data.", + "ge_cloud_data": "Uses GivEnergy Cloud daily energy data instead of the configured load_today, import_today and export_today entities.", + "ge_cloud_serial": "GivEnergy inverter serial number used when requesting data from GivEnergy Cloud.", + "ge_cloud_key": "API credential used to authenticate requests to GivEnergy Cloud.", + "ge_cloud_direct": "Uses Predbat's direct GivEnergy Cloud integration for live inverter data and control.", + "ge_cloud_automatic": "Allows the GivEnergy Cloud integration to discover the inverter and populate its matching Predbat settings automatically.", + "ge_cloud_load_today_ignore": "Ignores GivEnergy Cloud's load-today total when automatic cloud configuration is active.", + "ge_cloud_automatic_evc": "Allows GivEnergy Cloud discovery to configure a connected GivEnergy EV charger automatically.", + "ge_cloud_evc_control": "Allows Predbat to send supported charging commands to the discovered GivEnergy EV charger.", + "ge_cloud_automatic_shared_ct": "Treats discovered GivEnergy inverters as sharing one grid current transformer when configuring power and energy sources.", + "ge_cloud_automatic_split_ct": "Treats discovered GivEnergy inverters as having separate grid current transformers and combines their readings.", + "ge_cloud_automatic_split_pv": "Combines separate PV readings discovered across multiple GivEnergy inverters.", + "num_inverters": "Number of battery inverters Predbat should model and control. Inverter-specific lists normally require one entry per inverter.", + "validate_config_retries": "Number of times Predbat retries unavailable Home Assistant entities while validating apps.yaml during startup.", + "validate_config_retry_minutes": "Minutes between startup configuration-validation retries when required entities are unavailable.", + "givtcp_rest": "GivTCP REST base URL for each inverter. Direct REST control is faster and more reliable than writing Home Assistant entities.", + "givtcp_automatic": "Allows Predbat to discover GivTCP REST entities and populate inverter settings automatically. Disable to keep all mappings under manual control.", + "charge_rate": "Writable Home Assistant number entity for each inverter's battery charge-rate limit.", + "discharge_rate": "Writable Home Assistant number entity for each inverter's battery discharge-rate limit.", + "battery_power": "Live battery power sensor for each inverter. Predbat uses its sign and magnitude to determine charging or discharging power.", + "pv_power": "Live solar generation power sensor for each inverter or PV source, normally reported in watts.", + "load_power": "Live household demand power sensor for each inverter or meter, normally reported in watts.", + "soc_kw": "Home Assistant sensor reporting usable energy currently stored in each battery, normally in kWh.", + "soc_max": "Home Assistant sensor reporting the usable full capacity of each battery in kWh.", + "reserve": "Writable reserve or minimum state-of-charge entity for each inverter.", + "inverter_mode": "Writable operating-mode selector for each inverter, used to switch between normal, charge and export behaviour.", + "inverter_time": "Entity reporting each inverter's internal clock, used to detect clock drift before programming timed slots.", + "inverter_type": "Predbat inverter-definition name for each inverter when it cannot be detected automatically.", + "charge_start_time": "Writable entity containing the programmed battery charge-window start time for each inverter.", + "charge_end_time": "Writable entity containing the programmed battery charge-window end time for each inverter.", + "charge_limit": "Writable target state-of-charge entity for each inverter's scheduled charge window.", + "scheduled_charge_enable": "Writable switch that enables or disables scheduled battery charging on each inverter.", + "scheduled_discharge_enable": "Writable switch that enables or disables scheduled battery discharge on each inverter.", + "discharge_start_time": "Writable entity containing the programmed forced-discharge start time for each inverter.", + "discharge_end_time": "Writable entity containing the programmed forced-discharge end time for each inverter.", + "battery_temperature": "Battery temperature sensor for each inverter, used when applying temperature-dependent charge limits.", + "battery_calibration": "Optional calibration-status entity for each inverter. Predbat pauses control while a battery calibration cycle is active.", + "pause_mode": "Optional writable inverter mode used to pause battery charge, discharge or both.", + "pause_start_time": "Optional writable start time for an inverter pause window.", + "pause_end_time": "Optional writable end time for an inverter pause window.", + "inverter_limit": "Maximum combined AC power of each inverter in watts, used to cap modelled import, generation and discharge.", + "inverter_can_charge_during_export": "Allows an inverter to charge one battery while another inverter is deliberately exporting.", + "inverter_freeze_export_discharge_rate": "Discharge rate used when implementing a freeze-export action on inverters that require a small forced-discharge setting.", + "pv_ac_limit": "Maximum AC output from connected PV in watts. Predbat uses it to cap modelled solar generation after inverter conversion.", + "inverter_limit_charge": "Maximum AC battery charging power for each inverter in watts.", + "inverter_limit_charge_dc": "Maximum DC battery charging power for each inverter in watts.", + "inverter_limit_discharge": "Maximum battery discharge power for each inverter in watts.", + "inverter_limit_export": "Maximum inverter export power for each inverter in watts.", + "battery_rate_max": "Maximum normal battery charge and discharge rate for each inverter, used by the optimiser when modelling slots.", + "export_limit": "Site or inverter export limit in watts. Predbat prevents the plan from modelling export above this value.", + "inverter_battery_rate_min": "Minimum practical battery charge or discharge rate in watts; lower planned rates are treated as ineffective.", + "inverter_reserve_max": "Highest reserve percentage Predbat may program while protecting a planned battery minimum.", + "battery_charge_power_curve": "Optional state-of-charge to power multiplier curve used to model battery charging slowdown. Use 'auto' to learn it from history.", + "battery_discharge_power_curve": "Optional state-of-charge to power multiplier curve used to model battery discharge limits. Use 'auto' to learn it from history.", + "battery_charge_power_curve_default": "Fallback state-of-charge to charge-power curve used when an automatic curve has insufficient data.", + "battery_discharge_power_curve_default": "Fallback state-of-charge to discharge-power curve used when an automatic curve has insufficient data.", + "clock_skew": "Manual inverter clock correction in minutes, applied when interpreting or programming inverter time windows.", + "predbat_repository": "Git repository used by Predbat's updater. Change this only to test a fork or development branch.", + "solcast_api_key": "Solcast API key used to download site-level solar forecasts directly.", + "solcast_host": "Solcast API base URL. Leave at the documented default unless using a compatible proxy or alternative endpoint.", + "solcast_poll_hours": "Minimum hours between direct Solcast forecast downloads, used to stay within API request limits.", + "solcast_sites": "Solcast rooftop site resource IDs to download and combine into the PV forecast.", + "pv_forecast_today": "Home Assistant energy entity containing today's total forecast solar generation in kWh, used by Overview and forecast comparison views.", + "pv_array_kwp": "Installed DC solar-array capacity in kWp, used to stop uncertainty modelling from exceeding the physical panel capacity.", + "pv_forecast_tomorrow": "Home Assistant energy entity containing tomorrow's total forecast solar generation in kWh.", + "pv_forecast_d3": "Home Assistant energy entity containing the solar forecast total for the third forecast day in kWh.", + "pv_forecast_d4": "Home Assistant energy entity containing the solar forecast total for the fourth forecast day in kWh.", + "car_charging_power": "Live EV-charger power entity or list of entities, used to show charging power and separate EV demand from household load.", + "num_cars": "Number of EVs or chargers Predbat should model. Car-specific lists normally require one entry per car.", + "car_charging_planned": "Entity for each car indicating whether a charging session is planned by its external charger or tariff integration.", + "car_charging_planned_response": "State values from car_charging_planned that Predbat should interpret as a planned charging session.", + "car_charging_now": "Entity for each car indicating whether it is currently charging.", + "car_charging_now_response": "State values from car_charging_now that Predbat should interpret as actively charging.", + "car_charging_battery_size": "Usable battery capacity for each EV in kWh, supplied as a value or Home Assistant sensor.", + "car_charging_limit": "Desired EV charge limit for each car, normally a percentage reported by the vehicle or charger.", + "car_charging_exclusive": "For each car, prevents its planned charging slots from overlapping other modelled EV charging.", + "carbon_intensity": "Home Assistant entity reporting current or forecast grid carbon intensity, normally in gCO2/kWh.", + "carbon_postcode": "UK postcode region used to request local carbon-intensity forecasts when no entity is configured.", + "carbon_automatic": "Automatically obtains carbon-intensity data for carbon-aware plan calculations.", + "axle_pence_per_kwh": "Reward rate in pence per kWh used to value Axle Energy flexibility events in the plan.", + "octopus_intelligent_slot": "Entity for each car containing Intelligent Octopus dispatch slots or whether a smart-charge slot is active.", + "octopus_ready_time": "Entity for each car containing the time by which Intelligent Octopus should complete charging.", + "octopus_charge_limit": "Entity for each car containing the energy or state-of-charge target supplied by Intelligent Octopus.", + "octopus_slot_low_rate": "Treats all Intelligent Octopus dispatch slots as low-rate import periods in the battery plan.", + "octopus_slot_max": "Maximum number of Intelligent Octopus dispatch slots Predbat imports into one plan.", + "octopus_saving_session_octopoints_per_penny": "Conversion rate between Octopoints and pence used when valuing Saving Session rewards.", + "octopus_saving_session_min_octopoints_per_kwh": "Minimum offered Octopoints per kWh required before Predbat joins a Saving Session automatically.", + "octopus_saving_session_rate": "Fallback Saving Session reward rate used when the event does not provide one.", + "octopus_free_url": "Octopus API URL used to retrieve Free Electricity session events.", + "octopus_night_times": "Time ranges that should be treated as Octopus overnight periods for tariff and charging logic.", + "metric_octopus_import": "Home Assistant entity containing Octopus import cost or consumption data used for metric comparisons.", + "metric_octopus_export": "Home Assistant entity containing Octopus export income or energy data used for metric comparisons.", + "octopus_api_key": "Octopus Energy API key used to retrieve account tariffs, rates and supported smart-tariff data.", + "octopus_api_account": "Octopus Energy account number associated with the configured API key.", + "rates_import": "Manual import-tariff periods. Each item defines a rate and its start and end time.", + "rates_export": "Manual export-tariff periods. Each item defines a rate and its start and end time.", + "alerts": "Alert-feed configuration used to adjust planning for forecast grid events or other supported warnings.", + "rates_import_octopus_url": "Octopus product API URL used to retrieve future electricity import rates.", + "rates_export_octopus_url": "Octopus product API URL used to retrieve future electricity export rates.", + "rates_import_override": "Import-rate overrides applied to selected times after the normal tariff has been loaded.", + "rates_export_override": "Export-rate overrides applied to selected times after the normal tariff has been loaded.", + "days_previous": "Historical day offsets used to build the household-load forecast, for example [1, 2, 7] for yesterday, two days ago and last week.", + "days_previous_weight": "Relative weight for each entry in days_previous when averaging historical household demand.", + "days_previous_auto": "Lets Predbat choose suitable historical days automatically instead of using only the configured day offsets.", + "forecast_hours": "Number of future hours of tariff, load and solar data Predbat fetches for planning.", + "notify_devices": "Home Assistant mobile-app notification services or device identifiers that should receive Predbat notifications.", + "battery_scaling": "Per-inverter capacity scaling values used to reconcile reported battery energy with usable capacity.", + "battery_scaling_auto": "Automatically learns battery capacity scaling from charge and discharge history.", + "import_export_scaling": "Scale factor applied when reconciling measured import and export energy with Predbat's model.", + "export_triggers": "Optional price or time rules that trigger export behaviour outside the normal optimised windows.", + "iboost_energy_today": "Home Assistant energy entity reporting today's hot-water diverter or iBoost consumption in kWh.", + "metric_octopus_gas": "Home Assistant entity containing Octopus gas cost or consumption data used for metric comparisons.", + "rates_gas": "Manual gas-tariff periods used by hot-water and gas-versus-electricity cost calculations.", + "futurerate_url": "Nord Pool or compatible future-rate API URL used before the supplier publishes final tariff prices.", + "futurerate_adjust_import": "Adjusts predicted future import prices to match the shape and level of recently published real prices.", + "futurerate_adjust_export": "Adjusts predicted future export prices to match the shape and level of recently published real prices.", + "futurerate_adjust_auto": "Automatically enables future-rate adjustment when enough published tariff data is available.", + "futurerate_peak_start": "Start time of the daily peak window used when shaping predicted future rates.", + "futurerate_peak_end": "End time of the daily peak window used when shaping predicted future rates.", + "octopus_region": "Octopus tariff region letter used when constructing product rate URLs automatically.", + "compare_list": "Tariff scenarios evaluated by the Compare page. Each entry has an id, display name and optional import or export rate definitions.", + "watch_list": "Additional Home Assistant entities copied into Predbat debug data so their values can be inspected during troubleshooting.", + "charge_start_service": "Home Assistant service call or ordered calls used to start battery charging on a custom inverter integration.", + "charge_stop_service": "Home Assistant service call or ordered calls used to stop battery charging on a custom inverter integration.", + "discharge_start_service": "Home Assistant service call or ordered calls used to start forced battery discharge on a custom inverter integration.", + "discharge_stop_service": "Home Assistant service call or ordered calls used to stop forced battery discharge on a custom inverter integration.", + "charge_freeze_service": "Home Assistant service call or ordered calls used to hold battery state of charge by preventing discharge.", + "discharge_freeze_service": "Home Assistant service call or ordered calls used to prevent charging while allowing normal discharge.", + "device_id": "Home Assistant device identifier associated with custom inverter service calls.", + "predheat": "PredHeat configuration block used to model building temperature and forecast electrical heating demand.", + "forecast_solar": "Forecast.Solar array definitions, including panel orientation, tilt, location and installed capacity.", + "forecast_solar_max_age": "Maximum age in hours of cached Forecast.Solar data before Predbat considers it stale and downloads it again.", + "forecast_solar_open_meteo_backup": "Uses Open-Meteo solar forecasting when Forecast.Solar data is unavailable or stale.", + "forecast_solar_open_meteo_first": "Prefers Open-Meteo solar forecasting and uses Forecast.Solar as the fallback source.", + "open_meteo_forecast": "Open-Meteo PV array definitions, including panel orientation, tilt, location and installed capacity.", + "open_meteo_forecast_max_age": "Maximum age in hours of cached Open-Meteo forecast data before Predbat refreshes it.", + "enable_coarse_fine_levels": "Enables the two-stage coarse and fine optimiser search. This can improve plan quality while controlling calculation time.", + "load_power_fill_enable": "Uses live load power to fill gaps in recent household-load history before forecasting.", + "load_ml_enable": "Enables Predbat's machine-learning load forecast in addition to the normal weighted historical model.", + "gateway_device_id": "Stable Home Assistant device identifier used by Predbat Gateway discovery and entity registration.", + "gateway_mqtt_host": "MQTT broker hostname used by Predbat Gateway.", + "gateway_mqtt_port": "MQTT broker port used by Predbat Gateway.", + "gateway_mqtt_token": "Authentication token used by Predbat Gateway when connecting to its MQTT broker.", + "redact_strings": "Extra literal values to mask in logs and debug archives when Predbat cannot identify them as credentials automatically.", + "redact_strings_labelled": "Named values to mask in logs and debug archives. The label is shown in place of each secret to make redacted diagnostics easier to understand.", +} + + +INTEGRATION_NAMES = { + "axle": "Axle Energy", + "sigenergy": "Sigenergy", + "solis": "Solis Cloud", + "myenergi": "myenergi", + "fox": "FoxESS Cloud", + "deye": "Deye Cloud", + "sunsynk": "Sunsynk Connect", + "alphaess": "AlphaESS", + "teslemetry": "Teslemetry", + "enphase": "Enphase Cloud", +} + + +INTEGRATION_SETTING_DESCRIPTIONS = { + "api_key": "{integration} API key used to authenticate Predbat's requests.", + "api_secret": "{integration} API secret paired with the configured API key.", + "app_id": "{integration} application identifier supplied for API access.", + "app_key": "{integration} application key supplied for API access.", + "app_secret": "{integration} application secret supplied for API access.", + "key": "{integration} API credential or access key.", + "username": "{integration} account username used to authenticate Predbat.", + "password": "{integration} account password used to authenticate Predbat.", + "inverter_sn": "{integration} inverter serial number or list of serial numbers Predbat should use.", + "system_id": "{integration} system identifier or list of system identifiers Predbat should use.", + "site_id": "{integration} site identifier or list of site identifiers Predbat should use.", + "hub_serial": "{integration} hub serial number associated with the configured account.", + "base_url": "{integration} API base URL. Change it only when the service documentation requires a different regional or compatible endpoint.", + "mqtt_host": "{integration} MQTT hostname used for live telemetry and control.", + "automatic": "Allows Predbat to discover {integration} devices and populate matching apps.yaml settings automatically.", + "automatic_ignore_pv": "Keeps {integration} automatic discovery enabled but ignores its PV data when solar generation comes from another source.", + "automatic_zappi": "Allows {integration} discovery to configure connected Zappi chargers automatically.", + "automatic_eddi": "Allows {integration} discovery to configure connected Eddi diverters automatically.", + "enable_controls": "Allows Predbat to send supported control commands to {integration}; disable for monitoring only.", + "control_enable": "Allows Predbat to send supported inverter control commands to {integration}; disable for monitoring only.", + "control": "Allows Predbat to dispatch supported demand-response or device controls through {integration}.", + "zappi_control": "Allows Predbat to control the configured {integration} Zappi charger.", + "tbc_control": "Allows Predbat to control the Tesla Backup Controller through {integration}.", + "poll_seconds": "Seconds between {integration} status refreshes. Shorter intervals increase API and network use.", + "nominal_voltage": "Nominal battery voltage used to convert {integration} current limits into power.", + "battery_nominal_voltage": "Nominal battery voltage used to convert {integration} current and power values.", + "battery_rate_max": "Maximum battery charge and discharge power reported to the optimiser for {integration}.", + "api_delay": "Minimum delay between {integration} API operations.", + "min_write_interval": "Minimum seconds between control writes sent to {integration}.", + "region": "{integration} account or API region used to select the correct service endpoint.", + "data_center": "{integration} data-centre identifier used to select the correct regional API.", + "company_id": "{integration} installer or company identifier required by the API.", + "auth_method": "Authentication method currently used for {integration}. This is normally managed automatically after sign-in.", + "access_token": "Saved {integration} access token. Predbat normally creates and refreshes this automatically.", + "token_expires_at": "Expiry time of the saved {integration} access token. Predbat maintains this value automatically.", + "token_hash": "Hash used to detect whether the stored {integration} token changed. Predbat maintains this value automatically.", + "ca_pem": "Certificate-authority PEM file used to verify the {integration} MQTT service.", + "client_pem": "Client certificate PEM file used to authenticate with the {integration} MQTT service.", + "client_key": "Private key PEM file paired with the {integration} client certificate.", + "cloud_pv_load_ignore": "Ignores {integration} cloud PV and load readings when those measurements are provided by another source.", + "hybrid": "Treats the {integration} site as a hybrid installation when interpreting power flow and controls.", +} + + +def _apps_schema_description(name, spec): + """Return purpose-aware help text for an apps.yaml property.""" + description = APPS_SCHEMA_DESCRIPTIONS.get(name) + if description: + return description + + for prefix, integration in INTEGRATION_NAMES.items(): + marker = prefix + "_" + if not name.startswith(marker): + continue + suffix = name[len(marker) :] + template = INTEGRATION_SETTING_DESCRIPTIONS.get(suffix) + if template: + return template.format(integration=integration) + + label = name.replace("_", " ") + setting_type = spec.get("type", "string") + if "sensor" in setting_type: + return f"Home Assistant entity or list of entities supplying {label} data to Predbat for planning, control or dashboard reporting." + if "boolean" in setting_type: + return f"Controls whether Predbat uses the {label} feature. Disable it when the related integration or behaviour is not required." + if "dict" in setting_type: + return f"Structured apps.yaml configuration for {label}. Each nested key controls part of this Predbat feature." + if "list" in setting_type: + return f"Ordered apps.yaml values for {label}. Supply one entry for each relevant device or configured source." + return f"Configuration value used by Predbat for {label}. Its expected format is {setting_type.replace('|', ' or ')}." + + +def build_apps_json_schema(): + """Build the Monaco JSON Schema for a Predbat apps.yaml file.""" + properties = { + "module": {"type": "string", "const": "predbat", "description": "AppDaemon module to load for this app. Predbat must use the value 'predbat'."}, + "class": {"type": "string", "const": "PredBat", "description": "Python application class AppDaemon starts. Predbat must use the value 'PredBat'."}, + "dependencies": {"type": "array", "items": {"type": "string"}, "description": "Names of other AppDaemon apps that must finish starting before Predbat is initialised."}, + "prefix": { + "type": "string", + "default": "predbat", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Prefix added to every Home Assistant entity Predbat creates. Change it only when running multiple instances; use lowercase letters, numbers and underscores.", + }, + "timezone": {"type": "string", "default": "Europe/London", "description": "IANA timezone used to align tariffs, forecasts and plan times, for example Europe/London."}, + "template": {"type": "boolean", "description": "Marks the file as the unconfigured example template. Remove this setting after replacing the example entities and credentials."}, + "grid_power": { + **_apps_schema_value({"type": "sensor|sensor_list"}), + "description": "Live grid power entity or list of entities, normally in watts. Predbat uses the sign to distinguish import from export and sums multiple meters.", + }, + "geserial": { + "type": "string", + "description": "GivTCP serial-number value or regular-expression lookup used by {geserial} placeholders in entity IDs.", + }, + "geserial2": { + "type": "string", + "description": "Second GivTCP serial-number value or lookup used by {geserial2} placeholders in multi-inverter entity IDs.", + }, + "auto_restart": { + **_apps_schema_value({"type": "dict_list"}), + "description": "Home Assistant service calls Predbat may run to restart a failed inverter integration after repeated communication errors.", + }, + "charge_limit_enable": { + **_apps_schema_value({"type": "sensor_list"}), + "description": "Writable switch for each inverter that enables its scheduled-charge target state of charge.", + }, + "discharge_target_soc": { + **_apps_schema_value({"type": "sensor_list"}), + "description": "Writable state-of-charge target for each inverter's scheduled forced-discharge window.", + }, + "battery_temperature_history": { + **_apps_schema_value({"type": "sensor"}), + "description": "Battery temperature history entity used to forecast temperature-dependent charge-rate limits.", + }, + "battery_temperature_charge_curve": { + **_apps_schema_value({"type": "int_float_dict"}), + "description": "Battery-temperature to C-rate multiplier curve used to reduce charging power when the battery is cold or hot.", + }, + "inverter_clock_skew_start": { + "type": "integer", + "description": "Minutes added when programming charge-window start times to compensate for an inaccurate inverter clock.", + }, + "inverter_clock_skew_end": { + "type": "integer", + "description": "Minutes added when programming charge-window end times to compensate for an inaccurate inverter clock.", + }, + "inverter_clock_skew_discharge_start": { + "type": "integer", + "description": "Minutes added when programming forced-discharge start times to compensate for an inaccurate inverter clock.", + }, + "inverter_clock_skew_discharge_end": { + "type": "integer", + "description": "Minutes added when programming forced-discharge end times to compensate for an inaccurate inverter clock.", + }, + "octopus_saving_session": { + **_apps_schema_value({"type": "sensor"}), + "description": "Octopus event or binary-sensor entity announcing Saving Sessions that Predbat may model or join.", + }, + "octopus_free_session": { + **_apps_schema_value({"type": "sensor"}), + "description": "Octopus event entity announcing Free Electricity sessions that Predbat should include in the plan.", + }, + "metric_standing_charge": { + **_apps_schema_value({"type": "sensor"}), + "description": "Current daily electricity standing-charge entity, included in Predbat cost totals and comparisons.", + }, + } + config_by_name = {item.get("name"): item for item in CONFIG_ITEMS if item.get("name")} + for name, spec in APPS_SCHEMA.items(): + value_schema = _apps_schema_value(spec) + value_schema["description"] = spec.get("description") or _apps_schema_description(name, spec) + properties[name] = value_schema + + for name, item in config_by_name.items(): + value_schema = properties.get(name) + + if value_schema is None: + item_type = item.get("type") + if item_type == "switch": + value_schema = {"type": "boolean"} + elif item_type == "input_number": + value_schema = {"type": "number"} + elif item_type == "select": + value_schema = {"type": "string"} + else: + continue + + properties[name] = value_schema + + # Prefer the proper CONFIG_ITEMS description + if item.get("description"): + value_schema["description"] = item["description"] + elif not value_schema.get("description"): + value_schema["description"] = item.get( + "friendly_name", + name.replace("_", " ").capitalize(), + ) + + if "min" in item: + value_schema["minimum"] = item["min"] + if "max" in item: + value_schema["maximum"] = item["max"] + if isinstance(item.get("options"), list): + value_schema["enum"] = item["options"] + if isinstance(item.get("default"), (str, int, float, bool, list, dict)): + value_schema["default"] = item["default"] + + # PredHeat already owns these entity mappings. Describing the nested keys + # here lets Monaco offer the same Home Assistant entity autocomplete inside + # the predheat block instead of encouraging duplicate Overview settings. + properties["predheat"].update( + { + "properties": { + "mode": { + "type": "string", + "enum": ["gas", "pump"], + "description": "Heating-system type modelled by PredHeat. Pump mode also enables the Overview ASHP card.", + }, + "weather": { + "type": "string", + "x-ha-entity": True, + "description": "Home Assistant weather entity used by PredHeat and by Overview for current conditions and weather effects.", + }, + "heating_energy": { + "type": "string", + "x-ha-entity": True, + "description": "Cumulative heating-energy entity used by PredHeat to calculate today's ASHP energy for Overview.", + }, + }, + "additionalProperties": True, + } + ) + + required = ["module", "class"] + [name for name, spec in APPS_SCHEMA.items() if spec.get("required")] + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://predbat.local/apps.schema.json", + "title": "Predbat apps.yaml", + "type": "object", + "properties": {"pred_bat": {"$ref": "#/$defs/predbatApp"}}, + "required": ["pred_bat"], + "additionalProperties": True, + "$defs": { + "predbatApp": { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": True, + } + }, + } + + def state_as_of_slots(records, slots): """ Resolve each slot to the state in effect at it - the most recent record at or before the slot. @@ -469,7 +924,8 @@ def _register_annual_routes(self, app): Application and assert they exist, without booting a real TCP listener - the constructor for that Application performs no network I/O of its own. """ - app.router.add_get("/annual", self.annual_page.html_annual) + app.router.add_get("/annual", self.html_modern_ui if self.get_web_ui() == "modern" else self.annual_page.html_annual) + app.router.add_get("/legacy_annual", self.annual_page.html_annual) app.router.add_post("/annual", self.annual_page.html_annual_post) app.router.add_post("/annual_reset", self.annual_page.html_annual_reset) app.router.add_post("/annual_array", self.annual_page.html_annual_array) @@ -501,7 +957,8 @@ def _register_chat_routes(self, app): configured" when the component is not up yet - that per-request check is what stands in for a boot-time gate. """ - app.router.add_get("/chat", self.chat_page.html_chat) + app.router.add_get("/chat", self.html_modern_ui if self.get_web_ui() == "modern" else self.chat_page.html_chat) + app.router.add_get("/legacy_chat", self.chat_page.html_chat) app.router.add_get("/chat/conversations", self.chat_page.html_chat_conversations) app.router.add_post("/chat/conversations", self.chat_page.html_chat_create) app.router.add_post("/chat/rename", self.chat_page.html_chat_rename) @@ -523,22 +980,28 @@ def _register_chat_routes(self, app): async def start(self): # Start the web server app = web.Application() - app.router.add_get("/", self.html_default) - app.router.add_get("/plan", self.html_plan) - app.router.add_get("/log", self.html_log) - app.router.add_get("/apps", self.html_apps) + modern_ui = self.get_web_ui() == "modern" + app.router.add_get("/", self.html_modern_ui if modern_ui else self.html_default) + app.router.add_get("/plan", self.html_modern_ui if modern_ui else self.html_plan_legacy) + app.router.add_get("/log", self.html_modern_ui if modern_ui else self.html_log) + app.router.add_get("/apps", self.html_modern_ui if modern_ui else self.html_apps) + app.router.add_get("/legacy_apps", self.html_apps) app.router.add_post("/apps", self.html_apps_post) app.router.add_get("/apps_value", self.html_apps_value) - app.router.add_get("/charts", self.html_charts) - app.router.add_get("/config", self.html_config) + app.router.add_get("/charts", self.html_modern_ui if modern_ui else self.html_charts_legacy) + app.router.add_get("/legacy_charts", self.html_charts_legacy) + app.router.add_get("/config", self.html_modern_ui if modern_ui else self.html_config) app.router.add_get("/entity", self.html_entity) app.router.add_post("/entity", self.html_entity_post) app.router.add_post("/config", self.html_config_post) - app.router.add_get("/dash", self.html_dash) + app.router.add_get("/dash", self.html_modern_ui if modern_ui else self.html_dash_legacy) app.router.add_post("/dash", self.html_dash_post) app.router.add_get("/dash_content", self.html_dash_content) - app.router.add_get("/components", self.html_components) - app.router.add_get("/discovery", self.html_discovery) + app.router.add_get("/legacy_dash", self.html_dash_legacy) + app.router.add_get("/legacy_plan", self.html_plan_legacy) + app.router.add_get("/components", self.html_modern_ui if modern_ui else self.html_components) + app.router.add_get("/discovery", self.html_modern_ui if modern_ui else self.html_discovery) + app.router.add_get("/api/discovery", self.html_api_discovery) app.router.add_get("/component_entities", self.html_component_entities) app.router.add_post("/component_restart", self.html_component_restart) app.router.add_get("/component_config", self.html_component_config) @@ -551,35 +1014,54 @@ async def start(self): app.router.add_get("/debug_history_list", self.html_debug_history_list) app.router.add_get("/debug_history_download", self.html_debug_history_download) app.router.add_get("/debug_history_download_recent", self.html_debug_history_download_recent) - app.router.add_get("/compare", self.html_compare) + app.router.add_get("/compare", self.html_modern_ui if modern_ui else self.html_compare) app.router.add_post("/compare", self.html_compare_post) self._register_annual_routes(app) self._register_chat_routes(app) - app.router.add_get("/apps_editor", self.html_apps_editor) + app.router.add_get("/apps_editor", self.html_modern_ui if modern_ui else self.html_apps_editor) app.router.add_post("/apps_editor", self.html_apps_editor_post) app.router.add_get("/apps_editor_checksum", self.html_apps_editor_checksum) app.router.add_post("/plan_override", self.html_plan_override) app.router.add_post("/rate_override", self.html_rate_override) app.router.add_post("/restart", self.html_restart) + app.router.add_post("/api/restart", self.html_restart) app.router.add_post("/inverter_refresh", self.html_inverter_refresh) app.router.add_get("/api/state", self.html_api_get_state) app.router.add_get("/api/ping", self.html_api_ping) app.router.add_post("/api/state", self.html_api_post_state) app.router.add_post("/api/service", self.html_api_post_service) app.router.add_get("/api/plan_data", self.html_api_plan_data) + app.router.add_get("/api/chart_data", self.html_api_chart_data) + app.router.add_get("/api/compare", self.html_api_compare) + app.router.add_get("/api/apps_schema", self.html_api_apps_schema) + app.router.add_get("/api/apps_yaml", self.html_api_apps_yaml) + app.router.add_post("/api/apps_yaml", self.html_api_apps_yaml_post) app.router.add_get("/api/log", self.html_api_get_log) app.router.add_get("/api/entities", self.html_api_get_entities) + app.router.add_get("/api/config", self.html_api_config) + app.router.add_post("/api/config", self.html_config_post) + app.router.add_get("/api/components", self.html_api_components) + app.router.add_get("/api/browse", self.html_api_browse) app.router.add_post("/api/login", self.html_api_login) - app.router.add_get("/browse", self.html_browse) + app.router.add_get("/browse", self.html_modern_ui if modern_ui else self.html_browse) app.router.add_get("/download", self.html_download_file) app.router.add_get("/images/{filename}", self.html_logo_image) - app.router.add_get("/internals", self.html_internals) + app.router.add_get("/internals", self.html_modern_ui if modern_ui else self.html_internals) app.router.add_get("/api/internals", self.html_api_internals) + app.router.add_get("/api/internals/threads", self.html_api_internals_threads) app.router.add_get("/api/internals/download", self.html_api_internals_download) app.router.add_get("/api/status", self.html_api_get_status) + app.router.add_post("/api/dashboard_control", self.html_api_dashboard_control) + app.router.add_get("/api/power_flow", self.html_api_power_flow) + app.router.add_get("/overview", self.html_modern_ui if modern_ui else self.html_dash_legacy) app.router.add_get("/metrics", metrics_handler) app.router.add_get("/metrics/json", metrics_json_handler) app.router.add_get("/metrics_dashboard", self.html_metrics_dashboard) + if modern_ui: + app.router.add_get("/docs", self.html_modern_ui) + app.router.add_get("/assets/{filename:.*}", self.html_modern_ui_asset) + app.router.add_get("/favicon.svg", self.html_modern_ui_asset) + app.router.add_get("/icons.svg", self.html_modern_ui_asset) # Notify plugin system that web interface is ready if hasattr(self.base, "plugin_system") and self.base.plugin_system: @@ -2496,22 +2978,264 @@ async def html_api_post_state(self, request): async def html_api_get_status(self, request): """ - Get current Predbat status (calculating state and battery info) + Get current Predbat dashboard status. + + This endpoint provides the live operational state used by the dashboard. + Existing fields are retained for backwards compatibility. """ try: + # Existing calculation state calculating = self.get_arg("active", False) + if self.base.update_pending: calculating = True + # Existing battery HTML used by the legacy dashboard battery_icon = self.get_battery_status_icon() - status_data = {"calculating": calculating, "battery_html": battery_icon} + # Predbat status entity + status_entity = self.prefix + ".status" + + status = self.get_state_wrapper(status_entity, default="Unknown") + + detail = self.get_state_wrapper(status_entity, attribute="detail", default="") + + last_updated = self.get_state_wrapper(status_entity, attribute="last_updated", default=None) + + # Time Predbat was last started + last_started = self.get_state_wrapper(self.prefix + ".last_started", default=None) + + # Current operating mode + mode = self.get_arg("mode", "") + + # Dashboard controls + debug_enable = self.get_arg("debug_enable", False) + + read_only = self.get_arg("set_read_only", False) + + predbat_active, _ = self.get_ha_config("active", None) + + update_entity = "update.predbat_version" + updating = self.get_state_wrapper(update_entity, attribute="in_progress", default=False) is True + update_available = self.get_state_wrapper(update_entity, default="off") == "on" + latest_version = self.get_state_wrapper(update_entity, attribute="latest_version", default=None) + if latest_version in (None, "", "unknown", "unavailable", "check HACS"): + latest_version = None + + # Configuration health + config_errors = len(self.arg_errors) + + status_data = { + # Existing API fields + "calculating": calculating, + "updating": updating, + "battery_html": battery_icon, + # Dashboard status + "status": status, + "detail": detail, + "last_updated": last_updated, + "last_started": last_started, + "version": THIS_VERSION_DISPLAY, + "latest_version": latest_version, + "update_available": update_available, + # Predbat configuration + "mode": mode, + "debug_enable": debug_enable, + "read_only": read_only, + "active": predbat_active, + "chat_enabled": self.chat_enabled(), + "load_ml_enabled": bool(self.base.get_arg("load_ml_enable", False)), + # Configuration health + "config_ok": config_errors == 0, + "config_errors": config_errors, + } return web.Response(content_type="application/json", text=json.dumps(status_data)) + except Exception as e: self.log("Error getting status: {}".format(e)) + return web.Response(status=500, content_type="application/json", text=json.dumps({"error": str(e)})) + async def html_api_dashboard_control(self, request): + """ + Update a Predbat dashboard control. + + This provides the React dashboard with a JSON API for the same + controls exposed by the legacy /dash page. + + Supported controls: + - mode + - debug_enable + - set_read_only + - active + """ + try: + data = await request.json() + + control = data.get("control") + value = data.get("value") + + # Mode is represented by a Home Assistant select entity. + if control == "mode": + if not isinstance(value, str): + return web.json_response({"result": "error", "error": "Mode must be a string"}, status=400) + + entity_id = f"select.{self.prefix}_mode" + + await self.set_state_external(entity_id, value) + + # The remaining dashboard controls are Home Assistant switches. + elif control in ["debug_enable", "set_read_only", "active"]: + if not isinstance(value, bool): + return web.json_response({"result": "error", "error": "Switch value must be boolean"}, status=400) + + entity_id = f"switch.{self.prefix}_{control}" + + await self.set_state_external(entity_id, value) + + else: + return web.json_response({"result": "error", "error": f"Unsupported control: {control}"}, status=400) + + self.log(f"Dashboard control updated: {control} = {value}") + + return web.json_response({"result": "ok", "control": control, "value": value}) + + except Exception as e: + self.log(f"ERROR: Failed to update dashboard control: {str(e)}") + + return web.json_response({"result": "error", "error": str(e)}, status=500) + + async def html_api_power_flow(self, request): + """ + Return live power-flow and Overview data for the React dashboard. + + Uses the same values and sign conventions as the legacy SVG diagram so + the React UI does not have to reinterpret inverter data. Optional + Overview entities resolve here so the browser never needs Home + Assistant credentials. + """ + try: + grid_power = self.base.grid_power + battery_power = self.base.battery_power + pv_power = self.base.pv_power + load_power = self.base.load_power + + # Car charging is handled the same way as the legacy power-flow view. + car_configured = self.base.car_charging_power_configured + car_power = self.base.car_charging_power + car_inside_clamp = self.base.car_energy_reported_load + house_power = max(0, load_power - car_power) if car_configured and car_inside_clamp else load_power + + sun_state = self.get_state_wrapper(entity_id="sun.sun", default=None) + predheat_config = self.get_arg("predheat", default={}, indirect=False) + if not isinstance(predheat_config, dict): + predheat_config = {} + weather_entity = predheat_config.get("weather") or self.get_arg("weather", default=None, indirect=False) or "weather.forecast_home" + weather_state = self.get_state_wrapper(entity_id=weather_entity, default=None) + weather_temperature = _optional_float(self.get_state_wrapper(entity_id=weather_entity, attribute="temperature", default=None)) + weather_temperature_unit = self.get_state_wrapper(entity_id=weather_entity, attribute="temperature_unit", default="") + weather = None + if weather_state not in (None, "unknown", "unavailable") or weather_temperature is not None: + weather = { + "state": weather_state, + "temperature": weather_temperature, + "temperature_unit": weather_temperature_unit or "", + } + + pv_forecast_today = _optional_float(self.get_arg("pv_forecast_today", default=None, required_unit="kWh")) + + car_status_values = self.get_arg("car_charging_status", default=[]) + if not car_status_values: + car_status_values = self.get_arg("car_charging_planned", default=[]) + if not isinstance(car_status_values, list): + car_status_values = [car_status_values] if car_status_values else [] + car_status = next((str(value) for value in car_status_values if value not in (None, "unknown", "unavailable")), None) + + car_soc = _optional_float(self.get_arg("car_charging_soc", default=None, index=0)) + + car_energy_values = self.get_arg("car_charging_energy", default=[]) + if not isinstance(car_energy_values, list): + car_energy_values = [car_energy_values] if car_energy_values else [] + resolved_car_energy = [_optional_float(value) for value in car_energy_values] + car_energy_today = sum(value for value in resolved_car_energy if value is not None) if any(value is not None for value in resolved_car_energy) else None + if car_energy_today is None and getattr(self.base, "car_charging_energy", None): + car_energy_today = sum(self.base.get_from_incrementing(self.base.car_charging_energy, minute) for minute in range(self.base.minutes_now + 1)) + + ashp = None + predheat_is_pump = bool(predheat_config) and str(predheat_config.get("mode", "pump")).lower() == "pump" + ashp_enabled = predheat_is_pump + if "ashp_enable" in self.base.args: + ashp_enabled = self.get_arg("ashp_enable", default=False, indirect=False) + if ashp_enabled: + ashp_power_entity = self.get_arg("ashp_power", default=None, indirect=False) + ashp_status_entity = self.get_arg("ashp_status", default=None, indirect=False) + ashp_energy_entity = self.get_arg("heat_energy", default=None, indirect=False) + if not ashp_energy_entity: + ashp_energy_entity = self.get_arg("ashp_energy_today", default=None, indirect=False) + ashp_power = None + ashp_status = None + ashp_energy = None + + if ashp_power_entity: + ashp_power = _optional_float(self.get_state_wrapper(entity_id=ashp_power_entity, default=None, required_unit="W")) + if ashp_status_entity: + status_value = self.get_state_wrapper(entity_id=ashp_status_entity, default=None) + if status_value not in (None, "unknown", "unavailable"): + ashp_status = str(status_value) + if ashp_energy_entity: + ashp_energy = _optional_float(self.get_state_wrapper(entity_id=ashp_energy_entity, default=None, required_unit="kWh")) + elif predheat_is_pump: + ashp_energy = _optional_float(getattr(getattr(self.base, "predheat", None), "heat_energy_today", None)) + + ashp = {"power": ashp_power, "status": ashp_status, "energy_today": ashp_energy} + + # Match the existing diagram's direction thresholds/sign conventions. + grid_importing = grid_power <= -10 + battery_discharging = battery_power >= 10 + battery_charging = battery_power <= -10 + pv_generating = pv_power > 0 + + return web.json_response( + { + "grid_power": grid_power, + "battery_power": battery_power, + "pv_power": pv_power, + "load_power": load_power, + "house_power": house_power, + "soc_percent": self.base.soc_percent, + "grid_importing": grid_importing, + "battery_charging": battery_charging, + "battery_discharging": battery_discharging, + "pv_generating": pv_generating, + "sun_state": sun_state, + "currency_symbols": self.currency_symbols, + "pv_forecast_today": pv_forecast_today, + "totals": { + "load_today": _optional_float(getattr(self.base, "load_minutes_now", None)), + "pv_today": _optional_float(getattr(self.base, "pv_today_now", None)), + "import_today": _optional_float(getattr(self.base, "import_today_now", None)), + "export_today": _optional_float(getattr(self.base, "export_today_now", None)), + "cost_today": _optional_float(getattr(self.base, "cost_today_sofar", None)) or 0, + }, + "weather": weather, + "car": { + "configured": car_configured, + "power": car_power, + "inside_clamp": car_inside_clamp, + "charging": car_configured and car_power >= 10, + "status": car_status, + "soc": car_soc, + "energy_today": car_energy_today, + }, + "ashp": ashp, + } + ) + + except Exception as e: + self.log(f"ERROR: Failed to get power-flow data: {str(e)}") + return web.json_response({"error": str(e)}, status=500) + async def html_api_ping(self, request): """ Check if Predbat is running @@ -2570,6 +3294,14 @@ async def html_api_plan_data(self, request): # Get JSON data (with timestamps embedded in the JSON) plan_json = self.get_state_wrapper(entity_id=plan_entity, attribute="raw", default=None) + if plan_json: + # Work on our own copy rather than modifying the + # object returned from Home Assistant state. + plan_json = dict(plan_json) + + plan_json["car_charging_from_battery"] = bool(getattr(self.base, "car_charging_from_battery", True)) + + plan_json["car_energy_reported_load"] = bool(getattr(self.base, "car_energy_reported_load", False)) plan_timestamp = plan_json.get("timestamp", None) if plan_json else None yesterday_json = self.get_state_wrapper(entity_id=yesterday_entity, attribute="json", default=None) @@ -2645,6 +3377,15 @@ async def html_api_plan_data(self, request): return web.json_response(response_data) async def html_plan(self, request): + """ + Serve the selected plan interface. + """ + if self.get_web_ui() == "modern": + return await self.html_modern_ui(request) + + return await self.html_plan_legacy(request) + + async def html_plan_legacy(self, request): """ Return the Predbat plan as an HTML page with client-side JSON rendering """ @@ -2743,6 +3484,18 @@ async def html_plan(self, request): text += "\n" return web.Response(content_type="text/html", text=text) + async def html_dash_legacy_direct(self, request): + """ + Emergency/direct access to the legacy dashboard. + """ + return await self.html_dash_legacy(request) + + async def html_plan_legacy_direct(self, request): + """ + Emergency/direct access to the legacy plan. + """ + return await self.html_plan_legacy(request) + async def html_log(self, request): """ Return the Predbat log as an HTML page with dynamic updates @@ -3162,6 +3915,55 @@ async def html_debug_plan(self, request): html_plan = None return await self.html_file("predbat_plan.html", html_plan) + def get_web_ui(self): + """ + Return the selected web interface. + + Legacy remains the default so existing installations are + unchanged unless the modern UI is explicitly enabled. + """ + return ( + str( + self.get_arg( + "web_ui", + "legacy", + ) + ) + .strip() + .lower() + ) + + async def html_modern_ui(self, request): + """Serve the bundled React entry point for a modern UI page.""" + return await self.html_modern_ui_asset(request, "index.html") + + async def html_modern_ui_asset(self, request, filename=None): + """Serve one file from the bundled React archive.""" + if filename is None: + filename = request.match_info.get("filename") + if filename: + filename = "assets/" + filename + else: + filename = request.path.rsplit("/", 1)[-1] + + path = PurePosixPath(filename) + if path.is_absolute() or ".." in path.parts: + raise web.HTTPNotFound() + + archive_path = os.path.join(os.path.dirname(__file__), "frontend.zip") + try: + with zipfile.ZipFile(archive_path) as archive: + body = archive.read("dist/" + str(path)) + except KeyError: + raise web.HTTPNotFound() from None + except (OSError, zipfile.BadZipFile) as error: + self.log("Warn: Modern web interface bundle is unavailable: {}".format(error)) + return web.Response(status=503, text="Modern web interface bundle is unavailable") + + content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream" + cache_control = "no-store" if str(path) == "index.html" else "public, max-age=31536000, immutable" + return web.Response(body=body, content_type=content_type, headers={"Cache-Control": cache_control}) + async def html_dash_content(self, request): """ Return just the dashboard body content for AJAX refresh (preserves scroll position) @@ -3170,6 +3972,15 @@ async def html_dash_content(self, request): return web.Response(content_type="text/html", text=text) async def html_dash(self, request): + """ + Serve the selected dashboard interface. + """ + if self.get_web_ui() == "modern": + return await self.html_modern_ui(request) + + return await self.html_dash_legacy(request) + + async def html_dash_legacy(self, request): """ Render apps.yaml as an HTML page """ @@ -3249,31 +4060,372 @@ async def html_dash_post(self, request): # Redirect back to dashboard raise web.HTTPFound("./dash") - def get_chart(self, chart): + def get_battery_chart_data(self, soc_kw_best=None): """ - Return the HTML for a chart + Return the battery chart data shared by the legacy and modern interfaces. + + Series stay keyed by their ISO timestamps so no precision is lost while + moving them through JSON. The React chart converts the keys to epoch + milliseconds at its rendering boundary. """ now_str = self.now_utc.strftime(TIME_FORMAT) - soc_kw_h0 = {} + actual = {} if self.base.soc_kwh_history: - hist = self.base.soc_kwh_history + history = self.base.soc_kwh_history for minute in range(0, self.minutes_now, self.plan_interval_minutes): minute_timestamp = self.midnight_utc + timedelta(minutes=minute) - stamp = minute_timestamp.strftime(TIME_FORMAT) - soc_kw_h0[stamp] = hist.get(self.minutes_now - minute, 0) - soc_kw_h0[now_str] = self.base.soc_kw - soc_kw = self.get_entity_results(self.prefix + ".soc_kw") - soc_kw_best = self.get_entity_results(self.prefix + ".soc_kw_best") - # What earlier plans predicted for now, shifted forward by the horizon they were made at, so - # each lands on the moment it was forecasting and can be read straight against Actual. + actual[minute_timestamp.strftime(TIME_FORMAT)] = history.get(self.minutes_now - minute, 0) + actual[now_str] = self.base.soc_kw + + if soc_kw_best is None: + soc_kw_best = self.get_entity_results(self.prefix + ".soc_kw_best") + + # Earlier plans' one- and eight-hour forecasts are shifted onto the + # instant they predicted, allowing a direct comparison with Actual. soc_best_history = self.get_history_with_now_attrs(self.prefix + ".soc_kw_best", 7) - soc_kw_best_h1 = prune_today(history_attribute(soc_best_history, attributes=True, state_key="soc_h1"), self.now_utc, self.midnight_utc, prune=False, offset_minutes=60) - soc_kw_best_h8 = prune_today(history_attribute(soc_best_history, attributes=True, state_key="soc_h8"), self.now_utc, self.midnight_utc, prune=False, offset_minutes=60 * 8) - soc_kw_best10 = self.get_entity_results(self.prefix + ".soc_kw_best10") - soc_kw_base10 = self.get_entity_results(self.prefix + ".soc_kw_base10") - charge_limit_kw = self.get_entity_results(self.prefix + ".charge_limit_kw") - best_charge_limit_kw = self.get_entity_results(self.prefix + ".best_charge_limit_kw") - best_export_limit_kw = self.get_entity_results(self.prefix + ".best_export_limit_kw") + predicted_h1 = prune_today(history_attribute(soc_best_history, attributes=True, state_key="soc_h1"), self.now_utc, self.midnight_utc, prune=False, offset_minutes=60) + predicted_h8 = prune_today(history_attribute(soc_best_history, attributes=True, state_key="soc_h8"), self.now_utc, self.midnight_utc, prune=False, offset_minutes=60 * 8) + + return { + "chart": "battery", + "ready": bool(soc_kw_best), + "generated_at": now_str, + "soc_max": self.base.soc_max, + "series": { + "actual": actual, + "optimized": soc_kw_best, + "base": self.get_entity_results(self.prefix + ".soc_kw"), + "base10": self.get_entity_results(self.prefix + ".soc_kw_base10"), + "optimized10": self.get_entity_results(self.prefix + ".soc_kw_best10"), + "predicted_h1": predicted_h1, + "predicted_h8": predicted_h8, + "charge_limit_base": self.get_entity_results(self.prefix + ".charge_limit_kw"), + "charge_limit_optimized": self.get_entity_results(self.prefix + ".best_charge_limit_kw"), + "export_limit_optimized": self.get_entity_results(self.prefix + ".best_export_limit_kw"), + "record": self.get_entity_results(self.prefix + ".record"), + }, + } + + def get_power_chart_data(self): + """Return Predbat's optimised power forecast for the modern chart.""" + now_str = self.now_utc.strftime(TIME_FORMAT) + predicted_grid = self.get_entity_results(self.prefix + ".grid_power_best") + series = { + "battery": self.get_entity_results(self.prefix + ".battery_power_best"), + "solar": self.get_entity_results(self.prefix + ".pv_power_best"), + # Prediction stores import as positive, while Predbat's live power + # convention uses negative import and positive export. Normalise the + # chart API so forecast and live power use the same direction. + "grid": {timestamp: -value for timestamp, value in predicted_grid.items()}, + "load": self.get_entity_results(self.prefix + ".load_power_best"), + # iBoost is published as cumulative energy. The frontend converts + # each interval's change to kW before drawing it on the power axis. + "iboost_energy": self.get_entity_results(self.prefix + ".iboost_best"), + } + + return { + "chart": "power", + "ready": any(series[key] for key in ("battery", "solar", "grid", "load")), + "generated_at": now_str, + "series": series, + } + + def get_cost_chart_data(self): + """Return actual and forecast cumulative costs for the modern chart.""" + now_str = self.now_utc.strftime(TIME_FORMAT) + optimized = self.get_entity_results(self.prefix + ".best_metric") + + return { + "chart": "cost", + "ready": bool(optimized), + "generated_at": now_str, + "currency_symbol": self.currency_symbols[0], + "currency_unit": self.currency_symbols[1], + "series": { + "actual": self.get_entity_results(self.prefix + ".cost_today"), + "actual_import": self.get_entity_results(self.prefix + ".cost_today_import"), + "actual_export": self.get_entity_results(self.prefix + ".cost_today_export"), + "base": self.get_entity_results(self.prefix + ".metric"), + "optimized": optimized, + "base10": self.get_entity_results(self.prefix + ".base10_metric"), + "optimized10": self.get_entity_results(self.prefix + ".best10_metric"), + }, + } + + def get_rates_chart_data(self): + """Return forecast tariffs and measured average rates for the modern chart.""" + now_str = self.now_utc.strftime(TIME_FORMAT) + hourly_history = history_attribute(self.get_history_wrapper(self.prefix + ".ppkwh_hour", 2, required=False)) + today_history = history_attribute(self.get_history_wrapper(self.prefix + ".ppkwh_today", 2, required=False)) + series = { + "import": self.get_entity_results(self.prefix + ".rates"), + "export": self.get_entity_results(self.prefix + ".rates_export"), + "gas": self.get_entity_results(self.prefix + ".rates_gas"), + "actual_hourly": prune_today(hourly_history, self.now_utc, self.midnight_utc, prune=False, prune_future=False), + "actual_today": prune_today(today_history, self.now_utc, self.midnight_utc, prune=False, prune_future=False), + } + + return { + "chart": "rates", + "ready": bool(series["import"] or series["export"]), + "generated_at": now_str, + "currency_symbol": self.currency_symbols[0], + "currency_unit": self.currency_symbols[1], + "series": series, + } + + def get_inday_chart_data(self): + """Return today's actual, original and adjusted cumulative load forecasts.""" + now_str = self.now_utc.strftime(TIME_FORMAT) + adjustment_history = history_attribute(self.get_history_wrapper(self.prefix + ".load_inday_adjustment", 2, required=False)) + series = { + "actual": self.get_entity_results(self.prefix + ".load_energy_actual"), + "predicted": self.get_entity_results(self.prefix + ".load_energy_predicted"), + "adjusted": self.get_entity_results(self.prefix + ".load_energy_adjusted"), + "adjustment_factor": prune_today(adjustment_history, self.now_utc, self.midnight_utc, prune=True), + } + + return { + "chart": "inday", + "ready": bool(series["actual"] or series["predicted"] or series["adjusted"]), + "generated_at": now_str, + "series": series, + } + + def get_solar_chart_data(self): + """Return seven days of measured solar power and the current two-day forecast.""" + now_str = self.now_utc.strftime(TIME_FORMAT) + actual_history = history_attribute(self.get_history_wrapper(self.prefix + ".pv_power", 7, required=False)) + forecast_history = self.get_history_wrapper("sensor." + self.prefix + "_pv_forecast_h0_uncalibrated", 7, required=False) + forecast_history_calibrated = self.get_history_wrapper("sensor." + self.prefix + "_pv_forecast_h0", 7, required=False) + energy_actual_history = history_attribute(self.get_history_wrapper(self.prefix + ".pv_energy_h0", 7, required=False)) + today_history = self.get_history_wrapper("sensor." + self.prefix + "_pv_today", 7, required=False) + energy_total = history_attribute(today_history, attributes=True, state_key="totalCL") + energy_remaining = history_attribute(today_history, attributes=True, state_key="remainingCL") + energy_forecast = {timestamp: max(total - energy_remaining.get(timestamp, 0), 0) for timestamp, total in energy_total.items()} + + forecast = {} + forecast_low = {} + forecast_high = {} + forecast_calibrated = {} + for day_name in ("today", "tomorrow"): + entity = "sensor.{}_pv_{}".format(self.prefix, day_name) + forecast.update(self.get_entity_detailedForecast(entity, "pv_estimate")) + forecast_low.update(self.get_entity_detailedForecast(entity, "pv_estimate10")) + forecast_high.update(self.get_entity_detailedForecast(entity, "pv_estimate90")) + forecast_calibrated.update(self.get_entity_detailedForecast(entity, "pv_estimateCL")) + + series = { + "actual": prune_today(actual_history, self.now_utc, self.midnight_utc, prune=False), + "forecast_history": prune_today(history_attribute(forecast_history), self.now_utc, self.midnight_utc, prune=False, intermediate=True), + "forecast_history_calibrated": prune_today(history_attribute(forecast_history_calibrated, attributes=True, state_key="nowCL"), self.now_utc, self.midnight_utc, prune=False, intermediate=True), + "forecast": prune_today(forecast, self.now_utc, self.midnight_utc, prune=False, intermediate=True), + "forecast_low": prune_today(forecast_low, self.now_utc, self.midnight_utc, prune=False, intermediate=True), + "forecast_high": prune_today(forecast_high, self.now_utc, self.midnight_utc, prune=False, intermediate=True), + "forecast_calibrated": prune_today(forecast_calibrated, self.now_utc, self.midnight_utc, prune=False, intermediate=True), + "energy_actual": prune_today(energy_actual_history, self.now_utc, self.midnight_utc, prune=False), + "energy_forecast": prune_today(energy_forecast, self.now_utc, self.midnight_utc, prune=False), + } + + return { + "chart": "solar", + "ready": bool(series["actual"] or series["forecast_calibrated"] or series["forecast"]), + "generated_at": now_str, + "series": series, + } + + def get_savings_chart_data(self): + """Return daily and cumulative savings history for the modern chart.""" + now_str = self.now_utc.strftime(TIME_FORMAT) + series = { + "daily_predbat": history_attribute(self.get_history_wrapper(self.prefix + ".savings_yesterday_predbat", 28, required=False), daily=True, offset_days=-1, pounds=True, first=False), + "daily_pv_battery": history_attribute(self.get_history_wrapper(self.prefix + ".savings_yesterday_pvbat", 28, required=False), daily=True, offset_days=-1, pounds=True, first=False), + "daily_cost": history_attribute(self.get_history_wrapper(self.prefix + ".cost_yesterday", 28, required=False), daily=True, offset_days=-1, pounds=True, first=False), + "total_predbat": history_attribute(self.get_history_with_now_attrs(self.prefix + ".savings_total_predbat", 28), daily=True, pounds=True, first=False), + "total_pv_battery": history_attribute(self.get_history_with_now_attrs(self.prefix + ".savings_total_pvbat", 28), daily=True, pounds=True, first=False), + } + + return { + "chart": "savings", + "ready": any(series.values()), + "generated_at": now_str, + "currency_symbol": self.currency_symbols[0], + "series": series, + } + + def get_battery_degradation_chart_data(self): + """Return recent calculated battery capacity and degradation history.""" + inverters = [] + for inverter_id in range(int(self.base.get_arg("num_inverters", 1))): + suffix = "" if inverter_id == 0 else "_{}".format(inverter_id) + sensor_id = "sensor." + self.prefix + "_soc_max_calculated" + suffix + history = self.get_history_wrapper(sensor_id, 28, required=False) + calculated = history_attribute(history, daily=True, first=False) + nominal = history_attribute(history, attributes=True, state_key="nominal_capacity", daily=True, first=False) + degradation = history_attribute(history, attributes=True, state_key="degradation_percent", daily=True, first=False) + current = self.base.dashboard_values.get(sensor_id, {}) + today = self.now_utc.strftime("%Y-%m-%d") + + for target, value in ( + (calculated, current.get("state")), + (nominal, current.get("attributes", {}).get("nominal_capacity")), + (degradation, current.get("attributes", {}).get("degradation_percent")), + ): + try: + target[today] = float(value) + except (ValueError, TypeError): + pass + + inverters.append( + { + "id": inverter_id, + "nominal": nominal, + "calculated": calculated, + "degradation": degradation, + } + ) + + return { + "chart": "degradation", + "ready": any(item["calculated"] or item["nominal"] or item["degradation"] for item in inverters), + "generated_at": self.now_utc.strftime(TIME_FORMAT), + "automatic_scaling": bool(self.base.battery_scaling_auto), + "inverters": inverters, + } + + def get_marginal_costs_chart_data(self): + """Return historical and forecast marginal energy costs.""" + sensor_id = "sensor." + self.prefix + "_marginal_energy_costs" + sensor_attrs = self.base.dashboard_values.get(sensor_id, {}).get("attributes", {}) + matrix = sensor_attrs.get("matrix", {}) + history = self.get_history_with_now_attrs(sensor_id, 7) + time_labels = list(next(iter(matrix.values()), {}).keys()) + + def combined_series(history_key, forecast): + series = prune_today(history_attribute(history, attributes=True, state_key=history_key), self.now_utc, self.midnight_utc, prune=False, prune_past_days=7, prune_future=True) + for offset, time_label in zip(MARGINAL_TIME_OFFSETS, time_labels): + series[(self.now_utc + timedelta(minutes=offset)).isoformat()] = forecast.get(time_label, 0) + return series + + levels = [] + labels = {"low": "Low", "med": "Medium", "high": "High", "ev": "EV"} + for state_name, kwh in zip(MARGINAL_EXTRA_KWH_LEVEL_NAMES, MARGINAL_EXTRA_KWH_LEVELS): + forecast = matrix.get(kwh, matrix.get(str(kwh), {})) + levels.append( + { + "id": state_name, + "label": labels[state_name], + "kwh": kwh, + "current_cost": sensor_attrs.get("rate_now_{}_consumption".format(state_name)), + "cheap": self.base.dashboard_values.get("binary_sensor.{}_marginal_rate_now_{}_is_cheap".format(self.prefix, state_name), {}).get("state") == "on", + "moderate": self.base.dashboard_values.get("binary_sensor.{}_marginal_rate_now_{}_is_moderate".format(self.prefix, state_name), {}).get("state") == "on", + "series": combined_series("rate_now_{}_consumption".format(state_name), forecast), + } + ) + + return { + "chart": "marginal", + "ready": bool(matrix), + "generated_at": self.now_utc.strftime(TIME_FORMAT), + "currency_unit": self.currency_symbols[1], + "levels": levels, + "grid_import": combined_series("grid_import_now", sensor_attrs.get("grid_import", {})), + "grid_export": combined_series("grid_export_now", sensor_attrs.get("grid_export", {})), + } + + def get_carbon_chart_data(self): + """Return actual and forecast household carbon emissions.""" + intensity_start = self.midnight_utc + timedelta(minutes=self.minutes_now) + series = { + "actual": self.get_entity_results(self.prefix + ".carbon_today"), + "base": self.get_entity_results(self.prefix + ".carbon"), + "optimized": self.get_entity_results(self.prefix + ".carbon_best"), + "intensity": {(intensity_start + timedelta(minutes=minute)).strftime(TIME_FORMAT): value for minute, value in self.base.carbon_intensity.items() if minute >= 0}, + } + + return { + "chart": "carbon", + "ready": bool(series["actual"] or series["base"] or series["optimized"]), + "generated_at": self.now_utc.strftime(TIME_FORMAT), + "series": series, + } + + def get_load_ml_chart_data(self): + """Return learned load energy and power history with the current forecast.""" + stats_history = self.get_history_with_now_attrs("sensor." + self.prefix + "_load_ml_stats", 7) + forecast_energy = self.get_entity_results("sensor." + self.prefix + "_load_ml_forecast") + forecast_power = {} + previous_timestamp = None + previous_energy = 0 + for timestamp in sorted(forecast_energy): + energy = forecast_energy[timestamp] + if previous_timestamp: + hours = (datetime.strptime(timestamp, TIME_FORMAT) - datetime.strptime(previous_timestamp, TIME_FORMAT)).total_seconds() / 3600 + if hours > 0: + forecast_power[timestamp] = dp4(max(energy - previous_energy, 0) / hours) + previous_timestamp = timestamp + previous_energy = energy + + load_power = prune_today(history_attribute(self.get_history_wrapper(self.prefix + ".load_power", 7, required=False)), self.now_utc, self.midnight_utc, prune=True, prune_past_days=7) + car_power = prune_today(history_attribute(self.get_history_wrapper(self.prefix + ".car_charging_power", 7, required=False)), self.now_utc, self.midnight_utc, prune=True, prune_past_days=7) + series = { + "energy_actual": prune_today(history_attribute(stats_history, attributes=True, state_key="load_today"), self.now_utc, self.midnight_utc, prune=False), + "energy_predicted_h1": prune_today(history_attribute(stats_history, attributes=True, state_key="load_today_h1"), self.now_utc, self.midnight_utc, prune=False, offset_minutes=60), + "energy_predicted_h8": prune_today(history_attribute(stats_history, attributes=True, state_key="load_today_h8"), self.now_utc, self.midnight_utc, prune=False, offset_minutes=480), + "energy_forecast": forecast_energy, + "power_actual": load_power, + "power_actual_less_car": subtract_series(load_power, car_power), + "car_power": car_power, + "power_forecast": forecast_power, + "power_history": prune_today(history_attribute(stats_history, attributes=True, state_key="power_today"), self.now_utc, self.midnight_utc, prune=False), + "power_history_h1": prune_today(history_attribute(stats_history, attributes=True, state_key="power_today_h1"), self.now_utc, self.midnight_utc, prune=False, offset_minutes=60), + "power_history_h8": prune_today(history_attribute(stats_history, attributes=True, state_key="power_today_h8"), self.now_utc, self.midnight_utc, prune=False, offset_minutes=480), + "pv_actual": prune_today(history_attribute(self.get_history_wrapper(self.prefix + ".pv_power", 7, required=False)), self.now_utc, self.midnight_utc, prune=True, prune_past_days=7), + "pv_forecast": self.get_entity_results(self.prefix + ".pv_power_best"), + "temperature": prune_today(self.get_entity_results("sensor." + self.prefix + "_temperature"), self.now_utc, self.midnight_utc, prune_future=True, prune_future_days=2, prune=True, prune_past_days=7), + } + + return { + "chart": "loadml", + "ready": bool(series["energy_actual"] or series["energy_forecast"] or series["power_actual"]), + "generated_at": self.now_utc.strftime(TIME_FORMAT), + "car_configured": bool(self.base.car_charging_power_configured), + "series": series, + } + + async def html_api_chart_data(self, request): + """Return chart data for the modern interface as JSON.""" + chart = request.query.get("chart", "battery").strip().lower() + if chart == "battery": + return web.json_response(self.get_battery_chart_data()) + if chart == "power": + return web.json_response(self.get_power_chart_data()) + if chart == "cost": + return web.json_response(self.get_cost_chart_data()) + if chart == "rates": + return web.json_response(self.get_rates_chart_data()) + if chart == "inday": + return web.json_response(self.get_inday_chart_data()) + if chart == "solar": + return web.json_response(self.get_solar_chart_data()) + if chart == "savings": + return web.json_response(self.get_savings_chart_data()) + if chart == "degradation": + return web.json_response(self.get_battery_degradation_chart_data()) + if chart == "marginal": + return web.json_response(self.get_marginal_costs_chart_data()) + if chart == "carbon": + return web.json_response(self.get_carbon_chart_data()) + if chart == "loadml": + return web.json_response(self.get_load_ml_chart_data()) + return web.json_response({"error": "Unknown chart type"}, status=400) + + def get_chart(self, chart): + """ + Return the HTML for a chart + """ + now_str = self.now_utc.strftime(TIME_FORMAT) + soc_kw_best = self.get_entity_results(self.prefix + ".soc_kw_best") battery_power_best = self.get_entity_results(self.prefix + ".battery_power_best") pv_power_best = self.get_entity_results(self.prefix + ".pv_power_best") grid_power_best = self.get_entity_results(self.prefix + ".grid_power_best") @@ -3289,33 +4441,34 @@ def get_chart(self, chart): rates = self.get_entity_results(self.prefix + ".rates") rates_export = self.get_entity_results(self.prefix + ".rates_export") rates_gas = self.get_entity_results(self.prefix + ".rates_gas") - record = self.get_entity_results(self.prefix + ".record") text = "" if not soc_kw_best: text += "

Loading...

" elif chart == "Battery": + battery_data = self.get_battery_chart_data(soc_kw_best=soc_kw_best) + battery_series = battery_data["series"] series_data = [ - {"name": "Base", "data": soc_kw, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "color": "#3291a8"}, - {"name": "Base10", "data": soc_kw_base10, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "color": "#e8972c"}, - {"name": "Best", "data": soc_kw_best, "opacity": "1.0", "stroke_width": "4", "stroke_curve": "smooth", "color": "#eb2323"}, - {"name": "Best10", "data": soc_kw_best10, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "color": "#cd23eb"}, - {"name": "Actual", "data": soc_kw_h0, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "color": "#3291a8"}, - {"name": "Predicted (+1h)", "data": soc_kw_best_h1, "opacity": "0.7", "stroke_width": "2", "stroke_curve": "smooth", "color": "#f5a442"}, - {"name": "Predicted (+8h)", "data": soc_kw_best_h8, "opacity": "0.7", "stroke_width": "2", "stroke_curve": "smooth", "color": "#9b59b6"}, - {"name": "Charge Limit Base", "data": charge_limit_kw, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "stepline", "color": "#15eb8b"}, + {"name": "Base", "data": battery_series["base"], "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "color": "#3291a8"}, + {"name": "Base10", "data": battery_series["base10"], "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "color": "#e8972c"}, + {"name": "Best", "data": battery_series["optimized"], "opacity": "1.0", "stroke_width": "4", "stroke_curve": "smooth", "color": "#eb2323"}, + {"name": "Best10", "data": battery_series["optimized10"], "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "color": "#cd23eb"}, + {"name": "Actual", "data": battery_series["actual"], "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "color": "#3291a8"}, + {"name": "Predicted (+1h)", "data": battery_series["predicted_h1"], "opacity": "0.7", "stroke_width": "2", "stroke_curve": "smooth", "color": "#f5a442"}, + {"name": "Predicted (+8h)", "data": battery_series["predicted_h8"], "opacity": "0.7", "stroke_width": "2", "stroke_curve": "smooth", "color": "#9b59b6"}, + {"name": "Charge Limit Base", "data": battery_series["charge_limit_base"], "opacity": "1.0", "stroke_width": "2", "stroke_curve": "stepline", "color": "#15eb8b"}, { "name": "Charge Limit Best", - "data": best_charge_limit_kw, + "data": battery_series["charge_limit_optimized"], "opacity": "0.2", "stroke_width": "4", "stroke_curve": "stepline", "chart_type": "area", "color": "#e3e019", }, - {"name": "Best Export Limit", "data": best_export_limit_kw, "opacity": "1.0", "stroke_width": "3", "stroke_curve": "stepline", "color": "#15eb1c"}, - {"name": "Record", "data": record, "opacity": "0.5", "stroke_width": "4", "stroke_curve": "stepline", "color": "#000000", "chart_type": "area"}, + {"name": "Best Export Limit", "data": battery_series["export_limit_optimized"], "opacity": "1.0", "stroke_width": "3", "stroke_curve": "stepline", "color": "#15eb1c"}, + {"name": "Record", "data": battery_series["record"], "opacity": "0.5", "stroke_width": "4", "stroke_curve": "stepline", "color": "#000000", "chart_type": "area"}, ] text += self.render_chart(series_data, "kWh", "Battery SoC Prediction", now_str) elif chart == "Power": @@ -3339,31 +4492,24 @@ def get_chart(self, chart): ] text += self.render_chart(series_data, self.currency_symbols[1], "Home Cost Prediction", now_str) elif chart == "Rates": - cost_today_hist = history_attribute(self.get_history_wrapper(self.prefix + ".ppkwh_today", 2, required=False)) - cost_hour_hist = history_attribute(self.get_history_wrapper(self.prefix + ".ppkwh_hour", 2, required=False)) - - cost_pkwh_today = prune_today(cost_today_hist, self.now_utc, self.midnight_utc, prune=False, prune_future=False) - cost_pkwh_hour = prune_today(cost_hour_hist, self.now_utc, self.midnight_utc, prune=False, prune_future=False) + rates_data = self.get_rates_chart_data() + rates_series = rates_data["series"] series_data = [ - {"name": "Import", "data": rates, "opacity": "1.0", "stroke_width": "3", "stroke_curve": "stepline"}, - {"name": "Export", "data": rates_export, "opacity": "0.2", "stroke_width": "2", "stroke_curve": "stepline", "chart_type": "area"}, - {"name": "Gas", "data": rates_gas, "opacity": "0.2", "stroke_width": "2", "stroke_curve": "stepline", "chart_type": "area"}, - {"name": "Hourly {}/kWh".format(self.currency_symbols[1]), "data": cost_pkwh_hour, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "stepline"}, - {"name": "Today {}/kWh".format(self.currency_symbols[1]), "data": cost_pkwh_today, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "stepline"}, + {"name": "Import", "data": rates_series["import"], "opacity": "1.0", "stroke_width": "3", "stroke_curve": "stepline"}, + {"name": "Export", "data": rates_series["export"], "opacity": "0.2", "stroke_width": "2", "stroke_curve": "stepline", "chart_type": "area"}, + {"name": "Gas", "data": rates_series["gas"], "opacity": "0.2", "stroke_width": "2", "stroke_curve": "stepline", "chart_type": "area"}, + {"name": "Hourly {}/kWh".format(self.currency_symbols[1]), "data": rates_series["actual_hourly"], "opacity": "1.0", "stroke_width": "2", "stroke_curve": "stepline"}, + {"name": "Today {}/kWh".format(self.currency_symbols[1]), "data": rates_series["actual_today"], "opacity": "1.0", "stroke_width": "2", "stroke_curve": "stepline"}, ] text += self.render_chart(series_data, self.currency_symbols[1], "Energy Rates", now_str) elif chart == "InDay": - load_energy_actual = self.get_entity_results(self.prefix + ".load_energy_actual") - load_energy_predicted = self.get_entity_results(self.prefix + ".load_energy_predicted") - load_energy_adjusted = self.get_entity_results(self.prefix + ".load_energy_adjusted") - inday_adjust_hist = history_attribute(self.get_history_wrapper(self.prefix + ".load_inday_adjustment", 2, required=False)) - adjustment_factor = prune_today(inday_adjust_hist, self.now_utc, self.midnight_utc, prune=True) + inday_series = self.get_inday_chart_data()["series"] series_data = [ - {"name": "Actual", "data": load_energy_actual, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "kWh"}, - {"name": "Predicted", "data": load_energy_predicted, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "kWh"}, - {"name": "Adjusted", "data": load_energy_adjusted, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "kWh"}, - {"name": "Adjustment Factor", "data": adjustment_factor, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "%"}, + {"name": "Actual", "data": inday_series["actual"], "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "kWh"}, + {"name": "Predicted", "data": inday_series["predicted"], "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "kWh"}, + {"name": "Adjusted", "data": inday_series["adjusted"], "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "kWh"}, + {"name": "Adjustment Factor", "data": inday_series["adjustment_factor"], "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "%"}, ] secondary_axis = [ { @@ -3376,32 +4522,33 @@ def get_chart(self, chart): ] text += self.render_chart(series_data, "kWh", "In Day Adjustment", now_str, extra_yaxis=secondary_axis) elif chart == "PV" or chart == "PV7": - pv_power_hist = history_attribute(self.get_history_wrapper(self.prefix + ".pv_power", 7, required=False)) - pv_power = prune_today(pv_power_hist, self.now_utc, self.midnight_utc, prune=chart == "PV") - # The uncalibrated forecast: h0's state is the calibrated one while calibration is on, which would - # draw the same line as Forecast History CL below - pv_forecast_hist = history_attribute(self.get_history_wrapper("sensor." + self.prefix + "_pv_forecast_h0_uncalibrated", 7, required=False)) - pv_forecast_histCL = history_attribute(self.get_history_wrapper("sensor." + self.prefix + "_pv_forecast_h0", 7, required=False), attributes=True, state_key="nowCL") - - pv_forecast = prune_today(pv_forecast_hist, self.now_utc, self.midnight_utc, prune=chart == "PV", intermediate=True) - pv_forecastCL = prune_today(pv_forecast_histCL, self.now_utc, self.midnight_utc, prune=chart == "PV", intermediate=True) - pv_today_forecast = prune_today(self.get_entity_detailedForecast("sensor." + self.prefix + "_pv_today", "pv_estimate"), self.now_utc, self.midnight_utc, prune=False, intermediate=True) - pv_today_forecast10 = prune_today(self.get_entity_detailedForecast("sensor." + self.prefix + "_pv_today", "pv_estimate10"), self.now_utc, self.midnight_utc, prune=False, intermediate=True) - pv_today_forecast90 = prune_today(self.get_entity_detailedForecast("sensor." + self.prefix + "_pv_today", "pv_estimate90"), self.now_utc, self.midnight_utc, prune=False, intermediate=True) - pv_today_forecastCL = prune_today(self.get_entity_detailedForecast("sensor." + self.prefix + "_pv_today", "pv_estimateCL"), self.now_utc, self.midnight_utc, prune=False, intermediate=True) - pv_today_forecast.update(prune_today(self.get_entity_detailedForecast("sensor." + self.prefix + "_pv_tomorrow", "pv_estimate"), self.now_utc, self.midnight_utc, prune=False, intermediate=True)) - pv_today_forecast10.update(prune_today(self.get_entity_detailedForecast("sensor." + self.prefix + "_pv_tomorrow", "pv_estimate10"), self.now_utc, self.midnight_utc, prune=False, intermediate=True)) - pv_today_forecast90.update(prune_today(self.get_entity_detailedForecast("sensor." + self.prefix + "_pv_tomorrow", "pv_estimate90"), self.now_utc, self.midnight_utc, prune=False, intermediate=True)) - pv_today_forecastCL.update(prune_today(self.get_entity_detailedForecast("sensor." + self.prefix + "_pv_tomorrow", "pv_estimateCL"), self.now_utc, self.midnight_utc, prune=False, intermediate=True)) + solar_series = self.get_solar_chart_data()["series"] + prune_to_today = chart == "PV" series_data = [ - {"name": "PV Power", "data": pv_power, "opacity": "1.0", "stroke_width": "3", "stroke_curve": "smooth", "color": "#f5c43d"}, - {"name": "Forecast History", "data": pv_forecast, "opacity": "0.3", "stroke_width": "3", "stroke_curve": "smooth", "color": "#a8a8a7", "chart_type": "area"}, - {"name": "Forecast History CL", "data": pv_forecastCL, "opacity": "0.3", "stroke_width": "3", "stroke_curve": "smooth", "color": "#e90a0a", "chart_type": "area"}, - {"name": "Forecast", "data": pv_today_forecast, "opacity": "0.3", "stroke_width": "2", "stroke_curve": "smooth", "chart_type": "area", "color": "#a8a8a7"}, - {"name": "Forecast 10%", "data": pv_today_forecast10, "opacity": "0.3", "stroke_width": "2", "stroke_curve": "smooth", "chart_type": "area", "color": "#6b6b6b"}, - {"name": "Forecast 90%", "data": pv_today_forecast90, "opacity": "0.3", "stroke_width": "2", "stroke_curve": "smooth", "chart_type": "area", "color": "#cccccc"}, - {"name": "Forecast CL", "data": pv_today_forecastCL, "opacity": "0.3", "stroke_width": "2", "stroke_curve": "smooth", "chart_type": "area", "color": "#e90a0a"}, + {"name": "PV Power", "data": prune_today(solar_series["actual"], self.now_utc, self.midnight_utc, prune=prune_to_today), "opacity": "1.0", "stroke_width": "3", "stroke_curve": "smooth", "color": "#f5c43d"}, + { + "name": "Forecast History", + "data": prune_today(solar_series["forecast_history"], self.now_utc, self.midnight_utc, prune=prune_to_today, intermediate=True), + "opacity": "0.3", + "stroke_width": "3", + "stroke_curve": "smooth", + "color": "#a8a8a7", + "chart_type": "area", + }, + { + "name": "Forecast History CL", + "data": prune_today(solar_series["forecast_history_calibrated"], self.now_utc, self.midnight_utc, prune=prune_to_today, intermediate=True), + "opacity": "0.3", + "stroke_width": "3", + "stroke_curve": "smooth", + "color": "#e90a0a", + "chart_type": "area", + }, + {"name": "Forecast", "data": solar_series["forecast"], "opacity": "0.3", "stroke_width": "2", "stroke_curve": "smooth", "chart_type": "area", "color": "#a8a8a7"}, + {"name": "Forecast 10%", "data": solar_series["forecast_low"], "opacity": "0.3", "stroke_width": "2", "stroke_curve": "smooth", "chart_type": "area", "color": "#6b6b6b"}, + {"name": "Forecast 90%", "data": solar_series["forecast_high"], "opacity": "0.3", "stroke_width": "2", "stroke_curve": "smooth", "chart_type": "area", "color": "#cccccc"}, + {"name": "Forecast CL", "data": solar_series["forecast_calibrated"], "opacity": "0.3", "stroke_width": "2", "stroke_curve": "smooth", "chart_type": "area", "color": "#e90a0a"}, ] text += self.render_chart(series_data, "kW", "Solar Forecast", now_str) elif chart == "PVAccuracy": @@ -3719,32 +4866,40 @@ def _marginal_history(key): async def html_charts(self, request): """ - Render apps.yaml as an HTML page + Serve the selected charts interface. """ + if self.get_web_ui() == "modern": + return await self.html_modern_ui(request) + + return await self.html_charts_legacy(request) + + async def html_charts_legacy(self, request): + """Render the legacy charts interface.""" args = request.query chart = args.get("chart", "Battery") - self.default_page = "./charts?chart={}".format(chart) + chart_path = "./legacy_charts" if self.get_web_ui() == "modern" else "./charts" + self.default_page = "{}?chart={}".format(chart_path, chart) text = self.get_header("Predbat Charts", refresh=60 * 5) text += "\n" text += get_charts_css() text += '
' text += "

Charts

" - text += f'Battery' - text += f'Power' - text += f'Cost' - text += f'Rates' - text += f'InDay' - text += f'PV' - text += f'PV7' - text += f'PVAccuracy' - text += f'Savings' - text += f'BatteryDegradation' - text += f'MarginalCosts' + text += f'Battery' + text += f'Power' + text += f'Cost' + text += f'Rates' + text += f'InDay' + text += f'PV' + text += f'PV7' + text += f'PVAccuracy' + text += f'Savings' + text += f'BatteryDegradation' + text += f'MarginalCosts' # Only show LoadML chart if ML is enabled if self.base.get_arg("load_ml_enable", False): - text += f'LoadML' - text += f'LoadMLPower' + text += f'LoadML' + text += f'LoadMLPower' text += "
" if chart != "MarginalCosts": @@ -4409,6 +5564,76 @@ def to_pounds(self, cost): return res + def get_compare_data(self): + """Return tariff comparison results and history for the modern interface.""" + actual = history_attribute(self.get_history_wrapper(self.prefix + ".cost_yesterday", 28, required=False), daily=True, offset_days=-1, pounds=True) + if self.base.num_cars > 0: + car = history_attribute(self.get_history_wrapper(self.prefix + ".cost_yesterday_car", 28, required=False), daily=True, offset_days=-1, pounds=True) + actual_no_car = self.subtract_daily(actual, car) + else: + actual_no_car = {} + + tariffs = [] + for configured in self.get_arg("compare_list", []): + tariff_id = configured.get("id", "") + result = self.base.comparison.get_comparison(tariff_id) if self.base.comparison else {} + metric_history = {} + cost_at_1am = {} + entity_id = result.get("entity_id") + if entity_id: + entity_history = self.get_history_wrapper(entity_id, 28, required=False) + metric_history = history_attribute(entity_history, state_key="metric", attributes=True, daily=True, pounds=True) + cost_at_1am = self.history_daily_at_hour(entity_history) + + date = result.get("date", "") + try: + stamp = datetime.strptime(date, "%Y-%m-%d %H:%M:%S").strftime(TIME_FORMAT_DAILY) + metric_history[stamp] = dp2(result.get("metric", 0) / 100) + except (ValueError, TypeError): + pass + + average, average_days = self.average_cost_window(cost_at_1am, 7) + rolling = {key: dp2(value / 100) for key, value in self.rolling_7d_average(cost_at_1am).items()} + tariffs.append( + { + "id": tariff_id, + "name": configured.get("name", ""), + "date": date, + "true_cost": dp2(result.get("metric", 0) / 100) if result else None, + "cost": dp2(result.get("cost", 0) / 100) if result else None, + "cost10": dp2(result.get("cost10", 0) / 100) if result else None, + "average7": dp2(average / 100) if average is not None else None, + "average_days": average_days, + "export_kwh": result.get("export_kwh"), + "import_kwh": result.get("import_kwh"), + "soc": result.get("soc"), + "final_iboost": result.get("final_iboost"), + "final_carbon_g": result.get("final_carbon_g"), + "best": result.get("best", False), + "existing": result.get("existing_tariff", False), + "history": metric_history, + "rolling7": rolling, + "plan": result.get("raw"), + } + ) + + return { + "active": self.get_arg("compare_active", False), + "configured": bool(tariffs), + "ready": any(tariff["date"] for tariff in tariffs), + "generated_at": self.now_utc.strftime(TIME_FORMAT), + "currency_symbol": self.currency_symbols[0], + "show_iboost": self.base.iboost_enable, + "show_carbon": self.base.carbon_enable, + "actual": actual, + "actual_no_car": actual_no_car, + "tariffs": tariffs, + } + + async def html_api_compare(self, request): + """Return tariff comparison data as JSON.""" + return web.json_response(self.get_compare_data()) + async def html_compare(self, request): """ Return the Predbat compare page as an HTML page @@ -4679,6 +5904,45 @@ async def html_apps_editor_checksum(self, request): except Exception as e: return web.json_response({"error": str(e)}, status=500) + async def html_api_apps_schema(self, request): + """Return JSON Schema used by the modern apps.yaml editor.""" + return web.json_response(build_apps_json_schema()) + + async def html_api_apps_yaml(self, request): + """Return the unmasked apps.yaml content for editing.""" + try: + with open("apps.yaml", "r") as handle: + content = handle.read() + checksum = hashlib.md5(content.encode("utf-8")).hexdigest() + return web.json_response({"content": content, "checksum": checksum}) + except OSError as error: + return web.json_response({"error": str(error)}, status=500) + + async def html_api_apps_yaml_post(self, request): + """Save apps.yaml after checking it has not changed on disk.""" + try: + payload = await request.json() + content = payload.get("content") + if not isinstance(content, str): + return web.json_response({"error": "No content provided to save"}, status=400) + + with open("apps.yaml", "r") as handle: + current = handle.read() + checksum = hashlib.md5(current.encode("utf-8")).hexdigest() + if payload.get("checksum") != checksum: + return web.json_response({"error": "apps.yaml changed on disk; reload before saving"}, status=409) + + content = content.replace("\r\n", "\n").replace("\r", "\n") + shutil.copy2("apps.yaml", "apps.yaml.backup") + with open("apps.yaml", "w") as handle: + handle.write(content) + saved_checksum = hashlib.md5(content.encode("utf-8")).hexdigest() + self.log("Apps.yaml successfully saved to apps.yaml; backup created at apps.yaml.backup") + return web.json_response({"saved": True, "checksum": saved_checksum}) + except (OSError, ValueError, json.JSONDecodeError) as error: + self.log("ERROR: Failed to save apps.yaml: {}".format(error)) + return web.json_response({"error": str(error)}, status=500) + async def html_apps_editor_post(self, request): """ Handle POST request for apps.yaml editor - save the file @@ -4931,8 +6195,19 @@ async def html_rate_override(self, request): return web.json_response({"success": False, "message": "Unknown action"}, status=400) # Refresh plan - self.base.update_pending = True - self.base.plan_valid = False + # Setting a rate updates the manual rate input number, + # whose event already triggers a re-plan. + # + # Clearing a rate only updates the manual selector directly, + # so it needs to explicitly request a new plan here. + if action in [ + "Clear Import", + "Clear Export", + "Clear Load", + "Clear SOC", + ]: + self.base.update_pending = True + self.base.plan_valid = False # Return html plan again self.log("Rate override processed successfully") @@ -4989,8 +6264,8 @@ async def html_plan_override(self, request): return web.json_response({"success": False, "message": "Unknown action"}, status=400) # Refresh plan - self.base.update_pending = True - self.base.plan_valid = False + # self.base.update_pending = True + # self.base.plan_valid = False # Return html plan again return web.json_response({"success": True}, status=200) @@ -5066,6 +6341,19 @@ def _coordinator(self): components = getattr(self.base, "components", None) return getattr(components, "coordinator", None) if components else None + async def html_api_discovery(self, request): + """Return the redacted discovery catalogue, or raw values when explicitly requested.""" + coordinator = self._coordinator() + if coordinator is None: + return web.json_response({"error": "The discovery catalogue is not available yet."}, status=503) + + raw = str(request.query.get("raw", "")).lower() in ("1", "true", "yes", "on") + try: + return web.json_response(coordinator.catalogue_raw() if raw else coordinator.catalogue()) + except Exception as e: + self.log("Warn: Web: failed to read the discovery catalogue: {}".format(e)) + return web.json_response({"error": "Unable to read the discovery catalogue."}, status=500) + def _discovery_value_html(self, value): """Render one catalogue value - a scalar, a list or a nested container - as HTML. @@ -5215,6 +6503,98 @@ async def html_discovery(self, request): text += "\n" return web.Response(content_type="text/html", text=text) + async def html_api_components(self, request): + """Return component status and display-safe configuration as JSON.""" + from components import COMPONENT_LIST + + active_components = set(self.base.components.get_active()) + result = [] + + for component_name in self.base.components.get_all(): + component_info = COMPONENT_LIST.get(component_name, {}) + is_active = component_name in active_components + is_alive = self.base.components.is_alive(component_name) + load_error = self.base.components.load_error(component_name) + status = "error" if load_error or (is_active and not is_alive) else "active" if is_active else "disabled" + event_filter = component_info.get("event_filter", "") + settings = [] + + for arg_info in component_info.get("args", {}).values(): + config_key = arg_info.get("config", "") + if not config_key: + continue + value = self.get_arg(config_key, arg_info.get("default", ""), indirect=False) + if arg_info.get("secret"): + value = "Configured" if value else "Not set" + elif isinstance(value, (dict, list)): + value = "Configured" if value else "Not set" + elif value is None or value == "": + value = "Not set" + else: + value = str(value) + settings.append({"name": config_key, "required": bool(arg_info.get("required")), "value": value}) + + result.append( + { + "id": component_name, + "name": component_info.get("name", component_name), + "status": status, + "can_restart": bool(component_info.get("can_restart", True)) and (is_active or bool(load_error)), + "last_updated": format_time_ago(self.base.components.last_updated_time(component_name)), + "error": load_error, + "error_count": self.base.components.get_error_count(component_name), + "entity_count": self.count_entities_matching_filter(event_filter) if event_filter else None, + "event_filter": event_filter or None, + "settings": settings, + } + ) + + order = {"error": 0, "active": 1, "disabled": 2} + result.sort(key=lambda component: (order[component["status"]], component["name"].lower())) + return web.json_response({"components": result}) + + async def html_api_config(self, request): + """Return enabled Home Assistant configuration controls as JSON.""" + items = [] + schema = build_apps_json_schema()["$defs"]["predbatApp"]["properties"] + for item in self.base.CONFIG_ITEMS: + if not self.base.user_config_item_enabled(item) or not item.get("entity"): + continue + value = item.get("value") + if value is None: + value = item.get("default", "") + options = list(item.get("options", [])) + if item.get("type") == "select" and value not in options: + options.append(value) + friendly_name = item.get("friendly_name", "") + description = schema.get(item.get("name", ""), {}).get("description", "") + if not description or description == friendly_name: + if item.get("type") == "switch": + description = f"Turns {friendly_name.lower()} on or off." + elif item.get("type") in ["input_number", "number"]: + description = f"Sets {friendly_name.lower()} between {item.get('min', 0)} and {item.get('max', 100)}." + elif item.get("type") == "select": + description = f"Selects the value used for {friendly_name.lower()}." + else: + description = f"Shows the current value of {friendly_name.lower()}." + items.append( + { + "name": item.get("name", ""), + "friendly_name": friendly_name, + "description": description, + "entity": item["entity"], + "type": item.get("type", ""), + "value": value, + "default": item.get("default", ""), + "unit": self.base.convert_currency_unit(item.get("unit", "")), + "min": item.get("min"), + "max": item.get("max"), + "step": item.get("step"), + "options": options, + } + ) + return web.json_response({"items": items, "errors": dict(self.base.arg_errors)}) + async def html_components(self, request): """ Return the Components view as an HTML page showing status of all components @@ -5728,6 +7108,45 @@ async def html_component_config_save(self, request): traceback.print_exc() return web.json_response({"success": False, "message": str(e)}, status=500) + async def html_api_browse(self, request): + """List one directory for the modern read-only file browser.""" + requested_path = request.query.get("path", "").strip("/") + base_dir = os.path.realpath(os.getcwd()) + safe_path = os.path.realpath(os.path.join(base_dir, requested_path)) + + if os.path.commonpath([base_dir, safe_path]) != base_dir: + return web.json_response({"error": "Access denied"}, status=403) + if not os.path.isdir(safe_path): + return web.json_response({"error": "Directory not found"}, status=404) + + files = [] + try: + for name in os.listdir(safe_path): + if name.startswith("."): + continue + item_path = os.path.join(safe_path, name) + item_real_path = os.path.realpath(item_path) + if os.path.commonpath([base_dir, item_real_path]) != base_dir: + continue + try: + stat_info = os.stat(item_real_path) + except (OSError, PermissionError): + continue + relative_path = os.path.relpath(item_real_path, base_dir).replace("\\", "/") + files.append( + { + "name": name, + "isDirectory": os.path.isdir(item_real_path), + "path": "/" + relative_path, + "updatedAt": datetime.fromtimestamp(stat_info.st_mtime).astimezone().isoformat(), + "size": None if os.path.isdir(item_real_path) else stat_info.st_size, + } + ) + except PermissionError: + return web.json_response({"error": "Permission denied"}, status=403) + + return web.json_response({"files": files}) + async def html_browse(self, request): """ Return the Browse page as an HTML page with file browsing functionality @@ -6311,6 +7730,33 @@ async def html_api_internals(self, request): self.log(f"Error in internals API: {str(e)}") return web.Response(content_type="application/json", text=json.dumps({"success": False, "error": str(e)})) + async def html_api_internals_threads(self, request): + """Return thread stacks and asyncio tasks as JSON.""" + try: + frames = sys._current_frames() + known_threads = {thread.ident: thread for thread in threading.enumerate()} + threads = [] + + for thread_id, frame in frames.items(): + thread = known_threads.get(thread_id) + stack = [{"file": item.filename, "line": item.lineno, "name": item.name, "code": item.line or ""} for item in traceback.extract_stack(frame)] + threads.append( + { + "name": thread.name if thread else f"Thread-{thread_id}", + "id": thread_id, + "alive": thread.is_alive() if thread else None, + "daemon": thread.daemon if thread else None, + "stack": stack, + "tasks": self._get_thread_asyncio_tasks(frame), + } + ) + + threads.sort(key=lambda item: item["name"].lower()) + return web.json_response({"success": True, "threads": threads}) + except Exception as e: + self.log(f"Error getting thread stacks: {e}") + return web.json_response({"success": False, "error": str(e)}, status=500) + async def html_api_internals_download(self, request): """ API endpoint to download object as YAML diff --git a/coverage/deploy b/coverage/deploy index 0fbadd80a..721820357 100755 --- a/coverage/deploy +++ b/coverage/deploy @@ -6,3 +6,4 @@ echo "$GIT_HASH" > /Volumes/addon_configs/6adb4f0d_predbat/git_version.txt cp ../apps/predbat/*.py /Volumes/addon_configs/6adb4f0d_predbat cp ../apps/predbat/*.proto /Volumes/addon_configs/6adb4f0d_predbat cp ../apps/predbat/*.so /Volumes/addon_configs/6adb4f0d_predbat +cp ../apps/predbat/frontend.zip /Volumes/addon_configs/6adb4f0d_predbat diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index c87acc0cf..761323d9c 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -429,6 +429,15 @@ Docker users can change the web port for the Predbat web interface by setting ** web_port: 5052 ``` +The existing interface remains the default. To use the modern interface, set: + +```yaml + # Load the modern React interface. Use legacy to select the original UI. + web_ui: modern +``` + +Set `web_ui: legacy`, or remove the setting, to use the existing interface. See [Use the modern interface](web-interface.md#use-the-modern-interface) for the optional EV and ASHP entities used by Overview. + ### notify_devices A list of device names to notify when Predbat sends a notification. The default is just 'notify' which contacts all mobile devices diff --git a/docs/images/web-interface-overview-view.png b/docs/images/web-interface-overview-view.png new file mode 100644 index 000000000..04df99515 Binary files /dev/null and b/docs/images/web-interface-overview-view.png differ diff --git a/docs/web-interface.md b/docs/web-interface.md index 15e77b7e6..7104a1010 100644 --- a/docs/web-interface.md +++ b/docs/web-interface.md @@ -2,6 +2,95 @@ The Predbat Web Interface provides an easy to use way to see and change different aspects of your Predbat system including viewing the current plan, adjusting the configuration, viewing the charts, checking your apps.yaml and viewing the logfiles. +The existing interface is used by default. Add `web_ui: modern` to the `pred_bat` section of `apps.yaml` to use the new interface, or set `web_ui: legacy` to select the existing interface explicitly. + +## Use the modern interface + +The modern interface includes the Overview, Dashboard, Plan, Charts, Compare, configuration and system pages in one responsive interface. Select it in the `pred_bat` section of `apps.yaml`: + +```yaml +pred_bat: + # Load the modern React interface. Use legacy to select the original UI. + web_ui: modern +``` + +Predbat uses the legacy interface when `web_ui` is missing. Saving `apps.yaml` restarts Predbat and applies the selection. + +### Configure the Overview page + +![image](images/web-interface-overview-view.png) + +Overview reads the standard grid, solar, home and battery data from your existing Predbat configuration. You don't need to duplicate those entities. The electric vehicle (EV) and air-source heat pump (ASHP) cards accept extra, optional entities. + +The solar card can display the current weather, and optional weather effects can be enabled on the house view. To enable, add a weather entity: + +```yaml +pred_bat: + # Weather and temperature shown on Overview + weather: weather.forecast_home +``` + +Add the EV entries only when Predbat's supported charger integrations haven't configured them automatically. Use one list item per car, in the same order for every setting: + +```yaml +pred_bat: + # Tell Predbat how many cars the lists below describe. + num_cars: 1 + + # Current charger power in W, kW or another convertible power unit. + car_charging_power: + - sensor.your_ev_charger_power + + # Charger connection state, such as unplugged, connected or charging. + car_charging_status: + - sensor.your_ev_charger_status + + # Current vehicle battery state of charge as a percentage. + car_charging_soc: + - sensor.your_ev_battery_percentage + + # Daily incrementing charger energy in kWh. + car_charging_energy: + - sensor.your_ev_charger_energy_today +``` + +To add an ASHP to overview, enable the card explicitly and provide a daily energy entity if one is available: + +```yaml +pred_bat: + # Show the ASHP card without a PredHeat configuration. + ashp_enable: true + + # Optional current electrical power draw and operating mode. + ashp_power: sensor.heat_pump_power + ashp_status: sensor.heat_pump_status + + # Optional daily heat-pump electricity use in kWh. + heat_energy: sensor.heat_pump_energy_today +``` + +ashp_status should point to an entity that returns `off` / `hot_water` / `heating` depending on the status. An example helper for this: + +```yaml + +{% set output = states('sensor.heat_pump_energy_output') | float(0) %} +{% set unit = state_attr('sensor.heat_pump_energy_output', 'unit_of_measurement') %} +{% set watts = output * 1000 if unit == 'kW' else output %} + +{% if watts < 100 %} + off +{% elif is_state('sensor.ch_or_dhw', 'HW') %} + hot_water +{% else %} + heating +{% endif %} + +``` + +Overview uses `predheat.weather` when PredHeat is configured. Otherwise it uses the top-level `weather` setting shown above. For compatibility with earlier test releases it will still try `weather.forecast_home` when neither is set. If the selected entity does not exist, Overview hides the weather reading, weather effects and their toggle. + +The modern `apps.yaml` editor validates these settings and suggests entity IDs from Home Assistant. Open **Configuration > Editor**, enter one of the entity settings, then choose the matching entity from the suggestions. Hover over a setting to read its schema description. + ![image](images/web-interface-plan-view.png) ## Accessing the Web Interface @@ -261,6 +350,31 @@ Further details about the Predbat logfile and its location is [described in the The Config view provides a way to see and change all of Predbat's configuration entities in Home Assistant. For each entity is displayed the name, entity type, the current entity value and Predbat's default value for that entity. +#### Tariff helper + +The modern Config view includes a tariff helper which reviews Predbat's current configuration controls and its published import and export prices. It is a review tool: it does not change the tariff source, edit `apps.yaml`, or apply a setting until you select **Apply**. + +The helper reads the current and next 48 hours of rates from the same data used by the Rates chart. It classifies the import tariff as: + +- **Dynamic** when any import price is negative, or when at least eight distinct prices are published (rounded to 0.1 in the configured minor currency unit). +- **Time of use** when there is more than one import price, or the difference between the lowest and highest price is at least 4 per kWh. +- **Flat** when neither of those conditions applies. + +Export prices are treated separately. An export tariff is considered variable when its highest and lowest prices differ by at least 3 per kWh. If Predbat has no future export-rate data, the helper reports it as unavailable and makes no export suggestions. + +The helper can suggest changes to these existing Predbat controls when they are available: + +| Control | Flat or time-of-use suggestion | Dynamic or variable suggestion | +| --- | --- | --- | +| **Combine Charge Slots** | On, so adjacent low-price periods form one practical window | Off, preserving individual half-hour prices | +| **Set Charge Low Power Mode** | On, spreading charging across a known low-price window | Off, concentrating charging into the cheapest periods | +| **Combine Export Slots** | On when export prices are broadly flat | Off when export prices vary, preserving individual peaks | +| **Set Export Low Power Mode** | On when there is little price benefit from exporting faster | Off when export prices vary, concentrating export into the most valuable periods | + +Only controls whose suggested value differs from their current value are shown. Each suggestion displays the exact current and proposed values with a reason, and is selected individually so you can exclude any change before applying the remainder. Applying the selection updates the normal Home Assistant configuration entities through the same Config API as the controls below it. + +The tariff helper does not calculate a personalised saving or analyse historical household consumption. Predbat's normal optimiser already combines the selected controls with the load forecast, solar forecast, battery losses and available capacity when it builds the plan; the helper only chooses sensible control behaviour for the shape of the published tariff. + Any entities that are coloured pale red simply denote entities where you have changed the value from Predbat's default value - it does not indicate that the entity value is in error, its just that its different from the default value. Any entity name can be clicked to swap to the [Entities view](#entities-view) for that entity. diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..b166e408a --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# WebP copies generated from the house PNG masters +src/assets/house/*.webp diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 000000000..6fa991dad --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 000000000..4e0fa4a82 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Predbat + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 000000000..becd3e782 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2293 @@ +{ + "name": "frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "dependencies": { + "@cubone/react-file-manager": "^1.35.0", + "@fortawesome/fontawesome-svg-core": "^7.3.1", + "@fortawesome/free-brands-svg-icons": "^7.3.1", + "@fortawesome/free-solid-svg-icons": "^7.3.1", + "@fortawesome/react-fontawesome": "^3.5.0", + "chart.js": "^4.5.1", + "monaco-editor": "^0.54.0", + "monaco-yaml": "^5.5.1", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "sharp": "^0.35.5", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cubone/react-file-manager": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/@cubone/react-file-manager/-/react-file-manager-1.35.0.tgz", + "integrity": "sha512-yvMDY0DXw3JMPhJeFUahJI8NsmSStPU8cOOr6v3i9aYeIioQYVfm0jkVyHAKoW3wKlPgIOc0lL4BI2PV6FejQw==", + "license": "MIT", + "dependencies": { + "@fontsource/nunito-sans": "^5.2.7", + "i18next": "^25.0.0", + "react-collapsed": "^4.2.0", + "react-icons": "^5.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@fontsource/nunito-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/nunito-sans/-/nunito-sans-5.3.0.tgz", + "integrity": "sha512-CctNhebQ2YYJUwVMCAkHexQg7yedfz9s8JzaETALyc9QIQuDFRlNTWXux9H5PsNLgptPkrzRDomAdLJt1sDFpQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.1.tgz", + "integrity": "sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.3.1.tgz", + "integrity": "sha512-BoxVN3PKnMbgStHhjoaky/oWdxHomDqmBVA24IA3KEmssFGeI7u9YT/BJceOjIum/t6TpPa/vcMKaVYQeIQ/3Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.3.1.tgz", + "integrity": "sha512-8eiNzycuFhy3mZsi5EVO8JpO3H8l7/kSCl4Gk4iUew9hJuzYFqAAD6kqgeEup3+YZ9hL+5cjjorGzrRLkYuURg==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.1.tgz", + "integrity": "sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.5.0.tgz", + "integrity": "sha512-63mlRr6fiBbJ0wjr1Cf6dsDGtP2lNvk9lnatKgxs/fIkhslsZT291hIUzJkuUkI9yr69ZvWnWfgb2qXm4QyVaA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~6 || ~7", + "react": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.82.0.tgz", + "integrity": "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.82.0.tgz", + "integrity": "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.82.0.tgz", + "integrity": "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.82.0.tgz", + "integrity": "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.82.0.tgz", + "integrity": "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.82.0.tgz", + "integrity": "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.82.0.tgz", + "integrity": "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.82.0.tgz", + "integrity": "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.82.0.tgz", + "integrity": "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.82.0.tgz", + "integrity": "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.82.0.tgz", + "integrity": "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.82.0.tgz", + "integrity": "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.82.0.tgz", + "integrity": "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.82.0.tgz", + "integrity": "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.82.0.tgz", + "integrity": "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.82.0.tgz", + "integrity": "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.82.0.tgz", + "integrity": "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.82.0.tgz", + "integrity": "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.82.0.tgz", + "integrity": "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", + "license": "MIT" + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", + "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", + "license": "(MPL-2.0 OR Apache-2.0)" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/i18next": { + "version": "25.10.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz", + "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/monaco-editor": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz", + "integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.1.7", + "marked": "14.0.0" + } + }, + "node_modules/monaco-languageserver-types": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/monaco-languageserver-types/-/monaco-languageserver-types-0.4.1.tgz", + "integrity": "sha512-uLiYM6K6jPqAa1ni9lRBoj8ZJAErOcegBrfEpurIUUO8cBhlhrGYjWbjhl4OPcxUNoj1JvqSETjwCTyhGgwymQ==", + "license": "MIT", + "dependencies": { + "monaco-types": "^0.1.0", + "vscode-languageserver-protocol": "^3.0.0", + "vscode-uri": "^3.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + } + }, + "node_modules/monaco-marker-data-provider": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/monaco-marker-data-provider/-/monaco-marker-data-provider-1.2.5.tgz", + "integrity": "sha512-5ZdcYukhPwgYMCvlZ9H5uWs5jc23BQ8fFF5AhSIdrz5mvYLsqGZ58ZLxTv8rCX6+AxdJ8+vxg1HVSk+F2bLosg==", + "license": "MIT", + "dependencies": { + "monaco-types": "^0.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + } + }, + "node_modules/monaco-types": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/monaco-types/-/monaco-types-0.1.2.tgz", + "integrity": "sha512-8LwfrlWXsedHwAL41xhXyqzPibS8IqPuIXr9NdORhonS495c2/wky+sI1PRLvMCuiI0nqC2NH1six9hdiRY4Xg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + } + }, + "node_modules/monaco-worker-manager": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/monaco-worker-manager/-/monaco-worker-manager-2.0.1.tgz", + "integrity": "sha512-kdPL0yvg5qjhKPNVjJoym331PY/5JC11aPJXtCZNwWRvBr6jhkIamvYAyiY5P1AWFmNOy0aRDRoMdZfa71h8kg==", + "license": "MIT", + "peerDependencies": { + "monaco-editor": ">=0.30.0" + } + }, + "node_modules/monaco-yaml": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.5.1.tgz", + "integrity": "sha512-vLR1EoV7Au0tkuA9T6ZJr2q7CooRP69AvlqzWh9Gui6amuijdTsfCAQHtCFobRMwkhbuly6J1Q16NziTTiWtWQ==", + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "@vscode/l10n": "^0.0.18", + "jsonc-parser": "^3.0.0", + "monaco-languageserver-types": "^0.4.0", + "monaco-marker-data-provider": "^1.0.0", + "monaco-types": "^0.1.0", + "monaco-worker-manager": "^2.0.0", + "path-browserify": "^1.0.0", + "prettier": "^3.0.0", + "proxy-disposable": "^1.0.0", + "vscode-languageserver-textdocument": "^1.0.0", + "vscode-languageserver-types": "^3.0.0", + "vscode-uri": "^3.0.0", + "yaml": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + }, + "peerDependencies": { + "monaco-editor": ">=0.36" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.82.0.tgz", + "integrity": "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.82.0", + "@oxlint/binding-android-arm64": "1.82.0", + "@oxlint/binding-darwin-arm64": "1.82.0", + "@oxlint/binding-darwin-x64": "1.82.0", + "@oxlint/binding-freebsd-x64": "1.82.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.82.0", + "@oxlint/binding-linux-arm-musleabihf": "1.82.0", + "@oxlint/binding-linux-arm64-gnu": "1.82.0", + "@oxlint/binding-linux-arm64-musl": "1.82.0", + "@oxlint/binding-linux-ppc64-gnu": "1.82.0", + "@oxlint/binding-linux-riscv64-gnu": "1.82.0", + "@oxlint/binding-linux-riscv64-musl": "1.82.0", + "@oxlint/binding-linux-s390x-gnu": "1.82.0", + "@oxlint/binding-linux-x64-gnu": "1.82.0", + "@oxlint/binding-linux-x64-musl": "1.82.0", + "@oxlint/binding-openharmony-arm64": "1.82.0", + "@oxlint/binding-win32-arm64-msvc": "1.82.0", + "@oxlint/binding-win32-ia32-msvc": "1.82.0", + "@oxlint/binding-win32-x64-msvc": "1.82.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz", + "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-disposable": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/proxy-disposable/-/proxy-disposable-1.0.0.tgz", + "integrity": "sha512-Y673BKpFqk8XkYuoK9DGZ1RIzldz5SkzxTKc/+DkpaMdrDEbEalWlO/uMRMdJsXvdbTybJI26XDwN0KJpZDN4w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-collapsed": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/react-collapsed/-/react-collapsed-4.2.0.tgz", + "integrity": "sha512-8rQuV3wGTjBI4M1WzD5DpqCBRliHNDKEEFeC6tAQEVrRDic46xBOtySgMGjVVacIobEaMf7AiYlI5APCZIZtkg==", + "license": "MIT", + "dependencies": { + "tiny-warning": "^1.0.3" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17 || ^18 || ^19", + "react-dom": "^16.9.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-icons": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-jsonrpc": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz", + "integrity": "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.3.tgz", + "integrity": "sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "9.0.2", + "vscode-languageserver-types": "3.18.3" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz", + "integrity": "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz", + "integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..787b2b1ac --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,38 @@ +{ + "name": "frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "bundle": "npm run build && python3 -m zipfile -c ../apps/predbat/frontend.zip dist", + "lint": "oxlint", + "test": "node --test tests/*.test.mjs", + "preview": "vite preview", + "images:house": "node scripts/convert-house-images.mjs", + "prebuild": "npm run images:house", + "predev": "npm run images:house" + }, + "dependencies": { + "@cubone/react-file-manager": "^1.35.0", + "@fortawesome/fontawesome-svg-core": "^7.3.1", + "@fortawesome/free-brands-svg-icons": "^7.3.1", + "@fortawesome/free-solid-svg-icons": "^7.3.1", + "@fortawesome/react-fontawesome": "^3.5.0", + "chart.js": "^4.5.1", + "monaco-editor": "^0.54.0", + "monaco-yaml": "^5.5.1", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "sharp": "^0.35.5", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 000000000..0906f9ca5 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 000000000..e9522193d --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/scripts/README.md b/frontend/scripts/README.md new file mode 100644 index 000000000..107fbc0c9 --- /dev/null +++ b/frontend/scripts/README.md @@ -0,0 +1,14 @@ +# House image conversion + +Place the original PNG renders in `src/assets/house/`. `npm run build` and +`npm run dev` generate matching `.webp` files before starting. To convert manually, +run `npm run images:house` from `frontend/`. + +Import the `.webp` file from the page or component. Conversion preserves pixel +dimensions and lossless transparency, with colour quality 88. PNG masters are +kept intact; generated WebP files are ignored by Git. Unchanged images are skipped. +Replacing a PNG or changing the converter regenerates its WebP copy. + +Conversion runs at startup, so after adding or replacing PNGs during a development +session, run `npm run images:house` again. New filenames still need to be referenced +by the relevant page. Build environments must have the PNG masters available. diff --git a/frontend/scripts/convert-house-images.mjs b/frontend/scripts/convert-house-images.mjs new file mode 100644 index 000000000..d72309024 --- /dev/null +++ b/frontend/scripts/convert-house-images.mjs @@ -0,0 +1,36 @@ +import { readdir, stat } from 'node:fs/promises' +import { fileURLToPath, pathToFileURL } from 'node:url' +import path from 'node:path' +import sharp from 'sharp' + +/** Generate WebP siblings while retaining the PNG masters and their alpha channel. */ +export async function convertHouseImages(directory) { + const converter = await stat(fileURLToPath(import.meta.url)) + const results = [] + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (!entry.isFile() || !/\.png$/i.test(entry.name)) continue + const source = path.join(directory, entry.name) + const destination = source.replace(/\.png$/i, '.webp') + const original = await stat(source) + const existing = await stat(destination).catch(error => { + if (error.code === 'ENOENT') return null + throw error + }) + if (existing && existing.mtimeMs >= Math.max(original.mtimeMs, converter.mtimeMs)) { + results.push({ name: entry.name, skipped: true }) + continue + } + const output = await sharp(source).webp({ quality: 88, alphaQuality: 100, effort: 6 }).toFile(destination) + results.push({ name: entry.name, skipped: false, before: original.size, after: output.size }) + } + return results +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + const directory = fileURLToPath(new URL('../src/assets/house/', import.meta.url)) + const results = await convertHouseImages(directory) + for (const result of results.filter(result => !result.skipped)) { + console.log(`${result.name}: ${(result.before / 1024).toFixed(0)} KB → ${(result.after / 1024).toFixed(0)} KB`) + } + console.log(`House images: ${results.filter(result => !result.skipped).length} converted, ${results.filter(result => result.skipped).length} unchanged.`) +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 000000000..6432e6811 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,302 @@ +/* Global notices and application layout. Component overrides retain their cascade order. */ +.skip-link { + position: fixed; + top: 0.75rem; + left: 0.75rem; + z-index: 11000; + padding: 0.65rem 0.85rem; + transform: translateY(calc(-100% - 1rem)); + border-radius: 7px; + background: var(--color-text); + color: var(--color-surface); + font-size: 0.8rem; + font-weight: 700; + text-decoration: none; + transition: transform 0.15s ease; +} + +.skip-link:focus-visible { + transform: translateY(0); +} + +.dashboard-loading { + display: grid; + min-height: 100dvh; + place-items: center; + padding: 1rem; + color: var(--color-text-secondary); +} + +.dashboard-loading-status { + padding: 0.8rem 1rem; + border: 1px solid var(--color-border); + border-radius: 10px; + background: var(--color-surface); + box-shadow: var(--shadow-card); + font-size: 0.82rem; +} +.api-error-notice { + position: fixed; + left: 50%; + bottom: 1rem; + z-index: 1000; + display: flex; + align-items: center; + gap: 1rem; + width: min(540px, calc(100% - 2rem)); + padding: 0.8rem 0.9rem; + transform: translateX(-50%); + border: 1px solid var(--color-warning); + border-radius: 10px; + background: var(--color-surface); + color: var(--color-text); + box-shadow: var(--shadow-tooltip); +} + +.api-error-content { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.api-error-content strong { + font-size: 0.8rem; +} + +.api-error-content span { + margin-top: 0.15rem; + font-size: 0.72rem; + line-height: 1.35; + color: var(--color-text-secondary); +} + +.api-error-content small { + margin-top: 0.15rem; + font-size: 0.65rem; + line-height: 1.35; + color: var(--color-text-muted); +} + +.api-error-notice button, +.dashboard-load-error button { + flex: 0 0 auto; + padding: 0.4rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; +} + +.api-error-notice button:hover, +.dashboard-load-error button:hover { + border-color: var(--color-text-muted); +} + +.dashboard-load-error { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + max-width: 480px; + margin: 4rem auto; + padding: 1rem; + border: 1px solid var(--color-danger); + border-radius: 10px; + background: var(--color-surface); + box-shadow: var(--shadow-card); +} + +.dashboard-load-error span { + font-size: 0.8rem; + color: var(--color-text-secondary); +} + +.app-shell { + min-height: 100vh; + min-height: 100dvh; +} + +.app-content { + margin-left: 240px; + width: calc(100% - 240px); + min-width: 0; + transition: + margin-left 0.2s ease, + width 0.2s ease; +} + +.app-shell.navigation-collapsed .app-content { + margin-left: 72px; + width: calc(100% - 72px); +} + +@media (min-width: 701px) { + .app-shell.navigation-horizontal .app-content { + width: 100%; + margin-left: 0; + padding-top: 56px; + } +} + +.app-content > main { + width: 100%; + box-sizing: border-box; +} + +.dashboard-main { + padding-inline: 1.5rem; +} + +.dashboard-main > section { + min-width: 0; + margin-inline: 0; +} + +@media (max-width: 700px) { + .app-shell { + width: 100%; + max-width: 100%; + overflow-x: clip; + } + + .app-content, + .app-shell.navigation-collapsed .app-content { + margin-left: 0; + width: 100%; + } + + .dashboard-main { + display: grid; + gap: 0.75rem; + margin: 0; + padding: 0.75rem; + overflow-x: clip; + } + + .dashboard-main > section { + width: 100%; + min-width: 0; + margin: 0; + } +} + +/* + * A software update replaces files and restarts Predbat, so the + * current page must remain read-only until it completes. + */ +.app-shell.is-calculating { + user-select: none; +} + +/* + * Full-screen software-update blocker. + * + * backdrop-filter desaturates the existing UI while leaving it + * visible enough that the user can still see what is changing. + */ +.plan-calculating-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--color-bg) 45%, transparent); + backdrop-filter: grayscale(0.8) blur(1px); + -webkit-backdrop-filter: grayscale(0.8) blur(1px); + cursor: wait; +} + +/* Plan recalculation is safe to leave running while the user continues working. */ +.plan-calculating-overlay.is-non-blocking { + inset: auto 1rem 1rem auto; + display: block; + background: transparent; + backdrop-filter: none; + -webkit-backdrop-filter: none; + pointer-events: none; + cursor: default; +} + +/* + * Small status card in the centre of the page. + */ +.plan-calculating-message { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.85rem 1rem; + border: 1px solid var(--color-border); + border-radius: 10px; + background: var(--color-surface); + box-shadow: 0 10px 32px color-mix(in srgb, var(--color-text) 20%, transparent); + color: var(--color-text); +} + +.plan-calculating-message > div { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.plan-calculating-message strong { + font-size: 0.8rem; +} + +.plan-calculating-message span { + font-size: 0.68rem; + color: var(--color-text-muted); +} + +/* + * Simple CSS spinner means App.tsx doesn't need another icon + * dependency merely for the loading state. + */ +.plan-calculating-spinner { + width: 1rem; + height: 1rem; + border: 2px solid color-mix(in srgb, var(--color-text-muted) 25%, transparent); + border-top-color: var(--color-text); + border-radius: 50%; + animation: plan-calculating-spin 0.8s linear infinite; +} + +@keyframes plan-calculating-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 700px) { + .plan-calculating-overlay.is-non-blocking { + right: 0.75rem; + bottom: 0.75rem; + left: 0.75rem; + } + + .plan-calculating-overlay.is-non-blocking .plan-calculating-message { + width: 100%; + } +} + +/* ============================================================ + * Reduced motion + * ============================================================ */ + +@media (prefers-reduced-motion: reduce) { + .app-navigation { + transition: none; + } + + .predbat-flying-bat { + animation-duration: 1s; + } + + .predbat-flying-bat img { + animation: none; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 000000000..3423012b0 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,660 @@ +import { lazy, Suspense, useEffect, useRef, useState } from 'react' + +import AppNavigation from './components/AppNavigation' +import PlanSummary from './components/PlanSummary' +import { PlanDescription } from './components/PlanDescription' +import StatusCard from './components/StatusCard' +import PowerFlow from './components/PowerFlow' +import DebugPanel from './components/DebugPanel' +import MetricsPanel from './components/MetricsPanel' +import PlanPage from './pages/PlanPage' +import ChartsPage from './pages/ChartsPage' +import { useStoredState } from './hooks/useStoredState' +import { resolvePlanData, type PlanDataResponse } from './utils/planData' + +const AppsEditorPage = lazy(() => import('./pages/AppsEditorPage')) +const DocsPage = lazy(() => import('./pages/DocsPage')) +const LogPage = lazy(() => import('./pages/LogPage')) +const ComponentsPage = lazy(() => import('./pages/ComponentsPage')) +const BrowsePage = lazy(() => import('./pages/BrowsePage')) +const InternalsPage = lazy(() => import('./pages/InternalsPage')) +const ConfigPage = lazy(() => import('./pages/ConfigPage')) +const ComparePage = lazy(() => import('./pages/ComparePage')) +const AnnualPage = lazy(() => import('./pages/AnnualPage')) +const ChatPage = lazy(() => import('./pages/ChatPage')) +const AppsPage = lazy(() => import('./pages/AppsPage')) +const EntitiesPage = lazy(() => import('./pages/EntitiesPage')) +const DiscoveryPage = lazy(() => import('./pages/DiscoveryPage')) +const CardsPage = lazy(() => import('./pages/CardsPage')) +const OverviewPage = lazy(() => import('./pages/OverviewPage')) + +import type { PlanData } from './types/plan' +import type { PredbatStatus } from './types/status' +import type { PowerFlowData } from './types/powerFlow' + +import './App.css' + +type ApiSource = 'plan' | 'status' | 'powerFlow' + +/** Coordinates dashboard polling and refreshes the plan after a calculation completes. */ +function App() { + const pathPage = window.location.pathname.replace(/\/+$/, '').split('/').pop() ?? 'dash' + const currentPage = new URLSearchParams(window.location.search).get('page') ?? pathPage + + const API_FAILURE_THRESHOLD = 2 + + const API_SOURCE_NAMES: Record = { + plan: 'Plan data', + status: 'Status', + powerFlow: 'Power flow' + } + + /* + * Dashboard data. + */ + + const previousCalculatingRef = useRef(null) + + const [planData, setPlanData] = useState(null) + const planDataRef = useRef(null) + + const [statusData, setStatusData] = useState(null) + + const [powerFlowData, setPowerFlowData] = useState(null) + + const [navigationCollapsed, setNavigationCollapsed] = useState(() => { + try { + return localStorage.getItem('predbat-navigation-collapsed') === 'true' + } catch { + return false + } + }) + + useEffect(() => { + try { + localStorage.setItem('predbat-navigation-collapsed', String(navigationCollapsed)) + } catch { + // Navigation preference is non-critical. + } + }, [navigationCollapsed]) + + const [navigationLayout, setNavigationLayout] = useStoredState<'side' | 'horizontal'>( + 'predbat-navigation-layout', + 'horizontal', + ['side', 'horizontal'] + ) + + /* + * Errors from the three polling APIs. + * + * Each endpoint gets its own error so that recovery of one + * endpoint doesn't accidentally clear another endpoint's + * warning. + */ + const [apiErrors, setApiErrors] = useState>>({}) + + /* + * Used when the dashboard cannot complete its initial load. + * + * This is different from the non-blocking warning shown when + * already-loaded dashboard data becomes temporarily stale. + */ + const [initialError, setInitialError] = useState(null) + + /* + * Control errors are shown immediately. + * + * Unlike polling, a failed user action should not require two + * consecutive failures before the user is told about it. + */ + const [controlError, setControlError] = useState(null) + const [updateRequested, setUpdateRequested] = useState(false) + + useEffect(() => { + if (!updateRequested) return + + const timeout = window.setTimeout(() => setUpdateRequested(false), 15_000) + return () => window.clearTimeout(timeout) + }, [updateRequested]) + + /* + * Consecutive failure count for each endpoint. + * + * useRef is used because changing these counters does not + * itself need to trigger a render. + */ + const failedFetchesRef = useRef>({ + plan: 0, + status: 0, + powerFlow: 0 + }) + + /* + * Record a successful request. + * + * Only clear the error belonging to this API source. + */ + function recordFetchSuccess(source: ApiSource) { + failedFetchesRef.current[source] = 0 + + setApiErrors((current) => { + if (!current[source]) { + return current + } + + const next = { + ...current + } + + delete next[source] + + return next + }) + } + + /* + * Record a failed request. + * + * A single missed poll is ignored because Predbat may simply + * be restarting or recomputing. + * + * After two consecutive failures we show the user a warning. + */ + function recordFetchFailure(source: ApiSource, error: unknown) { + failedFetchesRef.current[source] += 1 + + const failureCount = failedFetchesRef.current[source] + + console.error(`Unable to fetch Predbat ${API_SOURCE_NAMES[source]}:`, error) + + if (failureCount < API_FAILURE_THRESHOLD) { + return + } + + const detail = error instanceof Error ? error.message : 'Unknown error' + + setApiErrors((current) => ({ + ...current, + + [source]: `${API_SOURCE_NAMES[source]} could not be refreshed. ${detail}` + })) + } + + /* + * Shared GET request helper. + * + * fetch() does NOT throw for HTTP 404, 500 or 503 responses, + * so response.ok must be checked explicitly. + * + * JSON parsing errors are also caught here. + */ + async function fetchJson(source: ApiSource, url: string): Promise { + try { + const response = await fetch(url, { + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const data = (await response.json()) as T + + recordFetchSuccess(source) + + return data + } catch (error) { + recordFetchFailure(source, error) + + throw error + } + } + + /* + * Fetch the Predbat plan. + */ + async function fetchPlan() { + const response = await fetchJson('plan', './api/plan_data') + const data = resolvePlanData(response, planDataRef.current) + + if (!data) { + const error = new Error('Predbat has not published a plan yet') + recordFetchFailure('plan', error) + throw error + } + + if (data !== planDataRef.current) { + planDataRef.current = data + setPlanData(data) + } + + return data + } + + async function fetchStatus() { + const data = await fetchJson('status', './api/status') + + /* + * Remember whether Predbat was calculating on the + * previous status poll. + */ + const previousCalculating = previousCalculatingRef.current + + /* + * Store the latest state ready for the next poll. + */ + previousCalculatingRef.current = data.calculating + + setStatusData(data) + if (data.updating) setUpdateRequested(false) + + /* + * Predbat has just completed a calculation. + * + * Re-fetch the plan once so the UI immediately gets + * the newly calculated rows. + */ + if (previousCalculating === true && data.calculating === false) { + fetchPlan().catch(() => { + /* + * fetchPlan() already handles/logs its own + * API failure. + */ + }) + } + } + + /* + * Fetch live Power Flow data. + */ + async function fetchPowerFlow() { + const data = await fetchJson('powerFlow', './api/power_flow') + + setPowerFlowData(data) + } + + /* + * Update a Predbat control. + * + * Control failures are reported immediately because the user + * has deliberately requested an action. + */ + async function updateControl(control: string, value: string | boolean) { + try { + const response = await fetch('./api/dashboard_control', { + method: 'POST', + + headers: { + 'Content-Type': 'application/json' + }, + + body: JSON.stringify({ + control, + value + }) + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + /* + * The actual control request succeeded. + */ + setControlError(null) + } catch (error) { + console.error('Unable to update Predbat control:', error) + + const detail = error instanceof Error ? error.message : 'Unknown error' + + setControlError(`Predbat could not update the requested setting. ${detail}`) + + return + } + + /* + * Re-read Predbat's real state after a successful change. + * + * Keep this outside the control try/catch. If refreshing + * Status fails, that is a Status API problem rather than a + * failed control change. + */ + fetchStatus().catch(() => { + /* + * fetchStatus() already records/logs its own failure. + */ + }) + } + + async function installLatestUpdate() { + setUpdateRequested(true) + + try { + const response = await fetch('./api/service', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + service: 'update/install', + data: { entity_id: 'update.predbat_version' } + }) + }) + + if (!response.ok) throw new Error(`HTTP ${response.status}`) + + const result = await response.json() as { result?: unknown } | null + if (result?.result === 'error') throw new Error('Home Assistant rejected the update request') + + setControlError(null) + fetchStatus().catch(() => { + // Polling will retry if Predbat restarts before returning status. + }) + } catch (error) { + console.error('Unable to start the Predbat update:', error) + const detail = error instanceof Error ? error.message : 'Unknown error' + setControlError(`Predbat could not start the update. ${detail}`) + setUpdateRequested(false) + } + } + + /* + * Manual retry used by the error notice. + */ + async function retryAll() { + setControlError(null) + + const results = await Promise.allSettled([fetchPlan(), fetchStatus(), fetchPowerFlow()]) + + /* + * Plan and Status are the two essential APIs needed to draw + * the main dashboard. + */ + const planSucceeded = results[0].status === 'fulfilled' + + const statusSucceeded = results[1].status === 'fulfilled' + + if (planSucceeded && statusSucceeded) { + setInitialError(null) + } + } + + useEffect(() => { + let cancelled = false + + /* + * Initial load is slightly different from polling. + * + * Promise.allSettled() allows all three requests to complete + * independently. A Power Flow failure therefore doesn't stop + * Plan or Status from loading. + */ + async function initialLoad() { + const results = await Promise.allSettled([fetchPlan(), fetchStatus(), fetchPowerFlow()]) + + if (cancelled) { + return + } + + const planFailed = results[0].status === 'rejected' + + const statusFailed = results[1].status === 'rejected' + + /* + * Plan and Status are essential. + * + * Power Flow is optional, so a failure there should not + * prevent the rest of the dashboard appearing. + */ + if (planFailed || statusFailed) { + setInitialError( + 'Predbat dashboard data could not be loaded. Predbat may still be starting.' + ) + } else { + setInitialError(null) + } + } + + initialLoad() + + /* + * Status is live operational data. + */ + const statusTimer = window.setInterval(() => { + fetchStatus().catch(() => { + /* + * Error handling is performed by fetchJson(). + */ + }) + }, 5000) + + /* + * The plan changes less frequently. + */ + const planTimer = window.setInterval(() => { + fetchPlan().catch(() => { + /* + * Error handling is performed by fetchJson(). + */ + }) + }, 30000) + + /* + * Power Flow is live data. + */ + const powerFlowTimer = window.setInterval(() => { + fetchPowerFlow().catch(() => { + /* + * Error handling is performed by fetchJson(). + */ + }) + }, 5000) + + return () => { + cancelled = true + + window.clearInterval(statusTimer) + window.clearInterval(planTimer) + window.clearInterval(powerFlowTimer) + } + }, []) + + /* + * Select the highest-priority warning to display. + * + * A failed control action comes first because it was initiated + * directly by the user. + */ + const apiWarning = + controlError ?? apiErrors.plan ?? apiErrors.status ?? apiErrors.powerFlow ?? null + + const updating = statusData?.updating === true + const calculating = statusData?.calculating === true + const active = updating || calculating + const activityOverlay = active ? ( +
+
+
+
+ ) : null + + if (currentPage === 'apps_editor' || currentPage === 'docs' || currentPage === 'log' || currentPage === 'components' || currentPage === 'discovery' || currentPage === 'cards' || currentPage === 'browse' || currentPage === 'internals' || currentPage === 'config' || currentPage === 'compare' || currentPage === 'annual' || currentPage === 'chat' || currentPage === 'apps' || currentPage === 'entity') { + return ( + <> + Skip to main content + +
+ +
+
+ Loading…
}> + {currentPage === 'apps_editor' ? : currentPage === 'docs' ? : currentPage === 'components' ? : currentPage === 'discovery' ? : currentPage === 'cards' ? : currentPage === 'browse' ? : currentPage === 'internals' ? : currentPage === 'config' ? : currentPage === 'compare' ? : currentPage === 'annual' ? : currentPage === 'chat' ? : currentPage === 'apps' ? : currentPage === 'entity' ? : } + + +
+ + + {activityOverlay} + + ) + } + + /* + * If the initial load couldn't obtain the essential data, + * show a useful error rather than leaving "Loading..." forever. + * + * Polling continues in the background, so the dashboard can + * recover automatically once Predbat becomes available. + */ + if (initialError && (!planData?.plan || !statusData)) { + return ( +
+
+ Unable to load Predbat + + {initialError} + + +
+
+ ) + } + + /* + * Normal first-load state. + */ + if (!planData?.plan || !statusData) { + return
Loading Predbat dashboard…
+ } + + return ( + <> + Skip to main content + +
+ + +
+
+ {currentPage === 'dash' &&

Predbat Dashboard

} + + {currentPage === 'plan' ? ( + { + fetchStatus().catch(() => { + /* + * fetchStatus() already handles its API error. + */ + }) + }} + /> + ) : currentPage === 'overview' ? ( + powerFlowData ? ( + Loading overview…
}> + + + ) : ( +
Live power data is unavailable.
+ ) + ) : currentPage === 'charts' ? ( + + ) : ( + <> + {apiWarning && ( +
+ {apiWarning} +
+ )} + + updateControl('mode', value)} + + onActiveChange={(value) => updateControl('active', value)} + + onReadOnlyChange={(value) => updateControl('set_read_only', value)} + + onDebugChange={(value) => updateControl('debug_enable', value)} + + onUpdate={installLatestUpdate} + /> + + + + + + {powerFlowData && ( + + )} + + + + {statusData.debug_enable && ( + + )} + + )} + +
+ + + {activityOverlay} + + ) +} + +export default App diff --git a/frontend/src/assets/bat_logo_dark.png b/frontend/src/assets/bat_logo_dark.png new file mode 100644 index 000000000..82620c4a7 Binary files /dev/null and b/frontend/src/assets/bat_logo_dark.png differ diff --git a/frontend/src/assets/bat_logo_light.png b/frontend/src/assets/bat_logo_light.png new file mode 100644 index 000000000..26b478883 Binary files /dev/null and b/frontend/src/assets/bat_logo_light.png differ diff --git a/frontend/src/assets/house/day-car-heat-pump-snow.png b/frontend/src/assets/house/day-car-heat-pump-snow.png new file mode 100644 index 000000000..2fb25da35 Binary files /dev/null and b/frontend/src/assets/house/day-car-heat-pump-snow.png differ diff --git a/frontend/src/assets/house/day-car-heat-pump.png b/frontend/src/assets/house/day-car-heat-pump.png new file mode 100644 index 000000000..a5beacba5 Binary files /dev/null and b/frontend/src/assets/house/day-car-heat-pump.png differ diff --git a/frontend/src/assets/house/day-car-snow.png b/frontend/src/assets/house/day-car-snow.png new file mode 100644 index 000000000..39d6e8505 Binary files /dev/null and b/frontend/src/assets/house/day-car-snow.png differ diff --git a/frontend/src/assets/house/day-car.png b/frontend/src/assets/house/day-car.png new file mode 100644 index 000000000..67b7f8823 Binary files /dev/null and b/frontend/src/assets/house/day-car.png differ diff --git a/frontend/src/assets/house/day-heat-pump-snow.png b/frontend/src/assets/house/day-heat-pump-snow.png new file mode 100644 index 000000000..8161844c8 Binary files /dev/null and b/frontend/src/assets/house/day-heat-pump-snow.png differ diff --git a/frontend/src/assets/house/day-heat-pump.png b/frontend/src/assets/house/day-heat-pump.png new file mode 100644 index 000000000..71af2304c Binary files /dev/null and b/frontend/src/assets/house/day-heat-pump.png differ diff --git a/frontend/src/assets/house/day-house-snow.png b/frontend/src/assets/house/day-house-snow.png new file mode 100644 index 000000000..655a5a128 Binary files /dev/null and b/frontend/src/assets/house/day-house-snow.png differ diff --git a/frontend/src/assets/house/day-house.png b/frontend/src/assets/house/day-house.png new file mode 100644 index 000000000..badb0e3a1 Binary files /dev/null and b/frontend/src/assets/house/day-house.png differ diff --git a/frontend/src/assets/house/night-car-heat-pump-snow.png b/frontend/src/assets/house/night-car-heat-pump-snow.png new file mode 100644 index 000000000..af0a5ca27 Binary files /dev/null and b/frontend/src/assets/house/night-car-heat-pump-snow.png differ diff --git a/frontend/src/assets/house/night-car-heat-pump.png b/frontend/src/assets/house/night-car-heat-pump.png new file mode 100644 index 000000000..05a6f73d7 Binary files /dev/null and b/frontend/src/assets/house/night-car-heat-pump.png differ diff --git a/frontend/src/assets/house/night-car-snow.png b/frontend/src/assets/house/night-car-snow.png new file mode 100644 index 000000000..b4e90421e Binary files /dev/null and b/frontend/src/assets/house/night-car-snow.png differ diff --git a/frontend/src/assets/house/night-car.png b/frontend/src/assets/house/night-car.png new file mode 100644 index 000000000..7164f5d4e Binary files /dev/null and b/frontend/src/assets/house/night-car.png differ diff --git a/frontend/src/assets/house/night-heat-pump-snow.png b/frontend/src/assets/house/night-heat-pump-snow.png new file mode 100644 index 000000000..dc35ba1e3 Binary files /dev/null and b/frontend/src/assets/house/night-heat-pump-snow.png differ diff --git a/frontend/src/assets/house/night-heat-pump.png b/frontend/src/assets/house/night-heat-pump.png new file mode 100644 index 000000000..c42865c18 Binary files /dev/null and b/frontend/src/assets/house/night-heat-pump.png differ diff --git a/frontend/src/assets/house/night-house-snow.png b/frontend/src/assets/house/night-house-snow.png new file mode 100644 index 000000000..5c44d0eca Binary files /dev/null and b/frontend/src/assets/house/night-house-snow.png differ diff --git a/frontend/src/assets/house/night-house.png b/frontend/src/assets/house/night-house.png new file mode 100644 index 000000000..3e973ece7 Binary files /dev/null and b/frontend/src/assets/house/night-house.png differ diff --git a/frontend/src/components/AppNavigation.css b/frontend/src/components/AppNavigation.css new file mode 100644 index 000000000..718e777f1 --- /dev/null +++ b/frontend/src/components/AppNavigation.css @@ -0,0 +1,1001 @@ +/* ============================================================ + * Predbat application navigation + * ============================================================ */ + +/* ------------------------------------------------------------ + * Desktop sidebar + * ------------------------------------------------------------ */ + +.app-navigation { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 900; + display: flex; + flex-direction: column; + width: 240px; + border-right: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.04); + transition: + width 0.2s ease, + transform 0.25s ease; + overflow: visible; +} + +/* + * Collapsed desktop sidebar. + */ +.app-navigation.is-collapsed { + width: 72px; +} + +/* ------------------------------------------------------------ + * Navigation header + * ------------------------------------------------------------ */ + +.navigation-header { + position: relative; + display: flex; + align-items: flex-start; + min-height: 78px; + padding: 0.8rem; + border-bottom: 1px solid var(--color-border); +} + +/* + * Branding is deliberately vertical because the live Predbat + * status sits underneath the logo/name. + */ +.navigation-brand { + display: flex; + flex: 1; + flex-direction: column; + align-items: flex-start; + min-width: 0; +} + +/* + * First branding row: + * + * [bat logo] Predbat + */ +.navigation-brand-main { + display: flex; + align-items: center; + gap: 0.55rem; + min-width: 0; +} + +/* ------------------------------------------------------------ + * Predbat logo + * ------------------------------------------------------------ */ + +.navigation-logo-button { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; +} + +.navigation-logo-button picture { + display: block; + width: 100%; + height: 100%; +} + +.navigation-logo-image { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.navigation-brand-name { + min-width: 0; + font-size: 1rem; + font-weight: 700; + color: var(--color-text); + white-space: nowrap; +} + +/* ------------------------------------------------------------ + * Live status under the Predbat logo + * + * ✓ battery 34% + * + * The tick becomes a spinner while Predbat calculates. + * ------------------------------------------------------------ */ + +.navigation-live-status { + display: flex; + align-items: center; + gap: 0.65rem; + margin-top: 0.3rem; + padding-left: 0.15rem; + min-height: 1.2rem; +} + +.navigation-plan-status { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + flex: 0 0 auto; + font-size: 0.85rem; +} + +.navigation-plan-status.is-ready { + color: var(--color-success, #22c55e); +} + +.navigation-plan-status.is-calculating { + color: var(--color-text-muted); +} + +.navigation-battery-status { + display: flex; + align-items: center; + gap: 0.3rem; + min-width: 0; + color: var(--color-text-secondary); + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; +} + +.navigation-battery-status > svg { + flex: 0 0 auto; + font-size: 0.9rem; + color: var(--color-flow-battery); +} + +.navigation-battery-soc { + min-width: 2.1rem; +} + +/* ------------------------------------------------------------ + * Desktop collapse button + * ------------------------------------------------------------ */ + +.navigation-collapse-button { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + margin-left: 0.35rem; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: + background 0.2s ease, + color 0.2s ease; +} + +.navigation-collapse-button:hover { + background: var(--color-bg); + color: var(--color-text); +} + +/* ------------------------------------------------------------ + * Navigation menu + * ------------------------------------------------------------ */ + +.navigation-menu { + flex: 1; + overflow-x: hidden; + overflow-y: auto; + padding: 0.65rem; +} + +.navigation-horizontal-shortcuts { + display: none; +} + +.navigation-group { + margin-bottom: 0.9rem; +} + +.navigation-group:last-child { + margin-bottom: 0; +} + +.navigation-group-label { + display: block; + padding: 0.35rem 0.6rem; + font-size: 0.58rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--color-text-muted); + white-space: nowrap; + list-style: none; +} + +.navigation-group-label::-webkit-details-marker { + display: none; +} + +.navigation-group > .navigation-group-label { + pointer-events: none; +} + +.navigation-group-items { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +/* ------------------------------------------------------------ + * Individual navigation links + * ------------------------------------------------------------ */ + +.navigation-item { + display: flex; + align-items: center; + gap: 0.7rem; + min-height: 2.45rem; + padding: 0.5rem 0.65rem; + border-radius: 7px; + color: var(--color-text-secondary); + text-decoration: none; + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; + transition: + background 0.15s ease, + color 0.15s ease; +} + +.navigation-item:hover { + background: var(--color-bg); + color: var(--color-text); +} + +.navigation-item.is-active { + background: var(--color-charge); + color: var(--color-semantic-contrast); +} + +.navigation-item-icon { + display: flex; + flex: 0 0 1.2rem; + align-items: center; + justify-content: center; + width: 1.2rem; + font-size: 0.9rem; +} + +.navigation-item-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ------------------------------------------------------------ + * Collapsed desktop navigation + * ------------------------------------------------------------ */ + +.app-navigation.is-collapsed .navigation-header { + flex-direction: column; + align-items: center; + padding-left: 0.55rem; + padding-right: 0.55rem; +} + +.app-navigation.is-collapsed .navigation-brand { + align-items: center; + width: 100%; +} + +.app-navigation.is-collapsed .navigation-brand-main { + justify-content: center; +} + +.app-navigation.is-collapsed .navigation-brand-name { + display: none; +} + +/* + * Keep plan status and battery icon visible when collapsed. + * + * The percentage is hidden because the narrow sidebar doesn't + * have enough room for it cleanly. + */ +.app-navigation.is-collapsed .navigation-live-status { + flex-direction: column; + gap: 0.3rem; + margin-top: 0.4rem; + padding-left: 0; +} + +.app-navigation.is-collapsed .navigation-battery-soc { + display: none; +} + +.app-navigation.is-collapsed .navigation-collapse-button { + position: absolute; + top: 0.7rem; + right: -1rem; + width: 1.7rem; + height: 1.7rem; + border: 1px solid var(--color-border); + border-radius: 50%; + background: var(--color-surface); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); +} + +.app-navigation.is-collapsed .navigation-menu { + width: 100%; + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.app-navigation.is-collapsed .navigation-group { + align-items: center; +} + +.app-navigation.is-collapsed .navigation-group-label { + display: none; +} + +.app-navigation.is-collapsed .navigation-item { + justify-content: center; + width: 100%; + padding-left: 0; + padding-right: 0; +} + +.app-navigation.is-collapsed .navigation-item-icon { + flex-basis: auto; + width: auto; + font-size: 1rem; +} + +.app-navigation.is-collapsed .navigation-item-label { + display: none; +} + +/* ------------------------------------------------------------ + * Mobile header + * + * Hidden on desktop. + * ------------------------------------------------------------ */ + +.mobile-app-header { + display: none; +} + +.mobile-menu-button { + display: flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-text); + font-size: 1rem; + cursor: pointer; +} + +.mobile-app-logo { + display: block; + width: 2.1rem; + height: 2.1rem; + object-fit: contain; +} + +/* ------------------------------------------------------------ + * Mobile drawer close button + * + * Hidden on desktop. + * ------------------------------------------------------------ */ + +.navigation-mobile-close { + display: none; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + margin-left: auto; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; +} + +/* ------------------------------------------------------------ + * Mobile backdrop + * ------------------------------------------------------------ */ + +.navigation-backdrop { + display: none; +} + +/* ============================================================ + * Flying bat Easter egg + * ============================================================ */ + +.predbat-flying-bat { + position: fixed; + top: 0; + left: 0; + z-index: 10000; + width: 72px; + height: 72px; + pointer-events: none; + animation: predbat-bat-flight 4.5s ease-in-out forwards; +} + +.predbat-flying-bat picture, +.predbat-flying-bat img { + display: block; + width: 100%; + height: 100%; +} + +.predbat-flying-bat img { + object-fit: contain; + animation: predbat-bat-flap 0.35s ease-in-out infinite alternate; +} + +@keyframes predbat-bat-flight { + 0% { + transform: translate(30px, 60px) rotate(-5deg) scale(0.8); + opacity: 0; + } + + 8% { + opacity: 1; + } + + 22% { + transform: translate(24vw, 18vh) rotate(12deg) scale(1); + } + + 42% { + transform: translate(72vw, 8vh) rotate(-10deg) scale(1.1); + } + + 62% { + transform: translate(78vw, 68vh) rotate(16deg) scale(0.95); + } + + 82% { + transform: translate(30vw, 76vh) rotate(-14deg) scale(1.05); + } + + 100% { + transform: translate(30px, 60px) rotate(0deg) scale(0.8); + opacity: 0; + } +} + +@keyframes predbat-bat-flap { + from { + transform: rotate(-6deg) scaleY(0.94); + } + + to { + transform: rotate(6deg) scaleY(1.04); + } +} + +/* ============================================================ + * Mobile navigation + * ============================================================ */ + +@media (max-width: 700px) { + /* + * Fixed header at the top of the mobile dashboard. + */ + .mobile-app-header { + position: sticky; + top: 0; + z-index: 850; + display: flex; + align-items: center; + gap: 0.65rem; + min-height: 52px; + padding: 0.45rem max(0.75rem, env(safe-area-inset-right)) 0.45rem max(0.75rem, env(safe-area-inset-left)); + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.05); + } + + .mobile-app-header strong { + font-size: 0.9rem; + color: var(--color-text); + } + + /* + * Sidebar becomes an off-canvas drawer. + */ + .app-navigation, + .app-navigation.is-collapsed { + top: 0; + width: min(280px, 85vw); + transform: translateX(-100%); + box-shadow: 4px 0 18px rgba(0, 0, 0, 0.18); + overscroll-behavior: contain; + } + + .app-navigation.is-mobile-open, + .app-navigation.is-collapsed.is-mobile-open { + transform: translateX(0); + } + + /* + * Desktop collapsed rules should not apply while the menu + * is being used as a mobile drawer. + */ + .app-navigation .navigation-header, + .app-navigation.is-collapsed .navigation-header { + flex-direction: row; + align-items: flex-start; + padding: 0.8rem; + } + + .app-navigation .navigation-brand, + .app-navigation.is-collapsed .navigation-brand { + align-items: flex-start; + width: auto; + } + + .app-navigation .navigation-brand-main, + .app-navigation.is-collapsed .navigation-brand-main { + justify-content: flex-start; + } + + .app-navigation.is-collapsed .navigation-brand-name { + display: inline; + } + + .app-navigation .navigation-live-status, + .app-navigation.is-collapsed .navigation-live-status { + flex-direction: row; + gap: 0.65rem; + margin-top: 0.3rem; + padding-left: 0.15rem; + } + + .app-navigation.is-collapsed .navigation-battery-soc { + display: inline; + } + + /* + * No desktop collapse button inside the mobile drawer. + */ + .navigation-collapse-button { + display: none; + } + + /* + * Mobile gets its own close button. + */ + .navigation-mobile-close { + display: flex; + } + + /* + * Always show labels inside the mobile drawer, regardless of + * the desktop collapsed preference. + */ + .app-navigation.is-collapsed .navigation-group { + align-items: stretch; + } + + .app-navigation.is-collapsed .navigation-group-label { + display: block; + } + + .app-navigation.is-collapsed .navigation-item { + justify-content: flex-start; + padding-left: 0.65rem; + padding-right: 0.65rem; + } + + .app-navigation.is-collapsed .navigation-item-icon { + flex: 0 0 1.2rem; + width: 1.2rem; + font-size: 0.9rem; + } + + .app-navigation.is-collapsed .navigation-item-label { + display: inline; + overflow: hidden; + text-overflow: ellipsis; + } + + /* + * Background overlay while the drawer is open. + */ + .navigation-backdrop { + position: fixed; + inset: 0; + z-index: 875; + display: block; + width: 100%; + height: 100%; + padding: 0; + border: 0; + background: rgba(0, 0, 0, 0.35); + cursor: default; + } +} + +/* ============================================================ + * Navigation footer + * ============================================================ */ + +.navigation-footer { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: space-between; + gap: 0.5rem; + min-height: 3rem; + padding: 0.65rem 0.8rem; + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} + +.navigation-version { + display: block; + width: 100%; + min-width: 0; + color: var(--color-text-muted); + font-size: 0.65rem; + font-weight: 600; + line-height: 1.3; + text-align: center; + overflow-wrap: anywhere; +} + +.navigation-theme-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.4rem 0.55rem; + border: 1px solid var(--color-border); + border-radius: 7px; + background: var(--color-bg); + color: var(--color-text-secondary); + font: inherit; + font-size: 0.68rem; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease, + border-color 0.15s ease; +} + +.navigation-footer-actions { + display: flex; + gap: 0.4rem; +} + +.navigation-layout-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.4rem 0.55rem; + border: 1px solid var(--color-border); + border-radius: 7px; + background: var(--color-bg); + color: var(--color-text-secondary); + font: inherit; + font-size: 0.68rem; + font-weight: 600; + cursor: pointer; +} + +.navigation-layout-button:hover { + border-color: var(--color-text-muted); + color: var(--color-text); +} + +.navigation-theme-button:hover { + border-color: var(--color-text-muted); + color: var(--color-text); +} + +/* ------------------------------------------------------------ + * Collapsed sidebar footer + * ------------------------------------------------------------ */ + +.app-navigation.is-collapsed .navigation-footer { + flex-direction: column; + gap: 0.45rem; + padding-left: 0.4rem; + padding-right: 0.4rem; +} + +.app-navigation.is-collapsed .navigation-version { + font-size: 0.55rem; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} + +.app-navigation.is-collapsed .navigation-theme-button { + width: 2rem; + height: 2rem; + padding: 0; +} + +.app-navigation.is-collapsed .navigation-footer-actions { + flex-direction: column; +} + +.app-navigation.is-collapsed .navigation-layout-button { + width: 2rem; + height: 2rem; + padding: 0; +} + +.app-navigation.is-collapsed .navigation-theme-label, +.app-navigation.is-collapsed .navigation-layout-label { + display: none; +} + +/* ------------------------------------------------------------ + * Restore full footer inside the mobile drawer + * ------------------------------------------------------------ */ + +@media (max-width: 700px) { + .app-navigation.is-collapsed .navigation-footer { + flex-direction: column; + padding: 0.65rem 0.8rem; + } + + .app-navigation.is-collapsed .navigation-version { + font-size: 0.65rem; + max-width: none; + } + + .app-navigation.is-collapsed .navigation-theme-button { + width: auto; + height: auto; + padding: 0.4rem 0.55rem; + } + + .app-navigation.is-collapsed .navigation-footer-actions { + flex-direction: row; + } + + .app-navigation.is-collapsed .navigation-layout-button { + width: auto; + height: auto; + padding: 0.4rem 0.55rem; + } + + .app-navigation.is-collapsed .navigation-theme-label, + .app-navigation.is-collapsed .navigation-layout-label { + display: inline; + } +} + +/* ============================================================ + * Horizontal desktop navigation + * ============================================================ */ + +@media (min-width: 701px) { + .app-navigation.is-horizontal, + .app-navigation.is-horizontal.is-collapsed { + right: 0; + bottom: auto; + flex-direction: row; + align-items: stretch; + width: 100%; + height: 56px; + border-right: 0; + border-bottom: 1px solid var(--color-border); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + } + + .app-navigation.is-horizontal .navigation-header, + .app-navigation.is-horizontal.is-collapsed .navigation-header { + flex: 0 0 auto; + flex-direction: row; + align-items: center; + min-height: 0; + padding: 0.45rem 0.75rem; + border-right: 1px solid var(--color-border); + border-bottom: 0; + } + + .app-navigation.is-horizontal .navigation-brand, + .app-navigation.is-horizontal.is-collapsed .navigation-brand { + flex-direction: row; + align-items: center; + width: auto; + } + + .app-navigation.is-horizontal .navigation-brand-name, + .app-navigation.is-horizontal.is-collapsed .navigation-brand-name, + .app-navigation.is-horizontal .navigation-battery-soc, + .app-navigation.is-horizontal.is-collapsed .navigation-battery-soc { + display: inline; + } + + .app-navigation.is-horizontal .navigation-logo-button { + width: 2.2rem; + height: 2.2rem; + } + + .app-navigation.is-horizontal .navigation-live-status, + .app-navigation.is-horizontal.is-collapsed .navigation-live-status { + flex-direction: row; + gap: 0.5rem; + margin: 0 0 0 0.75rem; + padding: 0; + } + + .app-navigation.is-horizontal .navigation-collapse-button { + display: none; + } + + .app-navigation.is-horizontal .navigation-menu, + .app-navigation.is-horizontal.is-collapsed .navigation-menu { + display: flex; + flex: 1; + align-items: center; + gap: 0.2rem; + min-width: 0; + padding: 0.45rem; + overflow: visible; + } + + .app-navigation.is-horizontal .navigation-group, + .app-navigation.is-horizontal.is-collapsed .navigation-group { + position: relative; + display: block; + margin: 0; + } + + .app-navigation.is-horizontal .navigation-group > .navigation-group-label { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.55rem 0.65rem; + border-radius: 7px; + color: var(--color-text-secondary); + font-size: 0.72rem; + letter-spacing: 0; + text-transform: none; + cursor: pointer; + pointer-events: auto; + } + + .app-navigation.is-horizontal .navigation-group > .navigation-group-label::after { + content: '▾'; + color: var(--color-text-muted); + font-size: 0.62rem; + } + + .app-navigation.is-horizontal .navigation-group:has(.navigation-item.is-active:not(.navigation-horizontal-shortcut-source)) > .navigation-group-label, + .app-navigation.is-horizontal .navigation-group[open] > .navigation-group-label, + .app-navigation.is-horizontal .navigation-group > .navigation-group-label:hover { + background: var(--color-bg); + color: var(--color-text); + } + + .app-navigation.is-horizontal .navigation-group-items { + position: absolute; + top: calc(100% + 0.45rem); + left: 0; + z-index: 950; + min-width: 12rem; + padding: 0.45rem; + border: 1px solid var(--color-border); + border-radius: 9px; + background: var(--color-surface); + box-shadow: var(--shadow-tooltip); + } + + .app-navigation.is-horizontal .navigation-item, + .app-navigation.is-horizontal.is-collapsed .navigation-item { + justify-content: flex-start; + width: 100%; + padding: 0.5rem 0.65rem; + } + + .app-navigation.is-horizontal .navigation-horizontal-shortcuts { + display: flex; + align-items: center; + gap: 0.2rem; + } + + .app-navigation.is-horizontal .navigation-horizontal-shortcuts .navigation-item { + width: auto; + } + + .app-navigation.is-horizontal .navigation-horizontal-shortcut-source { + display: none; + } + + .app-navigation.is-horizontal .navigation-item-icon, + .app-navigation.is-horizontal.is-collapsed .navigation-item-icon { + flex: 0 0 1.2rem; + width: 1.2rem; + font-size: 0.9rem; + } + + .app-navigation.is-horizontal .navigation-item-label, + .app-navigation.is-horizontal.is-collapsed .navigation-item-label { + display: inline; + } + + .app-navigation.is-horizontal .navigation-footer, + .app-navigation.is-horizontal.is-collapsed .navigation-footer { + flex: 0 0 auto; + flex-direction: row; + gap: 0.45rem; + min-height: 0; + padding: 0.45rem 0.65rem; + border-top: 0; + border-left: 1px solid var(--color-border); + } + + .app-navigation.is-horizontal .navigation-footer-actions, + .app-navigation.is-horizontal.is-collapsed .navigation-footer-actions { + flex-direction: row; + } + + .app-navigation.is-horizontal .navigation-layout-button, + .app-navigation.is-horizontal .navigation-theme-button, + .app-navigation.is-horizontal.is-collapsed .navigation-layout-button, + .app-navigation.is-horizontal.is-collapsed .navigation-theme-button { + width: auto; + height: auto; + padding: 0.4rem 0.55rem; + } + + .app-navigation.is-horizontal .navigation-layout-label, + .app-navigation.is-horizontal .navigation-theme-label, + .app-navigation.is-horizontal.is-collapsed .navigation-layout-label, + .app-navigation.is-horizontal.is-collapsed .navigation-theme-label { + display: inline; + } +} + +/* ============================================================ + * Reduced motion + * ============================================================ */ + +@media (prefers-reduced-motion: reduce) { + .app-navigation { + transition: none; + } + + .predbat-flying-bat { + animation-duration: 1s; + } + + .predbat-flying-bat img { + animation: none; + } +} diff --git a/frontend/src/components/AppNavigation.tsx b/frontend/src/components/AppNavigation.tsx new file mode 100644 index 000000000..fb4c2076f --- /dev/null +++ b/frontend/src/components/AppNavigation.tsx @@ -0,0 +1,554 @@ +import { useEffect, useState } from 'react' + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +import { + faBars, + faCircleCheck, + faCircleNotch, + faBatteryEmpty, + faBatteryQuarter, + faBatteryHalf, + faBatteryThreeQuarters, + faBatteryFull, + faBookOpen, + faCalendarDays, + faChartLine, + faChevronLeft, + faChevronRight, + faCode, + faComments, + faFileCode, + faGear, + faGaugeHigh, + faHouse, + faList, + faMicrochip, + faRightLeft, + faScroll, + faSitemap, + faServer, + faSliders, + faXmark, + faCircleHalfStroke, + faMoon, + faSun, + faTableColumns, +} from '@fortawesome/free-solid-svg-icons' + +import { + faGithub +} from '@fortawesome/free-brands-svg-icons' + +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core' + +import batLogoLight from '../assets/bat_logo_light.png' +import batLogoDark from '../assets/bat_logo_dark.png' + +import './AppNavigation.css' +import { getNextThemePreference, readThemePreference, resolveTheme, type ThemePreference } from './theme' + +type AppNavigationProps = { + collapsed: boolean + onCollapsedChange: (collapsed: boolean) => void + layout: 'side' | 'horizontal' + onLayoutChange: (layout: 'side' | 'horizontal') => void + + calculating: boolean + batterySoc: number | null + chatEnabled: boolean + + version: string +} + +type NavigationItem = { + label: string + href: string + icon: IconDefinition + external?: boolean +} + +type NavigationGroup = { + label: string + items: NavigationItem[] +} + +/* + * Predbat's existing server-rendered pages. + * + * Relative URLs are deliberate. Predbat can run behind + * Home Assistant ingress, so "/plan" is not necessarily + * the correct browser URL while "./plan" is. + */ +const navigationGroups: NavigationGroup[] = [ + { + label: 'Predbat', + items: [ + { + label: 'Dashboard', + href: './dash', + icon: faHouse + }, + { + label: 'Plan', + href: './plan', + icon: faCalendarDays + }, + { + label: 'Overview', + href: './overview', + icon: faGaugeHigh + }, + { + label: 'Charts', + href: './charts', + icon: faChartLine + }, + { + label: 'Entities', + href: './dash?page=entity', + icon: faList + }, + { + label: 'Compare', + href: './compare', + icon: faRightLeft + }, + { + label: 'What If', + href: './annual', + icon: faBatteryHalf + }, + { + label: 'Chat', + href: './chat', + icon: faComments + } + ] + }, + + { + label: 'Configuration', + items: [ + { + label: 'Config', + href: './config', + icon: faSliders + }, + { + label: 'Apps', + href: './apps', + icon: faGear + }, + { + label: 'Editor', + href: './apps_editor', + icon: faCode + }, + { + label: 'Components', + href: './components', + icon: faMicrochip + } + ] + }, + + { + label: 'System', + items: [ + { + label: 'Log', + href: './log', + icon: faScroll + }, + { + label: 'Browse', + href: './browse', + icon: faFileCode + }, + { + label: 'Discovery', + href: './discovery', + icon: faSitemap + }, + { + label: 'Cards', + href: './dash?page=cards', + icon: faTableColumns + }, + { + label: 'Internals', + href: './internals', + icon: faServer + }, + { + label: 'Docs', + href: './docs', + icon: faBookOpen + } + ] + }, + + { + label: 'Support', + items: [ + { + label: 'GitHub', + href: 'https://github.com/springfall2008/batpred', + icon: faGithub, + external: true, + } + ] + } +] + +const horizontalShortcuts = navigationGroups[0].items.slice(0, 3) + +function getBatteryIcon(soc: number) { + if (soc >= 88) { + return faBatteryFull + } + + if (soc >= 63) { + return faBatteryThreeQuarters + } + + if (soc >= 38) { + return faBatteryHalf + } + + if (soc >= 13) { + return faBatteryQuarter + } + + return faBatteryEmpty +} + +export default function AppNavigation({ + collapsed, + onCollapsedChange, + layout, + onLayoutChange, + calculating, + batterySoc, + chatEnabled, + version +}: AppNavigationProps) { + /* + * Mobile menu always starts closed. + * + * Unlike desktop collapse, we deliberately don't persist + * this because opening the site on mobile should never + * immediately cover the dashboard. + */ + const [mobileOpen, setMobileOpen] = useState(false) + + const [batFlying, setBatFlying] = useState(false) + + function flyBat() { + /* + * Ignore additional clicks while the bat is already flying. + */ + if (batFlying) { + return + } + + setBatFlying(true) + + /* + * This duration should match the CSS animation duration. + */ + window.setTimeout(() => { + setBatFlying(false) + }, 4500) + } + + /* + * Prevent the page underneath scrolling while the + * mobile navigation drawer is open. + */ + useEffect(() => { + if (!mobileOpen) { + return + } + + const originalOverflow = document.body.style.overflow + + document.body.style.overflow = 'hidden' + + return () => { + document.body.style.overflow = originalOverflow + } + }, [mobileOpen]) + + const [theme, setTheme] = useState(() => { + try { + return readThemePreference(localStorage.getItem('predbat-theme')) + } catch { + // Theme preference is non-critical. + return 'auto' + } + }) + const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches) + const resolvedTheme = resolveTheme(theme, systemDark) + + useEffect(() => { + const query = window.matchMedia('(prefers-color-scheme: dark)') + const updateSystemTheme = (event: MediaQueryListEvent) => setSystemDark(event.matches) + + query.addEventListener('change', updateSystemTheme) + + return () => query.removeEventListener('change', updateSystemTheme) + }, []) + + useEffect(() => { + document.documentElement.dataset.theme = resolvedTheme + document.querySelector('meta[name="theme-color"]')?.setAttribute( + 'content', + resolvedTheme === 'dark' ? '#111827' : '#f3f4f6' + ) + + try { + localStorage.setItem('predbat-theme', theme) + } catch { + // Theme preference is non-critical. + } + }, [resolvedTheme, theme]) + + const nextTheme = getNextThemePreference(theme) + const themeLabel = theme === 'light' ? 'Light' : theme === 'dark' ? 'Dark' : 'Auto' + const nextThemeLabel = nextTheme === 'light' ? 'Light' : nextTheme === 'dark' ? 'Dark' : 'Auto' + + const pathPage = window.location.pathname.replace(/\/+$/, '').split('/').pop() ?? 'dash' + const currentPage = new URLSearchParams(window.location.search).get('page') ?? pathPage + + return ( + <> + {/* + * Mobile top bar. + */} +
+ + + Predbat +
+ + {/* + * Semi-transparent background behind the drawer. + */} + {mobileOpen && ( + + + Predbat + + +
+ + + + + {batterySoc !== null && ( + + + + {Math.round(batterySoc)}% + + )} +
+ + + {/* + * Desktop collapse button. + */} + + + {/* + * Mobile close button. + */} + + + + + +
+
+ + + +
+ + {layout === 'side' && ( + + {version} + + )} +
+ + + {batFlying && ( + + )} + + ) +} diff --git a/frontend/src/components/BatteryCard.tsx b/frontend/src/components/BatteryCard.tsx new file mode 100644 index 000000000..6f4ac57c8 --- /dev/null +++ b/frontend/src/components/BatteryCard.tsx @@ -0,0 +1,23 @@ +type BatteryCardProps = { + soc: number + status: string + power: number +} + +function BatteryCard({ soc, status, power }: BatteryCardProps) { + return ( +
+
🔋
+ +
+ Battery + {soc}% + + {status} · {power.toFixed(1)} kW + +
+
+ ) +} + +export default BatteryCard diff --git a/frontend/src/components/BatteryChart.css b/frontend/src/components/BatteryChart.css new file mode 100644 index 000000000..acb18ed28 --- /dev/null +++ b/frontend/src/components/BatteryChart.css @@ -0,0 +1,270 @@ +.battery-chart-card { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: 12px; + background: var(--color-surface); + box-shadow: var(--shadow-card); +} + +.battery-chart-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1rem 0.75rem; +} + +.battery-chart-header h2, +.battery-chart-header p { + margin: 0; +} + +.battery-chart-header h2 { + font-size: 1.05rem; + color: var(--color-text); +} + +.battery-chart-header p { + margin-top: 0.2rem; + font-size: 0.74rem; + color: var(--color-text-muted); +} + +.battery-chart-range { + display: flex; + gap: 0.25rem; + padding: 0.2rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg); +} + +.solar-chart-controls { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; +} + +.battery-chart-range button { + padding: 0.35rem 0.55rem; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-text-muted); + font: inherit; + font-size: 0.7rem; + font-weight: 700; + cursor: pointer; +} + +.battery-chart-range button.is-active { + background: var(--color-surface); + color: var(--color-text); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); +} + +.battery-chart-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.65rem; + padding: 0 1rem 0.85rem; +} + +.battery-chart-summary > div { + display: flex; + flex-direction: column; + min-width: 0; + padding: 0.65rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: color-mix(in srgb, var(--color-bg) 65%, var(--color-surface)); +} + +.battery-chart-summary span, +.battery-chart-summary small { + overflow: hidden; + color: var(--color-text-muted); + font-size: 0.65rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.battery-chart-summary strong { + overflow: hidden; + margin-top: 0.2rem; + color: var(--color-text); + font-size: 0.88rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.battery-chart-summary small { + margin-top: 0.15rem; +} + +.battery-chart-summary-help { + position: relative; + width: fit-content; + overflow: visible; + cursor: help; + text-decoration: underline dotted; + text-underline-offset: 0.18rem; +} + +.solar-chart-availability { + margin: 0 1rem 0.75rem; + padding: 0.55rem 0.7rem; + border-left: 3px solid var(--color-warning); + border-radius: 6px; + background: color-mix(in srgb, var(--color-warning) 10%, transparent); + color: var(--color-text-secondary); + font-size: 0.72rem; +} + +.battery-chart-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.75rem 1.75rem; + padding: 0 1rem 0.7rem; +} + +.battery-chart-legend-item { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.6rem; + color: var(--color-text-secondary); + font-size: 0.7rem; + line-height: 1.2; + cursor: help; + outline: none; +} + +.power-chart-legend-item { + cursor: default; +} + +.battery-chart-legend-item:focus-visible { + border-radius: 4px; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-current) 45%, transparent); +} + +.battery-chart-legend-marker { + flex: 0 0 auto; + width: 0.72rem; + height: 0.72rem; + border-radius: 50%; + background: var(--battery-chart-legend-colour); +} + +.battery-chart-legend-item.is-square .battery-chart-legend-marker { + border-radius: 2px; +} + +.battery-chart-legend-item.is-line .battery-chart-legend-marker { + width: 1.25rem; + height: 3px; + border-radius: 2px; +} + +.battery-chart-help-popup { + position: absolute; + top: calc(100% + 0.45rem); + left: 50%; + z-index: 20; + display: none; + width: max-content; + max-width: min(260px, 75vw); + padding: 0.55rem 0.65rem; + transform: translateX(-50%); + border-radius: 7px; + background: var(--color-tooltip-bg); + color: var(--color-tooltip-text); + box-shadow: var(--shadow-tooltip); + font-size: 0.68rem; + font-weight: 500; + line-height: 1.4; + text-align: left; + white-space: normal; +} + +.battery-chart-legend-item:hover .battery-chart-help-popup, +.battery-chart-legend-item:focus .battery-chart-help-popup, +.battery-chart-summary-help:hover .battery-chart-help-popup, +.battery-chart-summary-help:focus .battery-chart-help-popup { + display: block; +} + +.battery-chart-canvas { + flex: 1 1 320px; + min-height: 320px; + padding: 0 0.65rem; +} + +.battery-chart-comparisons { + margin: 0.75rem 1rem 0; + padding: 0.65rem 0; + border-top: 1px solid var(--color-border); + color: var(--color-text-secondary); + font-size: 0.72rem; +} + +.battery-chart-comparisons summary { + width: fit-content; + font-weight: 700; + cursor: pointer; +} + +.battery-chart-comparisons > div { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 0.5rem 1rem; + margin-top: 0.65rem; +} + +.battery-chart-comparisons label { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.battery-chart-comparisons label.is-unavailable { + opacity: 0.55; + cursor: not-allowed; +} + +.battery-chart-comparisons label small { + margin-left: auto; + color: var(--color-text-muted); + font-size: 0.6rem; +} + +.battery-chart-band-key { + display: flex; + gap: 1.75rem; + padding: 0 1rem 0.85rem; + color: var(--color-text-muted); + font-size: 0.65rem; +} + +@media (max-width: 760px) { + .battery-chart-header { + flex-direction: column; + } + + .battery-chart-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .battery-chart-canvas { + flex-basis: 380px; + height: 380px; + } +} diff --git a/frontend/src/components/BatteryChart.tsx b/frontend/src/components/BatteryChart.tsx new file mode 100644 index 000000000..c80d497f2 --- /dev/null +++ b/frontend/src/components/BatteryChart.tsx @@ -0,0 +1,563 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Chart, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, + type ChartConfiguration, + type ChartDataset, + type Plugin +} from 'chart.js' + +import type { BatteryChartData, BatteryChartSeries } from '../types/charts' +import type { Plan } from '../types/plan' +import { useStoredState } from '../hooks/useStoredState' +import { + actionAtTime, + formatPlanAction, + getChartWindow, + hasSeriesData, + pointsInWindow, + predictionEndTime, + toChartPoints, + type ChartPoint +} from '../utils/batteryChart' + +import './BatteryChart.css' + +Chart.register(LineController, LineElement, PointElement, LinearScale, Filler, Tooltip, Legend) + +type BatteryChartProps = { + data: BatteryChartData + plan: Plan +} + +type ComparisonKey = + | 'base' + | 'base10' + | 'optimized10' + | 'predicted_h1' + | 'predicted_h8' + | 'charge_limit_base' + | 'record' + +type LegendItem = { + key: string + label: string + description: string + colour: string +} + +const primaryLegendItems: LegendItem[] = [ + { + key: 'actual', + label: 'Actual', + description: 'Battery energy measured so far today.', + colour: 'var(--color-current)' + }, + { + key: 'optimized', + label: 'Optimised forecast', + description: 'Expected battery energy if Predbat follows its current lowest-cost plan.', + colour: 'var(--color-danger)' + }, + { + key: 'charge-limit', + label: 'Charge limit', + description: 'The battery energy target Predbat plans to charge to during a charge window.', + colour: 'var(--color-charge)' + }, + { + key: 'export-limit', + label: 'Export limit', + description: 'The battery energy target Predbat plans to discharge to during an export window.', + colour: 'var(--color-export)' + } +] + +const comparisonOptions: Array = [ + { + key: 'base', + label: 'Base forecast', + description: 'Expected battery energy if Predbat takes no further action and leaves the inverter on its current settings.', + colour: '#0891b2' + }, + { + key: 'base10', + label: 'Base forecast (10%)', + description: 'The base forecast using Predbat’s pessimistic solar and household-load scenario.', + colour: '#d97706' + }, + { + key: 'optimized10', + label: 'Optimised forecast (10%)', + description: 'The optimised plan using Predbat’s pessimistic solar and household-load scenario.', + colour: '#9333ea' + }, + { + key: 'predicted_h1', + label: 'Previous +1h prediction', + description: 'What earlier plans predicted the battery level would be one hour later, aligned with the time that prediction was for.', + colour: '#ea580c' + }, + { + key: 'predicted_h8', + label: 'Previous +8h prediction', + description: 'What earlier plans predicted the battery level would be eight hours later, aligned with the time that prediction was for.', + colour: '#7c3aed' + }, + { + key: 'charge_limit_base', + label: 'Base charge limit', + description: 'The charge target already configured on the inverter before Predbat applies its optimised plan.', + colour: '#059669' + }, + { + key: 'record', + label: 'Prediction end', + description: 'A vertical marker showing where Predbat’s recorded prediction data ends.', + colour: '#64748b' + } +] + +function LegendHelpItem({ item, square = false }: { item: LegendItem; square?: boolean }) { + return ( + + + ) +} + +function formatEnergy(value: number | null): string { + return value === null ? '—' : `${value.toFixed(2)} kWh` +} + +function latestValue(points: ChartPoint[], at: number): number | null { + return points.reduce((latest, point) => { + return point.x <= at ? point.y : latest + }, null) +} + +function makeChartOverlayPlugin( + plan: Plan, + start: number, + end: number, + now: number, + predictionEnd: number | null, + textColour: string +): Plugin<'line'> { + return { + id: 'predbat-chart-overlays', + beforeDatasetsDraw(chart) { + const { ctx, chartArea, scales } = chart + const rows = plan.rows + + ctx.save() + + // Distinguish history from the forecast without obscuring the Actual line. + const nowPixel = Math.min(Math.max(scales.x.getPixelForValue(now), chartArea.left), chartArea.right) + ctx.fillStyle = 'rgba(15, 23, 42, 0.07)' + ctx.fillRect(chartArea.left, chartArea.top, nowPixel - chartArea.left, chartArea.bottom - chartArea.top) + + rows.forEach((row, index) => { + const rowStart = Date.parse(row.time) + const nextStart = rows[index + 1] ? Date.parse(rows[index + 1].time) : rowStart + 30 * 60 * 1000 + const bandStart = Math.max(rowStart, start) + const bandEnd = Math.min(nextStart, end) + + if (bandEnd <= bandStart) { + return + } + + const state = row.state.toLowerCase() + if (!state.includes('chrg') && !state.includes('charge') && !state.includes('exp')) { + return + } + + ctx.fillStyle = state.includes('exp') ? 'rgba(124, 58, 237, 0.08)' : 'rgba(37, 99, 235, 0.08)' + const left = scales.x.getPixelForValue(bandStart) + const right = scales.x.getPixelForValue(bandEnd) + ctx.fillRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top) + }) + ctx.restore() + }, + afterDatasetsDraw(chart) { + const { ctx, chartArea, scales } = chart + + function drawTimeMarker(timestamp: number, label: string, colour: string, alignRight = false) { + if (timestamp < start || timestamp > end) { + return + } + + const x = scales.x.getPixelForValue(timestamp) + ctx.save() + ctx.strokeStyle = colour + ctx.lineWidth = 1.5 + ctx.setLineDash([5, 4]) + ctx.beginPath() + ctx.moveTo(x, chartArea.top) + ctx.lineTo(x, chartArea.bottom) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = colour + ctx.font = '600 11px sans-serif' + ctx.textAlign = alignRight ? 'right' : 'left' + ctx.fillText(label, x + (alignRight ? -5 : 5), chartArea.top + 13) + ctx.restore() + } + + drawTimeMarker(now, 'Now', textColour) + if (predictionEnd !== null) { + drawTimeMarker(predictionEnd, 'Prediction ends', '#64748b', true) + } + } + } +} + +/** Render the interactive battery history and forecast chart. */ +export default function BatteryChart({ data, plan }: BatteryChartProps) { + const canvasRef = useRef(null) + const [forecastHours, setForecastHours] = useStoredState('predbat-chart-battery-range', 24, [12, 24, 48]) + const [comparisons, setComparisons] = useState>(new Set()) + const [themeRevision, setThemeRevision] = useState(0) + + const generatedAt = Date.parse(data.generated_at) + const window = useMemo( + () => getChartWindow(data.generated_at, forecastHours), + [data.generated_at, forecastHours] + ) + const actualPoints = useMemo(() => toChartPoints(data.series.actual), [data.series.actual]) + const optimizedPoints = useMemo( + () => pointsInWindow(toChartPoints(data.series.optimized), window), + [data.series.optimized, window] + ) + + const current = latestValue(actualPoints, generatedAt) + const futurePoints = optimizedPoints.filter((point) => point.x >= generatedAt) + const forecastMinimum = futurePoints.length + ? Math.min(...futurePoints.map((point) => point.y)) + : null + const horizonEnd = futurePoints.at(-1)?.y ?? null + const nextAction = plan.rows.find( + (row) => Date.parse(row.time) >= generatedAt && row.state.toLowerCase() !== 'demand' + ) + const predictionEnd = useMemo( + () => predictionEndTime(data.series.record), + [data.series.record] + ) + const comparisonAvailability = useMemo>(() => ({ + base: hasSeriesData(data.series.base, window), + base10: hasSeriesData(data.series.base10, window), + optimized10: hasSeriesData(data.series.optimized10, window), + predicted_h1: hasSeriesData(data.series.predicted_h1, window), + predicted_h8: hasSeriesData(data.series.predicted_h8, window), + charge_limit_base: hasSeriesData(data.series.charge_limit_base, window, true), + record: predictionEnd !== null && predictionEnd >= window.start && predictionEnd <= window.end + }), [data.series, predictionEnd, window]) + + function unavailableReason(key: ComparisonKey): string { + if (key === 'charge_limit_base') { + return 'No baseline charge window in this range' + } + + if (key === 'record' && predictionEnd !== null) { + return 'Prediction continues beyond this range' + } + + return 'No data in this range' + } + + useEffect(() => { + const observer = new MutationObserver(() => setThemeRevision((revision) => revision + 1)) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!canvasRef.current) { + return + } + + const styles = getComputedStyle(document.documentElement) + const textColour = styles.getPropertyValue('--color-text-secondary').trim() || '#4b5563' + const gridColour = styles.getPropertyValue('--color-border').trim() || '#e5e7eb' + const actualColour = styles.getPropertyValue('--color-current').trim() || '#3b82f6' + const optimizedColour = styles.getPropertyValue('--color-danger').trim() || '#dc2626' + const chargeColour = styles.getPropertyValue('--color-charge').trim() || '#2563eb' + const exportColour = styles.getPropertyValue('--color-export').trim() || '#7c3aed' + const nowColour = styles.getPropertyValue('--color-warning').trim() || '#f59e0b' + + const datasets: ChartDataset<'line', ChartPoint[]>[] = [ + { + label: 'Actual', + data: pointsInWindow(actualPoints, window), + borderColor: actualColour, + backgroundColor: actualColour, + borderWidth: 3, + pointRadius: 0, + tension: 0.2 + }, + { + label: 'Optimised forecast', + data: optimizedPoints, + borderColor: optimizedColour, + backgroundColor: optimizedColour, + borderWidth: 3, + pointRadius: 0, + tension: 0.2 + }, + { + label: 'Charge limit', + data: pointsInWindow(toChartPoints(data.series.charge_limit_optimized), window), + borderColor: chargeColour, + backgroundColor: chargeColour, + borderWidth: 1.5, + borderDash: [6, 4], + pointRadius: 0, + stepped: true + }, + { + label: 'Export limit', + data: pointsInWindow(toChartPoints(data.series.export_limit_optimized), window), + borderColor: exportColour, + backgroundColor: exportColour, + borderWidth: 1.5, + borderDash: [2, 4], + pointRadius: 0, + stepped: true + } + ] + + comparisonOptions.forEach(({ key, label, colour }) => { + if (!comparisons.has(key) || !comparisonAvailability[key] || key === 'record') { + return + } + + datasets.push({ + label, + data: pointsInWindow(toChartPoints(data.series[key] as BatteryChartSeries[ComparisonKey]), window), + borderColor: colour, + backgroundColor: colour, + borderWidth: 1.5, + borderDash: [5, 5], + pointRadius: 0, + tension: 0.15, + stepped: key.includes('limit') + }) + }) + + const configuration: ChartConfiguration<'line', ChartPoint[]> = { + type: 'line', + data: { datasets }, + plugins: [ + makeChartOverlayPlugin( + plan, + window.start, + window.end, + generatedAt, + comparisons.has('record') && comparisonAvailability.record ? predictionEnd : null, + nowColour + ) + ], + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + parsing: false, + normalized: true, + interaction: { intersect: false, mode: 'nearest' }, + scales: { + x: { + type: 'linear', + min: window.start, + max: window.end, + grid: { color: gridColour }, + ticks: { + color: textColour, + maxTicksLimit: 9, + callback(value) { + return new Intl.DateTimeFormat(undefined, { + weekday: 'short', + hour: '2-digit', + minute: '2-digit' + }).format(Number(value)) + } + } + }, + y: { + beginAtZero: true, + suggestedMax: data.soc_max, + grid: { color: gridColour }, + ticks: { + color: textColour, + callback(value) { + return `${value} kWh` + } + } + } + }, + plugins: { + legend: { + display: false + }, + tooltip: { + callbacks: { + title(items) { + const timestamp = Number(items[0]?.parsed.x) + return new Intl.DateTimeFormat(undefined, { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }).format(timestamp) + }, + label(item) { + const value = Number(item.parsed.y) + const percentage = data.soc_max > 0 ? ` · ${Math.round((value / data.soc_max) * 100)}%` : '' + return `${item.dataset.label}: ${value.toFixed(2)} kWh${percentage}` + }, + footer(items) { + const action = actionAtTime(plan.rows, Number(items[0]?.parsed.x)) + return action ? `Plan: ${formatPlanAction(action)}` : '' + } + } + } + } + } + } + + const chart = new Chart(canvasRef.current, configuration) + return () => chart.destroy() + }, [actualPoints, comparisonAvailability, comparisons, data, generatedAt, optimizedPoints, plan, predictionEnd, themeRevision, window]) + + function toggleComparison(key: ComparisonKey) { + setComparisons((currentComparisons) => { + const nextComparisons = new Set(currentComparisons) + if (nextComparisons.has(key)) { + nextComparisons.delete(key) + } else { + nextComparisons.add(key) + } + return nextComparisons + }) + } + + return ( +
+
+
+

Battery

+

Recent state of charge and Predbat's optimised forecast.

+
+ +
+ {[12, 24, 48].map((hours) => ( + + ))} +
+
+ +
+
Current{formatEnergy(current)}
+
Forecast minimum{formatEnergy(forecastMinimum)}
+
+ + Battery at chart end + + Forecast battery energy at the end of the selected {forecastHours}-hour range. + + + {formatEnergy(horizonEnd)} +
+
+ Next action + {nextAction ? formatPlanAction(nextAction.state) : 'No scheduled action'} + {nextAction && {new Date(nextAction.time).toLocaleString()}} +
+
+ +
+ {primaryLegendItems.map((item) => )} + {comparisonOptions + .filter(({ key }) => comparisons.has(key) && comparisonAvailability[key]) + .map((item) => )} +
+ +
+ +
+ +
+ Compare other forecasts +
+ {comparisonOptions.map((item) => ( + + ))} +
+
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/components/BatteryDegradationChart.tsx b/frontend/src/components/BatteryDegradationChart.tsx new file mode 100644 index 000000000..adb8a715c --- /dev/null +++ b/frontend/src/components/BatteryDegradationChart.tsx @@ -0,0 +1,183 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Chart, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, + type ChartConfiguration, + type ChartDataset +} from 'chart.js' + +import { useStoredState } from '../hooks/useStoredState' +import type { BatteryDegradationChartData } from '../types/charts' +import { pointsInWindow, toChartPoints, type ChartPoint } from '../utils/batteryChart' + +import './BatteryChart.css' + +Chart.register(LineController, LineElement, PointElement, LinearScale, Tooltip, Legend) + +const colours = [ + ['#0891b2', '#dc2626', '#d97706'], + ['#7c3aed', '#059669', '#ca8a04'], + ['#2563eb', '#be123c', '#9333ea'], + ['#475569', '#0f766e', '#c2410c'] +] + +const descriptions = { + nominal: 'The battery capacity specified by the manufacturer.', + calculated: 'The usable capacity Predbat has estimated from recent charging data.', + degradation: 'The percentage of the original capacity Predbat estimates has been lost.' +} + +function latest(points: ChartPoint[]): number | null { + return points.at(-1)?.y ?? null +} + +/** Show Predbat's recent battery capacity estimates. */ +export default function BatteryDegradationChart({ data }: { data: BatteryDegradationChartData }) { + const canvasRef = useRef(null) + const [rangeDays, setRangeDays] = useStoredState('predbat-chart-degradation-range', 28, [7, 14, 28]) + const [themeRevision, setThemeRevision] = useState(0) + const window = useMemo(() => ({ + start: Date.parse(data.generated_at) - rangeDays * 24 * 60 * 60 * 1000, + end: Date.parse(data.generated_at) + }), [data.generated_at, rangeDays]) + const inverters = useMemo(() => data.inverters.map((inverter) => ({ + ...inverter, + nominal: pointsInWindow(toChartPoints(inverter.nominal), window), + calculated: pointsInWindow(toChartPoints(inverter.calculated), window), + degradation: pointsInWindow(toChartPoints(inverter.degradation), window) + })), [data.inverters, window]) + + useEffect(() => { + const observer = new MutationObserver(() => setThemeRevision((revision) => revision + 1)) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!canvasRef.current) return + + const styles = getComputedStyle(document.documentElement) + const textColour = styles.getPropertyValue('--color-text-secondary').trim() || '#4b5563' + const gridColour = styles.getPropertyValue('--color-border').trim() || '#e5e7eb' + const datasets: ChartDataset<'line', ChartPoint[]>[] = inverters.flatMap((inverter, index) => { + const suffix = data.inverters.length > 1 ? ` ${inverter.id + 1}` : '' + const palette = colours[index % colours.length] + return [ + { label: `Nominal capacity${suffix}`, data: inverter.nominal, borderColor: palette[0], yAxisID: 'y' }, + { label: `Estimated capacity${suffix}`, data: inverter.calculated, borderColor: palette[1], yAxisID: 'y' }, + { label: `Degradation${suffix}`, data: inverter.degradation, borderColor: palette[2], yAxisID: 'y1' } + ].map((dataset) => ({ + ...dataset, + backgroundColor: dataset.borderColor, + borderWidth: 3, + pointRadius: dataset.data.length === 1 ? 4 : 1, + pointHoverRadius: 4, + stepped: true, + spanGaps: true + })) + }) + const configuration: ChartConfiguration<'line', ChartPoint[]> = { + type: 'line', + data: { datasets }, + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + parsing: false, + normalized: true, + interaction: { intersect: false, mode: 'index' }, + scales: { + x: { + type: 'linear', + min: window.start, + max: window.end, + grid: { color: gridColour }, + ticks: { + color: textColour, + maxTicksLimit: 10, + callback: (value) => new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(Number(value)) + } + }, + y: { + beginAtZero: false, + grid: { color: gridColour }, + ticks: { color: textColour, callback: (value) => `${Number(value).toFixed(1)} kWh` }, + title: { display: true, text: 'Capacity', color: textColour } + }, + y1: { + position: 'right', + beginAtZero: true, + grid: { drawOnChartArea: false }, + ticks: { color: textColour, callback: (value) => `${Number(value).toFixed(1)}%` }, + title: { display: true, text: 'Degradation', color: textColour } + } + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title: (items) => new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }).format(Number(items[0]?.parsed.x)), + label: (item) => `${item.dataset.label}: ${Number(item.parsed.y).toFixed(1)}${item.dataset.yAxisID === 'y1' ? '%' : ' kWh'}` + } + } + } + } + } + + const chart = new Chart(canvasRef.current, configuration) + return () => chart.destroy() + }, [data.inverters.length, inverters, themeRevision, window]) + + return ( +
+
+
+

Battery degradation

+

Predbat's estimate of usable battery capacity over time.

+
+
+ {[7, 14, 28].map((days) => ( + + ))} +
+
+ +
+ {inverters.map((inverter) => ( +
+ {data.inverters.length > 1 ? `Battery ${inverter.id + 1}` : 'Battery'} + {latest(inverter.degradation)?.toFixed(1) ?? '—'}% degradation + {latest(inverter.calculated)?.toFixed(1) ?? '—'} of {latest(inverter.nominal)?.toFixed(1) ?? '—'} kWh usable +
+ ))} +
+ Automatic capacity scaling + {data.automatic_scaling ? 'Enabled' : 'Disabled'} + {data.automatic_scaling ? 'Predbat adjusts usable capacity' : 'Current estimate is advisory'} +
+
+ +
+ {inverters.flatMap((inverter, index) => { + const suffix = data.inverters.length > 1 ? ` ${inverter.id + 1}` : '' + return (['nominal', 'calculated', 'degradation'] as const).map((key, colourIndex) => ( + + + )) + })} +
+ +
+ +
+
+ ) +} diff --git a/frontend/src/components/CarbonChart.tsx b/frontend/src/components/CarbonChart.tsx new file mode 100644 index 000000000..92e0cf9ad --- /dev/null +++ b/frontend/src/components/CarbonChart.tsx @@ -0,0 +1,174 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Chart, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, + type ChartConfiguration, + type ChartDataset, + type Plugin +} from 'chart.js' + +import { useStoredState } from '../hooks/useStoredState' +import type { CarbonChartData, TimestampSeries } from '../types/charts' +import { pointsInWindow, toChartPoints, type ChartPoint } from '../utils/batteryChart' +import { getCostChartWindow, latestCostValue } from '../utils/costChart' + +import './BatteryChart.css' + +Chart.register(LineController, LineElement, PointElement, LinearScale, Tooltip, Legend) + +const legends = [ + ['actual', 'Actual emissions', 'var(--color-current)', 'Your cumulative net grid emissions since midnight. Grid export can make this fall.'], + ['optimized', 'Optimised forecast', 'var(--color-export)', 'Predbat’s expected cumulative emissions when following the optimised plan.'], + ['base', 'Base forecast', '#d97706', 'Expected cumulative emissions if Predbat takes no further optimising action.'], + ['intensity', 'Grid intensity', '#7c3aed', 'Forecast grams of CO₂ produced per kWh of grid electricity.'] +] as const + +function inKg(series: TimestampSeries): ChartPoint[] { + return toChartPoints(series).map((point) => ({ ...point, y: point.y / 1000 })) +} + +function formatKg(value: number | null): string { + return value === null ? '—' : `${value.toFixed(2)} kg` +} + +function nowMarker(now: number, colour: string): Plugin<'line'> { + return { + id: 'predbat-carbon-now', + beforeDatasetsDraw({ ctx, chartArea, scales }) { + const x = Math.min(Math.max(scales.x.getPixelForValue(now), chartArea.left), chartArea.right) + ctx.save() + ctx.fillStyle = 'rgba(15, 23, 42, 0.07)' + ctx.fillRect(chartArea.left, chartArea.top, x - chartArea.left, chartArea.bottom - chartArea.top) + ctx.restore() + }, + afterDatasetsDraw({ ctx, chartArea, scales }) { + const x = scales.x.getPixelForValue(now) + ctx.save() + ctx.strokeStyle = colour + ctx.setLineDash([5, 4]) + ctx.beginPath() + ctx.moveTo(x, chartArea.top) + ctx.lineTo(x, chartArea.bottom) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = colour + ctx.font = '600 11px sans-serif' + ctx.fillText('Now', x + 5, chartArea.top + 13) + ctx.restore() + } + } +} + +/** Show actual and forecast household CO₂ alongside forecast grid intensity. */ +export default function CarbonChart({ data }: { data: CarbonChartData }) { + const canvasRef = useRef(null) + const [forecastHours, setForecastHours] = useStoredState('predbat-chart-carbon-range', 24, [12, 24, 48]) + const [themeRevision, setThemeRevision] = useState(0) + const now = Date.parse(data.generated_at) + const actual = useMemo(() => inKg(data.series.actual), [data.series.actual]) + const window = useMemo(() => getCostChartWindow(actual, data.generated_at, forecastHours), [actual, data.generated_at, forecastHours]) + const points = useMemo(() => ({ + actual: pointsInWindow(actual, window), + optimized: pointsInWindow(inKg(data.series.optimized), window), + base: pointsInWindow(inKg(data.series.base), window), + intensity: pointsInWindow(toChartPoints(data.series.intensity), window) + }), [actual, data.series, window]) + + useEffect(() => { + const observer = new MutationObserver(() => setThemeRevision((revision) => revision + 1)) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!canvasRef.current) return + const styles = getComputedStyle(document.documentElement) + const colours = { + actual: styles.getPropertyValue('--color-current').trim() || '#3b82f6', + optimized: styles.getPropertyValue('--color-export').trim() || '#16a34a', + base: '#d97706', + intensity: '#7c3aed' + } + const text = styles.getPropertyValue('--color-text-secondary').trim() || '#4b5563' + const grid = styles.getPropertyValue('--color-border').trim() || '#e5e7eb' + const datasets: ChartDataset<'line', ChartPoint[]>[] = legends.map(([key, label]) => ({ + label, + data: points[key], + borderColor: colours[key], + backgroundColor: colours[key], + borderWidth: key === 'actual' || key === 'optimized' ? 3 : 2, + borderDash: key === 'base' ? [6, 4] : undefined, + pointRadius: 0, + tension: 0.15, + yAxisID: key === 'intensity' ? 'yIntensity' : 'y' + })) + const configuration: ChartConfiguration<'line', ChartPoint[]> = { + type: 'line', + data: { datasets }, + plugins: [nowMarker(now, styles.getPropertyValue('--color-warning').trim() || '#f59e0b')], + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + parsing: false, + normalized: true, + interaction: { intersect: false, mode: 'nearest' }, + scales: { + x: { + type: 'linear', min: window.start, max: window.end, grid: { color: grid }, + ticks: { color: text, maxTicksLimit: 9, callback: (value) => new Intl.DateTimeFormat(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(Number(value)) } + }, + y: { grid: { color: grid }, ticks: { color: text, callback: (value) => `${value} kg` }, title: { display: true, text: 'Household CO₂', color: text } }, + yIntensity: { position: 'right', grid: { drawOnChartArea: false }, ticks: { color: colours.intensity, callback: (value) => `${value} g/kWh` }, title: { display: true, text: 'Grid intensity', color: colours.intensity } } + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title: (items) => new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(Number(items[0]?.parsed.x)), + label: (item) => `${item.dataset.label}: ${Number(item.parsed.y).toFixed(item.dataset.yAxisID === 'yIntensity' ? 0 : 2)} ${item.dataset.yAxisID === 'yIntensity' ? 'g/kWh' : 'kg'}` + } + } + } + } + } + const chart = new Chart(canvasRef.current, configuration) + return () => chart.destroy() + }, [now, points, themeRevision, window]) + + const actualNow = latestCostValue(points.actual, now) + const optimizedEnd = latestCostValue(points.optimized, window.end) + const baseEnd = latestCostValue(points.base, window.end) + const intensityNow = latestCostValue(points.intensity, now) + + return ( +
+
+

CO₂

Actual and forecast carbon emissions from your home’s net grid use.

+
+ {[12, 24, 48].map((hours) => )} +
+
+
+
Actual today{formatKg(actualNow)}
+
Optimised horizon{formatKg(optimizedEnd)}
+
Base horizon{formatKg(baseEnd)}
+
Grid intensity now{intensityNow === null ? '—' : `${intensityNow.toFixed(0)} g/kWh`}
+
+
+ {legends.map(([key, label, colour, description]) => ( + + + ))} +
+
+
+ ) +} diff --git a/frontend/src/components/CostChart.tsx b/frontend/src/components/CostChart.tsx new file mode 100644 index 000000000..502d844c2 --- /dev/null +++ b/frontend/src/components/CostChart.tsx @@ -0,0 +1,417 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Chart, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, + type ChartConfiguration, + type ChartDataset, + type Plugin +} from 'chart.js' + +import type { CostChartData, CostChartSeries } from '../types/charts' +import type { Plan } from '../types/plan' +import { useStoredState } from '../hooks/useStoredState' +import { + actionAtTime, + formatPlanAction, + hasSeriesData, + pointsInWindow, + toChartPoints, + type ChartPoint +} from '../utils/batteryChart' +import { + formatMinorCurrency, + getCostChartWindow, + latestCostValue, + plannedSaving +} from '../utils/costChart' + +import './BatteryChart.css' + +Chart.register(LineController, LineElement, PointElement, LinearScale, Filler, Tooltip, Legend) + +type CostChartProps = { + data: CostChartData + plan: Plan +} + +type ComparisonKey = 'actual_import' | 'actual_export' | 'base' | 'base10' | 'optimized10' + +type CostLegendItem = { + key: keyof CostChartSeries + label: string + colour: string + description?: string +} + +const primaryLegendItems: CostLegendItem[] = [ + { key: 'actual', label: 'Actual cost', colour: 'var(--color-current)' }, + { key: 'optimized', label: 'Optimised forecast', colour: 'var(--color-danger)' } +] + +const comparisonOptions: Array = [ + { + key: 'actual_import', + label: 'Import cost', + colour: '#d97706', + description: 'Cumulative cost of energy imported from the grid today.' + }, + { + key: 'actual_export', + label: 'Export credit', + colour: '#059669', + description: 'Cumulative credit earned from grid exports today, shown below zero.' + }, + { + key: 'base', + label: 'Base forecast', + colour: '#0891b2', + description: 'Expected total cost if Predbat takes no further optimising action.' + }, + { + key: 'base10', + label: 'Base forecast (10%)', + colour: '#7c3aed', + description: 'Base forecast using Predbat’s pessimistic solar and household-load scenario.' + }, + { + key: 'optimized10', + label: 'Optimised forecast (10%)', + colour: '#9333ea', + description: 'Optimised forecast using Predbat’s pessimistic solar and household-load scenario.' + } +] + +function CostLegend({ item, square = false }: { item: CostLegendItem; square?: boolean }) { + return ( + + + ) +} + +function makeCostOverlayPlugin( + plan: Plan, + start: number, + end: number, + now: number, + nowColour: string, + zeroColour: string +): Plugin<'line'> { + return { + id: 'predbat-cost-overlays', + beforeDatasetsDraw(chart) { + const { ctx, chartArea, scales } = chart + const nowPixel = Math.min(Math.max(scales.x.getPixelForValue(now), chartArea.left), chartArea.right) + + ctx.save() + ctx.fillStyle = 'rgba(15, 23, 42, 0.07)' + ctx.fillRect(chartArea.left, chartArea.top, nowPixel - chartArea.left, chartArea.bottom - chartArea.top) + + plan.rows.forEach((row, index) => { + const rowStart = Date.parse(row.time) + const nextStart = plan.rows[index + 1] ? Date.parse(plan.rows[index + 1].time) : rowStart + 30 * 60 * 1000 + const bandStart = Math.max(rowStart, start) + const bandEnd = Math.min(nextStart, end) + const state = row.state.toLowerCase() + + if (bandEnd <= bandStart || (!state.includes('chrg') && !state.includes('charge') && !state.includes('exp'))) { + return + } + + ctx.fillStyle = state.includes('exp') ? 'rgba(124, 58, 237, 0.08)' : 'rgba(37, 99, 235, 0.08)' + const left = scales.x.getPixelForValue(bandStart) + const right = scales.x.getPixelForValue(bandEnd) + ctx.fillRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top) + }) + ctx.restore() + }, + afterDatasetsDraw(chart) { + const { ctx, chartArea, scales } = chart + const zero = scales.y.getPixelForValue(0) + const nowPixel = scales.x.getPixelForValue(now) + + if (zero >= chartArea.top && zero <= chartArea.bottom) { + ctx.save() + ctx.strokeStyle = zeroColour + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(chartArea.left, zero) + ctx.lineTo(chartArea.right, zero) + ctx.stroke() + ctx.restore() + } + + ctx.save() + ctx.strokeStyle = nowColour + ctx.lineWidth = 1.5 + ctx.setLineDash([5, 4]) + ctx.beginPath() + ctx.moveTo(nowPixel, chartArea.top) + ctx.lineTo(nowPixel, chartArea.bottom) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = nowColour + ctx.font = '600 11px sans-serif' + ctx.fillText('Now', nowPixel + 5, chartArea.top + 13) + ctx.restore() + } + } +} + +/** Render Predbat's cumulative actual and forecast energy costs. */ +export default function CostChart({ data, plan }: CostChartProps) { + const canvasRef = useRef(null) + const [forecastHours, setForecastHours] = useStoredState('predbat-chart-cost-range', 24, [12, 24, 48]) + const [comparisons, setComparisons] = useState>(new Set()) + const [themeRevision, setThemeRevision] = useState(0) + const generatedAt = Date.parse(data.generated_at) + const actualPoints = useMemo(() => toChartPoints(data.series.actual), [data.series.actual]) + const window = useMemo( + () => getCostChartWindow(actualPoints, data.generated_at, forecastHours), + [actualPoints, data.generated_at, forecastHours] + ) + const optimizedPoints = useMemo( + () => pointsInWindow(toChartPoints(data.series.optimized), window), + [data.series.optimized, window] + ) + const basePoints = useMemo( + () => pointsInWindow(toChartPoints(data.series.base), window), + [data.series.base, window] + ) + const current = latestCostValue(actualPoints, generatedAt) + const optimizedEnd = latestCostValue(optimizedPoints, window.end) + const baseEnd = latestCostValue(basePoints, window.end) + const saving = plannedSaving(baseEnd, optimizedEnd) + const comparisonAvailability = useMemo>(() => ({ + actual_import: hasSeriesData(data.series.actual_import, window), + actual_export: hasSeriesData(data.series.actual_export, window), + base: hasSeriesData(data.series.base, window), + base10: hasSeriesData(data.series.base10, window), + optimized10: hasSeriesData(data.series.optimized10, window) + }), [data.series, window]) + + useEffect(() => { + const observer = new MutationObserver(() => setThemeRevision((revision) => revision + 1)) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!canvasRef.current) { + return + } + + const styles = getComputedStyle(document.documentElement) + const textColour = styles.getPropertyValue('--color-text-secondary').trim() || '#4b5563' + const gridColour = styles.getPropertyValue('--color-border').trim() || '#e5e7eb' + const actualColour = styles.getPropertyValue('--color-current').trim() || '#3b82f6' + const optimizedColour = styles.getPropertyValue('--color-danger').trim() || '#dc2626' + const nowColour = styles.getPropertyValue('--color-warning').trim() || '#f59e0b' + const comparisonColours = Object.fromEntries(comparisonOptions.map((item) => [item.key, item.colour])) + + const datasets: ChartDataset<'line', ChartPoint[]>[] = [ + { + label: 'Actual cost', + data: pointsInWindow(actualPoints, window), + borderColor: actualColour, + backgroundColor: actualColour, + borderWidth: 3, + pointRadius: 0, + tension: 0.2 + }, + { + label: 'Optimised forecast', + data: optimizedPoints, + borderColor: optimizedColour, + backgroundColor: optimizedColour, + borderWidth: 3, + pointRadius: 0, + tension: 0.2 + } + ] + + comparisonOptions.forEach(({ key, label }) => { + if (!comparisons.has(key) || !comparisonAvailability[key]) { + return + } + + datasets.push({ + label, + data: pointsInWindow(toChartPoints(data.series[key]), window), + borderColor: comparisonColours[key], + backgroundColor: comparisonColours[key], + borderWidth: 1.5, + borderDash: [5, 5], + pointRadius: 0, + tension: 0.15 + }) + }) + + const configuration: ChartConfiguration<'line', ChartPoint[]> = { + type: 'line', + data: { datasets }, + plugins: [makeCostOverlayPlugin(plan, window.start, window.end, generatedAt, nowColour, textColour)], + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + parsing: false, + normalized: true, + interaction: { intersect: false, mode: 'nearest' }, + scales: { + x: { + type: 'linear', + min: window.start, + max: window.end, + grid: { color: gridColour }, + ticks: { + color: textColour, + maxTicksLimit: 9, + callback(value) { + return new Intl.DateTimeFormat(undefined, { + weekday: 'short', + hour: '2-digit', + minute: '2-digit' + }).format(Number(value)) + } + } + }, + y: { + grid: { color: gridColour }, + ticks: { + color: textColour, + callback(value) { + return `${value}${data.currency_unit}` + } + } + } + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title(items) { + return new Intl.DateTimeFormat(undefined, { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }).format(Number(items[0]?.parsed.x)) + }, + label(item) { + return `${item.dataset.label}: ${formatMinorCurrency(Number(item.parsed.y), data.currency_symbol)}` + }, + footer(items) { + const action = actionAtTime(plan.rows, Number(items[0]?.parsed.x)) + return action ? `Plan: ${formatPlanAction(action)}` : '' + } + } + } + } + } + } + + const chart = new Chart(canvasRef.current, configuration) + return () => chart.destroy() + }, [actualPoints, comparisonAvailability, comparisons, data, generatedAt, optimizedPoints, plan, themeRevision, window]) + + function toggleComparison(key: ComparisonKey) { + setComparisons((currentComparisons) => { + const nextComparisons = new Set(currentComparisons) + if (nextComparisons.has(key)) { + nextComparisons.delete(key) + } else { + nextComparisons.add(key) + } + return nextComparisons + }) + } + + return ( +
+
+
+

Cost

+

Cumulative energy cost today and Predbat's optimised forecast.

+
+ +
+ {[12, 24, 48].map((hours) => ( + + ))} +
+
+ +
+
Cost so far{formatMinorCurrency(current, data.currency_symbol)}
+
Forecast total{formatMinorCurrency(optimizedEnd, data.currency_symbol)}
+
Base total{formatMinorCurrency(baseEnd, data.currency_symbol)}
+
Planned saving{formatMinorCurrency(saving, data.currency_symbol)}
+
+ +
+ {primaryLegendItems.map((item) => )} + {comparisonOptions + .filter(({ key }) => comparisons.has(key) && comparisonAvailability[key]) + .map((item) => )} +
+ +
+ +
+ +
+ Compare cost breakdowns and forecasts +
+ {comparisonOptions.map((item) => ( + + ))} +
+
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/components/DebugPanel.css b/frontend/src/components/DebugPanel.css new file mode 100644 index 000000000..c39dc8dd0 --- /dev/null +++ b/frontend/src/components/DebugPanel.css @@ -0,0 +1,272 @@ +.debug-panel { + border: 1px solid var(--color-border); + border-radius: 12px; + background: var(--color-surface); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.debug-panel-header { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--color-border); +} + +.debug-panel-header > svg { + font-size: 1rem; + color: var(--color-text-muted); +} + +.debug-panel-header > div { + display: flex; + flex-direction: column; +} + +.debug-panel-header strong { + font-size: 0.85rem; + color: var(--color-text); +} + +.debug-panel-header span { + margin-top: 0.1rem; + font-size: 0.65rem; + color: var(--color-text-muted); +} + +.debug-panel-content { + display: flex; + flex-direction: column; + padding: 0.4rem 1rem 1rem; +} + +.debug-action-row { + display: grid; + grid-template-columns: + 2rem + minmax(0, 1fr) + auto; + align-items: center; + gap: 0.7rem; + padding: 0.7rem 0; + border-bottom: 1px solid var(--color-border); +} + +.debug-action-icon { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 7px; + background: var(--color-bg); + color: var(--color-text-muted); +} + +.debug-action-description { + display: flex; + flex-direction: column; + min-width: 0; +} + +.debug-action-description strong { + font-size: 0.75rem; + color: var(--color-text); +} + +.debug-action-description span { + margin-top: 0.1rem; + font-size: 0.65rem; + line-height: 1.35; + color: var(--color-text-muted); +} + +.debug-action-buttons { + display: flex; + gap: 0.4rem; +} + +.debug-action-button, +.debug-action-buttons button, +.debug-restart-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.4rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: 0.68rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: + background 0.2s ease, + border-color 0.2s ease; +} + +.debug-action-button:hover, +.debug-action-buttons button:hover { + background: var(--color-bg); + border-color: var(--color-text-muted); +} + +.debug-restart-button { + border-color: var(--color-danger); + color: var(--color-danger); +} + +.debug-restart-button:hover { + background: var(--color-danger); + color: var(--color-semantic-contrast); +} + +.debug-restart-button:disabled { + opacity: 0.6; + cursor: default; +} + +.debug-restart-message { + margin-top: 0.75rem; + padding: 0.55rem 0.7rem; + border: 1px solid var(--color-border); + border-radius: 7px; + background: var(--color-bg); + font-size: 0.7rem; + color: var(--color-text-secondary); +} + +.debug-companion-note { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin-top: 0.8rem; + padding: 0.65rem 0.75rem; + border-radius: 8px; + background: var(--color-bg); +} + +.debug-companion-note strong { + font-size: 0.68rem; + color: var(--color-text-secondary); +} + +.debug-companion-note span { + font-size: 0.64rem; + line-height: 1.4; + color: var(--color-text-muted); +} + +.debug-companion-note code { + margin-left: 0.25rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.debug-api-data { + margin-top: 0.8rem; + border: 1px solid var(--color-border); + border-radius: 8px; + overflow: hidden; +} + +.debug-api-data > summary { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.65rem 0.75rem; + background: var(--color-bg); + color: var(--color-text-secondary); + font-size: 0.72rem; + font-weight: 700; + cursor: pointer; +} + +.debug-api-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.65rem 0.75rem; + background: var(--color-bg); + color: var(--color-text-secondary); + font-size: 0.72rem; + font-weight: 700; + cursor: pointer; + list-style: none; +} + +.debug-api-summary::-webkit-details-marker { + display: none; +} + +.debug-api-summary-label { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.debug-api-chevron { + flex: 0 0 auto; + font-size: 0.65rem; + color: var(--color-text-muted); + transition: transform 0.2s ease; +} + +.debug-api-data[open] .debug-api-chevron { + transform: rotate(180deg); +} + +.debug-api-data-content { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.65rem; +} + +.debug-panel-section { + border: 1px solid var(--color-border); + border-radius: 7px; + overflow: hidden; +} + +.debug-panel-section summary { + padding: 0.55rem 0.65rem; + background: var(--color-bg); + color: var(--color-text-secondary); + font-size: 0.68rem; + font-weight: 600; + cursor: pointer; +} + +.debug-panel-section pre { + max-height: 400px; + margin: 0; + padding: 0.75rem; + overflow: auto; + background: var(--color-surface); + color: var(--color-text-secondary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.63rem; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +@media (max-width: 600px) { + .debug-action-row { + grid-template-columns: + 2rem + minmax(0, 1fr); + } + + .debug-action-row > .debug-action-button, + .debug-action-row > .debug-action-buttons, + .debug-action-row > .debug-restart-button { + grid-column: 2; + justify-self: start; + } +} diff --git a/frontend/src/components/DebugPanel.tsx b/frontend/src/components/DebugPanel.tsx new file mode 100644 index 000000000..009038153 --- /dev/null +++ b/frontend/src/components/DebugPanel.tsx @@ -0,0 +1,292 @@ +import { useState } from 'react' + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +import { + faBug, + faDownload, + faFileCode, + faFileLines, + faClockRotateLeft, + faRotateRight, + faDatabase, + faChevronDown +} from '@fortawesome/free-solid-svg-icons' + +import type { PlanData } from '../types/plan' +import type { PredbatStatus } from '../types/status' +import type { PowerFlowData } from '../types/powerFlow' + +import './DebugPanel.css' + +type DebugPanelProps = { + planData: PlanData + statusData: PredbatStatus + powerFlowData: PowerFlowData | null +} + +function DebugJson({ title, data }: { title: string; data: unknown }) { + return ( +
+ {title} + +
{JSON.stringify(data, null, 2)}
+
+ ) +} + +export default function DebugPanel({ planData, statusData, powerFlowData }: DebugPanelProps) { + const [restarting, setRestarting] = useState(false) + + const [restartMessage, setRestartMessage] = useState(null) + + /* + * Download either the live in-memory apps.yaml or the + * apps.yaml file from disk. + * + * Predbat supports masked and unmasked versions of both. + */ + function downloadApps(source: 'live' | 'file') { + const includeCredentials = window.confirm( + 'Download apps.yaml with real credentials?\n\n' + + 'OK = full unmasked file\n' + + 'Cancel = masked file (credentials redacted)' + ) + + // Cancelling still downloads a file, but keeps credentials redacted. + const masked = includeCredentials ? '0' : '1' + + const endpoint = source === 'live' ? './debug_apps_live' : './debug_apps' + + window.location.href = `${endpoint}?masked=${masked}` + } + + /* + * Restart Predbat using the existing restart endpoint. + */ + async function restartPredbat() { + const confirmed = window.confirm('Are you sure you want to restart Predbat?') + + if (!confirmed) { + return + } + + setRestarting(true) + setRestartMessage(null) + + try { + const response = await fetch('./api/restart', { + method: 'POST', + cache: 'no-store', + headers: { + 'Content-Type': 'application/json' + } + }) + + const result = await response.json() + + if (!response.ok || !result.success) { + throw new Error(result.message || `HTTP ${response.status}`) + } + + setRestartMessage('Restart initiated. Predbat will restart shortly.') + } catch (error) { + console.error('Unable to restart Predbat:', error) + + setRestartMessage( + error instanceof Error ? `Restart failed: ${error.message}` : 'Restart failed.' + ) + + setRestarting(false) + } + } + + return ( +
+
+ + +
+ Debug + + Diagnostic files and developer information +
+
+ +
+ {/* + * apps.yaml + */} +
+
+ +
+ +
+ apps.yaml + + Download Predbat configuration +
+ +
+ + + +
+
+ + {/* + * Predbat debug YAML + */} +
+
+ +
+ +
+ Predbat debug YAML + + Generate a diagnostic snapshot +
+ + + + Create + +
+ + {/* + * Log file + */} +
+
+ +
+ +
+ predbat.log + + Download the current Predbat log +
+ + + + Download + +
+ + {/* + * HTML plan + */} +
+
+ +
+ +
+ predbat_plan.html + + Download the current generated plan +
+ + + + Download + +
+ + {/* + * Debug history + */} +
+
+ +
+ +
+ Debug history + + Download all retained debug snapshots +
+ + + + Download all + +
+ + {/* + * Restart + */} +
+
+ +
+ +
+ Restart Predbat + + Stop and restart the Predbat process +
+ + +
+ + {restartMessage &&
{restartMessage}
} + + {/* + * HA Companion warning retained from the original UI. + */} +
+ Home Assistant Companion app + + + File downloads may not save correctly from the Companion app. Files can instead be found + in + /config/debug/. + +
+ + {/* + * Raw data from the new dashboard APIs. + * + * This isn't part of the old dashboard but is useful + * for development, so keep it as a secondary section. + */} +
+ +
+ + Raw API data +
+ + +
+ +
+ + + + + +
+
+
+
+ ) +} diff --git a/frontend/src/components/GridIcon.tsx b/frontend/src/components/GridIcon.tsx new file mode 100644 index 000000000..f4b014575 --- /dev/null +++ b/frontend/src/components/GridIcon.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' + +export default function GridIcon(props: SVGProps) { + return ( + + ) +} diff --git a/frontend/src/components/InDayChart.tsx b/frontend/src/components/InDayChart.tsx new file mode 100644 index 000000000..8a06237dc --- /dev/null +++ b/frontend/src/components/InDayChart.tsx @@ -0,0 +1,319 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Chart, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, + type ChartConfiguration, + type ChartDataset, + type Plugin +} from 'chart.js' + +import type { InDayChartData, InDayChartSeries } from '../types/charts' +import { toChartPoints, type ChartPoint } from '../utils/batteryChart' +import { finalValue, getInDayWindow, latestValueAt } from '../utils/inDayChart' + +import './BatteryChart.css' + +Chart.register(LineController, LineElement, PointElement, LinearScale, Filler, Tooltip, Legend) + +type InDayChartProps = { + data: InDayChartData +} + +type InDayLegendItem = { + key: keyof InDayChartSeries + label: string + colour: string + description: string +} + +const legendItems: InDayLegendItem[] = [ + { + key: 'actual', + label: 'Actual load', + colour: 'var(--color-current)', + description: 'The cumulative energy your home has used since midnight. This line stops at Now.' + }, + { + key: 'predicted', + label: 'Day-ahead forecast', + colour: '#0891b2', + description: 'Predbat’s original estimate of today’s cumulative home energy use, before it saw today’s actual usage.' + }, + { + key: 'adjusted', + label: 'Adjusted forecast', + colour: 'var(--color-export)', + description: 'Predbat’s updated estimate for the whole day, combining actual usage so far with an adjusted forecast for the remaining hours.' + }, + { + key: 'adjustment_factor', + label: 'Adjustment', + colour: '#7c3aed', + description: 'The percentage correction Predbat currently applies to the remaining load forecast. Positive means it expects more load than originally forecast.' + } +] + +function InDayLegend({ item }: { item: InDayLegendItem }) { + return ( + + + ) +} + +function formatEnergy(value: number | null): string { + return value === null ? '—' : `${value.toFixed(2)} kWh` +} + +function formatAdjustment(value: number | null): string { + if (value === null) { + return '—' + } + return `${value > 0 ? '+' : ''}${value.toFixed(0)}%` +} + +function makeInDayOverlayPlugin(now: number, nowColour: string): Plugin<'line'> { + return { + id: 'predbat-inday-overlays', + beforeDatasetsDraw(chart) { + const { ctx, chartArea, scales } = chart + const nowPixel = Math.min(Math.max(scales.x.getPixelForValue(now), chartArea.left), chartArea.right) + + ctx.save() + ctx.fillStyle = 'rgba(15, 23, 42, 0.07)' + ctx.fillRect(chartArea.left, chartArea.top, nowPixel - chartArea.left, chartArea.bottom - chartArea.top) + ctx.restore() + }, + afterDatasetsDraw(chart) { + const { ctx, chartArea, scales } = chart + const nowPixel = scales.x.getPixelForValue(now) + + if (nowPixel < chartArea.left || nowPixel > chartArea.right) { + return + } + + ctx.save() + ctx.strokeStyle = nowColour + ctx.lineWidth = 1.5 + ctx.setLineDash([5, 4]) + ctx.beginPath() + ctx.moveTo(nowPixel, chartArea.top) + ctx.lineTo(nowPixel, chartArea.bottom) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = nowColour + ctx.font = '600 11px sans-serif' + ctx.fillText('Now', nowPixel + 5, chartArea.top + 13) + ctx.restore() + } + } +} + +/** Compare today's measured cumulative load with Predbat's original and adjusted forecasts. */ +export default function InDayChart({ data }: InDayChartProps) { + const canvasRef = useRef(null) + const [showAdjustment, setShowAdjustment] = useState(false) + const [themeRevision, setThemeRevision] = useState(0) + const generatedAt = Date.parse(data.generated_at) + const actualAll = useMemo(() => toChartPoints(data.series.actual), [data.series.actual]) + const actualPoints = useMemo( + () => actualAll.filter((point) => point.x <= generatedAt), + [actualAll, generatedAt] + ) + const predictedPoints = useMemo(() => toChartPoints(data.series.predicted), [data.series.predicted]) + const adjustedPoints = useMemo(() => toChartPoints(data.series.adjusted), [data.series.adjusted]) + const adjustmentPoints = useMemo(() => toChartPoints(data.series.adjustment_factor), [data.series.adjustment_factor]) + const window = useMemo( + () => getInDayWindow([actualAll, predictedPoints, adjustedPoints], generatedAt), + [actualAll, adjustedPoints, generatedAt, predictedPoints] + ) + const currentActual = latestValueAt(actualPoints, generatedAt) + const predictedTotal = finalValue(predictedPoints) + const adjustedTotal = finalValue(adjustedPoints) + const currentAdjustment = latestValueAt(adjustmentPoints, generatedAt) + const adjustmentAvailable = adjustmentPoints.length > 0 + + useEffect(() => { + const observer = new MutationObserver(() => setThemeRevision((revision) => revision + 1)) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!canvasRef.current) { + return + } + + const styles = getComputedStyle(document.documentElement) + const textColour = styles.getPropertyValue('--color-text-secondary').trim() || '#4b5563' + const gridColour = styles.getPropertyValue('--color-border').trim() || '#e5e7eb' + const actualColour = styles.getPropertyValue('--color-current').trim() || '#3b82f6' + const adjustedColour = styles.getPropertyValue('--color-export').trim() || '#7c3aed' + const nowColour = styles.getPropertyValue('--color-warning').trim() || '#f59e0b' + const datasets: ChartDataset<'line', ChartPoint[]>[] = [ + { + label: 'Actual load', + data: actualPoints, + borderColor: actualColour, + backgroundColor: actualColour, + borderWidth: 3, + pointRadius: 0 + }, + { + label: 'Day-ahead forecast', + data: predictedPoints, + borderColor: '#0891b2', + backgroundColor: '#0891b2', + borderWidth: 2, + borderDash: [6, 4], + pointRadius: 0 + }, + { + label: 'Adjusted forecast', + data: adjustedPoints, + borderColor: adjustedColour, + backgroundColor: adjustedColour, + borderWidth: 3, + pointRadius: 0 + } + ] + + if (showAdjustment && adjustmentAvailable) { + datasets.push({ + label: 'Adjustment', + data: adjustmentPoints, + borderColor: '#7c3aed', + backgroundColor: '#7c3aed', + borderWidth: 2, + borderDash: [3, 4], + pointRadius: 0, + yAxisID: 'yAdjustment' + }) + } + + const configuration: ChartConfiguration<'line', ChartPoint[]> = { + type: 'line', + data: { datasets }, + plugins: [makeInDayOverlayPlugin(generatedAt, nowColour)], + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + parsing: false, + normalized: true, + interaction: { intersect: false, mode: 'nearest' }, + scales: { + x: { + type: 'linear', + min: window.start, + max: window.end, + grid: { color: gridColour }, + ticks: { + color: textColour, + maxTicksLimit: 9, + callback(value) { + return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(Number(value)) + } + } + }, + y: { + beginAtZero: true, + grid: { color: gridColour }, + ticks: { color: textColour, callback: (value) => `${value} kWh` } + }, + yAdjustment: { + display: showAdjustment && adjustmentAvailable, + position: 'right', + grid: { drawOnChartArea: false }, + ticks: { color: '#7c3aed', callback: (value) => `${value}%` } + } + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title(items) { + return new Intl.DateTimeFormat(undefined, { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }).format(Number(items[0]?.parsed.x)) + }, + label(item) { + const suffix = item.dataset.yAxisID === 'yAdjustment' ? '%' : ' kWh' + return `${item.dataset.label}: ${Number(item.parsed.y).toFixed(item.dataset.yAxisID === 'yAdjustment' ? 0 : 2)}${suffix}` + } + } + } + } + } + } + + const chart = new Chart(canvasRef.current, configuration) + return () => chart.destroy() + }, [actualPoints, adjustedPoints, adjustmentAvailable, adjustmentPoints, data, generatedAt, predictedPoints, showAdjustment, themeRevision, window]) + + return ( +
+
+
+

In-day load adjustment

+

How today’s measured home energy use changes Predbat’s forecast for the rest of the day.

+
+
+ +
+
Actual so far{formatEnergy(currentActual)}
+
Original day forecast{formatEnergy(predictedTotal)}
+
Adjusted day forecast{formatEnergy(adjustedTotal)}
+
+ + Current adjustment + The correction Predbat applies to the remaining load forecast based on today’s usage so far. + + {formatAdjustment(currentAdjustment)} +
+
+ +
+ {legendItems + .filter(({ key }) => key !== 'adjustment_factor' || (showAdjustment && adjustmentAvailable)) + .map((item) => )} +
+ +
+ +
+ +
+ Show adjustment detail +
+ +
+
+
+ ) +} diff --git a/frontend/src/components/LoadMlChart.tsx b/frontend/src/components/LoadMlChart.tsx new file mode 100644 index 000000000..7d5cc7d95 --- /dev/null +++ b/frontend/src/components/LoadMlChart.tsx @@ -0,0 +1,194 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Chart, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, + type ChartConfiguration, + type ChartDataset, + type Plugin +} from 'chart.js' + +import { useStoredState } from '../hooks/useStoredState' +import type { LoadMlChartData } from '../types/charts' +import { pointsInWindow, toChartPoints, type ChartPoint } from '../utils/batteryChart' + +import './BatteryChart.css' + +Chart.register(LineController, LineElement, PointElement, LinearScale, Tooltip, Legend) + +type View = 'energy' | 'power' + +const descriptions: Record = { + 'Actual load': 'The load measured by Predbat.', + 'Prediction made 1 hour earlier': 'What the load model predicted one hour before each reading.', + 'Prediction made 8 hours earlier': 'What the load model predicted eight hours before each reading.', + 'Current ML forecast': 'Predbat’s current machine-learned load forecast.', + 'Actual load, excluding car': 'Measured house load with configured car charging removed to match what the model predicts.', + 'Car charging': 'Power used by the configured car charger.', + 'PV actual': 'Solar power measured by the inverter.', + 'PV forecast': 'Predbat’s current solar power forecast.', + Temperature: 'Predbat’s temperature forecast, shown on the right axis.', + 'Learned load history': 'The load model’s estimate recorded at each historical point.', + 'History predicted 1 hour earlier': 'The load model’s estimate made one hour before each historical point.', + 'History predicted 8 hours earlier': 'The load model’s estimate made eight hours before each historical point.' +} + +function marker(now: number, colour: string): Plugin<'line'> { + return { + id: 'predbat-load-ml-now', + beforeDatasetsDraw({ ctx, chartArea, scales }) { + const x = Math.min(Math.max(scales.x.getPixelForValue(now), chartArea.left), chartArea.right) + ctx.save() + ctx.fillStyle = 'rgba(15, 23, 42, 0.07)' + ctx.fillRect(chartArea.left, chartArea.top, x - chartArea.left, chartArea.bottom - chartArea.top) + ctx.restore() + }, + afterDatasetsDraw({ ctx, chartArea, scales }) { + const x = scales.x.getPixelForValue(now) + ctx.save() + ctx.strokeStyle = colour + ctx.setLineDash([5, 4]) + ctx.beginPath() + ctx.moveTo(x, chartArea.top) + ctx.lineTo(x, chartArea.bottom) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = colour + ctx.font = '600 11px sans-serif' + ctx.fillText('Now', x + 5, chartArea.top + 13) + ctx.restore() + } + } +} + +function latest(points: ChartPoint[], at: number): number | null { + return points.reduce((value, point) => point.x <= at ? point.y : value, null) +} + +/** Show Predbat's machine-learned load history and forecast. */ +export default function LoadMlChart({ data }: { data: LoadMlChartData }) { + const canvasRef = useRef(null) + const [view, setView] = useStoredState('predbat-chart-load-ml-view', 'energy', ['energy', 'power']) + const [days, setDays] = useStoredState('predbat-chart-load-ml-range', 7, [1, 3, 7]) + const [history, setHistory] = useStoredState<'show' | 'hide'>('predbat-chart-load-ml-history', 'hide', ['show', 'hide']) + const [themeRevision, setThemeRevision] = useState(0) + const now = Date.parse(data.generated_at) + const window = useMemo(() => ({ start: now - days * 86400000, end: now + 48 * 3600000 }), [days, now]) + const points = useMemo(() => Object.fromEntries( + Object.entries(data.series).map(([key, series]) => [key, pointsInWindow(toChartPoints(series), window)]) + ) as Record, [data.series, window]) + const useAdjustedLoad = data.car_configured && points.power_actual_less_car.length > 0 + const showCar = data.car_configured && points.car_power.length > 0 + + useEffect(() => { + const observer = new MutationObserver(() => setThemeRevision((revision) => revision + 1)) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!canvasRef.current) return + const styles = getComputedStyle(document.documentElement) + const colours = { + actual: styles.getPropertyValue('--color-current').trim() || '#2563eb', + forecast: '#dc2626', h1: '#d97706', h8: '#7c3aed', + adjusted: styles.getPropertyValue('--color-export').trim() || '#16a34a', + car: '#db2777', pv: styles.getPropertyValue('--color-flow-solar').trim() || '#ca8a04', + pvForecast: '#f59e0b', temperature: '#65a30d' + } + const definitions = view === 'energy' + ? [ + ['Actual load', points.energy_actual, colours.actual], + ['Prediction made 1 hour earlier', points.energy_predicted_h1, colours.h1], + ['Prediction made 8 hours earlier', points.energy_predicted_h8, colours.h8], + ['Current ML forecast', points.energy_forecast, colours.forecast] + ] as const + : [ + [useAdjustedLoad ? 'Actual load, excluding car' : 'Actual load', useAdjustedLoad ? points.power_actual_less_car : points.power_actual, colours.actual], + ['Current ML forecast', points.power_forecast, colours.forecast], + ...(showCar ? [['Car charging', points.car_power, colours.car] as const] : []), + ['PV actual', points.pv_actual, colours.pv], + ['PV forecast', points.pv_forecast, colours.pvForecast], + ['Temperature', points.temperature, colours.temperature], + ...(history === 'show' ? [ + ['Learned load history', points.power_history, colours.forecast] as const, + ['History predicted 1 hour earlier', points.power_history_h1, colours.h1] as const, + ['History predicted 8 hours earlier', points.power_history_h8, colours.h8] as const + ] : []) + ] as const + const datasets: ChartDataset<'line', ChartPoint[]>[] = definitions + .filter(([, series]) => series.length) + .map(([label, series, colour]) => ({ + label, + data: series, + borderColor: colour, + backgroundColor: colour, + borderWidth: label === 'Actual load' || label === 'Actual load, excluding car' || label === 'Current ML forecast' ? 3 : 2, + borderDash: label.includes('predicted') || label === 'PV forecast' ? [6, 4] : undefined, + pointRadius: 0, + tension: 0.2, + yAxisID: label === 'Temperature' ? 'temperature' : 'y' + })) + const text = styles.getPropertyValue('--color-text-secondary').trim() || '#4b5563' + const grid = styles.getPropertyValue('--color-border').trim() || '#e5e7eb' + const configuration: ChartConfiguration<'line', ChartPoint[]> = { + type: 'line', + data: { datasets }, + plugins: [marker(now, styles.getPropertyValue('--color-warning').trim() || '#f59e0b')], + options: { + animation: false, responsive: true, maintainAspectRatio: false, parsing: false, normalized: true, + interaction: { intersect: false, mode: 'nearest' }, + scales: { + x: { type: 'linear', min: window.start, max: window.end, grid: { color: grid }, ticks: { color: text, maxTicksLimit: 9, callback: (value) => new Intl.DateTimeFormat(undefined, days === 1 ? { hour: '2-digit', minute: '2-digit' } : { weekday: 'short', day: 'numeric', hour: '2-digit' }).format(Number(value)) } }, + y: { beginAtZero: true, grid: { color: grid }, ticks: { color: text, callback: (value) => `${value} ${view === 'energy' ? 'kWh' : 'kW'}` } }, + temperature: { display: view === 'power', position: 'right', grid: { drawOnChartArea: false }, ticks: { color: colours.temperature, callback: (value) => `${value} °C` } } + }, + plugins: { + legend: { display: false }, + tooltip: { callbacks: { + title: (items) => new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(Number(items[0]?.parsed.x)), + label: (item) => `${item.dataset.label}: ${Number(item.parsed.y).toFixed(2)} ${item.dataset.yAxisID === 'temperature' ? '°C' : view === 'energy' ? 'kWh' : 'kW'}` + } } + } + } + } + const chart = new Chart(canvasRef.current, configuration) + return () => chart.destroy() + }, [days, history, now, points, showCar, themeRevision, useAdjustedLoad, view, window]) + + const legendItems = view === 'energy' + ? [['Actual load', 'var(--color-current)'], ['Prediction made 1 hour earlier', '#d97706'], ['Prediction made 8 hours earlier', '#7c3aed'], ['Current ML forecast', '#dc2626']] + : [[useAdjustedLoad ? 'Actual load, excluding car' : 'Actual load', 'var(--color-current)'], ['Current ML forecast', '#dc2626'], ...(showCar ? [['Car charging', '#db2777']] : []), ['PV actual', 'var(--color-flow-solar)'], ['PV forecast', '#f59e0b'], ['Temperature', '#65a30d'], ...(history === 'show' ? [['Learned load history', '#dc2626'], ['History predicted 1 hour earlier', '#d97706'], ['History predicted 8 hours earlier', '#7c3aed']] : [])] + const summary = view === 'energy' + ? [['Actual now', latest(points.energy_actual, now), 'kWh'], ['Forecast now', latest(points.energy_forecast, now), 'kWh']] + : [['Load now', latest(useAdjustedLoad ? points.power_actual_less_car : points.power_actual, now), 'kW'], ['Forecast now', latest(points.power_forecast, now), 'kW'], ['PV now', latest(points.pv_actual, now), 'kW'], ['Temperature', latest(points.temperature, now), '°C']] + + return ( +
+
+

Machine-learned load

Compare Predbat’s learned household demand with measured load and its current forecast.

+
+
+ + +
+
+ {[1, 3, 7].map((range) => )} +
+
+
+
+ {summary.map(([label, value, unit]) =>
{label}{value === null ? '—' : `${Number(value).toFixed(2)} ${unit}`}
)} +
+
+ {legendItems.map(([label, colour]) => )} +
+
+ {view === 'power' &&
Compare earlier predictions
} +
+ ) +} diff --git a/frontend/src/components/MarginalCostsChart.tsx b/frontend/src/components/MarginalCostsChart.tsx new file mode 100644 index 000000000..50dd63150 --- /dev/null +++ b/frontend/src/components/MarginalCostsChart.tsx @@ -0,0 +1,197 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Chart, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, + type ChartConfiguration, + type ChartDataset, + type Plugin +} from 'chart.js' + +import { useStoredState } from '../hooks/useStoredState' +import type { MarginalCostsChartData } from '../types/charts' +import { pointsInWindow, toChartPoints, type ChartPoint } from '../utils/batteryChart' +import { formatRate } from '../utils/currency' + +import './BatteryChart.css' + +Chart.register(LineController, LineElement, PointElement, LinearScale, Tooltip, Legend) + +const colours = ['#0891b2', '#d97706', '#dc2626', '#7c3aed'] + +function nowMarker(now: number, colour: string): Plugin<'line'> { + return { + id: 'predbat-marginal-now', + beforeDatasetsDraw({ ctx, chartArea, scales }) { + const x = scales.x.getPixelForValue(now) + ctx.save() + ctx.fillStyle = 'rgba(15, 23, 42, 0.07)' + ctx.fillRect(chartArea.left, chartArea.top, Math.max(0, x - chartArea.left), chartArea.bottom - chartArea.top) + ctx.restore() + }, + afterDatasetsDraw({ ctx, chartArea, scales }) { + const x = scales.x.getPixelForValue(now) + ctx.save() + ctx.strokeStyle = colour + ctx.setLineDash([5, 4]) + ctx.beginPath() + ctx.moveTo(x, chartArea.top) + ctx.lineTo(x, chartArea.bottom) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = colour + ctx.font = '600 11px sans-serif' + ctx.fillText('Now', x + 5, chartArea.top + 13) + ctx.restore() + } + } +} + +/** Show the extra cost of consuming more energy at different times. */ +export default function MarginalCostsChart({ data }: { data: MarginalCostsChartData }) { + const canvasRef = useRef(null) + const [rangeHours, setRangeHours] = useStoredState('predbat-chart-marginal-range', 24, [24, 72, 168]) + const [themeRevision, setThemeRevision] = useState(0) + const now = Date.parse(data.generated_at) + const window = useMemo(() => ({ + start: now - (rangeHours - 12) * 60 * 60 * 1000, + end: now + 12 * 60 * 60 * 1000 + }), [now, rangeHours]) + + useEffect(() => { + const observer = new MutationObserver(() => setThemeRevision((revision) => revision + 1)) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!canvasRef.current) return + + const styles = getComputedStyle(document.documentElement) + const textColour = styles.getPropertyValue('--color-text-secondary').trim() || '#4b5563' + const gridColour = styles.getPropertyValue('--color-border').trim() || '#e5e7eb' + const nowColour = styles.getPropertyValue('--color-warning').trim() || '#f59e0b' + const datasets: ChartDataset<'line', ChartPoint[]>[] = data.levels.map((level, index) => ({ + label: `${level.label} (+${level.kwh} kWh)`, + data: pointsInWindow(toChartPoints(level.series), window), + borderColor: colours[index], + backgroundColor: colours[index], + borderWidth: 2.5, + pointRadius: 0, + stepped: true + })) + + datasets.push( + { + label: 'Import rate', + data: pointsInWindow(toChartPoints(data.grid_import), window), + borderColor: '#2563eb', + backgroundColor: '#2563eb', + borderWidth: 1.5, + borderDash: [5, 5], + pointRadius: 0, + stepped: true + }, + { + label: 'Export rate', + data: pointsInWindow(toChartPoints(data.grid_export), window), + borderColor: '#16a34a', + backgroundColor: '#16a34a', + borderWidth: 1.5, + borderDash: [5, 5], + pointRadius: 0, + stepped: true + } + ) + + const configuration: ChartConfiguration<'line', ChartPoint[]> = { + type: 'line', + data: { datasets }, + plugins: [nowMarker(now, nowColour)], + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + parsing: false, + normalized: true, + interaction: { intersect: false, mode: 'nearest' }, + scales: { + x: { + type: 'linear', + min: window.start, + max: window.end, + grid: { color: gridColour }, + ticks: { + color: textColour, + maxTicksLimit: 9, + callback: (value) => new Intl.DateTimeFormat(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(Number(value)) + } + }, + y: { + grid: { color: gridColour }, + ticks: { color: textColour, callback: (value) => `${value}${data.currency_unit}` }, + title: { display: true, text: `${data.currency_unit}/kWh`, color: textColour } + } + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title: (items) => new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(Number(items[0]?.parsed.x)), + label: (item) => `${item.dataset.label}: ${formatRate(Number(item.parsed.y), data.currency_unit)}` + } + } + } + } + } + + const chart = new Chart(canvasRef.current, configuration) + return () => chart.destroy() + }, [data, now, themeRevision, window]) + + return ( +
+
+
+

Marginal costs

+

The cost of using more energy than forecast after Predbat adjusts the plan.

+
+
+ {[[24, '24h'], [72, '3d'], [168, '7d']].map(([hours, label]) => ( + + ))} +
+
+ +
+ {data.levels.map((level) => ( +
+ {level.label} consumption · +{level.kwh} kWh + {level.current_cost == null ? '—' : formatRate(level.current_cost, data.currency_unit)} + {level.cheap ? 'Cheap now' : level.moderate ? 'Moderate now' : 'High now'} +
+ ))} +
+ +
+ {data.levels.map((level, index) => ( + + + ))} + Import rate + Export rate +
+ +
+ +
+
+ ) +} diff --git a/frontend/src/components/MetricsCharts.tsx b/frontend/src/components/MetricsCharts.tsx new file mode 100644 index 000000000..5c27c0961 --- /dev/null +++ b/frontend/src/components/MetricsCharts.tsx @@ -0,0 +1,177 @@ +import { useEffect, useRef, useState } from 'react' +import { + ArcElement, + BarController, + BarElement, + CategoryScale, + Chart, + DoughnutController, + LinearScale, + Tooltip +} from 'chart.js' + +import type { DashboardMetrics } from '../types/metrics' +import { metricEnergyValues, metricPowerValues } from '../utils/metrics' + +Chart.register(ArcElement, BarController, BarElement, CategoryScale, DoughnutController, LinearScale, Tooltip) + +function cssColour(styles: CSSStyleDeclaration, name: string, fallback: string): string { + return styles.getPropertyValue(name).trim() || fallback +} + +/** Display the live battery and power charts plus today's cumulative energy totals. */ +export default function MetricsCharts({ metrics }: { metrics: DashboardMetrics }) { + const socRef = useRef(null) + const powerRef = useRef(null) + const energyRef = useRef(null) + const [themeRevision, setThemeRevision] = useState(0) + + useEffect(() => { + const observer = new MutationObserver(() => setThemeRevision((revision) => revision + 1)) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!socRef.current || !powerRef.current || !energyRef.current) return + + const styles = getComputedStyle(document.documentElement) + const colours = { + charge: cssColour(styles, '--color-charge', '#2563eb'), + discharge: cssColour(styles, '--color-freeze-charge', '#0891b2'), + load: cssColour(styles, '--color-flow-home', '#16a34a'), + solar: cssColour(styles, '--color-flow-solar', '#f59e0b'), + import: cssColour(styles, '--color-danger', '#dc2626'), + export: cssColour(styles, '--color-export', '#7c3aed'), + battery: cssColour(styles, '--color-battery-high', '#22c55e'), + track: cssColour(styles, '--color-battery-track', '#e5e7eb'), + border: cssColour(styles, '--color-border', '#e5e7eb'), + text: cssColour(styles, '--color-text', '#111827'), + muted: cssColour(styles, '--color-text-muted', '#6b7280') + } + const socPercent = Math.min(100, Math.max(0, metrics.battery_soc_percent || 0)) + const powerValues = metricPowerValues(metrics) + const energyValues = metricEnergyValues(metrics) + const barColours = [colours.charge, colours.discharge, colours.load, colours.solar, colours.import, colours.export] + const energyColours = [colours.load, colours.solar, colours.import, colours.export] + + const socChart = new Chart(socRef.current, { + type: 'doughnut', + data: { + labels: ['Battery charge', 'Available capacity'], + datasets: [{ data: [socPercent, 100 - socPercent], backgroundColor: [colours.battery, colours.track], borderWidth: 0 }] + }, + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + cutout: '72%', + plugins: { legend: { display: false }, tooltip: { enabled: false } } + }, + plugins: [{ + id: 'metrics-battery-centre', + afterDraw(chart) { + const { ctx, chartArea } = chart + const x = (chartArea.left + chartArea.right) / 2 + const y = (chartArea.top + chartArea.bottom) / 2 + ctx.save() + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillStyle = colours.text + ctx.font = '700 1.8rem Inter, sans-serif' + ctx.fillText(`${socPercent.toFixed(0)}%`, x, y - 11) + ctx.fillStyle = colours.muted + ctx.font = '0.82rem Inter, sans-serif' + ctx.fillText(`${metrics.battery_soc_kwh.toFixed(1)} / ${metrics.battery_max_kwh.toFixed(1)} kWh`, x, y + 17) + ctx.restore() + } + }] + }) + + const powerChart = new Chart(powerRef.current, { + type: 'bar', + data: { + labels: ['Battery charge', 'Battery discharge', 'Load', 'PV', 'Grid import', 'Grid export'], + datasets: [{ data: powerValues, backgroundColor: barColours, borderRadius: 4 }] + }, + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + indexAxis: 'y', + plugins: { + legend: { display: false }, + tooltip: { callbacks: { label: (item) => `${Number(item.parsed.x).toFixed(2)} kW` } } + }, + scales: { + x: { + beginAtZero: true, + grid: { color: colours.border }, + ticks: { color: colours.muted }, + title: { display: true, text: 'kW', color: colours.muted } + }, + y: { grid: { display: false }, ticks: { color: colours.text } } + } + } + }) + + const energyChart = new Chart(energyRef.current, { + type: 'bar', + data: { + labels: ['Load', 'PV generation', 'Grid import', 'Grid export'], + datasets: [{ data: energyValues, backgroundColor: energyColours, borderRadius: 4 }] + }, + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + indexAxis: 'y', + plugins: { + legend: { display: false }, + tooltip: { callbacks: { label: (item) => `${Number(item.parsed.x).toFixed(1)} kWh` } } + }, + scales: { + x: { + beginAtZero: true, + grid: { color: colours.border }, + ticks: { color: colours.muted }, + title: { display: true, text: 'kWh', color: colours.muted } + }, + y: { grid: { display: false }, ticks: { color: colours.text } } + } + } + }) + + return () => { + socChart.destroy() + powerChart.destroy() + energyChart.destroy() + } + }, [metrics, themeRevision]) + + return ( + <> +
+

Battery status

+
+
+

State of charge

+
+
+
+

Live power

+
+
+
+
+ +
+

Energy today

+

Running totals since midnight.

+
+
+
+
+ + ) +} diff --git a/frontend/src/components/MetricsPanel.css b/frontend/src/components/MetricsPanel.css new file mode 100644 index 000000000..87e7bcd16 --- /dev/null +++ b/frontend/src/components/MetricsPanel.css @@ -0,0 +1,305 @@ +.metrics-panel { + overflow: hidden; + margin: 1rem; + border: 1px solid var(--color-border); + border-radius: 12px; + background: var(--color-surface); + box-shadow: var(--shadow-card); + color: var(--color-text); +} + +.metrics-panel-message { + padding: 1rem; + color: var(--color-text-muted); + font-size: 0.78rem; +} + +.metrics-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border-bottom: 1px solid var(--color-border); +} + +.metrics-panel-header h2, +.metrics-panel-header p, +.metrics-section h3, +.metrics-empty { + margin: 0; +} + +.metrics-panel-header h2 { + font-size: 1.05rem; +} + +.metrics-panel-header p, +.metrics-panel-header small { + color: var(--color-text-muted); + font-size: 0.7rem; +} + +.metrics-panel-header p { + margin-top: 0.2rem; +} + +.metrics-panel-header small { + white-space: nowrap; +} + +.metrics-section { + min-width: 0; + padding: 1rem; +} + +.metrics-section + .metrics-section, +.metrics-section-row + .metrics-section, +.metrics-section + .metrics-section-row { + border-top: 1px solid var(--color-border); +} + +.metrics-section h3 { + margin-bottom: 0.7rem; + color: var(--color-text-secondary); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.055em; +} + +.metrics-section-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid var(--color-border); +} + +.metrics-section-row > .metrics-section + .metrics-section { + border-top: 0; + border-left: 1px solid var(--color-border); +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0.65rem; +} + +.metrics-grid-compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.metrics-card { + display: flex; + flex-direction: column; + min-width: 0; + padding: 0.7rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: color-mix(in srgb, var(--color-bg) 65%, var(--color-surface)); +} + +.metrics-card > span, +.metrics-card small { + overflow: hidden; + color: var(--color-text-muted); + font-size: 0.64rem; + text-overflow: ellipsis; +} + +.metrics-card > span { + white-space: nowrap; +} + +.metrics-card strong { + margin-top: 0.2rem; + font-size: 0.9rem; +} + +.metrics-card small { + margin-top: 0.15rem; +} + +.metrics-config-errors { + margin-top: 0.75rem; + padding: 0.75rem; + border: 1px solid color-mix(in srgb, var(--color-danger) 55%, var(--color-border)); + border-radius: 8px; + background: color-mix(in srgb, var(--color-danger) 8%, var(--color-surface)); + color: var(--color-text-secondary); + font-size: 0.72rem; +} + +.metrics-config-errors > strong { color: var(--color-danger); } +.metrics-config-errors ul { margin: 0.45rem 0; padding-left: 1.2rem; } +.metrics-config-errors li + li { margin-top: 0.25rem; } +.metrics-config-errors b { color: var(--color-text); } +.metrics-config-errors a { color: var(--color-current); font-weight: 700; } + +.metrics-card .is-good { + color: var(--color-success); +} + +.metrics-card .is-warning, +.metrics-table-wrap td.is-warning { + color: var(--color-warning); +} + +.metrics-card .is-bad { + color: var(--color-danger); +} + +.metrics-table-wrap { + overflow-x: auto; + border: 1px solid var(--color-border); + border-radius: 8px; +} + +.metrics-table-wrap table { + width: 100%; + border-collapse: collapse; + font-size: 0.7rem; +} + +.metrics-table-wrap th, +.metrics-table-wrap td { + padding: 0.55rem 0.65rem; + border-bottom: 1px solid var(--color-border); + text-align: left; + white-space: nowrap; +} + +.metrics-table-wrap th { + background: var(--color-bg); + color: var(--color-text-muted); + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.metrics-table-wrap tr:last-child td { + border-bottom: 0; +} + +.metrics-empty { + padding: 1rem; + color: var(--color-text-muted); + font-size: 0.72rem; + text-align: center; +} + +.metrics-quota-card .metrics-quota { + overflow: hidden; + height: 0.35rem; + margin-top: 0.45rem; + border-radius: 1rem; + background: var(--color-border); +} + +.metrics-quota span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--color-success); +} + +.metrics-conflict-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.metrics-conflict-table { + margin-top: 0.7rem; +} + +@media (max-width: 1100px) { + .metrics-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .metrics-grid-compact, + .metrics-conflict-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .metrics-panel { + margin: 0.75rem; + } + + .metrics-panel-header, + .metrics-section-row { + display: flex; + flex-direction: column; + } + + .metrics-section-row > .metrics-section + .metrics-section { + border-top: 1px solid var(--color-border); + border-left: 0; + } + + .metrics-grid, + .metrics-grid-compact, + .metrics-conflict-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 460px) { + .metrics-grid, + .metrics-grid-compact, + .metrics-conflict-summary { + grid-template-columns: 1fr; + } +} + +.metrics-chart-grid { + display: grid; + grid-template-columns: minmax(220px, 0.38fr) minmax(0, 1fr); + gap: 0.75rem; +} + +.metrics-chart-card { + min-width: 0; + padding: 0.8rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: color-mix(in srgb, var(--color-bg) 65%, var(--color-surface)); +} + +.metrics-chart-card h4, +.metrics-section-description { + margin: 0; + color: var(--color-text-muted); + font-size: 0.7rem; +} + +.metrics-chart-card h4 { + margin-bottom: 0.5rem; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.metrics-section-description { + margin: -0.45rem 0 0.7rem; +} + +.metrics-chart-canvas { + position: relative; + height: 245px; +} + +.metrics-chart-card-soc .metrics-chart-canvas { + max-width: 260px; + margin-inline: auto; +} + +.metrics-chart-canvas-energy { + height: 210px; +} + +@media (max-width: 760px) { + .metrics-chart-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/components/MetricsPanel.tsx b/frontend/src/components/MetricsPanel.tsx new file mode 100644 index 000000000..bd0fcb412 --- /dev/null +++ b/frontend/src/components/MetricsPanel.tsx @@ -0,0 +1,173 @@ +import { useEffect, useState } from 'react' + +import type { DashboardMetrics } from '../types/metrics' +import { formatMajorCurrency } from '../utils/currency' +import MetricsCharts from './MetricsCharts' +import { formatMetricAge, metricVersion, sumMetricValues } from '../utils/metrics' + +import './MetricsPanel.css' + +function formatUptime(value: string | null, currentTime: Date | null) { + if (!value || !currentTime) return 'Unknown' + + const minutes = Math.max(0, Math.floor((currentTime.getTime() - new Date(value).getTime()) / 60000)) + const days = Math.floor(minutes / 1440) + const hours = Math.floor((minutes % 1440) / 60) + const mins = minutes % 60 + + if (days > 0) return `${days}d ${hours}h` + if (hours > 0) return `${hours}h ${mins}m` + return `${mins}m` +} + +function MetricCard({ label, value, detail, tone }: { label: string; value: string; detail?: string; tone?: 'good' | 'warning' | 'bad' }) { + return ( +
+ {label} + {value} + {detail && {detail}} +
+ ) +} + +/** Display operational metrics that complement the main dashboard cards. */ +export default function MetricsPanel({ lastStarted }: { lastStarted: string | null }) { + const [metrics, setMetrics] = useState(null) + const [error, setError] = useState(false) + const [refreshedAt, setRefreshedAt] = useState(null) + + useEffect(() => { + let active = true + + async function refresh() { + try { + const response = await fetch('./metrics/json', { cache: 'no-store' }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const data = await response.json() as DashboardMetrics + if (active) { + setMetrics(data) + setRefreshedAt(new Date()) + setError(false) + } + } catch { + if (active) setError(true) + } + } + + refresh() + const timer = window.setInterval(refresh, 30_000) + return () => { + active = false + window.clearInterval(timer) + } + }, []) + + if (!metrics) { + return error ?
Dashboard metrics are unavailable.
: null + } + + const errors = sumMetricValues(metrics.errors_total) + const services = Object.entries(metrics.api_services).sort(([first], [second]) => first.localeCompare(second)) + const conflicts = [...metrics.control_conflicts_events].reverse() + const quotaPercent = metrics.solcast_api_limit > 0 ? Math.min(100, metrics.solcast_api_used / metrics.solcast_api_limit * 100) : 0 + + return ( +
+
+
+

Performance and health

+

Energy, cost and service metrics collected by Predbat.

+
+ {error ? 'Refresh failed · showing previous values' : refreshedAt ? `Updated ${refreshedAt.toLocaleTimeString()}` : ''} +
+ +
+

System health

+
+ + + + + + = metrics.data_age_required_days ? 'good' : 'warning'} /> +
+ {Object.keys(metrics.config_errors ?? {}).length > 0 && ( +
+ Configuration errors +
    + {Object.entries(metrics.config_errors).map(([name, message]) => ( +
  • {name}: {message}
  • + ))} +
+ Review configuration +
+ )} +
+ + + +
+

Cost and savings

+
+ + + + +
+
+ +
+
+

API services

+
+ {services.length ? ( + + + + {services.map(([name, service]) => ( + + ))} + +
ServiceRequestsFailuresLast success
{name}{service.requests.toFixed(0)}{service.failures.toFixed(0)}{formatMetricAge(service.last_success)}
+ ) :

No API calls recorded yet.

} +
+
+ +
+

Solar forecasting

+
+
+ Solcast API quota + {metrics.solcast_api_used.toFixed(0)} / {metrics.solcast_api_limit.toFixed(0)} +
+ {metrics.solcast_api_remaining.toFixed(0)} remaining +
+ + + +
+
+
+ +
+

Control conflicts

+
+ + +
+ {conflicts.length > 0 && ( +
+ + + + {conflicts.map((event, index) => ( + + ))} + +
WhenControlEntityPredbat setCurrent value
{formatMetricAge(event.at)}{event.control ?? '—'}{event.entity_id ?? '—'}{event.we_set == null ? '—' : String(event.we_set)}{event.now_reads == null ? '—' : String(event.now_reads)}
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/NordPoolIcon.tsx b/frontend/src/components/NordPoolIcon.tsx new file mode 100644 index 000000000..e90503b6a --- /dev/null +++ b/frontend/src/components/NordPoolIcon.tsx @@ -0,0 +1,25 @@ +import type { SVGProps } from 'react' + +export default function NordPoolIcon(props: SVGProps) { + return ( + + ) +} diff --git a/frontend/src/components/PlanDescription.css b/frontend/src/components/PlanDescription.css new file mode 100644 index 000000000..33ea722b6 --- /dev/null +++ b/frontend/src/components/PlanDescription.css @@ -0,0 +1,32 @@ +.plan-description { + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-text); + border-radius: 16px; + padding: 1.5rem; + box-shadow: var(--shadow-card); +} + +.plan-description-header { + margin-bottom: 0.75rem; +} + +.plan-description-header h2 { + margin: 0 0 1.5rem; + font-size: 1.25rem; + font-weight: 600; +} + +.plan-description-list { + margin: 0; + padding-left: 1.15rem; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.plan-description-list li { + font-size: 0.82rem; + line-height: 1.45; + color: var(--color-text-secondary); +} diff --git a/frontend/src/components/PlanDescription.tsx b/frontend/src/components/PlanDescription.tsx new file mode 100644 index 000000000..f075b63be --- /dev/null +++ b/frontend/src/components/PlanDescription.tsx @@ -0,0 +1,25 @@ +import './PlanDescription.css' + +type PlanDescriptionProps = { + description?: string[] +} + +export function PlanDescription({ description }: PlanDescriptionProps) { + if (!description || description.length === 0) { + return null + } + + return ( +
+
+

Plan explanation

+
+ +
    + {description.map((line, index) => ( +
  • {line}
  • + ))} +
+
+ ) +} diff --git a/frontend/src/components/PlanSummary.css b/frontend/src/components/PlanSummary.css new file mode 100644 index 000000000..84e35cca5 --- /dev/null +++ b/frontend/src/components/PlanSummary.css @@ -0,0 +1,392 @@ +.plan-summary { + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-text); + border-radius: 16px; + padding: 1.5rem; + margin: 1.5rem 0; + box-shadow: var(--shadow-card); +} + +.plan-summary h2 { + margin: 0 0 1.5rem; + font-size: 1.25rem; + font-weight: 600; +} + +.plan-summary-metrics { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0; +} + +.plan-summary-metric { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0 1.25rem; + min-width: 0; +} + +.plan-summary-metric:first-child { + padding-left: 0; +} + +.plan-summary-metric:last-child { + padding-right: 0; +} + +.plan-summary-metric + .plan-summary-metric { + border-left: 1px solid var(--color-border); +} + +.metric-label { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.metric-value { + font-size: 1.5rem; + font-weight: 600; + line-height: 1.2; + color: var(--color-text); +} + +.metric-detail { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.75rem; +} + +.metric-label, +.metric-detail { + color: var(--color-text-muted); +} + +.state-reason { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.8rem; + color: var(--color-text-secondary); +} + +.plan-summary-next { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border-soft); + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.reason-tooltip { + position: relative; + display: inline-flex; + align-items: center; + cursor: help; + color: var(--color-text-subtle); +} + +.reason-tooltip:hover { + color: var(--color-text-secondary); +} + +.reason-tooltip-content { + visibility: hidden; + opacity: 0; + position: absolute; + z-index: 20; + width: 320px; + max-width: 80vw; + padding: 0.75rem 1rem; + bottom: calc(100% + 10px); + left: 50%; + transform: translateX(-50%); + background: var(--color-tooltip-bg); + color: var(--color-tooltip-text); + box-shadow: var(--shadow-tooltip); + border-radius: 8px; + font-size: 0.8rem; + line-height: 1.45; + font-weight: 400; + transition: opacity 0.15s ease; +} + +.reason-tooltip:hover .reason-tooltip-content { + visibility: visible; + opacity: 1; +} + +.rate-good { + color: var(--color-success); +} + +.rate-bad { + color: var(--color-danger); +} + +.action-value { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.action-value svg { + font-size: 0.75em; +} + +.action-charge { + color: var(--color-charge); +} + +.action-hold-charge { + color: var(--color-hold-charge); +} + +.action-freeze-charge { + color: var(--color-freeze-charge); +} + +.action-export { + color: var(--color-export); +} + +.action-hold-export { + color: var(--color-hold-export); +} + +.action-freeze-export { + color: var(--color-freeze-export); +} + +.action-hold-car { + color: var(--color-hold-car); +} + +.action-hold-iboost { + color: var(--color-hold-iboost); +} + +.action-no-charge { + color: var(--color-no-charge); +} + +.action-demand { + color: var(--color-demand); +} + +.battery-bar { + width: 100%; + max-width: 140px; + height: 6px; + + /* Empty part of the battery */ + background: var(--color-battery-track); + border-radius: 3px; + overflow: hidden; + position: relative; +} + +.battery-bar-fill { + position: relative; + height: 100%; + border-radius: 3px; + overflow: hidden; + transition: width 0.3s ease; +} + +/* Battery level colours */ + +.battery-low { + background: var(--color-battery-low); +} + +.battery-medium { + background: var(--color-battery-medium); +} + +.battery-high { + background: var(--color-battery-high); +} + +/* Charging Animation */ + +.battery-bar-fill.is-charging::after, +.battery-bar-fill.is-exporting::after { + content: ''; + position: absolute; + inset: 0; + width: 50%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.18), transparent); + animation: battery-flow 2.5s linear infinite; +} + +.battery-bar-fill.is-exporting::after { + animation-direction: reverse; +} + +@keyframes battery-flow { + from { + left: -50%; + } + + to { + left: 100%; + } +} + +.plan-summary-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.plan-summary-full-link { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--color-charge); + font-size: 0.7rem; + font-weight: 600; + text-decoration: none; +} + +.plan-summary-full-link:hover { + text-decoration: underline; +} + +@media (prefers-reduced-motion: reduce) { + .battery-bar-fill.is-charging::after, + .battery-bar-fill.is-exporting::after { + animation: none; + } +} + +@media (max-width: 900px) { + .reason-tooltip-content { + right: 0; + left: auto; + transform: none; + } + + .plan-summary-metrics { + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem 0; + } + + .plan-summary-metric:nth-child(odd) { + padding-left: 0; + padding-right: 1rem; + border-left: none; + } + + .plan-summary-metric:nth-child(even) { + padding-left: 1rem; + padding-right: 0; + border-left: 1px solid var(--color-border); + } +} + +@media (max-width: 700px) { + .plan-summary { + width: 100%; + padding: 1rem; + margin: 0; + } +} + +@media (max-width: 400px) { + .plan-summary { + padding: 0.5rem; + margin: 0; + } + + .plan-summary-metrics { + grid-template-columns: 1fr; + } + + .plan-summary-metric, + .plan-summary-metric:nth-child(odd), + .plan-summary-metric:nth-child(even) { + padding: 1rem 0; + border-left: none; + border-top: 1px solid var(--color-border); + } + + .plan-summary-metric:first-child { + border-top: none; + padding-top: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .battery-bar-fill.is-charging::after { + animation: none; + } +} + +.plan-summary-context { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 1rem; + margin-bottom: 0.5rem; +} + +.plan-summary-context-now, +.plan-summary-context-next { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.plan-summary-context-now { + grid-column: 1 / 4; +} + +.plan-summary-context-next { + grid-column: 4; +} + +.plan-summary-next-label-compact { + display: none; +} + +.plan-summary-context-now, +.plan-summary-context-next { + position: relative; + padding-bottom: 0.4rem; +} + +.plan-summary-context-now::after, +.plan-summary-context-next::after { + content: ''; + position: absolute; + left: 0; + right: 1.5rem; + bottom: 0; + height: 1px; + background: var(--color-border); +} + +.plan-summary-context-next::after { + right: 0; +} + +@media (max-width: 900px) { + .plan-summary-context, + .plan-summary-next-label-desktop { + display: none; + } + + .plan-summary-next-label-compact { + display: block; + } +} diff --git a/frontend/src/components/PlanSummary.tsx b/frontend/src/components/PlanSummary.tsx new file mode 100644 index 000000000..49d6b2795 --- /dev/null +++ b/frontend/src/components/PlanSummary.tsx @@ -0,0 +1,643 @@ +import type { Plan } from '../types/plan' +import { formatRate, resolveCurrencySymbols } from '../utils/currency' +import './PlanSummary.css' + +import { + FontAwesomeIcon, + FontAwesomeLayers +} from '@fortawesome/react-fontawesome' + +import { + faArrowTrendUp, + faArrowTrendDown, + faArrowRight, + faCircleInfo, + faBolt, + faSnowflake, + faHouse, + faPause, + faCar, + faWater, + faBan +} from '@fortawesome/free-solid-svg-icons' + +/** + * Props supplied to the PlanSummary component. + * + * The whole Plan object is passed in rather than individual values because + * the summary uses both row-level forecast data and plan-level values such + * as the actual battery energy and maximum capacity. + */ +type PlanSummaryProps = { + plan: Plan + showPlanLink?: boolean +} + +/** + * Normalised action types used by the React UI. + * + * Predbat uses short/raw state names such as "Chrg" and "HoldChrg". + * Converting those to our own semantic action types means the rest of the + * UI does not need to know about Predbat's internal abbreviations. + */ +type ActionType = + | 'charge' + | 'hold-charge' + | 'freeze-charge' + | 'export' + | 'hold-export' + | 'freeze-export' + | 'hold-car' + | 'hold-iboost' + | 'no-charge' + | 'demand' + | 'unknown' + +/** + * Convert a raw Predbat plan state into a semantic UI action type. + * + * These labels are also interpreted by PlanTable and PlanVisual; keep their + * mappings aligned when adding a new backend state. + */ +function getActionType(state: string): ActionType { + switch (state) { + case 'Chrg': + return 'charge' + + case 'HoldChrg': + return 'hold-charge' + + case 'FrzChrg': + return 'freeze-charge' + + case 'Exp': + return 'export' + + case 'HoldExp': + return 'hold-export' + + case 'FrzExp': + return 'freeze-export' + + case 'Hold for car': + case 'Demand, Hold for car': + return 'hold-car' + + case 'Hold for iBoost': + case 'Demand, Hold for iBoost': + return 'hold-iboost' + + case 'No Charge': + return 'no-charge' + + case 'Demand': + return 'demand' + + default: + return 'unknown' + } +} + +/** + * Human-readable label for each semantic action type. + */ +function getActionLabel(action: ActionType) { + switch (action) { + case 'charge': + return 'Charge' + + case 'hold-charge': + return 'Hold Charge' + + case 'freeze-charge': + return 'Freeze Charge' + + case 'export': + return 'Export' + + case 'hold-export': + return 'Hold Export' + + case 'freeze-export': + return 'Freeze Export' + + case 'hold-car': + return 'Hold for Car' + + case 'hold-iboost': + return 'Hold for iBoost' + + case 'no-charge': + return 'No Charge' + + case 'demand': + return 'Demand' + + default: + return 'Unknown' + } +} + +/** + * Return the Font Awesome icon associated with an action. + * + * Icons are deliberately semantic: + * - bolt: battery charging + * - bolt + export arrow: battery exporting + * - snowflake: freeze behaviour + * - pause: hold behaviour + * - car: EV hold + * - water: iBoost hold + * - ban: charging disabled + * - house: normal household demand + */ +function getActionIcon(action?: ActionType) { + switch (action) { + case 'charge': + case 'hold-charge': + return + + case 'freeze-charge': + return + + case 'export': + return ( + + + + + ) + + case 'hold-export': + return + + case 'freeze-export': + return + + case 'hold-car': + return + + case 'hold-iboost': + return + + case 'no-charge': + return + + case 'demand': + return + + default: + return + } +} + +/** + * Convert a raw Predbat state into a readable label. + * + * Known states use our semantic action mapping. Unknown states are returned + * unchanged so that a newly-added Predbat state is still visible rather + * than being hidden behind "Unknown". + */ +function getStateLabel(state: string) { + const action = getActionType(state) + + return action === 'unknown' ? state : getActionLabel(action) +} + +/** + * Return the CSS class associated with an action. + * + * The component CSS contains no hard-coded action colours; these classes + * reference semantic colour variables from theme.css instead. + */ +function getActionClass(action?: ActionType) { + switch (action) { + case 'charge': + return 'action-charge' + + case 'hold-charge': + return 'action-hold-charge' + + case 'freeze-charge': + return 'action-freeze-charge' + + case 'export': + return 'action-export' + + case 'hold-export': + return 'action-hold-export' + + case 'freeze-export': + return 'action-freeze-export' + + case 'hold-car': + return 'action-hold-car' + + case 'hold-iboost': + return 'action-hold-iboost' + + case 'no-charge': + return 'action-no-charge' + + case 'demand': + return 'action-demand' + + default: + return '' + } +} + +/** + * Replace placeholders in Predbat's reason templates. + * + * Example: + * + * "Charge to {target_percentage}% at {import_rate}p" + * + * becomes: + * + * "Charge to 69% at 8.00c" + * + * Unknown placeholders are deliberately left intact. That makes missing + * data obvious rather than silently removing useful diagnostic information. + */ +function formatReason(template: string, params: Record) { + return template.replace(/\{(\w+)\}/g, (_match, key) => { + const value = params[key] + + return value !== undefined ? String(value) : `{${key}}` + }) +} + +/** + * Summary card shown above the detailed Predbat plan. + * + * The card answers four immediate questions: + * + * 1. How full is the battery right now? + * 2. What is Predbat doing now? + * 3. What is the current import rate and when does it change? + * 4. What will Predbat do next? + */ +function PlanSummary({ plan, showPlanLink = true }: PlanSummaryProps) { + const rows = plan.rows + const reasonTemplates = plan.reason_templates + const { minor: currencyMinor } = resolveCurrencySymbols(plan.currency_symbols) + + // A plan with no rows cannot provide any useful summary information. + if (rows.length === 0) { + return

No plan data available

+ } + + /** + * ------------------------------------------------------------- + * CURRENT PLAN ROW + * ------------------------------------------------------------- + * + * Predbat includes some rows from before the current time, so rows[0] + * must not be assumed to represent "now". + * + * Find the latest plan row whose timestamp is not in the future. + */ + const now = new Date() + + const currentIndex = rows.findLastIndex((row) => new Date(row.time) <= now) + + // Fall back to the first row if the plan unexpectedly begins in the future. + const current = currentIndex >= 0 ? rows[currentIndex] : rows[0] + + /** + * ------------------------------------------------------------- + * BATTERY + * ------------------------------------------------------------- + * + * plan.soc is the actual current stored energy. + * current.soc_percent is the modelled/planned SoC for a plan row, so it + * should not be used as the live battery percentage. + */ + const batteryPercent = plan.soc_max === 0 ? 0 : (plan.soc / plan.soc_max) * 100 + + // Predicted battery movement during the current plan slot. + const socDirection = + current.soc_change > 0 ? 'rising' : current.soc_change < 0 ? 'falling' : 'steady' + + const socDirectionIcon = + socDirection === 'rising' + ? faArrowTrendUp + : socDirection === 'falling' + ? faArrowTrendDown + : faArrowRight + + // Colour the battery bar according to its current SoC. + const batteryLevelClass = + batteryPercent <= 20 ? 'battery-low' : batteryPercent <= 50 ? 'battery-medium' : 'battery-high' + + /** + * ------------------------------------------------------------- + * CURRENT STATE / EXPLANATION + * ------------------------------------------------------------- + * + * Each Predbat row can provide a reason code plus parameters. + * The reason code selects a human-readable template supplied by Predbat. + */ + const currentReason = current.reasons[0] + + // Add row-derived values to the parameters supplied by Predbat. + const currentReasonParams = { + ...currentReason?.params, + target_percentage: current.state_target, + import_rate: current.import_rate.toFixed(2), + export_rate: current.export_rate.toFixed(2), + currency_unit: currencyMinor + } + + // Full reason is shown in the information tooltip. + const currentExplanation = + currentReason && reasonTemplates[currentReason.code] + ? formatReason(reasonTemplates[currentReason.code], currentReasonParams) + : undefined + + /** + * ------------------------------------------------------------- + * NEXT IMPORT RATE CHANGE + * ------------------------------------------------------------- + * + * Search forward from the current plan row for the first slot whose + * import rate differs from the current rate. + */ + const nextRateChange = rows + .slice(currentIndex + 1) + .find((row) => row.import_rate !== current.import_rate) + + let nextRate = current.import_rate + let nextRateTime = '' + let nextRateDirection: 'rising' | 'falling' | 'steady' = 'steady' + + if (nextRateChange) { + nextRate = nextRateChange.import_rate + + nextRateDirection = nextRate < current.import_rate ? 'falling' : 'rising' + + const time = new Date(nextRateChange.time) + + nextRateTime = time.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit' + }) + } + + // Falling import prices are positive; rising prices are negative. + const nextRateClass = + nextRateDirection === 'falling' ? 'rate-good' : nextRateDirection === 'rising' ? 'rate-bad' : '' + + const nextRateIcon = + nextRateDirection === 'rising' + ? faArrowTrendUp + : nextRateDirection === 'falling' + ? faArrowTrendDown + : faArrowRight + + /* + * Current and Next Action + * + * Keep Predbat's operational states distinct here. + * + * In particular: + * + * Charge -> Hold Charge -> Charge + * + * must be treated as three separate phases. Combining + * Hold Charge into Charge makes a period of flat SOC look + * as though charging has failed. + */ + const currentActionType = getActionType(current.state) + + /* + * Battery animation is only shown while energy is + * actually being charged into or exported from the + * battery. + */ + const isCharging = currentActionType === 'charge' + + const isExporting = currentActionType === 'export' + + /* + * Find the first future row whose actual action differs + * from what Predbat is doing now. + * + * Hold Charge and Charge deliberately count as different + * actions here. + */ + const nextActionOffset = rows + .slice(currentIndex + 1) + .findIndex((row) => getActionType(row.state) !== currentActionType) + + const nextActionRowIndex = nextActionOffset >= 0 ? currentIndex + 1 + nextActionOffset : -1 + + const nextActionRow = nextActionRowIndex >= 0 ? rows[nextActionRowIndex] : undefined + + const nextActionType = nextActionRow ? getActionType(nextActionRow.state) : undefined + + const nextActionLabel = nextActionType ? getActionLabel(nextActionType) : 'No scheduled action' + + let nextActionStart = '' + let nextActionEnd = '' + + let nextActionLimit: number | undefined + + if (nextActionRow && nextActionType) { + const startTime = new Date(nextActionRow.time) + + nextActionStart = startTime.toLocaleTimeString('en-GB', { + hour: '2-digit', + + minute: '2-digit' + }) + + /* + * Collect only consecutive rows belonging to this + * exact action. + * + * Charge stops when Hold Charge starts, and vice + * versa. + */ + const remainingRows = rows.slice(nextActionRowIndex) + + const endOffset = remainingRows.findIndex((row) => getActionType(row.state) !== nextActionType) + + const actionRows = endOffset >= 0 ? remainingRows.slice(0, endOffset) : remainingRows + + /* + * Use the target at the end of this particular + * operational phase. + */ + const finalActionRow = actionRows[actionRows.length - 1] + + const finalTarget = finalActionRow?.state_target?.trim() + + if (finalTarget) { + const parsedTarget = Number(finalTarget) + + nextActionLimit = Number.isNaN(parsedTarget) ? undefined : parsedTarget + } + + /* + * The first row belonging to a different action marks + * the end of this phase. + */ + const actionEndRow = endOffset >= 0 ? remainingRows[endOffset] : undefined + + if (actionEndRow) { + const endTime = new Date(actionEndRow.time) + + nextActionEnd = endTime.toLocaleTimeString('en-GB', { + hour: '2-digit', + + minute: '2-digit' + }) + } + } + + return ( +
+
+

Plan Summary

+ + {showPlanLink && ( + + View full plan + + + )} +
+ +
+ Now + + Next +
+ +
+ {/* -------------------------------------------------------- + BATTERY SOC + Live battery level plus active charge/export indicator. + -------------------------------------------------------- */} +
+ Battery SOC + + + {isCharging && } + + {isExporting && ( + + + + + )} + + {batteryPercent.toFixed(0)}% + + +
+
+
+ + + {plan.soc.toFixed(1)} + {' / '} + {plan.soc_max.toFixed(1)} kWh + +
+ + {/* -------------------------------------------------------- + NOW + Current Predbat state and explanation. + -------------------------------------------------------- */} +
+ Action + + + {getActionIcon(currentActionType)} + + {getStateLabel(current.state)} + + + + + + SOC {socDirection} + + {currentExplanation && ( + + + + {currentExplanation} + + )} + +
+ + {/* -------------------------------------------------------- + IMPORT RATE + Current import price and next price movement. + -------------------------------------------------------- */} +
+ Import Rate + + {formatRate(current.import_rate, currencyMinor)} + + {nextRateChange && ( + + + + + {formatRate(nextRate, currencyMinor)} at {nextRateTime} + + + )} +
+ + {/* -------------------------------------------------------- + NEXT ACTION + Next action family, its window and final planned limit. + -------------------------------------------------------- */} +
+ Action + + Next action + + + {nextActionType && getActionIcon(nextActionType)} + + {nextActionLabel} + + + {nextActionRow && ( + + {nextActionStart} + + {nextActionEnd && ` → ${nextActionEnd}`} + + )} + + {nextActionLimit !== undefined && ( + Limit {nextActionLimit}% + )} +
+
+
+ ) +} + +export default PlanSummary diff --git a/frontend/src/components/PlanTable.css b/frontend/src/components/PlanTable.css new file mode 100644 index 000000000..bc6fb95de --- /dev/null +++ b/frontend/src/components/PlanTable.css @@ -0,0 +1,806 @@ +.plan-table-card { + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: 10px; + background: var(--color-surface); +} + +.plan-table-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border-bottom: 1px solid var(--color-border); +} + +.plan-table-header h2 { + margin: 0; + font-size: 1rem; + color: var(--color-text); +} + +.plan-table-header p { + margin: 0.2rem 0 0; + font-size: 0.72rem; + color: var(--color-text-muted); +} + +/* Preserve readable columns on narrow screens by scrolling the table horizontally. */ +.plan-table-scroll { + width: 100%; + overflow-x: auto; +} + +.plan-table { + width: max-content; + min-width: 100%; + table-layout: auto; + border-collapse: collapse; + font-size: 0.72rem; +} + +.plan-debug-notice { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.55rem 1rem; + border-bottom: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-current) 8%, var(--color-surface)); + color: var(--color-text-secondary); + font-size: 0.68rem; + line-height: 1.4; +} + +.plan-colour-style { + display: inline-flex; + flex: 0 0 auto; + padding: 0.12rem; + border: 1px solid var(--color-border); + border-radius: 7px; + background: var(--color-surface); +} + +.plan-colour-style button { + padding: 0.25rem 0.45rem; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--color-text-muted); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.plan-colour-style button.is-active { + background: var(--color-current); + color: white; +} + +.plan-table thead th { + position: sticky; + top: 0; + z-index: 20; + padding: 0.65rem 0.7rem; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + color: var(--color-text-muted); + text-align: left; + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + overflow: visible; +} + +.plan-table td { + padding: 0.55rem 0.7rem; + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent); + color: var(--color-text-secondary); + vertical-align: middle; +} + +.plan-row:hover { + background: color-mix(in srgb, var(--color-text) 4%, transparent); +} + +/* + * Current Predbat slot. + */ +.plan-row.is-current { + background: color-mix(in srgb, var(--color-text) 6%, transparent); +} + +.plan-row.is-current td { + background: color-mix(in srgb, var(--color-current) 10%, var(--color-surface)); +} + +.plan-row.is-current { + box-shadow: inset 4px 0 0 var(--color-current); +} + +.plan-row.is-current .plan-now-label { + background: var(--color-current); + color: white; +} + +/* + * Date divider. + */ +.plan-day-row td { + padding: 0.65rem 0.7rem; + background: var(--color-bg); + border-top: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + color: var(--color-text); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.035em; +} + +.plan-time { + min-width: 5.3rem; + color: var(--color-text); + font-weight: 700; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.plan-now-label { + display: inline-block; + margin-left: 0.4rem; + padding: 0.1rem 0.3rem; + border-radius: 999px; + background: color-mix(in srgb, var(--color-text) 10%, transparent); + color: var(--color-text); + font-size: 0.55rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.plan-number { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.plan-pv-value.is-generating { + color: var(--color-flow-solar); + font-weight: 700; +} + +.plan-value-with-indicator { + display: inline-flex; + align-items: center; + gap: 0.38rem; +} + +.plan-value-indicator { + width: 0.42rem; + height: 0.42rem; + flex: 0 0 auto; + border: 1px solid color-mix(in srgb, var(--color-text) 18%, transparent); + border-radius: 50%; +} + +.plan-table.is-cell-colours .plan-value-indicator { + display: none; +} + +.plan-table.is-cell-colours td.has-plan-cell-colour { + background: color-mix(in srgb, var(--plan-cell-colour) 24%, var(--color-surface)); +} + +.plan-totals-row td { + border-top: 2px solid var(--color-border); + border-bottom: 0; + background: color-mix(in srgb, var(--color-text) 3%, var(--color-surface)); + color: var(--color-text); + font-weight: 700; +} + +.plan-debug-value, +.plan-rate-values small { + display: block; + margin-top: 0.12rem; + color: var(--color-text-muted); + font-size: 0.58rem; + font-weight: 500; + line-height: 1.2; +} + +.plan-debug-column { + background: color-mix(in srgb, var(--color-current) 4%, transparent); +} + +.plan-rate-values { + display: inline-flex; + flex-direction: column; + align-items: flex-start; +} + +.plan-soc { + font-weight: 700; + color: var(--color-text); +} + +/* + * Detailed action badges. + */ +.plan-action { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.45rem; + border-radius: 999px; + font-size: 0.65rem; + font-weight: 700; + white-space: nowrap; + cursor: help; +} + +/* + * Active charge. + */ +.plan-action-charge { + background: color-mix(in srgb, var(--color-charge) 15%, transparent); + color: var(--color-charge); +} + +/* + * Hold/freeze charge still belongs to a charge window, + * but is deliberately visually quieter than active charging. + */ +.plan-action-charge-secondary { + background: color-mix(in srgb, var(--color-charge) 8%, transparent); + color: var(--color-charge); +} + +/* + * Active export. + */ +.plan-action-export { + background: color-mix(in srgb, var(--color-export) 15%, transparent); + color: var(--color-export); +} + +/* + * Hold/freeze export. + */ +.plan-action-export-secondary { + background: color-mix(in srgb, var(--color-export) 8%, transparent); + color: var(--color-export); +} + +/* + * Demand and miscellaneous states remain neutral. + * + * Normal battery discharge is not shown as a charge/export + * action colour. + */ +.plan-action-neutral { + background: color-mix(in srgb, var(--color-text-muted) 10%, transparent); + color: var(--color-text-secondary); +} + +.plan-table-empty { + padding: 2rem; + color: var(--color-text-muted); + text-align: center; +} + +.plan-column-heading { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.plan-column-label-mobile { + display: none; +} + +.plan-column-heading .plan-tooltip-trigger { + font-size: 0.65rem; + color: var(--color-text-muted); + opacity: 1; + cursor: help; +} + +.plan-rate-predicted { + color: var(--color-current); + cursor: help; +} + +/* + * Column-header tooltips open downwards. + * + * Opening upwards causes them to be clipped by the table/card + * overflow because the headings sit at the very top. + */ +.plan-column-heading .plan-tooltip-trigger::after { + top: calc(100% + 0.6rem); + bottom: auto; +} + +/* + * Generic tooltip trigger. + * + * Used both for column descriptions and the explanation behind + * individual Predbat actions. + */ +.plan-tooltip-trigger { + position: relative; + outline: none; +} + +.plan-tooltip-trigger:focus-visible { + border-radius: 4px; + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.plan-tooltip-trigger::after { + display: none; + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 0.6rem); + z-index: 50; + width: max-content; + max-width: 320px; + padding: 0.55rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: 7px; + background: color-mix(in srgb, var(--color-surface) 96%, var(--color-text) 4%); + box-shadow: 0 8px 24px color-mix(in srgb, var(--color-text) 16%, transparent); + font-size: 0.7rem; + font-weight: 400; + line-height: 1.4; + text-align: left; + text-transform: none; + letter-spacing: normal; + white-space: normal; + pointer-events: none; + opacity: 0; + visibility: hidden; + transform: translate(-50%, 4px); + transition: + opacity 0.12s ease, + transform 0.12s ease, + visibility 0.12s ease; +} + +.plan-tooltip-trigger:hover::after, +.plan-tooltip-trigger:focus::after { + display: block; + opacity: 1; + visibility: visible; + transform: translate(-50%, 0); +} + +/* + * Edge column tooltips must stay inside the horizontally + * scrollable table container. + * + * The normal tooltip is centred over its info icon, which means + * the first and last column tooltips can extend beyond the + * scroll container and be clipped. + */ + +/* First column: align tooltip's left edge with the trigger. */ +.plan-table thead th:first-child .plan-tooltip-trigger::after { + left: 0; + right: auto; + transform: translate(0, 4px); +} + +.plan-table thead th:first-child .plan-tooltip-trigger:hover::after, +.plan-table thead th:first-child .plan-tooltip-trigger:focus::after { + transform: translate(0, 0); +} + +/* Last column: align tooltip's right edge with the trigger. */ +.plan-table thead th:last-child .plan-tooltip-trigger::after { + left: auto; + right: 0; + transform: translate(0, 4px); +} + +.plan-table thead th:last-child .plan-tooltip-trigger:hover::after, +.plan-table thead th:last-child .plan-tooltip-trigger:focus::after { + transform: translate(0, 0); +} + +@media (max-width: 700px) { + .plan-table-header { + padding: 0.8rem; + } +} + +/* + * Completed slots should recede visually without affecting + * tooltip readability. + */ +.plan-row.is-past td { + background: color-mix(in srgb, var(--color-bg) 80%, var(--color-surface)); + color: color-mix(in srgb, var(--color-text-secondary) 68%, var(--color-bg)); +} + +.plan-row.is-past .plan-time, +.plan-row.is-past .plan-soc { + color: color-mix(in srgb, var(--color-text) 75%, var(--color-bg)); +} + +.plan-row.is-past .plan-pv-value.is-generating { + color: color-mix(in srgb, var(--color-flow-solar) 78%, var(--color-bg)); +} + +/* + * Keep the semantic action colour while making completed actions quieter. + */ +.plan-row.is-past .plan-action { + opacity: 0.82; +} + +.plan-action-control { + position: relative; + display: inline-block; +} + +/* + * The action is now both an explanation target and, + * for current/future rows, the manual-override control. + */ +.plan-action { + border: 0; + font: inherit; + appearance: none; +} + +.plan-row:not(.is-past) .plan-action { + cursor: pointer; +} + +.plan-row.is-past .plan-action { + cursor: help; +} + +.plan-action-chevron { + margin-left: 0.1rem; + font-size: 0.55rem; + opacity: 0.65; +} + +/* + * A small marker makes manual actions distinguishable from + * actions selected automatically by Predbat. + */ +.plan-action-manual-dot { + width: 0.35rem; + height: 0.35rem; + margin-left: 0.05rem; + border-radius: 50%; + background: currentColor; +} + +.plan-saving-session-badge { + --event-colour: var(--color-text-secondary); + display: inline-flex; + align-items: center; + gap: 0.22rem; + margin-left: 0.12rem; + padding: 0.12rem 0.32rem; + border: 1px solid color-mix(in srgb, var(--event-colour) 45%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--event-colour) 14%, transparent); + color: var(--event-colour); + font-size: 0.55rem; + font-weight: 700; + line-height: 1; + white-space: nowrap; +} + +.plan-saving-session-badge.is-power-down { + --event-colour: var(--color-danger); +} + +.plan-saving-session-badge.is-power-up { + --event-colour: var(--color-success); +} + +.plan-saving-session-badge.is-axle-import { + --event-colour: var(--color-flow-grid); +} + +.plan-saving-session-badge.is-axle-export { + --event-colour: var(--color-export); +} + +.plan-saving-session-badge.is-axle-event { + --event-colour: var(--color-warning); +} + +@media (max-width: 720px) { + .plan-saving-session-badge > span { + display: none; + } + + .plan-saving-session-badge { + padding-inline: 0.22rem; + } +} + +/* + * Don't leave the explanation tooltip sitting over the menu + * once the action has been clicked. + */ +.plan-action-control.is-open .plan-tooltip-trigger::after { + opacity: 0 !important; + visibility: hidden !important; +} + +/* + * Manual action picker. + */ +.plan-action-menu { + position: absolute; + top: calc(100% + 0.4rem); + left: 0; + z-index: 100; + width: 240px; + padding: 0.35rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-surface); + box-shadow: 0 10px 30px color-mix(in srgb, var(--color-text) 22%, transparent); +} + +.plan-action-menu-reason { + padding: 0.45rem 0.5rem; + color: var(--color-text-muted); + font-size: 0.68rem; + line-height: 1.4; +} + +.plan-action-menu-divider { + height: 1px; + margin: 0.3rem 0; + background: var(--color-border); +} + +.plan-action-menu button { + display: grid; + grid-template-columns: + 1rem + 1fr + 1rem; + align-items: center; + gap: 0.45rem; + width: 100%; + padding: 0.5rem 0.55rem; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-text-secondary); + font: inherit; + font-size: 0.72rem; + text-align: left; + cursor: pointer; +} + +.plan-action-menu button:hover, +.plan-action-menu button:focus-visible { + background: color-mix(in srgb, var(--color-text) 7%, transparent); + color: var(--color-text); +} + +.plan-action-menu button.is-selected { + font-weight: 700; + color: var(--color-text); +} + +.plan-action-menu-check { + justify-self: end; +} + +.plan-action-menu-clear { + color: var(--color-text-muted) !important; +} + +.plan-action-menu-status { + padding: 0.45rem 0.5rem; + color: var(--color-text-muted); + font-size: 0.68rem; +} + +.plan-action-menu-error { + margin-top: 0.3rem; + padding: 0.45rem 0.5rem; + border-radius: 5px; + background: color-mix(in srgb, #dc2626 10%, var(--color-surface)); + color: #dc2626; + font-size: 0.68rem; + line-height: 1.35; +} + +.plan-target { + font-weight: 700; +} + +.plan-table td.plan-target-charge { + color: var(--color-charge); +} + +.plan-table td.plan-target-export { + color: var(--color-export); +} +.plan-rate-cell { + position: relative; +} + +.plan-rate-control { + position: relative; + display: inline-block; +} + +.plan-rate-button { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.18rem 0.35rem; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: inherit; + font: inherit; + font-variant-numeric: tabular-nums; + cursor: pointer; +} + +.plan-rate-button:hover, +.plan-rate-button:focus-visible { + background: color-mix(in srgb, var(--color-text) 6%, transparent); +} + +.plan-rate-button.is-manual { + border-color: color-mix(in srgb, var(--color-text-muted) 35%, transparent); +} + +.plan-rate-manual-dot { + width: 0.35rem; + height: 0.35rem; + border-radius: 50%; + background: currentColor; +} + +.plan-row.is-past .plan-rate-button { + cursor: default; +} + +.plan-cell-popover { + position: absolute; + top: calc(100% + 0.4rem); + left: 0; + z-index: 100; + display: flex; + flex-direction: column; + gap: 0.55rem; + width: 210px; + padding: 0.7rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-surface); + box-shadow: 0 10px 30px color-mix(in srgb, var(--color-text) 20%, transparent); +} + +.plan-cell-popover label { + display: flex; + flex-direction: column; + gap: 0.35rem; + color: var(--color-text-secondary); + font-size: 0.7rem; + font-weight: 700; +} + +.plan-rate-input { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.plan-rate-input input { + min-width: 0; + width: 100%; + padding: 0.4rem 0.45rem; + border: 1px solid var(--color-border); + border-radius: 5px; + background: var(--color-bg); + color: var(--color-text); + font: inherit; +} + +.plan-rate-input > span { + white-space: nowrap; + color: var(--color-text-muted); + font-size: 0.65rem; +} + +.plan-cell-popover button { + padding: 0.45rem 0.55rem; + border: 1px solid var(--color-border); + border-radius: 5px; + background: var(--color-bg); + color: var(--color-text); + font: inherit; + font-size: 0.7rem; + cursor: pointer; +} + +.plan-cell-popover .plan-rate-clear { + color: var(--color-text-secondary); +} + +.plan-cell-popover .plan-rate-clear:hover { + color: var(--color-text); + background: color-mix(in srgb, var(--color-text) 7%, transparent); +} + +.plan-rate-error { + color: #dc2626; + font-size: 0.67rem; + line-height: 1.35; +} + +.plan-target-control { + position: relative; + display: inline-block; +} + +.plan-target-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.3rem; + min-width: 3.2rem; + padding: 0.18rem 0.35rem; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: inherit; + font: inherit; + font-weight: 700; + font-variant-numeric: tabular-nums; + cursor: pointer; +} + +.plan-target-button:hover, +.plan-target-button:focus-visible { + background: color-mix(in srgb, var(--color-text) 6%, transparent); +} + +.plan-target-button.is-manual { + border-color: color-mix(in srgb, currentColor 40%, transparent); +} + +.plan-target-manual-dot { + width: 0.35rem; + height: 0.35rem; + border-radius: 50%; + background: currentColor; +} + +.plan-car-value { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 0.3rem; + font-variant-numeric: tabular-nums; +} + +.plan-car.is-charging { + color: var(--color-car); + font-weight: 700; +} diff --git a/frontend/src/components/PlanTable.tsx b/frontend/src/components/PlanTable.tsx new file mode 100644 index 000000000..ae95a5a82 --- /dev/null +++ b/frontend/src/components/PlanTable.tsx @@ -0,0 +1,1754 @@ +import { Fragment, useEffect, useState, useRef, type CSSProperties } from 'react' + +import { + FontAwesomeIcon, + FontAwesomeLayers +} from '@fortawesome/react-fontawesome' + +import { + faArrowRight, + faArrowUpFromBracket, + faArrowTrendUp, + faBan, + faBolt, + faCar, + faCircleInfo, + faHouse, + faPause, + faSnowflake, + faWater, + faCheck, + faChevronDown, + faRotateLeft +} from '@fortawesome/free-solid-svg-icons' + +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core' + +import NordPoolIcon from './NordPoolIcon' + +import type { Plan, PlanOverrides } from '../types/plan' +import { formatMajorCurrency, resolveCurrencySymbols } from '../utils/currency' + +import './PlanTable.css' + +type PlanTableProps = { + plan: Plan + overrides: PlanOverrides + debugEnabled: boolean + readOnly?: boolean + onOverrideSubmitted: () => void +} + +type PlanRow = Plan['rows'][number] + +const RATE_EVENT_DETAILS: Record = { + octopus_power_down: { + label: 'Power Down', + description: 'Octopus Power Down / Saving Session: Predbat has applied the joined session reward to this slot.', + colour: 'power-down' + }, + octopus_power_up: { + label: 'Power Up', + description: 'Octopus Power Up / free electricity session: Predbat has applied the event rate to this slot.', + colour: 'power-up' + }, + octopus_happy_hour: { + label: 'Happy Hour', + description: 'Octopus Weekend Happy Hour: Predbat has applied the free electricity rate to this slot.', + colour: 'power-up' + }, + octopus_free_electricity: { + label: 'Free electricity', + description: 'Free electricity session.', + colour: 'power-up' + }, + axle_import: { + label: 'Axle import', + description: 'Axle import event: Predbat has applied the event reward to this slot.', + colour: 'axle-import' + }, + axle_export: { + label: 'Axle export', + description: 'Axle export event: Predbat has applied the event reward to this slot.', + colour: 'axle-export' + }, + axle_event: { + label: 'Axle event', + description: 'Axle VPP event: Predbat has applied the event reward to this slot.', + colour: 'axle-event' + }, + energy_event: { + label: 'Energy event', + description: 'A joined energy event has adjusted this slot’s rate.', + colour: 'generic' + } +} + +function cellColour(colour?: string): CSSProperties | undefined { + return colour ? { '--plan-cell-colour': colour } as CSSProperties : undefined +} + +type ActionType = + | 'charge' + | 'hold-charge' + | 'freeze-charge' + | 'export' + | 'hold-export' + | 'freeze-export' + | 'hold-car' + | 'hold-iboost' + | 'no-charge' + | 'demand' + | 'unknown' + +type OverrideAction = + | 'Manual Demand' + | 'Manual Charge' + | 'Manual Export' + | 'Manual Freeze Charge' + | 'Manual Freeze Export' + +const OVERRIDE_OPTIONS: { + action: OverrideAction + label: string + icon: IconDefinition +}[] = [ + { + action: 'Manual Demand', + label: 'Demand', + icon: faHouse + }, + { + action: 'Manual Charge', + label: 'Charge', + icon: faBolt + }, + { + action: 'Manual Export', + label: 'Export', + icon: faArrowUpFromBracket + }, + { + action: 'Manual Freeze Charge', + label: 'Freeze Charge', + icon: faSnowflake + }, + { + action: 'Manual Freeze Export', + label: 'Freeze Export', + icon: faSnowflake + } + ] + +function getManualOverride(slotMinute: number, overrides: PlanOverrides): OverrideAction | null { + if (overrides.manual_charge_times.includes(slotMinute)) { + return 'Manual Charge' + } + + if (overrides.manual_export_times.includes(slotMinute)) { + return 'Manual Export' + } + + if (overrides.manual_demand_times.includes(slotMinute)) { + return 'Manual Demand' + } + + if (overrides.manual_freeze_charge_times.includes(slotMinute)) { + return 'Manual Freeze Charge' + } + + if (overrides.manual_freeze_export_times.includes(slotMinute)) { + return 'Manual Freeze Export' + } + + return null +} + +function formatOverrideTime(value: string) { + const date = new Date(value) + + const day = date.toLocaleDateString('en-GB', { + weekday: 'short' + }) + + const time = date.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }) + + return `${day} ${time}` +} + +type RateType = 'import' | 'export' + +function getManualRateOverride(slotMinute: number, type: RateType, overrides: PlanOverrides) { + const rates = type === 'import' ? overrides.manual_import_rates : overrides.manual_export_rates + + return rates.find((override) => override.minutes === slotMinute) ?? null +} + +type RateCellProps = { + row: PlanRow + type: RateType + overrides: PlanOverrides + isPast: boolean + debugEnabled: boolean + currencyMinor: string + onOverrideSubmitted: () => void + open: boolean + onToggle: () => void + onClose: () => void +} + +function RateCell({ + row, + type, + overrides, + isPast, + debugEnabled, + currencyMinor, + onOverrideSubmitted, + open, + onToggle, + onClose +}: RateCellProps) { + const override = getManualRateOverride(row.slot_minute, type, overrides) + + const rate = type === 'import' ? row.import_rate : row.export_rate + + const adjustedRate = type === 'import' ? row.import_rate_adjusted : row.export_rate_adjusted + + const isPredicted = (type === 'import' ? row.import_rate_adjust_type : row.export_rate_adjust_type) === 'future' + + const [value, setValue] = useState('') + + const [saving, setSaving] = useState(false) + + const [error, setError] = useState(null) + + const controlRef = useRef(null) + + useEffect(() => { + if (!open) { + return + } + + function handlePointerDown(event: PointerEvent) { + if (controlRef.current && !controlRef.current.contains(event.target as Node)) { + onClose() + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + onClose() + } + } + + document.addEventListener('pointerdown', handlePointerDown) + + document.addEventListener('keydown', handleKeyDown) + + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + + document.removeEventListener('keydown', handleKeyDown) + } + }, [open, onClose]) + + function openEditor() { + if (isPast) { + return + } + + /* + * Only initialise the input when opening, + * otherwise clicking the current editor closed + * would unnecessarily reset its value. + */ + if (!open) { + setValue(String(override?.rate ?? rate)) + + setError(null) + } + + onToggle() + } + + async function sendOverride( + action: 'Set Import' | 'Clear Import' | 'Set Export' | 'Clear Export', + overrideRate: number + ) { + setSaving(true) + setError(null) + + const formData = new FormData() + + formData.append('time', formatOverrideTime(row.time)) + + formData.append('action', action) + + formData.append('rate', String(overrideRate)) + + try { + const response = await fetch('./rate_override', { + method: 'POST', + body: formData + }) + + const result = (await response.json()) as { + success?: boolean + message?: string + } + + if (!response.ok || result.success === false) { + throw new Error(result.message || `HTTP ${response.status}`) + } + + onClose() + + /* + * App immediately refreshes status. + * Predbat then recalculates and the global + * calculating overlay takes over. + */ + onOverrideSubmitted() + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + + setError(`Unable to update rate: ${message}`) + } finally { + setSaving(false) + } + } + + async function setRate(event: React.FormEvent) { + event.preventDefault() + + const numericRate = Number(value) + + /* + * Negative electricity prices are legitimate, + * so deliberately don't enforce min="0". + */ + if (!Number.isFinite(numericRate)) { + setError('Enter a valid rate.') + + return + } + + await sendOverride(type === 'import' ? 'Set Import' : 'Set Export', numericRate) + } + + async function clearRate() { + if (!override) { + return + } + + await sendOverride( + type === 'import' ? 'Clear Import' : 'Clear Export', + + /* + * Predbat looks up the stored value itself, + * but sending the actual value mirrors the + * legacy implementation. + */ + override.rate + ) + } + + return ( + +
+ + + {open && !isPast && ( +
+ + + + + {override && ( + + )} + + {error && ( +
+ {error} +
+ )} +
+ )} +
+ + ) +} + +type TargetCellProps = { + row: PlanRow + overrides: PlanOverrides + debugEnabled: boolean + isPast: boolean + open: boolean + onToggle: () => void + onClose: () => void + onOverrideSubmitted: () => void +} + +function TargetCell({ + row, + overrides, + debugEnabled, + isPast, + open, + onToggle, + onClose, + onOverrideSubmitted +}: TargetCellProps) { + const controlRef = useRef(null) + + const [value, setValue] = useState('') + + const [saving, setSaving] = useState(false) + + const [error, setError] = useState(null) + + const override = overrides.manual_soc.find((item) => item.minutes === row.slot_minute) ?? null + + const plannedTarget = row.state_target?.trim() ? Number(row.state_target) : null + + /* + * If there is a manual target, show that as the + * target in the cell. Otherwise show Predbat's + * calculated target. + */ + const displayedTarget = override?.target ?? plannedTarget + const debugLimit = row.show_limit?.match(/\(([^)]+)\)$/)?.[1] + + function openEditor() { + if (isPast) { + return + } + + if (!open) { + /* + * Prefer the existing manual target, + * then Predbat's target. + * + * If this slot doesn't currently have a + * target, use its predicted SOC as a useful + * starting point. + */ + const initialValue = override?.target ?? plannedTarget ?? row.soc_percent + + setValue(String(initialValue)) + + setError(null) + } + + onToggle() + } + + async function sendOverride(action: 'Set SOC' | 'Clear SOC', target: number) { + setSaving(true) + setError(null) + + const formData = new FormData() + + formData.append('time', formatOverrideTime(row.time)) + + formData.append('action', action) + + /* + * Predbat calls this field "rate" even for + * SOC overrides. + */ + formData.append('rate', String(target)) + + try { + const response = await fetch('./rate_override', { + method: 'POST', + body: formData + }) + + const result = (await response.json()) as { + success?: boolean + message?: string + } + + if (!response.ok || result.success === false) { + throw new Error(result.message || `HTTP ${response.status}`) + } + + onClose() + + onOverrideSubmitted() + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + + setError(`Unable to update target SOC: ${message}`) + } finally { + setSaving(false) + } + } + + async function setTarget(event: React.FormEvent) { + event.preventDefault() + + const target = Number(value) + + if (!Number.isFinite(target) || target < 0 || target > 100) { + setError('Enter a target between 0 and 100%.') + + return + } + + await sendOverride('Set SOC', target) + } + + async function clearTarget() { + if (!override) { + return + } + + await sendOverride('Clear SOC', override.target) + } + + useEffect(() => { + if (!open) { + return + } + + function handlePointerDown(event: PointerEvent) { + if (controlRef.current && !controlRef.current.contains(event.target as Node)) { + onClose() + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + onClose() + } + } + + document.addEventListener('pointerdown', handlePointerDown) + + document.addEventListener('keydown', handleKeyDown) + + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + + document.removeEventListener('keydown', handleKeyDown) + } + }, [open, onClose]) + + const action = getActionType(row.state) + + return ( + +
+ + + {open && !isPast && ( +
+ + + + + {override && ( + + )} + + {error && ( +
+ {error} +
+ )} +
+ )} +
+ + ) +} + +/* + * Convert Predbat's raw state into a UI action type. + * + * Keep the detailed states separate here. Unlike the dashboard + * summary, this view should distinguish Charge from Hold Charge, + * Freeze Charge, etc. + */ +function getActionType(state: string): ActionType { + switch (state) { + case 'Chrg': + return 'charge' + + case 'HoldChrg': + return 'hold-charge' + + case 'FrzChrg': + return 'freeze-charge' + + case 'Exp': + return 'export' + + case 'HoldExp': + return 'hold-export' + + case 'FrzExp': + return 'freeze-export' + + case 'Hold for car': + case 'Demand, Hold for car': + return 'hold-car' + + case 'Hold for iBoost': + case 'Demand, Hold for iBoost': + return 'hold-iboost' + + case 'No Charge': + return 'no-charge' + + case 'Demand': + return 'demand' + + default: + return 'unknown' + } +} + +/* + * Friendly action label. + */ +function getActionLabel(action: ActionType) { + switch (action) { + case 'charge': + return 'Charge' + + case 'hold-charge': + return 'Hold Charge' + + case 'freeze-charge': + return 'Freeze Charge' + + case 'export': + return 'Export' + + case 'hold-export': + return 'Hold Export' + + case 'freeze-export': + return 'Freeze Export' + + case 'hold-car': + return 'Hold for Car' + + case 'hold-iboost': + return 'Hold for iBoost' + + case 'no-charge': + return 'No Charge' + + case 'demand': + return 'Demand' + + default: + return 'Unknown' + } +} + +/** + * Return the Font Awesome icon associated with an action. + * + * Icons are deliberately semantic: + * - bolt: battery charging + * - bolt + export arrow: battery exporting + * - snowflake: freeze behaviour + * - pause: hold behaviour + * - car: EV hold + * - water: iBoost hold + * - ban: charging disabled + * - house: normal household demand + */ +function getActionIcon(action?: ActionType) { + switch (action) { + case 'charge': + case 'hold-charge': + return + + case 'freeze-charge': + return + + case 'export': + return ( + + + + + ) + + case 'hold-export': + return + + case 'freeze-export': + return + + case 'hold-car': + return + + case 'hold-iboost': + return + + case 'no-charge': + return + + case 'demand': + return + + default: + return + } +} + +/* + * CSS class for the action badge. + * + * Charge and export actions deliberately use the same semantic + * colours as the rest of the modern UI. + */ +function getActionClass(action: ActionType) { + switch (action) { + case 'charge': + return 'plan-action-charge' + + case 'hold-charge': + case 'freeze-charge': + return 'plan-action-charge-secondary' + + case 'export': + return 'plan-action-export' + + case 'hold-export': + case 'freeze-export': + return 'plan-action-export-secondary' + + default: + return 'plan-action-neutral' + } +} + +function getTargetClass(action: ActionType) { + switch (action) { + case 'charge': + case 'hold-charge': + case 'freeze-charge': + return 'plan-target-charge' + + case 'export': + case 'hold-export': + case 'freeze-export': + return 'plan-target-export' + + default: + return '' + } +} + +/* + * Format a plan timestamp as local time. + */ +function formatTime(value: string) { + return new Date(value).toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit' + }) +} + +/* + * A stable local date key used to determine when a new day begins. + */ +function getDateKey(value: string) { + const date = new Date(value) + + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0') + ].join('-') +} + +/* + * Friendly heading displayed between days. + */ +function formatDateHeading(value: string) { + const date = new Date(value) + + const today = new Date() + + const todayKey = getDateKey(today.toISOString()) + + const rowKey = getDateKey(value) + + const prefix = rowKey === todayKey ? 'Today · ' : '' + + return ( + prefix + + date.toLocaleDateString('en-GB', { + weekday: 'long', + day: 'numeric', + month: 'short' + }) + ) +} + +/* + * Human-readable version of a reason code. + * + * Used as a fallback if Predbat did not supply a template. + */ +function getReasonLabel(code: string) { + return code.replaceAll('_', ' ').replace(/\b\w/g, (character) => character.toUpperCase()) +} + +/* + * Turn Predbat's structured reason into the explanation supplied + * by the backend. + */ +function getReasonText(plan: Plan, row: PlanRow) { + const reason = row.reasons?.[0] + + if (!reason) { + return '' + } + + const template = plan.reason_templates?.[reason.code] + const { minor: currencyMinor } = resolveCurrencySymbols(plan.currency_symbols) + + if (!template) { + return getReasonLabel(reason.code) + } + + /* + * Most values are supplied directly in reason.params. + * + * The additional values here provide sensible fallbacks for + * templates which reference values already available on the row. + */ + const params: Record = { + ...(reason.params ?? {}), + + target_percent: reason.params?.target_percent ?? row.state_target, + + target_percentage: reason.params?.target_percentage ?? row.state_target, + + import_rate: row.import_rate.toFixed(2), + + export_rate: row.export_rate.toFixed(2), + + currency_unit: currencyMinor + } + + return template.replace(/\{(\w+)\}/g, (_match, key: string) => { + const value = params[key] + + return value !== undefined && value !== '' ? String(value) : `{${key}}` + }) +} + +/* + * Find the currently active row. + * + * Predbat's rows represent the start of each slot, so the latest + * row whose start time is before "now" is the active one. + */ +function findCurrentRowIndex(rows: Plan['rows']) { + const now = new Date() + + return rows.findLastIndex((row) => new Date(row.time) <= now) +} + +type ColumnHeadingProps = { + label: string + mobileLabel?: string + help: string +} + +function ColumnHeading({ label, mobileLabel, help }: ColumnHeadingProps) { + return ( + + {label} + + {mobileLabel ?? label} + + + + + + ) +} + +export default function PlanTable({ + plan, + overrides, + debugEnabled, + readOnly = false, + onOverrideSubmitted +}: PlanTableProps) { + const rows = plan.rows ?? [] + const { major: currencyMajor, minor: currencyMinor } = resolveCurrencySymbols(plan.currency_symbols) + + const showCar = plan.num_cars > 0 + + const showIBoost = plan.iboost_enable === true + + const showExtraLoad = debugEnabled && rows.some((row) => row.extra_load !== undefined) + + const showCarbon = plan.carbon_enable === true + + const columnCount = 10 + (showCar ? 1 : 0) + (showIBoost ? 1 : 0) + (debugEnabled ? 1 : 0) + (showExtraLoad ? 1 : 0) + (showCarbon ? 2 : 0) + + const [openOverrideTime, setOpenOverrideTime] = useState(null) + + const [savingOverrideTime, setSavingOverrideTime] = useState(null) + + const [overrideError, setOverrideError] = useState(null) + + const currentRowIndex = readOnly ? -1 : findCurrentRowIndex(rows) + + const [openCellEditor, setOpenCellEditor] = useState(null) + + const [colourStyle, setColourStyle] = useState<'dots' | 'cells'>(() => { + try { + return localStorage.getItem('predbat-plan-colour-style') === 'cells' ? 'cells' : 'dots' + } catch { + return 'dots' + } + }) + + const colourCells = colourStyle === 'cells' + + useEffect(() => { + try { + localStorage.setItem('predbat-plan-colour-style', colourStyle) + } catch { + // Browsers may disable storage; the in-memory preference still works. + } + }, [colourStyle]) + + useEffect(() => { + function closeMenu() { + setOpenOverrideTime(null) + setOverrideError(null) + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + closeMenu() + } + } + + document.addEventListener('pointerdown', closeMenu) + + document.addEventListener('keydown', handleKeyDown) + + return () => { + document.removeEventListener('pointerdown', closeMenu) + + document.removeEventListener('keydown', handleKeyDown) + } + }, []) + + // Keep hooks above this return: a later poll can populate an initially empty plan. + if (rows.length === 0) { + return ( +
+
No detailed plan is currently available.
+
+ ) + } + + async function setPlanOverride(row: PlanRow, action: OverrideAction | 'Clear') { + setSavingOverrideTime(row.time) + + setOverrideError(null) + + const formData = new FormData() + + formData.append('time', formatOverrideTime(row.time)) + + formData.append('action', action) + + try { + const response = await fetch('./plan_override', { + method: 'POST', + body: formData + }) + + const result = (await response.json()) as { + success?: boolean + message?: string + } + + if (!response.ok || result.success === false) { + throw new Error(result.message || `HTTP ${response.status}`) + } + + setOpenOverrideTime(null) + + /* + * The override handler has now marked Predbat's plan as + * needing recalculation. Ask App to update its status + * immediately instead of waiting for the next 5-second poll. + */ + onOverrideSubmitted() + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + + setOverrideError(`Unable to update this slot: ${message}`) + } finally { + setSavingOverrideTime(null) + } + } + + return ( +
+
+
+

Detailed Plan

+ +

Forecast actions and energy use for each plan slot.

+
+ +
+ + +
+
+ + {debugEnabled && ( +
+ + Debug details are enabled: bracketed forecasts show the 10% confidence case, and adjusted + rates include conversion losses and battery cycling. + + +
+ )} + +
+ + + + + + + + + + + + + + + + {debugEnabled && ( + + )} + + {showExtraLoad && ( + + )} + + {showCar && ( + + )} + + {showIBoost && ( + + )} + + + + + + + + + + {showCarbon && ( + <> + + + + + )} + + + + + {rows.map((row, index) => { + const previousRow = index > 0 ? rows[index - 1] : undefined + + const newDay = !previousRow || getDateKey(previousRow.time) !== getDateKey(row.time) + + const action = getActionType(row.state) + + const reason = getReasonText(plan, row) + + const isCurrent = index === currentRowIndex + + const isPast = readOnly || (currentRowIndex >= 0 && index < currentRowIndex) + + const manualOverride = getManualOverride(row.slot_minute, overrides) + + const menuOpen = openOverrideTime === row.time + + const saving = savingOverrideTime === row.time + + const isSavingSession = + row.import_rate_adjust_type === 'saving' || + row.export_rate_adjust_type === 'saving' + + const rateEvent = isSavingSession + ? RATE_EVENT_DETAILS[row.rate_event_type ?? 'energy_event'] ?? RATE_EVENT_DETAILS.energy_event + : null + + const actionTooltip = [ + rateEvent?.description ?? '', + reason || 'No additional explanation is available for this slot.' + ].filter(Boolean).join(' ') + + return ( + + {newDay && ( + + + + )} + + + + + + + { + const key = `rate:${row.time}:import` + + setOpenCellEditor((current) => (current === key ? null : key)) + }} + onClose={() => { + setOpenCellEditor(null) + }} + /> + + { + const key = `rate:${row.time}:export` + + setOpenCellEditor((current) => (current === key ? null : key)) + }} + onClose={() => { + setOpenCellEditor(null) + }} + /> + + + + + + {debugEnabled && ( + + )} + + {showExtraLoad && ( + + )} + + {showCar && ( + + )} + + {showIBoost && ( + + )} + + { + const key = `target:${row.time}` + + setOpenCellEditor((current) => (current === key ? null : key)) + }} + onClose={() => { + setOpenCellEditor(null) + }} + onOverrideSubmitted={onOverrideSubmitted} + /> + + + + + + + + {showCarbon && ( + <> + + + + + )} + + + ) + })} + + + {plan.totals && ( + + + + + + {debugEnabled && } + {showExtraLoad && } + {showCar && } + {showIBoost && } + + + + + {showCarbon && ( + <> + + + + )} + + + )} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{formatDateHeading(row.time)}
+ {formatTime(row.time)} + + {isCurrent && Now} + +
{ + event.stopPropagation() + }} + > + + + {menuOpen && ( +
+ {reason &&
{reason}
} + +
+ + {OVERRIDE_OPTIONS.map((option) => { + const selected = manualOverride === option.action + + return ( + + ) + })} + + {manualOverride && ( + <> +
+ + + + )} + + {saving && ( +
Updating plan…
+ )} + + {overrideError && ( +
+ {overrideError} +
+ )} +
+ )} +
+
0 ? 'is-generating' : '' + ].filter(Boolean).join(' ')} + > + {row.pv_forecast.toFixed(2)} + {debugEnabled && (row.pv_forecast10 ?? 0) > 0 && ( + + ({row.pv_forecast10?.toFixed(2)}) + + )} + + + {row.load_forecast > 0 && row.load_color && ( + + {debugEnabled && (row.load_forecast10 ?? 0) > 0 && ( + + ({row.load_forecast10?.toFixed(2)}) + + )} + + {(row.clipped ?? 0) === 0 ? '—' : row.clipped?.toFixed(2)} + {row.extra_load || '—'} 0 ? 'is-charging' : '' + ] + .filter(Boolean) + .join(' ')} + > + {(row.car_charging ?? 0) > 0 ? ( + + + + {row.car_charging?.toFixed(2)} + + ) : ( + '—' + )} + 0 && row.iboost_color ? 'has-plan-cell-colour' : '' + ].filter(Boolean).join(' ')} + style={cellColour((row.iboost_change ?? 0) > 0 ? row.iboost_color : undefined)} + > + {(row.iboost_change ?? 0) > 0 ? ( + + {row.iboost_color && ( + + ) : (row.iboost ?? 0) > 0 ? ( + (row.iboost ?? 0).toFixed(2) + ) : ( + '—' + )} + {row.soc_percent}%= 0.005 && row.cost_color ? 'has-plan-cell-colour' : ''].filter(Boolean).join(' ')} + style={cellColour(Math.abs(row.cost_change) >= 0.005 ? row.cost_color : undefined)} + > + + {Math.abs(row.cost_change) >= 0.005 && row.cost_color && ( + + {formatMajorCurrency(row.total_cost, currencyMajor)} + + {row.carbon_intensity_color && ( + + = 10 && row.carbon_color ? 'has-plan-cell-colour' : ''].filter(Boolean).join(' ')} + style={cellColour(Math.abs(row.carbon_change ?? 0) >= 10 ? row.carbon_color : undefined)} + > + + {Math.abs(row.carbon_change ?? 0) >= 10 && row.carbon_color && ( + +
Plan total 0 ? 'plan-pv-value is-generating' : ''}> + {plan.totals.pv_forecast.toFixed(2)} + {plan.totals.load_forecast.toFixed(2)}{(plan.totals.clipped ?? 0).toFixed(2)}{(plan.totals.extra_load ?? 0).toFixed(2)}{(plan.totals.car_charging ?? 0).toFixed(2)}{(plan.totals.iboost ?? 0) > 0 ? (plan.totals.iboost ?? 0).toFixed(2) : '—'}—{plan.totals.soc_percent}%—{formatMajorCurrency(plan.totals.total_cost, currencyMajor)}—{plan.totals.total_carbon?.toFixed(2) ?? '—'}
+
+
+ ) +} diff --git a/frontend/src/components/PlanVisual.css b/frontend/src/components/PlanVisual.css new file mode 100644 index 000000000..97f002cb2 --- /dev/null +++ b/frontend/src/components/PlanVisual.css @@ -0,0 +1,686 @@ +.plan-timeline { + margin: 1rem 0; + border: 1px solid var(--color-border); + border-radius: 10px; + background: var(--color-surface); + overflow: hidden; +} + +/* + * Header + */ +.plan-timeline-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem 1rem 0.75rem; +} + +.plan-timeline-header h3 { + margin: 0; + font-size: 0.9rem; +} + +.plan-timeline-header p { + margin: 0.18rem 0 0; + color: var(--color-text-muted); + font-size: 0.68rem; +} + +/* + * Small visual key. + */ +.plan-timeline-key { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.35rem 0.8rem; + color: var(--color-text-muted); + font-size: 0.64rem; +} + +.plan-timeline-key span { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.plan-timeline-key span::before { + content: ''; + width: 0.65rem; + height: 0.25rem; + border-radius: 999px; + background: currentColor; +} + +.timeline-key-charge { + color: var(--color-charge); +} + +.timeline-key-export { + color: var(--color-export); +} + +.timeline-key-demand { + color: var(--color-text-muted); +} + +.timeline-key-soc { + color: var(--color-flow-battery); +} + +/* + * Event detail panel. + * + * This sits outside the horizontal scroller, so there are + * no clipping problems with hover information. + */ +.plan-timeline-detail { + margin: 0 1rem 0.9rem; + padding: 0.8rem 0.9rem; + border: 1px solid var(--color-border); + border-left-width: 3px; + border-radius: 8px; + background: color-mix(in srgb, var(--color-bg) 35%, var(--color-surface)); +} + +.plan-timeline-detail.timeline-action-charge, +.plan-timeline-detail.timeline-action-hold-charge, +.plan-timeline-detail.timeline-action-freeze-charge { + border-left-color: var(--color-charge); +} + +.plan-timeline-detail.timeline-action-export, +.plan-timeline-detail.timeline-action-hold-export, +.plan-timeline-detail.timeline-action-freeze-export { + border-left-color: var(--color-export); +} + +.plan-timeline-detail-heading { + display: flex; + align-items: center; + gap: 0.7rem; +} + +.plan-timeline-detail-icon { + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + flex: 0 0 2rem; + border-radius: 50%; + background: color-mix(in srgb, currentColor 10%, transparent); + color: var(--color-text-muted); +} + +.timeline-action-charge .plan-timeline-detail-icon, +.timeline-action-hold-charge .plan-timeline-detail-icon, +.timeline-action-freeze-charge .plan-timeline-detail-icon { + color: var(--color-charge); +} + +.timeline-action-export .plan-timeline-detail-icon, +.timeline-action-hold-export .plan-timeline-detail-icon, +.timeline-action-freeze-export .plan-timeline-detail-icon { + color: var(--color-export); +} + +.plan-timeline-detail-heading > div { + display: flex; + flex-direction: column; + min-width: 0; +} + +.plan-timeline-detail-heading strong { + font-size: 0.8rem; +} + +.plan-timeline-detail-heading span { + color: var(--color-text-muted); + font-size: 0.66rem; +} + +.plan-timeline-detail-values { + display: flex; + flex-wrap: wrap; + gap: 0.7rem 1.4rem; + margin-top: 0.75rem; +} + +.plan-timeline-detail-values > div { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 72px; +} + +.plan-timeline-detail-values span { + color: var(--color-text-muted); + font-size: 0.58rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.plan-timeline-detail-values strong { + font-size: 0.7rem; + font-variant-numeric: tabular-nums; +} + +.plan-timeline-explanation { + margin: 0.7rem 0 0; + padding-top: 0.65rem; + border-top: 1px solid var(--color-border); + color: var(--color-text-muted); + font-size: 0.67rem; + line-height: 1.45; +} + +/* + * Horizontal timeline viewport. + */ +.plan-timeline-scroll { + width: 100%; + overflow-x: auto; + overflow-y: hidden; + border-top: 1px solid var(--color-border); + overscroll-behavior-x: contain; +} + +.plan-timeline-canvas { + position: relative; + min-height: 235px; + padding-bottom: 0.35rem; +} + +/* + * Time axis. + */ +.plan-timeline-axis { + position: relative; + height: 52px; +} + +.plan-timeline-tick { + position: absolute; + top: 0; + bottom: -180px; + z-index: 0; + pointer-events: none; +} + +.plan-timeline-tick::after { + content: ''; + position: absolute; + top: 44px; + bottom: 0; + left: 0; + width: 1px; + background: var(--color-border); + opacity: 0.45; +} + +.plan-timeline-tick span { + position: absolute; + left: 0; + transform: translateX(-50%); + white-space: nowrap; + color: var(--color-text-muted); + font-size: 0.58rem; + font-variant-numeric: tabular-nums; +} + +.plan-timeline-tick .plan-timeline-tick-date { + top: 7px; + color: var(--color-text-secondary); + font-weight: 700; +} + +.plan-timeline-tick .plan-timeline-tick-time { + top: 26px; +} + +.plan-timeline-tick.is-start span { + transform: none; +} + +.plan-timeline-tick.is-end span { + transform: translateX(-100%); +} + +/* + * Main action ribbon. + */ +.plan-timeline-track { + position: relative; + z-index: 2; + height: 58px; +} + +.plan-timeline-track::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 44px; + right: 14px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: color-mix(in srgb, var(--color-text-muted) 5%, transparent); +} + +/* + * Each meaningful Predbat action is a real button. + * + * This gives us mouse, keyboard and touch interaction + * without custom hit-testing. + */ +.plan-timeline-event { + appearance: none; + position: absolute; + top: 0; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.15rem; + height: 58px; + min-width: 2px; + padding: 0 0.3rem; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-surface); + color: var(--color-text); + font: inherit; + cursor: pointer; + transition: + filter 0.12s ease, + box-shadow 0.12s ease, + transform 0.12s ease; + white-space: nowrap; +} + +.plan-timeline-event:hover, +.plan-timeline-event:focus-visible, +.plan-timeline-event.is-active { + z-index: 4; + outline: none; + filter: brightness(1.08); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-text) 18%, transparent); +} + +.plan-timeline-event.is-current { + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--color-text) 40%, transparent); +} + +.plan-timeline-event-main { + display: flex; + align-items: center; + justify-content: center; + gap: 0.3rem; + max-width: 100%; + font-size: 0.68rem; + font-weight: 700; +} + +.plan-timeline-event-main span { + overflow: hidden; + text-overflow: ellipsis; +} + +.plan-timeline-event-rate { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.57rem; + opacity: 0.85; +} + +/* + * Demand deliberately stays visually quiet. + */ +.plan-timeline-event.timeline-action-demand { + background: color-mix(in srgb, var(--color-text-muted) 9%, var(--color-surface)); + color: var(--color-text-muted); +} + +/* + * Charging is the established Predbat blue. + */ +.plan-timeline-event.timeline-action-charge { + border-color: var(--color-charge); + background: var(--color-charge); + color: var(--color-semantic-contrast); +} + +.plan-timeline-event.timeline-action-hold-charge { + border-color: var(--color-charge); + background: color-mix(in srgb, var(--color-charge) 22%, var(--color-surface)); + color: var(--color-charge); +} + +.plan-timeline-event.timeline-action-hold-export { + border-color: var(--color-export); + background: color-mix(in srgb, var(--color-export) 22%, var(--color-surface)); + color: var(--color-export); +} + +/* + * Freeze Charge belongs to the charge family, but is + * deliberately visually distinct from active charging. + */ +.plan-timeline-event.timeline-action-freeze-charge { + border-color: var(--color-charge); + border-style: dashed; + background: color-mix(in srgb, var(--color-charge) 18%, var(--color-surface)); + color: var(--color-charge); +} + +/* + * Export uses the established purple action colour. + */ +.plan-timeline-event.timeline-action-export { + border-color: var(--color-export); + background: var(--color-export); + color: var(--color-semantic-contrast); +} + +.plan-timeline-event.timeline-action-freeze-export { + border-color: var(--color-export); + border-style: dashed; + background: color-mix(in srgb, var(--color-export) 18%, var(--color-surface)); + color: var(--color-export); +} + +.plan-timeline-event.timeline-action-hold-car, +.plan-timeline-event.timeline-action-hold-iboost, +.plan-timeline-event.timeline-action-no-charge, +.plan-timeline-event.timeline-action-unknown { + background: color-mix(in srgb, var(--color-text-muted) 14%, var(--color-surface)); + color: var(--color-text); +} + +/* Energy-session colours take priority over the underlying battery action. */ +.plan-timeline-event.timeline-rate-event-power-down { + border-color: var(--color-danger); + background: var(--color-danger); + color: var(--color-semantic-contrast); +} + +.plan-timeline-event.timeline-rate-event-power-up { + border-color: var(--color-success); + background: var(--color-success); + color: var(--color-semantic-contrast); +} + +.plan-timeline-event.timeline-rate-event-axle-import { + border-color: var(--color-flow-grid); + background: var(--color-flow-grid); + color: var(--color-semantic-contrast); +} + +.plan-timeline-event.timeline-rate-event-axle-export { + border-color: var(--color-export); + background: var(--color-export); + color: var(--color-semantic-contrast); +} + +.plan-timeline-event.timeline-rate-event-axle-event { + border-color: var(--color-warning); + background: var(--color-warning); + color: var(--color-semantic-contrast); +} + +.plan-timeline-event.timeline-rate-event-generic { + border-color: var(--color-text-muted); + background: var(--color-text-muted); + color: var(--color-semantic-contrast); +} + +.plan-timeline-rate-event { + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin-left: auto; + padding: 0.2rem 0.4rem; + border: 1px solid currentColor; + border-radius: 999px; + font-size: 0.58rem; + font-weight: 700; + line-height: 1; + white-space: nowrap; +} + +.plan-timeline-rate-event.is-power-down { + color: var(--color-danger); +} + +.plan-timeline-rate-event.is-power-up { + color: var(--color-success); +} + +.plan-timeline-rate-event.is-axle-import { + color: var(--color-flow-grid); +} + +.plan-timeline-rate-event.is-axle-export { + color: var(--color-export); +} + +.plan-timeline-rate-event.is-axle-event { + color: var(--color-warning); +} + +.plan-timeline-rate-event.is-generic { + color: var(--color-text-muted); +} + +/* + * SOC heading. + */ +.plan-timeline-soc-title { + position: relative; + z-index: 2; + margin: 12px 0 0 44px; + color: var(--color-text-muted); + font-size: 0.61rem; + font-weight: 700; +} + +/* + * SOC graph. + */ +.plan-timeline-soc { + position: relative; + z-index: 1; + display: block; +} + +.plan-timeline-active-range { + fill: color-mix(in srgb, var(--color-text) 4%, transparent); +} + +.plan-timeline-soc-grid { + stroke: var(--color-border); + stroke-width: 1; + stroke-dasharray: 2 4; + opacity: 0.7; +} + +.plan-timeline-time-grid { + stroke: var(--color-border); + stroke-width: 1; + opacity: 0.35; +} + +.plan-timeline-soc-scale { + fill: var(--color-text-muted); + font-family: inherit; + font-size: 8px; +} + +.plan-timeline-soc-line { + fill: none; + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; +} + +.plan-timeline-soc-point { + fill: var(--color-surface); + stroke-width: 2; +} + +.plan-timeline-soc-value { + font-family: inherit; + font-size: 8px; + font-weight: 700; + paint-order: stroke; + stroke: var(--color-surface); + stroke-width: 3px; + stroke-linejoin: round; +} + +.plan-timeline-soc-hover-area { + fill: transparent; + cursor: crosshair; +} + +.plan-timeline-soc-hover > line { + stroke: var(--color-text-secondary); + stroke-width: 1; + stroke-dasharray: 2 2; +} + +.plan-timeline-soc-hover > circle { + fill: var(--color-surface); + stroke: var(--color-current); + stroke-width: 2; +} + +.plan-timeline-soc-hover rect { + fill: var(--color-tooltip-bg); + filter: drop-shadow(0 2px 4px rgb(0 0 0 / 20%)); +} + +.plan-timeline-soc-hover text { + fill: var(--color-tooltip-text); + font-family: inherit; + font-size: 8px; + font-weight: 700; +} + +/* + * Targets are subtle horizontal dashed lines underneath + * the corresponding action window. + */ +.plan-timeline-target { + stroke-width: 1.5; + stroke-dasharray: 4 3; + opacity: 0.8; +} + +.plan-timeline-target.timeline-target-charge, +.plan-timeline-target.timeline-target-hold-charge + .plan-timeline-target.timeline-target-freeze-charge { + stroke: var(--color-charge); +} + +.plan-timeline-target.timeline-target-export, +.plan-timeline-target.timeline-target-hold-export, +.plan-timeline-target.timeline-target-freeze-export { + stroke: var(--color-export); +} + +.plan-timeline-target.timeline-target-demand, +.plan-timeline-target.timeline-target-hold-car, +.plan-timeline-target.timeline-target-hold-iboost, +.plan-timeline-target.timeline-target-no-charge, +.plan-timeline-target.timeline-target-unknown { + stroke: var(--color-text-muted); +} + +/* + * Current-time marker. + * + * It runs down through both the action strip and SOC graph. + */ +.plan-timeline-now { + position: absolute; + top: 40px; + bottom: 10px; + z-index: 5; + width: 0; + border-left: 1.5px dashed var(--color-text); + pointer-events: none; +} + +.plan-timeline-now span { + position: absolute; + top: -17px; + left: 4px; + padding: 0.05rem 0.22rem; + border-radius: 3px; + background: var(--color-text); + color: var(--color-surface); + font-size: 0.5rem; + font-weight: 800; + letter-spacing: 0.04em; +} + +/* + * Interaction hint. + */ +.plan-timeline-hint { + margin: 0; + padding: 0.55rem 1rem 0.65rem; + border-top: 1px solid var(--color-border); + color: var(--color-text-muted); + font-size: 0.6rem; +} + +/* + * Mobile. + * + * The chart keeps its useful horizontal scale and scrolls. + * The detail panel itself remains fully responsive. + */ +@media (max-width: 700px) { + .plan-timeline-header { + flex-direction: column; + gap: 0.6rem; + } + + .plan-timeline-key { + justify-content: flex-start; + } + + .plan-timeline-detail { + margin: 0 0.65rem 0.75rem; + padding: 0.7rem; + } + + .plan-timeline-detail-values { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; + } + + .plan-timeline-hint { + padding-left: 0.65rem; + padding-right: 0.65rem; + } +} + +/* + * Avoid unnecessary motion for users requesting reduced + * animation. + */ +@media (prefers-reduced-motion: reduce) { + .plan-timeline-event { + transition: none; + } +} diff --git a/frontend/src/components/PlanVisual.tsx b/frontend/src/components/PlanVisual.tsx new file mode 100644 index 000000000..f187f64dd --- /dev/null +++ b/frontend/src/components/PlanVisual.tsx @@ -0,0 +1,1478 @@ +import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' + +import { + FontAwesomeIcon, + FontAwesomeLayers +} from '@fortawesome/react-fontawesome' + +import { + faArrowTrendUp, + faArrowRight, + faBan, + faBolt, + faCar, + faHouse, + faPause, + faSnowflake, + faWater +} from '@fortawesome/free-solid-svg-icons' + +import type { Plan } from '../types/plan' +import { resolveCurrencySymbols } from '../utils/currency' + +import './PlanVisual.css' + +type PlanVisualProps = { + plan: Plan +} + +type PlanRow = Plan['rows'][number] + +type TimelineAction = + | 'charge' + | 'hold-charge' + | 'freeze-charge' + | 'export' + | 'hold-export' + | 'freeze-export' + | 'hold-car' + | 'hold-iboost' + | 'no-charge' + | 'demand' + | 'unknown' + +type TimelineEvent = { + id: string + + action: TimelineAction + + startIndex: number + endIndex: number + + start: Date + end: Date + + startSoc: number + endSoc: number + + carCharging: number + + target: number | null + targetSocStart: number | null + targetSocEnd: number | null + + importRateMin: number + importRateMax: number + + exportRateMin: number + exportRateMax: number + + carbonIntensityMin: number | null + carbonIntensityMax: number | null + carbonChange: number + + explanation?: string + + rateEventType?: NonNullable +} + +const RATE_EVENT_DETAILS: Record, { label: string; description: string; colour: string }> = { + octopus_power_down: { label: 'Power Down', description: 'Octopus Power Down has adjusted the export rate for this period.', colour: 'power-down' }, + octopus_power_up: { label: 'Power Up', description: 'Octopus Power Up has made electricity free for this period.', colour: 'power-up' }, + octopus_happy_hour: { label: 'Happy Hour', description: 'Octopus Weekend Happy Hour has made electricity free for this period.', colour: 'power-up' }, + octopus_free_electricity: { label: 'Free electricity', description: 'Predbat has applied a free electricity rate for this period.', colour: 'power-up' }, + axle_import: { label: 'Axle import', description: 'An Axle import event has adjusted the rate for this period.', colour: 'axle-import' }, + axle_export: { label: 'Axle export', description: 'An Axle export event has adjusted the rate for this period.', colour: 'axle-export' }, + axle_event: { label: 'Axle event', description: 'An Axle event has adjusted the rate for this period.', colour: 'axle-event' }, + energy_event: { label: 'Energy event', description: 'A joined energy event has adjusted the rate for this period.', colour: 'generic' } +} + +function getRateEventType(row: PlanRow) { + const isEnergyEvent = + row.import_rate_adjust_type === 'saving' || row.export_rate_adjust_type === 'saving' + + return isEnergyEvent ? (row.rate_event_type ?? 'energy_event') : undefined +} + +type TimelineTick = { + time: number + label: string + dateLabel?: string + edge: 'start' | 'middle' | 'end' +} + +type SocPoint = { + time: number + soc: number +} + +type SocBoundary = { + time: number + soc: number +} + +type SocHover = SocPoint & { + x: number +} + +/* + * Space reserved on the left and right of the graph. + * + * The action track and SOC graph use exactly the same + * horizontal plotting area, so events and SOC remain + * visually aligned. + */ +const PLOT_LEFT = 44 +const PLOT_RIGHT = 14 + +function getTimelineBlockLabel(action: TimelineAction, width: number): string | null { + // Very small blocks: icon only. + if (width < 44) { + return null + } + + // Wide enough for the full action name. + if (width >= 105) { + return getActionLabel(action) + } + + // Medium-width blocks get a compact label. + switch (action) { + case 'charge': + return 'Charge' + + case 'hold-charge': + return 'Hold' + + case 'freeze-charge': + return 'Freeze' + + case 'export': + return 'Export' + + case 'hold-export': + return 'Hold' + + case 'freeze-export': + return 'Freeze' + + case 'hold-car': + return 'Car' + + case 'hold-iboost': + return 'iBoost' + + case 'no-charge': + return 'No Chg' + + case 'demand': + return 'Demand' + + default: + return null + } +} + +/* + * Predbat's detailed states are converted into the + * user-facing action windows we want on the timeline. + * + * Hold states stay distinct from active charging/exporting. Event grouping + * combines consecutive rows only while this action remains unchanged. + */ +function getTimelineAction(state: string): TimelineAction { + switch (state) { + case 'Chrg': + return 'charge' + + case 'HoldChrg': + return 'hold-charge' + + case 'FrzChrg': + return 'freeze-charge' + + case 'Exp': + return 'export' + + case 'HoldExp': + return 'hold-export' + + case 'FrzExp': + return 'freeze-export' + + case 'Hold for car': + case 'Demand, Hold for car': + return 'hold-car' + + case 'Hold for iBoost': + case 'Demand, Hold for iBoost': + return 'hold-iboost' + + case 'No Charge': + return 'no-charge' + + case 'Demand': + return 'demand' + + default: + return 'unknown' + } +} + +function getActionLabel(action: TimelineAction) { + switch (action) { + case 'charge': + return 'Charge' + + case 'hold-charge': + return 'Hold Charge' + + case 'freeze-charge': + return 'Freeze Charge' + + case 'export': + return 'Export' + + case 'hold-export': + return 'Hold Export' + + case 'freeze-export': + return 'Freeze Export' + + case 'hold-car': + return 'Hold for Car' + + case 'hold-iboost': + return 'Hold for iBoost' + + case 'no-charge': + return 'No Charge' + + case 'demand': + return 'Demand' + + default: + return 'Other' + } +} + +/** + * Return the Font Awesome icon associated with an action. + * + * Icons are deliberately semantic: + * - bolt: battery charging + * - bolt + export arrow: battery exporting + * - snowflake: freeze behaviour + * - pause: hold behaviour + * - car: EV hold + * - water: iBoost hold + * - ban: charging disabled + * - house: normal household demand + */ +function getActionIcon(action?: TimelineAction) { + switch (action) { + case 'charge': + case 'hold-charge': + return + + case 'freeze-charge': + return + + case 'export': + return ( + + + + + ) + + case 'hold-export': + return + + case 'freeze-export': + return + + case 'hold-car': + return + + case 'hold-iboost': + return + + case 'no-charge': + return + + case 'demand': + return + + default: + return + } +} + +function getSocColour(soc: number) { + if (soc <= 20) { + return 'var(--color-battery-low)' + } + + if (soc <= 50) { + return 'var(--color-battery-medium)' + } + + return 'var(--color-battery-high)' +} + +/* + * Parse state_target safely. + * + * A target of 0 is perfectly valid, so this must not use + * simple truthiness checks. + */ +function parseTarget(value: unknown): number | null { + if (value === '' || value === null || value === undefined) { + return null + } + + const parsed = Number(value) + + return Number.isFinite(parsed) ? parsed : null +} + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(maximum, Math.max(minimum, value)) +} + +// oxlint-disable-next-line react/only-export-components -- exported for the timeline hover test +export function interpolateSoc(points: SocPoint[], time: number) { + const nextIndex = points.findIndex((point) => point.time >= time) + + if (nextIndex === -1) { + return points[points.length - 1]?.soc ?? 0 + } + + if (nextIndex === 0) { + return points[0]?.soc ?? 0 + } + + const previous = points[nextIndex - 1] + const next = points[nextIndex] + const progress = (time - previous.time) / Math.max(1, next.time - previous.time) + + return previous.soc + (next.soc - previous.soc) * progress +} + +/* + * Predbat can start a plan with a partial current slot. + * + * For example, the first point may be 19:40 followed by + * normal half-hour slots at 20:00, 20:30, etc. + * + * Taking the median interval gives us the normal plan slot + * length rather than accidentally treating that first + * partial interval as the standard slot duration. + */ +function inferSlotDuration(rows: PlanRow[]) { + const differences = rows + .slice(1) + .map((row, index) => { + return new Date(row.time).getTime() - new Date(rows[index].time).getTime() + }) + .filter((value) => value > 0) + .sort((first, second) => first - second) + + if (differences.length === 0) { + return 30 * 60 * 1000 + } + + return differences[Math.floor(differences.length / 2)] +} + +function formatReason(template: string, params: Record) { + return template.replace(/\{(\w+)\}/g, (_match, key: string) => { + const value = params[key] + + return value !== undefined && value !== null ? String(value) : `{${key}}` + }) +} + +function parseTargetSoc(value: string | number | null | undefined): number | null { + if (value === null || value === undefined || value === '') { + return null + } + + const parsed = Number(value) + + return Number.isFinite(parsed) ? parsed : null +} + +/* + * Convert the detailed half-hour rows into meaningful + * user-facing events. + */ +function buildTimelineEvents( + plan: Plan, + slotDuration: number, + initialSoc: number +): TimelineEvent[] { + const rows = plan.rows + const { minor: currencyMinor } = resolveCurrencySymbols(plan.currency_symbols) + + if (rows.length === 0) { + return [] + } + + const events: TimelineEvent[] = [] + + let startIndex = 0 + + let currentAction = getTimelineAction(rows[0].state) + + let currentRateEventType = getRateEventType(rows[0]) + + function addEvent(endIndex: number) { + const eventRows = rows.slice(startIndex, endIndex + 1) + + const targetSocValues = eventRows + .map((row) => parseTargetSoc(row.state_target)) + .filter((value): value is number => value !== null) + + const targetSocStart = targetSocValues.length > 0 ? targetSocValues[0] : null + + const targetSocEnd = + targetSocValues.length > 0 ? targetSocValues[targetSocValues.length - 1] : null + + const first = eventRows[0] + + const last = eventRows[eventRows.length - 1] + + const start = new Date(first.time) + + const end = + endIndex < rows.length - 1 + ? new Date(rows[endIndex + 1].time) + : new Date(new Date(last.time).getTime() + slotDuration) + + // Row SOC is the end-of-slot forecast; an event starts at the previous boundary. + const startSoc = startIndex === 0 ? initialSoc : rows[startIndex - 1].soc_percent + + const targets = eventRows + .map((row) => parseTarget(row.state_target)) + .filter((target): target is number => target !== null) + + /* + * Charge/export windows normally carry the same + * target throughout. If Predbat does change it, + * the final target is the most meaningful summary + * of where the window is trying to finish. + */ + const target = targets.length > 0 ? targets[targets.length - 1] : null + + const importRates = eventRows.map((row) => row.import_rate) + + const exportRates = eventRows.map((row) => row.export_rate) + + const carbonIntensities = eventRows + .map((row) => row.carbon_intensity) + .filter((value): value is number => value !== undefined) + + const carbonChange = eventRows.reduce((total, row) => total + (row.carbon_change ?? 0), 0) + + const carCharging = eventRows.reduce((total, row) => total + (row.car_charging ?? 0), 0) + + /* + * Use the first reason as the event explanation. + * + * This normally describes why Predbat entered the + * action window. Internal state changes later in the + * same window remain intentionally hidden here. + */ + const reason = first.reasons?.[0] + + let explanation: string | undefined + + if (reason && plan.reason_templates[reason.code]) { + const relevantRate = + currentAction === 'export' || currentAction === 'freeze-export' + ? first.export_rate + : first.import_rate + + explanation = formatReason(plan.reason_templates[reason.code], { + ...(reason.params ?? {}), + + target_percent: target ?? first.state_target, + + target_percentage: target ?? first.state_target, + + rate: relevantRate.toFixed(2), + + import_rate: first.import_rate.toFixed(2), + + export_rate: first.export_rate.toFixed(2), + + currency_unit: currencyMinor + }) + } + + events.push({ + id: `${first.time}-${currentAction}`, + + action: currentAction, + + startIndex, + + endIndex, + + start, + + end, + + startSoc, + + endSoc: last.soc_percent, + + targetSocStart, + targetSocEnd, + + target, + + importRateMin: Math.min(...importRates), + + importRateMax: Math.max(...importRates), + + exportRateMin: Math.min(...exportRates), + + exportRateMax: Math.max(...exportRates), + + carbonIntensityMin: + carbonIntensities.length > 0 ? Math.min(...carbonIntensities) : null, + + carbonIntensityMax: + carbonIntensities.length > 0 ? Math.max(...carbonIntensities) : null, + + carbonChange, + + carCharging, + + explanation, + + rateEventType: currentRateEventType + }) + } + + for (let index = 1; index < rows.length; index += 1) { + const action = getTimelineAction(rows[index].state) + + const rateEventType = getRateEventType(rows[index]) + + if (action !== currentAction || rateEventType !== currentRateEventType) { + addEvent(index - 1) + + startIndex = index + + currentAction = action + + currentRateEventType = rateEventType + } + } + + addEvent(rows.length - 1) + + return events +} + +function getEventTitle(event: TimelineEvent) { + switch (event.action) { + case 'charge': + return event.target !== null ? `Charge to ${event.target}%` : 'Charge' + + case 'hold-charge': + return event.target !== null ? `Hold Charge at ${event.target}%` : 'Hold Charge' + + case 'export': + return event.target !== null ? `Export to ${event.target}%` : 'Export' + + case 'hold-export': + return event.target !== null ? `Hold Export at ${event.target}%` : 'Hold Export' + + case 'freeze-charge': + return event.target !== null ? `Freeze Charge ${event.target}%` : 'Freeze Charge' + + case 'freeze-export': + return event.target !== null ? `Freeze Export ${event.target}%` : 'Freeze Export' + + default: + return getActionLabel(event.action) + } +} + +function formatTargetSoc(start: number | null, end: number | null): string | null { + if (start === null && end === null) { + return null + } + + if (start !== null && end !== null) { + if (start === end) { + return `${Math.round(start)}%` + } + + return `${Math.round(start)}% → ${Math.round(end)}%` + } + + const value = start ?? end + + return value !== null ? `${Math.round(value)}%` : null +} + +function formatRateRange(minimum: number, maximum: number, currencyMinor: string) { + if (Math.abs(maximum - minimum) < 0.005) { + return `${minimum.toFixed(2)}${currencyMinor}/kWh` + } + + return `${minimum.toFixed(2)}–` + `${maximum.toFixed(2)}${currencyMinor}/kWh` +} + +function getRelevantRate(event: TimelineEvent, currencyMinor: string) { + switch (event.action) { + case 'charge': + case 'hold-charge': + case 'freeze-charge': + return formatRateRange(event.importRateMin, event.importRateMax, currencyMinor) + + case 'export': + case 'hold-export': + case 'freeze-export': + return formatRateRange(event.exportRateMin, event.exportRateMax, currencyMinor) + + default: + return null + } +} + +function formatDuration(start: Date, end: Date) { + const minutes = Math.max(0, Math.round((end.getTime() - start.getTime()) / 60000)) + + const hours = Math.floor(minutes / 60) + + const remainingMinutes = minutes % 60 + + if (hours === 0) { + return `${remainingMinutes}m` + } + + if (remainingMinutes === 0) { + return `${hours}h` + } + + return `${hours}h ${remainingMinutes}m` +} + +function formatEventRange(start: Date, end: Date) { + const sameDay = + start.getFullYear() === end.getFullYear() && + start.getMonth() === end.getMonth() && + start.getDate() === end.getDate() + + const startText = start.toLocaleString('en-GB', { + weekday: 'short', + + day: 'numeric', + + month: 'short', + + hour: '2-digit', + + minute: '2-digit' + }) + + const endText = end.toLocaleString( + 'en-GB', + sameDay + ? { + hour: '2-digit', + + minute: '2-digit' + } + : { + weekday: 'short', + + day: 'numeric', + + month: 'short', + + hour: '2-digit', + + minute: '2-digit' + } + ) + + return `${startText} → ${endText}` +} + +/* + * Build an uncomplicated three-hour time scale. + * + * Start and end are always labelled, with regular three-hour + * landmarks in between. + */ +// oxlint-disable-next-line react/only-export-components -- exported for the timeline spacing test +export function buildTimeTicks(startMs: number, endMs: number): TimelineTick[] { + const ticks: TimelineTick[] = [] + + const start = new Date(startMs) + + ticks.push({ + time: startMs, + + label: start.toLocaleTimeString('en-GB', { + hour: '2-digit', + + minute: '2-digit' + }), + + dateLabel: start.toLocaleDateString('en-GB', { + weekday: 'short', + + day: 'numeric', + + month: 'short' + }), + + edge: 'start' + }) + + const cursor = new Date(startMs) + + cursor.setMinutes(0, 0, 0) + + const nextHour = (Math.floor(cursor.getHours() / 3) + 1) * 3 + + cursor.setHours(nextHour) + + while (cursor.getTime() < endMs) { + const midnight = cursor.getHours() === 0 + + ticks.push({ + time: cursor.getTime(), + + label: cursor.toLocaleTimeString('en-GB', { + hour: '2-digit', + + minute: '2-digit' + }), + + dateLabel: midnight + ? cursor.toLocaleDateString('en-GB', { + weekday: 'short', + + day: 'numeric', + + month: 'short' + }) + : undefined, + + edge: 'middle' + }) + + cursor.setHours(cursor.getHours() + 3) + } + + const end = new Date(endMs) + + /* + * Avoid two almost-identical labels at the far right. + */ + const lastTick = ticks[ticks.length - 1] + + if (!lastTick || endMs - lastTick.time > 60 * 60 * 1000) { + ticks.push({ + time: endMs, + + label: end.toLocaleTimeString('en-GB', { + hour: '2-digit', + + minute: '2-digit' + }), + + dateLabel: end.getHours() === 0 + ? end.toLocaleDateString('en-GB', { + weekday: 'short', + + day: 'numeric', + + month: 'short' + }) + : undefined, + + edge: 'end' + }) + } else { + lastTick.edge = 'end' + } + + return ticks +} + +export default function PlanVisual({ plan }: PlanVisualProps) { + const scrollRef = useRef(null) + const { minor: currencyMinor } = resolveCurrencySymbols(plan.currency_symbols) + + const [availableWidth, setAvailableWidth] = useState(0) + + /* + * Keep the timeline at least as wide as the available + * card space. + * + * Longer plans can still exceed this width and scroll + * horizontally. + */ + useEffect(() => { + const element = scrollRef.current + + if (!element) { + return + } + + function updateWidth() { + setAvailableWidth(element?.clientWidth ?? 0) + } + + updateWidth() + + const observer = new ResizeObserver(updateWidth) + + observer.observe(element) + + return () => { + observer.disconnect() + } + }, []) + + const [hoveredEventId, setHoveredEventId] = useState(null) + + const [selectedEventId, setSelectedEventId] = useState(null) + + const [socHover, setSocHover] = useState(null) + + const rows = plan.rows + + if (rows.length === 0) { + return null + } + + const slotDuration = inferSlotDuration(rows) + + const timelineStart = new Date(rows[0].time).getTime() + + const timelineEnd = new Date(rows[rows.length - 1].time).getTime() + slotDuration + + const timelineDuration = Math.max(1, timelineEnd - timelineStart) + + /* + * Use actual current battery SOC as the first point + * where possible rather than the end-of-first-slot SOC. + */ + const initialSoc = + plan.soc_max > 0 ? clamp((plan.soc / plan.soc_max) * 100, 0, 100) : rows[0].soc_percent + + const events = buildTimelineEvents(plan, slotDuration, initialSoc) + + const durationHours = timelineDuration / (60 * 60 * 1000) + + /* + * Natural width is based on the duration of the plan. + * + * If the card is wider than that, expand the timeline to + * fill the card instead of leaving unused space. + */ + const naturalTimelineWidth = Math.min(2800, Math.max(960, durationHours * 26)) + + const timelineWidth = Math.max(naturalTimelineWidth, availableWidth) + + const plotWidth = timelineWidth - PLOT_LEFT - PLOT_RIGHT + + function xForTime(time: number) { + return PLOT_LEFT + ((time - timelineStart) / timelineDuration) * plotWidth + } + + const now = Date.now() + + const currentEvent = + events.find((event) => now >= event.start.getTime() && now < event.end.getTime()) ?? null + + const hoveredEvent = hoveredEventId + ? (events.find((event) => event.id === hoveredEventId) ?? null) + : null + + const selectedEvent = selectedEventId + ? (events.find((event) => event.id === selectedEventId) ?? null) + : null + + /* + * Desktop: + * hover/focus temporarily takes priority. + * + * Mobile: + * tapping an event selects it. + * + * With no interaction, details for the current event are + * shown by default. + */ + const activeEvent = hoveredEvent ?? selectedEvent ?? currentEvent ?? events[0] ?? null + + const activeTargetSoc = activeEvent + ? formatTargetSoc(activeEvent.targetSocStart, activeEvent.targetSocEnd) + : null + + const activeRateEvent = activeEvent?.rateEventType + ? RATE_EVENT_DETAILS[activeEvent.rateEventType] + : null + + const ticks = buildTimeTicks(timelineStart, timelineEnd) + + /* + * SOC data is plotted at the END of each plan slot. + * + * The first point is the battery's current SOC at the + * beginning of the timeline. + */ + const socPoints: SocPoint[] = [ + { + time: timelineStart, + + soc: initialSoc + }, + + ...rows.map((row, index) => { + const time = index < rows.length - 1 ? new Date(rows[index + 1].time).getTime() : timelineEnd + + return { + time, + + soc: row.soc_percent + } + }) + ] + + const SOC_HEIGHT = 132 + + const SOC_TOP = 18 + + const SOC_BOTTOM = 103 + + function yForSoc(soc: number) { + return SOC_BOTTOM - (clamp(soc, 0, 100) / 100) * (SOC_BOTTOM - SOC_TOP) + } + + function updateSocHover(event: ReactPointerEvent) { + const bounds = event.currentTarget.getBoundingClientRect() + const x = clamp(((event.clientX - bounds.left) / bounds.width) * timelineWidth, PLOT_LEFT, timelineWidth - PLOT_RIGHT) + const time = timelineStart + ((x - PLOT_LEFT) / plotWidth) * timelineDuration + + setSocHover({ x, time, soc: interpolateSoc(socPoints, time) }) + } + + const socPath = socPoints + .map((point, index) => { + const x = xForTime(point.time) + + const y = yForSoc(point.soc) + + return `${index === 0 ? 'M' : 'L'} ${x} ${y}` + }) + .join(' ') + + /* + * SOC labels are only shown at action boundaries rather + * than for every individual plan slot. + */ + const boundaries: SocBoundary[] = [ + { + time: timelineStart, + + soc: initialSoc + }, + + ...events.map((event) => ({ + time: event.end.getTime(), + + soc: event.endSoc + })) + ] + + /* + * Suppress labels that would land almost on top of the + * previous one. The final value is always retained. + */ + const labelledBoundaries: SocBoundary[] = [] + + let previousLabelX = Number.NEGATIVE_INFINITY + + boundaries.forEach((boundary, index) => { + const x = xForTime(boundary.time) + + const isFirst = index === 0 + + const isLast = index === boundaries.length - 1 + + if (isFirst || isLast || x - previousLabelX >= 48) { + labelledBoundaries.push(boundary) + + previousLabelX = x + } + }) + + const showNow = now >= timelineStart && now <= timelineEnd + + const nowX = showNow ? xForTime(now) : null + + return ( +
+
+
+

Plan Timeline

+ +

Predbat's planned battery strategy

+
+ +
+ Charge + + Export + + Demand +
+
+ + {activeEvent && ( +
+
+ + {getActionIcon(activeEvent.action)} + + +
+ {getEventTitle(activeEvent)} + + {formatEventRange(activeEvent.start, activeEvent.end)} +
+ + {activeRateEvent && ( + + + )} +
+ +
+ {activeTargetSoc && ( +
+ Target SOC + {activeTargetSoc} +
+ )} + + {activeEvent.startSoc !== null && activeEvent.endSoc !== null && ( +
+ Predicted SOC + + {Math.round(activeEvent.startSoc)}%{' → '} + {Math.round(activeEvent.endSoc)}% + +
+ )} + + {activeEvent.target !== null && ( +
+ Target + + {activeEvent.target}% +
+ )} + +
+ Import + + + {formatRateRange(activeEvent.importRateMin, activeEvent.importRateMax, currencyMinor)} + +
+ +
+ Export + + + {formatRateRange(activeEvent.exportRateMin, activeEvent.exportRateMax, currencyMinor)} + +
+ +
+ Duration + + {formatDuration(activeEvent.start, activeEvent.end)} +
+ + {plan.carbon_enable && activeEvent.carbonIntensityMin !== null && ( +
+ CO₂ intensity + + + {activeEvent.carbonIntensityMin === activeEvent.carbonIntensityMax + ? activeEvent.carbonIntensityMin.toFixed(0) + : `${activeEvent.carbonIntensityMin.toFixed(0)}–${activeEvent.carbonIntensityMax?.toFixed(0)}`}{' '} + g/kWh + +
+ )} + + {plan.carbon_enable && ( +
+ Net CO₂ + + + {activeEvent.carbonChange > 0 ? '+' : ''} + {(activeEvent.carbonChange / 1000).toFixed(2)} kg + +
+ )} +
+ + {(activeRateEvent || activeEvent.explanation) && ( +

+ {[activeRateEvent?.description, activeEvent.explanation].filter(Boolean).join(' ')} +

+ )} +
+ )} + +
+
+ {/* + * Shared time scale. + */} + + + {/* + * Action ribbon. + */} +
+ {events.map((event) => { + const startX = xForTime(event.start.getTime()) + + const endX = xForTime(event.end.getTime()) + + const eventWidth = Math.max(2, endX - startX) + + const blockLabel = getTimelineBlockLabel(event.action, eventWidth) + + const showRate = eventWidth >= 110 + + const rate = getRelevantRate(event, currencyMinor) + + const isCurrent = currentEvent?.id === event.id + + const isActive = activeEvent?.id === event.id + + const rateEvent = event.rateEventType + ? RATE_EVENT_DETAILS[event.rateEventType] + : null + + return ( + + ) + })} +
+ +
Predicted SOC
+ + {/* + * SOC graph shares exactly the same X scale + * as the action ribbon above it. + */} + setSocHover(null)} + > + + {/* + * SOC colour follows the vertical battery level: + * + * 0–20% = low + * 20–50% = medium + * 50–100% = high + * + * gradientUnits="userSpaceOnUse" is important because + * it makes the colours correspond to the actual 0–100% + * SOC scale rather than the bounding box of the path. + */} + + + + + + + + + + + + + + + + {/* + * Active event range. + */} + {activeEvent && ( + + )} + + {/* + * SOC reference lines. + */} + {[0, 50, 100].map((level) => { + const y = yForSoc(level) + + return ( + + + + + {level}% + + + ) + })} + + {/* + * Vertical three-hour guide lines. + */} + {ticks.map((tick, index) => { + const x = xForTime(tick.time) + + return ( + + ) + })} + + {/* + * Target SOC lines appear only during + * windows which actually have a target. + */} + {events.map((event) => { + if (event.target === null) { + return null + } + + return ( + + ) + })} + + + + {/* + * SOC boundary points. + */} + {boundaries.map((boundary, index) => { + return ( + + ) + })} + + {/* + * Numeric SOC labels are restricted to + * meaningful action boundaries. + */} + {labelledBoundaries.map((boundary, index) => { + const x = xForTime(boundary.time) + + const y = yForSoc(boundary.soc) + + const first = index === 0 + + const last = index === labelledBoundaries.length - 1 + + return ( + 88 ? y + 14 : y - 7} + textAnchor={first ? 'start' : last ? 'end' : 'middle'} + style={{ + fill: getSocColour(boundary.soc) + }} + className="plan-timeline-soc-value" + > + {Math.round(boundary.soc)}% + + ) + })} + + {socHover && ( + + + + + + + {new Date(socHover.time).toLocaleString('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit' })} · {socHover.soc.toFixed(1)}% + + + + )} + + + + + {/* + * One Now marker runs through both the action + * ribbon and SOC graph. + */} + {nowX !== null && ( + + )} +
+
+ +

+ Hover or focus an action for details. On touch devices, tap an action to keep it selected. +

+
+ ) +} diff --git a/frontend/src/components/PowerChart.tsx b/frontend/src/components/PowerChart.tsx new file mode 100644 index 000000000..9f3ed6146 --- /dev/null +++ b/frontend/src/components/PowerChart.tsx @@ -0,0 +1,355 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Chart, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, + type ChartConfiguration, + type ChartDataset, + type Plugin +} from 'chart.js' + +import type { PowerChartData } from '../types/charts' +import type { Plan } from '../types/plan' +import { useStoredState } from '../hooks/useStoredState' +import { formatPlanAction, pointsInWindow, toChartPoints, type ChartPoint } from '../utils/batteryChart' +import { cumulativeEnergyToPower, getPowerChartWindow, gridPowerPeaks } from '../utils/powerChart' + +import './BatteryChart.css' + +Chart.register(LineController, LineElement, PointElement, LinearScale, Filler, Tooltip, Legend) + +type PowerChartProps = { + data: PowerChartData + plan: Plan +} + +type PowerSeriesKey = 'battery' | 'solar' | 'grid' | 'load' | 'iboost' + +type PowerLegendItem = { + key: PowerSeriesKey + label: string + colour: string +} + +const legendItems: PowerLegendItem[] = [ + { + key: 'solar', + label: 'Solar generation', + colour: 'var(--color-flow-solar)' + }, + { + key: 'load', + label: 'Home demand', + colour: 'var(--color-flow-home)' + }, + { + key: 'battery', + label: 'Battery power', + colour: 'var(--color-flow-battery)' + }, + { + key: 'grid', + label: 'Grid power', + colour: 'var(--color-flow-grid)' + }, + { + key: 'iboost', + label: 'iBoost heating', + colour: 'var(--color-hold-iboost)' + } +] + +function PowerLegendItem({ item, square = false }: { item: PowerLegendItem; square?: boolean }) { + return ( + + + ) +} + +function formatPower(value: number | null): string { + return value === null ? '—' : `${value.toFixed(2)} kW` +} + +function maximum(points: ChartPoint[]): number | null { + return points.length ? Math.max(...points.map((point) => point.y)) : null +} + +function makePowerOverlayPlugin( + plan: Plan, + start: number, + end: number, + now: number, + nowColour: string, + zeroColour: string +): Plugin<'line'> { + return { + id: 'predbat-power-overlays', + beforeDatasetsDraw(chart) { + const { ctx, chartArea, scales } = chart + const nowPixel = scales.x.getPixelForValue(now) + + ctx.save() + ctx.fillStyle = 'rgba(15, 23, 42, 0.07)' + ctx.fillRect(chartArea.left, chartArea.top, nowPixel - chartArea.left, chartArea.bottom - chartArea.top) + + plan.rows.forEach((row, index) => { + const rowStart = Date.parse(row.time) + const nextStart = plan.rows[index + 1] ? Date.parse(plan.rows[index + 1].time) : rowStart + 30 * 60 * 1000 + const bandStart = Math.max(rowStart, start) + const bandEnd = Math.min(nextStart, end) + const state = row.state.toLowerCase() + + if (bandEnd <= bandStart || (!state.includes('chrg') && !state.includes('charge') && !state.includes('exp'))) { + return + } + + ctx.fillStyle = state.includes('exp') ? 'rgba(124, 58, 237, 0.08)' : 'rgba(37, 99, 235, 0.08)' + const left = scales.x.getPixelForValue(bandStart) + const right = scales.x.getPixelForValue(bandEnd) + ctx.fillRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top) + }) + ctx.restore() + }, + afterDatasetsDraw(chart) { + const { ctx, chartArea, scales } = chart + const zero = scales.y.getPixelForValue(0) + const nowPixel = scales.x.getPixelForValue(now) + + ctx.save() + ctx.strokeStyle = zeroColour + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(chartArea.left, zero) + ctx.lineTo(chartArea.right, zero) + ctx.stroke() + + ctx.strokeStyle = nowColour + ctx.setLineDash([5, 4]) + ctx.beginPath() + ctx.moveTo(nowPixel, chartArea.top) + ctx.lineTo(nowPixel, chartArea.bottom) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = nowColour + ctx.font = '600 11px sans-serif' + ctx.fillText('Now', nowPixel + 5, chartArea.top + 13) + ctx.restore() + } + } +} + +function powerDirection(key: PowerSeriesKey, value: number): string { + if (key === 'battery') { + return value < 0 ? 'charging' : value > 0 ? 'discharging' : 'idle' + } + if (key === 'grid') { + return value < 0 ? 'importing' : value > 0 ? 'exporting' : 'idle' + } + if (key === 'solar') { + return 'generating' + } + if (key === 'load') { + return 'home demand' + } + return 'water heating' +} + +/** Render Predbat's optimised power forecast with explicit flow direction. */ +export default function PowerChart({ data, plan }: PowerChartProps) { + const canvasRef = useRef(null) + const [forecastHours, setForecastHours] = useStoredState('predbat-chart-power-range', 24, [12, 24, 48]) + const [themeRevision, setThemeRevision] = useState(0) + const generatedAt = Date.parse(data.generated_at) + const window = useMemo( + () => getPowerChartWindow(data.generated_at, forecastHours), + [data.generated_at, forecastHours] + ) + + const points = useMemo(() => ({ + battery: pointsInWindow(toChartPoints(data.series.battery), window), + solar: pointsInWindow(toChartPoints(data.series.solar), window), + grid: pointsInWindow(toChartPoints(data.series.grid), window), + load: pointsInWindow(toChartPoints(data.series.load), window), + iboost: pointsInWindow(cumulativeEnergyToPower(data.series.iboost_energy), window) + }), [data.series, window]) + const hasIBoost = points.iboost.some((point) => point.y > 0) + const gridPeaks = gridPowerPeaks(points.grid) + + useEffect(() => { + const observer = new MutationObserver(() => setThemeRevision((revision) => revision + 1)) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!canvasRef.current) { + return + } + + const styles = getComputedStyle(document.documentElement) + const colours: Record = { + solar: styles.getPropertyValue('--color-flow-solar').trim() || '#f59e0b', + load: styles.getPropertyValue('--color-flow-home').trim() || '#16a34a', + battery: styles.getPropertyValue('--color-flow-battery').trim() || '#2563eb', + grid: styles.getPropertyValue('--color-flow-grid').trim() || '#6b7280', + iboost: styles.getPropertyValue('--color-hold-iboost').trim() || '#0284c7' + } + const textColour = styles.getPropertyValue('--color-text-secondary').trim() || '#4b5563' + const gridColour = styles.getPropertyValue('--color-border').trim() || '#e5e7eb' + const nowColour = styles.getPropertyValue('--color-warning').trim() || '#f59e0b' + const visibleItems = legendItems.filter((item) => item.key !== 'iboost' || hasIBoost) + + const datasets: ChartDataset<'line', ChartPoint[]>[] = visibleItems.map((item) => ({ + label: item.label, + data: points[item.key], + borderColor: colours[item.key], + backgroundColor: colours[item.key], + borderWidth: item.key === 'solar' || item.key === 'load' ? 3 : 2, + pointRadius: 0, + tension: 0.2 + })) + + const configuration: ChartConfiguration<'line', ChartPoint[]> = { + type: 'line', + data: { datasets }, + plugins: [makePowerOverlayPlugin(plan, window.start, window.end, generatedAt, nowColour, textColour)], + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + parsing: false, + normalized: true, + interaction: { intersect: false, mode: 'nearest' }, + scales: { + x: { + type: 'linear', + min: window.start, + max: window.end, + grid: { color: gridColour }, + ticks: { + color: textColour, + maxTicksLimit: 9, + callback(value) { + return new Intl.DateTimeFormat(undefined, { + weekday: 'short', + hour: '2-digit', + minute: '2-digit' + }).format(Number(value)) + } + } + }, + y: { + grid: { color: gridColour }, + ticks: { + color: textColour, + callback(value) { + return `${value} kW` + } + } + } + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title(items) { + return new Intl.DateTimeFormat(undefined, { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }).format(Number(items[0]?.parsed.x)) + }, + label(item) { + const key = visibleItems[item.datasetIndex]?.key ?? 'load' + const value = Number(item.parsed.y) + return `${item.dataset.label}: ${Math.abs(value).toFixed(2)} kW · ${powerDirection(key, value)}` + }, + footer(items) { + const timestamp = Number(items[0]?.parsed.x) + const row = [...plan.rows].reverse().find((candidate) => Date.parse(candidate.time) <= timestamp) + return row ? `Plan: ${formatPlanAction(row.state)}` : '' + } + } + } + } + } + } + + const chart = new Chart(canvasRef.current, configuration) + return () => chart.destroy() + }, [data, generatedAt, hasIBoost, plan, points, themeRevision, window]) + + return ( +
+
+
+

Power

+

Predbat's optimised forecast for generation, demand and energy flow.

+
+ +
+ {[12, 24, 48].map((hours) => ( + + ))} +
+
+ +
+
Peak solar{formatPower(maximum(points.solar))}
+
Peak home demand{formatPower(maximum(points.load))}
+
Peak grid import{formatPower(gridPeaks.import)}
+
Peak grid export{formatPower(gridPeaks.export)}
+
+ +
+ {legendItems + .filter((item) => item.key !== 'iboost' || hasIBoost) + .map((item) => )} +
+ +
+ +
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/components/PowerFlow.css b/frontend/src/components/PowerFlow.css new file mode 100644 index 000000000..f24e6b156 --- /dev/null +++ b/frontend/src/components/PowerFlow.css @@ -0,0 +1,29 @@ +.power-flow-card { + margin: 1.5rem 0; + padding: 1.5rem; + border: 1px solid var(--color-border); + border-radius: 16px; + background: var(--color-surface); + color: var(--color-text); + box-shadow: var(--shadow-card); +} + +.power-flow-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.power-flow-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.power-flow-subtitle { + display: block; + margin-top: 0.2rem; + color: var(--color-text-muted); + font-size: 0.75rem; +} diff --git a/frontend/src/components/PowerFlow.tsx b/frontend/src/components/PowerFlow.tsx new file mode 100644 index 000000000..f05aabc17 --- /dev/null +++ b/frontend/src/components/PowerFlow.tsx @@ -0,0 +1,25 @@ +import SimplePowerFlow from './SimplePowerFlow' +import type { PowerFlowData } from '../types/powerFlow' + +import './PowerFlow.css' + +type PowerFlowProps = { + data: PowerFlowData +} + +function PowerFlow({ data }: PowerFlowProps) { + return ( +
+
+
+

Power Flow

+ Live energy flow around your home +
+
+ + +
+ ) +} + +export default PowerFlow diff --git a/frontend/src/components/RatesChart.tsx b/frontend/src/components/RatesChart.tsx new file mode 100644 index 000000000..fd49fed75 --- /dev/null +++ b/frontend/src/components/RatesChart.tsx @@ -0,0 +1,401 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Chart, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, + type ChartConfiguration, + type ChartDataset, + type Plugin +} from 'chart.js' + +import type { RatesChartData, RatesChartSeries } from '../types/charts' +import type { Plan } from '../types/plan' +import { useStoredState } from '../hooks/useStoredState' +import { + actionAtTime, + formatPlanAction, + getChartWindow, + hasSeriesData, + pointsInWindow, + toChartPoints, + type ChartPoint +} from '../utils/batteryChart' +import { formatRate } from '../utils/currency' +import { currentRate, upcomingRateExtremes } from '../utils/ratesChart' + +import './BatteryChart.css' + +Chart.register(LineController, LineElement, PointElement, LinearScale, Filler, Tooltip, Legend) + +type RatesChartProps = { + data: RatesChartData + plan: Plan +} + +type ComparisonKey = 'gas' | 'actual_hourly' | 'actual_today' + +type RateLegendItem = { + key: keyof RatesChartSeries + label: string + colour: string + description?: string +} + +const primaryLegendItems: RateLegendItem[] = [ + { key: 'import', label: 'Import rate', colour: 'var(--color-current)' }, + { key: 'export', label: 'Export rate', colour: 'var(--color-success)' } +] + +const comparisonOptions: Array = [ + { + key: 'gas', + label: 'Gas rate', + colour: '#d97706', + description: 'Forecast gas tariff supplied to Predbat.' + }, + { + key: 'actual_hourly', + label: 'Hourly average cost', + colour: '#0891b2', + description: 'Measured average electricity cost per kWh for each recent hour.' + }, + { + key: 'actual_today', + label: 'Daily average cost', + colour: '#64748b', + description: 'Running average electricity cost per kWh for today.' + } +] + +function RateLegend({ item, square = false }: { item: RateLegendItem; square?: boolean }) { + return ( + + + ) +} + +function formatRateValue(value: number | null, currencyUnit: string): string { + return value === null ? '—' : formatRate(value, currencyUnit) +} + +function makeRatesOverlayPlugin( + plan: Plan, + start: number, + end: number, + now: number, + nowColour: string, + zeroColour: string +): Plugin<'line'> { + return { + id: 'predbat-rates-overlays', + beforeDatasetsDraw(chart) { + const { ctx, chartArea, scales } = chart + const nowPixel = Math.min(Math.max(scales.x.getPixelForValue(now), chartArea.left), chartArea.right) + + ctx.save() + ctx.fillStyle = 'rgba(15, 23, 42, 0.07)' + ctx.fillRect(chartArea.left, chartArea.top, nowPixel - chartArea.left, chartArea.bottom - chartArea.top) + + plan.rows.forEach((row, index) => { + const rowStart = Date.parse(row.time) + const nextStart = plan.rows[index + 1] ? Date.parse(plan.rows[index + 1].time) : rowStart + 30 * 60 * 1000 + const bandStart = Math.max(rowStart, start) + const bandEnd = Math.min(nextStart, end) + const state = row.state.toLowerCase() + + if (bandEnd <= bandStart || (!state.includes('chrg') && !state.includes('charge') && !state.includes('exp'))) { + return + } + + ctx.fillStyle = state.includes('exp') ? 'rgba(124, 58, 237, 0.08)' : 'rgba(37, 99, 235, 0.08)' + const left = scales.x.getPixelForValue(bandStart) + const right = scales.x.getPixelForValue(bandEnd) + ctx.fillRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top) + }) + ctx.restore() + }, + afterDatasetsDraw(chart) { + const { ctx, chartArea, scales } = chart + const zeroPixel = scales.y.getPixelForValue(0) + const nowPixel = scales.x.getPixelForValue(now) + + if (zeroPixel >= chartArea.top && zeroPixel <= chartArea.bottom) { + ctx.save() + ctx.strokeStyle = zeroColour + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(chartArea.left, zeroPixel) + ctx.lineTo(chartArea.right, zeroPixel) + ctx.stroke() + ctx.restore() + } + + ctx.save() + ctx.strokeStyle = nowColour + ctx.lineWidth = 1.5 + ctx.setLineDash([5, 4]) + ctx.beginPath() + ctx.moveTo(nowPixel, chartArea.top) + ctx.lineTo(nowPixel, chartArea.bottom) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = nowColour + ctx.font = '600 11px sans-serif' + ctx.fillText('Now', nowPixel + 5, chartArea.top + 13) + ctx.restore() + } + } +} + +/** Render forecast import/export tariffs and optional measured average rates. */ +export default function RatesChart({ data, plan }: RatesChartProps) { + const canvasRef = useRef(null) + const [forecastHours, setForecastHours] = useStoredState('predbat-chart-rates-range', 24, [12, 24, 48]) + const [comparisons, setComparisons] = useState>(new Set()) + const [themeRevision, setThemeRevision] = useState(0) + const generatedAt = Date.parse(data.generated_at) + const window = useMemo( + () => getChartWindow(data.generated_at, forecastHours), + [data.generated_at, forecastHours] + ) + const importPoints = useMemo( + () => pointsInWindow(toChartPoints(data.series.import), window), + [data.series.import, window] + ) + const exportPoints = useMemo( + () => pointsInWindow(toChartPoints(data.series.export), window), + [data.series.export, window] + ) + const currentImport = currentRate(importPoints, generatedAt) + const currentExport = currentRate(exportPoints, generatedAt) + const extremes = upcomingRateExtremes(importPoints, exportPoints, generatedAt, window.end) + const comparisonAvailability = useMemo>(() => ({ + gas: hasSeriesData(data.series.gas, window), + actual_hourly: hasSeriesData(data.series.actual_hourly, window), + actual_today: hasSeriesData(data.series.actual_today, window) + }), [data.series, window]) + + useEffect(() => { + const observer = new MutationObserver(() => setThemeRevision((revision) => revision + 1)) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!canvasRef.current) { + return + } + + const styles = getComputedStyle(document.documentElement) + const textColour = styles.getPropertyValue('--color-text-secondary').trim() || '#4b5563' + const gridColour = styles.getPropertyValue('--color-border').trim() || '#e5e7eb' + const importColour = styles.getPropertyValue('--color-current').trim() || '#3b82f6' + const exportColour = styles.getPropertyValue('--color-success').trim() || '#16a34a' + const nowColour = styles.getPropertyValue('--color-warning').trim() || '#f59e0b' + + const datasets: ChartDataset<'line', ChartPoint[]>[] = [ + { + label: 'Import rate', + data: importPoints, + borderColor: importColour, + backgroundColor: importColour, + borderWidth: 3, + pointRadius: 0, + stepped: true + }, + { + label: 'Export rate', + data: exportPoints, + borderColor: exportColour, + backgroundColor: exportColour, + borderWidth: 3, + pointRadius: 0, + stepped: true + } + ] + + comparisonOptions.forEach(({ key, label, colour }) => { + if (!comparisons.has(key) || !comparisonAvailability[key]) { + return + } + + datasets.push({ + label, + data: pointsInWindow(toChartPoints(data.series[key]), window), + borderColor: colour, + backgroundColor: colour, + borderWidth: 1.5, + borderDash: [5, 5], + pointRadius: 0, + stepped: true + }) + }) + + const configuration: ChartConfiguration<'line', ChartPoint[]> = { + type: 'line', + data: { datasets }, + plugins: [makeRatesOverlayPlugin(plan, window.start, window.end, generatedAt, nowColour, textColour)], + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + parsing: false, + normalized: true, + interaction: { intersect: false, mode: 'nearest' }, + scales: { + x: { + type: 'linear', + min: window.start, + max: window.end, + grid: { color: gridColour }, + ticks: { + color: textColour, + maxTicksLimit: 9, + callback(value) { + return new Intl.DateTimeFormat(undefined, { + weekday: 'short', + hour: '2-digit', + minute: '2-digit' + }).format(Number(value)) + } + } + }, + y: { + grid: { color: gridColour }, + ticks: { + color: textColour, + callback(value) { + return `${value}${data.currency_unit}` + } + } + } + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title(items) { + return new Intl.DateTimeFormat(undefined, { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }).format(Number(items[0]?.parsed.x)) + }, + label(item) { + return `${item.dataset.label}: ${formatRate(Number(item.parsed.y), data.currency_unit)}` + }, + footer(items) { + const action = actionAtTime(plan.rows, Number(items[0]?.parsed.x)) + return action ? `Plan: ${formatPlanAction(action)}` : '' + } + } + } + } + } + } + + const chart = new Chart(canvasRef.current, configuration) + return () => chart.destroy() + }, [comparisonAvailability, comparisons, data, exportPoints, generatedAt, importPoints, plan, themeRevision, window]) + + function toggleComparison(key: ComparisonKey) { + setComparisons((currentComparisons) => { + const nextComparisons = new Set(currentComparisons) + if (nextComparisons.has(key)) { + nextComparisons.delete(key) + } else { + nextComparisons.add(key) + } + return nextComparisons + }) + } + + return ( +
+
+
+

Rates

+

Import and export tariffs alongside Predbat's scheduled actions.

+
+ +
+ {[12, 24, 48].map((hours) => ( + + ))} +
+
+ +
+
Current import{formatRateValue(currentImport, data.currency_unit)}
+
Current export{formatRateValue(currentExport, data.currency_unit)}
+
Lowest upcoming import{formatRateValue(extremes.lowestImport, data.currency_unit)}
+
Highest upcoming export{formatRateValue(extremes.highestExport, data.currency_unit)}
+
+ +
+ {primaryLegendItems.map((item) => )} + {comparisonOptions + .filter(({ key }) => comparisons.has(key) && comparisonAvailability[key]) + .map((item) => )} +
+ +
+ +
+ +
+ Compare measured averages and gas +
+ {comparisonOptions.map((item) => ( + + ))} +
+
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/components/SavingsChart.tsx b/frontend/src/components/SavingsChart.tsx new file mode 100644 index 000000000..775a0cc34 --- /dev/null +++ b/frontend/src/components/SavingsChart.tsx @@ -0,0 +1,235 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + BarController, + BarElement, + Chart, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, + type ChartConfiguration, + type ChartDataset +} from 'chart.js' + +import type { SavingsChartData, SavingsChartSeries } from '../types/charts' +import { useStoredState } from '../hooks/useStoredState' +import { pointsInWindow, toChartPoints, type ChartPoint } from '../utils/batteryChart' +import { formatMajorCurrency } from '../utils/currency' +import { alignedSavingsAxisBounds } from '../utils/savingsChart' + +import './BatteryChart.css' + +Chart.register(BarController, BarElement, LineController, LineElement, PointElement, LinearScale, Tooltip, Legend) + +type SavingsLegendItem = { + key: keyof SavingsChartSeries + label: string + colour: string + description: string + bar?: boolean +} + +const legendItems: SavingsLegendItem[] = [ + { + key: 'daily_predbat', + label: 'Predbat saving', + colour: '#f59e0b', + description: 'Money saved each day compared with the configured simple charging baseline.', + bar: true + }, + { + key: 'daily_pv_battery', + label: 'Solar and battery saving', + colour: '#0891b2', + description: 'Money saved each day compared with having no solar panels or battery.', + bar: true + }, + { + key: 'daily_cost', + label: 'Actual cost', + colour: '#dc2626', + description: 'The actual net energy cost for each day.', + bar: true + }, + { + key: 'total_predbat', + label: 'Total Predbat saving', + colour: '#ca8a04', + description: 'Running total saved by Predbat compared with the configured simple charging baseline.' + }, + { + key: 'total_pv_battery', + label: 'Total solar and battery saving', + colour: '#059669', + description: 'Running total saved by the solar and battery system compared with grid-only energy use.' + } +] + +function latestValue(points: ChartPoint[]): number | null { + return points.at(-1)?.y ?? null +} + +function SavingsLegend({ item }: { item: SavingsLegendItem }) { + return ( + + + ) +} + +/** Render daily costs and savings with their cumulative totals. */ +export default function SavingsChart({ data }: { data: SavingsChartData }) { + const canvasRef = useRef(null) + const [rangeDays, setRangeDays] = useStoredState('predbat-chart-savings-range', 28, [7, 14, 28]) + const [themeRevision, setThemeRevision] = useState(0) + const window = useMemo(() => ({ + start: Date.parse(data.generated_at) - rangeDays * 24 * 60 * 60 * 1000, + end: Date.parse(data.generated_at) + }), [data.generated_at, rangeDays]) + const points = useMemo(() => Object.fromEntries( + Object.entries(data.series).map(([key, series]) => [key, pointsInWindow(toChartPoints(series), window)]) + ) as Record, [data.series, window]) + + useEffect(() => { + const observer = new MutationObserver(() => setThemeRevision((revision) => revision + 1)) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!canvasRef.current) { + return + } + + const styles = getComputedStyle(document.documentElement) + const textColour = styles.getPropertyValue('--color-text-secondary').trim() || '#4b5563' + const gridColour = styles.getPropertyValue('--color-border').trim() || '#e5e7eb' + const dailyDatasets: ChartDataset<'bar' | 'line', ChartPoint[]>[] = legendItems.filter((item) => item.bar).map((item) => ({ + type: 'bar', + label: item.label, + data: points[item.key], + borderColor: item.colour, + backgroundColor: `${item.colour}b3`, + borderWidth: 1, + order: 2, + yAxisID: 'y' + })) + const totalDatasets: ChartDataset<'bar' | 'line', ChartPoint[]>[] = legendItems.filter((item) => !item.bar).map((item) => ({ + type: 'line', + label: item.label, + data: points[item.key], + borderColor: item.colour, + backgroundColor: item.colour, + borderWidth: 3, + pointRadius: points[item.key].length === 1 ? 4 : 0, + pointHoverRadius: 4, + showLine: true, + spanGaps: true, + tension: 0.2, + order: 1, + yAxisID: 'y1' + })) + const dailyValues = dailyDatasets.flatMap((dataset) => dataset.data.map((point) => point.y)) + const totalValues = totalDatasets.flatMap((dataset) => dataset.data.map((point) => point.y)) + const axisBounds = alignedSavingsAxisBounds(dailyValues, totalValues) + const configuration: ChartConfiguration<'bar' | 'line', ChartPoint[]> = { + type: 'bar', + data: { datasets: [...dailyDatasets, ...totalDatasets] }, + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + parsing: false, + normalized: true, + interaction: { intersect: false, mode: 'index' }, + scales: { + x: { + type: 'linear', + min: window.start, + max: window.end, + grid: { color: gridColour }, + ticks: { + color: textColour, + maxTicksLimit: 10, + callback(value) { + return new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(Number(value)) + } + } + }, + y: { + min: axisBounds.daily.min, + max: axisBounds.daily.max, + grid: { color: gridColour }, + ticks: { color: textColour, callback: (value) => formatMajorCurrency(Number(value), data.currency_symbol) }, + title: { display: true, text: 'Daily', color: textColour } + }, + y1: { + position: 'right', + min: axisBounds.total.min, + max: axisBounds.total.max, + grid: { drawOnChartArea: false }, + ticks: { color: textColour, callback: (value) => formatMajorCurrency(Number(value), data.currency_symbol) }, + title: { display: true, text: 'Running total', color: textColour } + } + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title(items) { + return new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }).format(Number(items[0]?.parsed.x)) + }, + label(item) { + return `${item.dataset.label}: ${formatMajorCurrency(Number(item.parsed.y), data.currency_symbol)}` + } + } + } + } + } + } + + const chart = new Chart(canvasRef.current, configuration) + return () => chart.destroy() + }, [data.currency_symbol, points, themeRevision, window]) + + return ( +
+
+
+

Savings

+

Daily energy costs and the savings delivered by Predbat, solar and battery storage.

+
+
+ {[7, 14, 28].map((days) => ( + + ))} +
+
+ +
+
Latest Predbat saving{formatMajorCurrency(latestValue(points.daily_predbat) ?? 0, data.currency_symbol)}
+
Latest solar and battery saving{formatMajorCurrency(latestValue(points.daily_pv_battery) ?? 0, data.currency_symbol)}
+
Total Predbat saving{formatMajorCurrency(latestValue(points.total_predbat) ?? 0, data.currency_symbol)}
+
Total solar and battery saving{formatMajorCurrency(latestValue(points.total_pv_battery) ?? 0, data.currency_symbol)}
+
+ +
+ {legendItems.map((item) => )} +
+ +
+ +
+
+ ) +} diff --git a/frontend/src/components/SimplePowerFlow.css b/frontend/src/components/SimplePowerFlow.css new file mode 100644 index 000000000..8d3669e85 --- /dev/null +++ b/frontend/src/components/SimplePowerFlow.css @@ -0,0 +1,197 @@ +.simple-power-flow { + position: relative; + isolation: isolate; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-areas: + '. solar .' + 'battery home grid' + '. car .'; + align-items: start; + gap: 2rem 1rem; + min-height: 320px; + padding: 1rem 0; +} + +/* Measured SVG connections sit below the nodes and must not intercept pointer input. */ +.simple-power-flow-lines { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; + overflow: visible; + pointer-events: none; +} + +.simple-node { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + text-align: center; +} + +.simple-solar { + grid-area: solar; +} + +.simple-battery { + grid-area: battery; +} + +.simple-home { + grid-area: home; +} + +.right-100 { + position: relative; + display: flex; + flex-direction: column; + left: 50px; + top: -15px; +} + +.simple-grid { + grid-area: grid; +} + +.simple-car { + grid-area: car; +} + +.simple-node-circle { + display: flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + margin: 0.25rem; + border-radius: 50%; + background: var(--color-bg); + border: 2px solid var(--color-border); +} + +.simple-node-circle svg { + font-size: 1.7rem; +} + +.simple-node strong { + font-size: 0.85rem; +} + +.simple-node-power { + font-size: 1.15rem; + font-weight: 600; +} + +.simple-node span, +.simple-node small { + color: var(--color-text-muted); +} + +.simple-node small { + font-size: 0.7rem; +} + +.grid-icon { + width: 1.6rem; + height: 1.6rem; +} + +.simple-car.is-charging .simple-node-power, +.simple-car.is-charging small { + color: var(--color-charge); +} + +/* Base connection line */ +.simple-flow-line { + fill: none; + stroke: currentColor; + stroke-width: 3; + stroke-linecap: round; + vector-effect: non-scaling-stroke; +} + +/* Semantic flow colours */ +.flow-solar { + color: var(--color-warning, #f59e0b); +} + +.flow-battery { + color: var(--color-success, #16a34a); +} + +.flow-grid { + color: var(--color-text-muted, #6b7280); +} + +.flow-car { + color: var(--color-hold-car, #d97706); +} + +.simple-flow-base { + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + vector-effect: non-scaling-stroke; + transition: + opacity 0.2s ease, + stroke-width 0.2s ease; +} + +/* Idle connection remains visible but subdued */ +.simple-flow-base.is-idle { + opacity: 0.2; +} + +/* Active connection becomes a little stronger */ +.simple-flow-base.is-active { + opacity: 0.45; +} + +/* Animated energy particles */ +.simple-flow-dot { + fill: currentColor; + pointer-events: none; + filter: drop-shadow(0 0 3px currentColor); +} + +.simple-flow-dot-primary { + opacity: 1; +} + +.simple-flow-dot-secondary { + opacity: 0.65; +} + +.simple-flow-dot-tertiary { + opacity: 0.35; +} + +/* Semantic colours */ + +.flow-solar { + color: var(--color-flow-solar); +} + +.flow-battery { + color: var(--color-flow-battery); +} + +.flow-grid { + color: var(--color-flow-grid); +} + +.flow-car { + color: var(--color-flow-car); +} + +/* Remove animation for users who prefer reduced motion */ +@media (prefers-reduced-motion: reduce) { + .simple-flow-dot { + display: none; + } +} diff --git a/frontend/src/components/SimplePowerFlow.tsx b/frontend/src/components/SimplePowerFlow.tsx new file mode 100644 index 000000000..6f4d63668 --- /dev/null +++ b/frontend/src/components/SimplePowerFlow.tsx @@ -0,0 +1,569 @@ +import { useLayoutEffect, useRef, useState } from 'react' + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +import { + faSolarPanel, + faHouse, + faBatteryEmpty, + faBatteryQuarter, + faBatteryHalf, + faBatteryThreeQuarters, + faBatteryFull, + faCar +} from '@fortawesome/free-solid-svg-icons' + +import GridIcon from './GridIcon' + +import type { PowerFlowData } from '../types/powerFlow' + +import './SimplePowerFlow.css' + +/** + * Data supplied by the parent PowerFlow component. + */ +type SimplePowerFlowProps = { + data: PowerFlowData +} + +/** + * A point within the power-flow container. + * + * Coordinates are measured in actual CSS pixels, so the SVG overlay + * follows the rendered layout rather than relying on hard-coded positions. + */ +type Point = { + x: number + y: number +} + +/** + * Start/end coordinates for one power-flow connection. + */ +type LineCoordinates = { + start: Point + end: Point +} + +/** + * All possible connections in the simple power-flow diagram. + * + * Car is optional because the EV node is only rendered when a + * car charging sensor is configured in Predbat. + */ +type FlowLines = { + solar?: LineCoordinates + battery?: LineCoordinates + grid?: LineCoordinates + car?: LineCoordinates +} + +/** + * Props for an individual SVG flow line. + */ +type FlowLineProps = { + x1: number + y1: number + x2: number + y2: number + + active: boolean + direction: 'forward' | 'reverse' + + className: string + power: number +} + +/** + * Format watts into a compact value for display. + * + * Values below 1kW remain in watts. + * Larger values are shown in kW. + */ +function formatPower(power: number) { + const value = Math.abs(power) + + if (value >= 1000) { + return `${(value / 1000).toFixed(2)} kW` + } + + return `${value.toFixed(0)} W` +} + +function getBatteryIcon(socPercent: number) { + if (socPercent >= 88) { + return faBatteryFull + } + + if (socPercent >= 63) { + return faBatteryThreeQuarters + } + + if (socPercent >= 38) { + return faBatteryHalf + } + + if (socPercent >= 13) { + return faBatteryQuarter + } + + return faBatteryEmpty +} + +/** + * Calculate a line connecting the EDGES of two circular DOM elements. + * + * getBoundingClientRect() gives us the real on-screen position of each + * circle. We convert those positions into coordinates relative to the + * SimplePowerFlow container. + * + * The line starts and ends one radius away from each centre, so it meets + * the circle edge rather than passing underneath the icons. + */ +function getConnection( + from: HTMLElement, + to: HTMLElement, + container: HTMLElement +): LineCoordinates { + const fromRect = from.getBoundingClientRect() + const toRect = to.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + + const fromCenter: Point = { + x: fromRect.left - containerRect.left + fromRect.width / 2, + + y: fromRect.top - containerRect.top + fromRect.height / 2 + } + + const toCenter: Point = { + x: toRect.left - containerRect.left + toRect.width / 2, + + y: toRect.top - containerRect.top + toRect.height / 2 + } + + const dx = toCenter.x - fromCenter.x + const dy = toCenter.y - fromCenter.y + + const distance = Math.sqrt(dx * dx + dy * dy) + + // Defensive fallback if two nodes somehow occupy the same point. + if (distance === 0) { + return { + start: fromCenter, + end: toCenter + } + } + + // Unit vector pointing from the first circle to the second. + const ux = dx / distance + const uy = dy / distance + + const fromRadius = Math.min(fromRect.width, fromRect.height) / 2 + + const toRadius = Math.min(toRect.width, toRect.height) / 2 + + return { + start: { + x: fromCenter.x + ux * fromRadius, + y: fromCenter.y + uy * fromRadius + }, + + end: { + x: toCenter.x - ux * toRadius, + y: toCenter.y - uy * toRadius + } + } +} + +function getFlowDuration(power: number) { + const value = Math.abs(power) + + // Clamp between roughly 0.8s and 2.8s + const duration = 2.8 - Math.min(value, 3000) / 1500 + + return `${Math.max(0.8, duration).toFixed(2)}s` +} + +/** + * Draw one power-flow connection. + * + * Direction does not change the physical line coordinates. + * SVG animateMotion keyPoints reverse particle travel along the same path. + */ +function FlowLine({ x1, y1, x2, y2, active, direction, className, power }: FlowLineProps) { + const path = `M ${x1} ${y1} L ${x2} ${y2}` + const duration = getFlowDuration(power) + + return ( + + + + {active && ( + <> + + + + + + + + + + + + + )} + + ) +} + +/** + * Simple live power-flow view. + * + * The node positions are controlled by CSS Grid. + * An absolutely positioned SVG sits underneath those nodes and its + * connection coordinates are calculated from the actual DOM layout. + * + * This keeps the lines attached to the circles when the card resizes. + */ +function SimplePowerFlow({ data }: SimplePowerFlowProps) { + /** + * Reference to the whole diagram. + * + * All SVG coordinates are calculated relative to this element. + */ + const containerRef = useRef(null) + + /** + * References to each circular node. + * + * We deliberately measure the circle itself rather than the whole + * node because the surrounding labels have different heights. + */ + const solarRef = useRef(null) + + const batteryRef = useRef(null) + + const homeRef = useRef(null) + + const gridRef = useRef(null) + + const carRef = useRef(null) + + /** + * Calculated SVG coordinates. + */ + const [flowLines, setFlowLines] = useState({}) + + const batteryIcon = getBatteryIcon(data.soc_percent) + + /** + * Determine whether each connection is currently carrying power. + * + * Predbat already gives us battery and grid direction information, + * so the frontend doesn't need to reinterpret their signs. + */ + const batteryActive = data.battery_charging || data.battery_discharging + + const gridActive = Math.abs(data.grid_power) >= 10 + + /** + * Measure the rendered circles and update every SVG connection. + * + * ResizeObserver means this also runs whenever responsive CSS changes + * the diagram dimensions, not just when the browser window fires a + * traditional resize event. + */ + useLayoutEffect(() => { + const container = containerRef.current + + if (!container) { + return + } + + function updateFlowLines() { + const container = containerRef.current + + const solar = solarRef.current + + const battery = batteryRef.current + + const home = homeRef.current + + const grid = gridRef.current + + if (!container || !solar || !battery || !home || !grid) { + return + } + + const lines: FlowLines = { + // Solar always connects towards Home. + solar: getConnection(solar, home, container), + + // Battery direction is controlled by charge/discharge state. + battery: getConnection(battery, home, container), + + // Grid connection is defined Grid -> Home. + // FlowLine reverses particle travel during export. + grid: getConnection(grid, home, container) + } + + /** + * Car connection only exists when Predbat has a configured + * car charging power sensor. + */ + if (data.car.configured && carRef.current) { + lines.car = getConnection(home, carRef.current, container) + } + + setFlowLines(lines) + } + + // Calculate immediately after the DOM layout is available. + updateFlowLines() + + /** + * Running once more on the next animation frame catches small layout + * adjustments such as font rendering that can occur immediately + * after the first measurement. + */ + const frame = window.requestAnimationFrame(updateFlowLines) + + const resizeObserver = new ResizeObserver(updateFlowLines) + + resizeObserver.observe(container) + + if (solarRef.current) { + resizeObserver.observe(solarRef.current) + } + + if (batteryRef.current) { + resizeObserver.observe(batteryRef.current) + } + + if (homeRef.current) { + resizeObserver.observe(homeRef.current) + } + + if (gridRef.current) { + resizeObserver.observe(gridRef.current) + } + + if (carRef.current) { + resizeObserver.observe(carRef.current) + } + + return () => { + window.cancelAnimationFrame(frame) + resizeObserver.disconnect() + } + }, [data.car.configured]) + + return ( +
+ {/* + * SVG CONNECTION LAYER + * + * This fills the entire diagram and sits underneath the + * HTML nodes. + * + * No viewBox is used: coordinates correspond directly to + * CSS pixels inside simple-power-flow. + */} + + + {/* + * SOLAR NODE + */} +
+ Solar + + {formatPower(data.pv_power)} + +
+ +
+
+ + {/* + * BATTERY NODE + * + * The icon comes FIRST so its circle lines up horizontally + * with Home and Grid regardless of how much descriptive text + * is displayed underneath. + */} +
+
+ +
+ + Battery + + {formatPower(data.battery_power)} + + {data.soc_percent}% SoC + + + {data.battery_charging ? 'Charging' : data.battery_discharging ? 'Discharging' : 'Idle'} + +
+ + {/* + * HOME NODE + */} +
+
+ +
+ +
+ Home + {formatPower(data.house_power)} +
+ +
+ + {/* + * GRID NODE + * + * Uses our custom electricity pylon SVG rather than the + * Font Awesome broadcast tower. + */} +
+
+ +
+ + Grid + + {formatPower(data.grid_power)} + + {!gridActive ? 'Idle' : data.grid_importing ? 'Importing' : 'Exporting'} +
+ + {/* + * CAR NODE + * + * Hide the entire node when no car charging sensor has been + * configured, matching Predbat's existing power-flow behaviour. + */} + {data.car.configured && ( +
+
+ +
+ + Car + + {formatPower(data.car.power)} + + {data.car.charging ? 'Charging' : 'Idle'} +
+ )} +
+ ) +} + +export default SimplePowerFlow diff --git a/frontend/src/components/SolarChart.tsx b/frontend/src/components/SolarChart.tsx new file mode 100644 index 000000000..2f6c10360 --- /dev/null +++ b/frontend/src/components/SolarChart.tsx @@ -0,0 +1,485 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Chart, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, + type ChartConfiguration, + type ChartDataset, + type Plugin +} from 'chart.js' + +import type { SolarChartData } from '../types/charts' +import { useStoredState } from '../hooks/useStoredState' +import { pointsInWindow, toChartPoints, type ChartPoint } from '../utils/batteryChart' +import { combineSolarForecast, getSolarChartWindow, getSolarPowerWindow, peakSolarPower, solarAccuracy, sumDailyCumulative } from '../utils/solarChart' + +import './BatteryChart.css' + +Chart.register(LineController, LineElement, PointElement, LinearScale, Filler, Tooltip, Legend) + +type SolarChartProps = { + data: SolarChartData +} + +type ComparisonKey = 'raw' | 'range' +type SolarView = 'power' | 'accuracy' + +type SolarLegendItem = { + key: string + label: string + colour: string + description: string +} + +const legendItems: SolarLegendItem[] = [ + { + key: 'actual', + label: 'Actual generation', + colour: 'var(--color-flow-solar)', + description: 'The solar power your inverter measured. Historical readings stop at Now.' + }, + { + key: 'calibrated', + label: 'Calibrated forecast', + colour: 'var(--color-current)', + description: 'Predbat’s forecast adjusted using recent differences between predicted and measured solar generation.' + }, + { + key: 'raw', + label: 'Original forecast', + colour: '#64748b', + description: 'The solar provider forecast before Predbat applies its local calibration.' + }, + { + key: 'low', + label: 'Lower estimate', + colour: '#a855f7', + description: 'Predbat’s conservative solar scenario, derived from recent lower-generation days.' + }, + { + key: 'high', + label: 'Upper estimate', + colour: '#16a34a', + description: 'Predbat’s optimistic solar scenario, derived from recent higher-generation days.' + } +] + +const accuracyLegendItems: SolarLegendItem[] = [ + { + key: 'energy_actual', + label: 'Actual energy', + colour: 'var(--color-flow-solar)', + description: 'Cumulative solar energy generated since midnight on each day.' + }, + { + key: 'energy_forecast', + label: 'Forecast energy', + colour: 'var(--color-current)', + description: 'Cumulative calibrated solar energy Predbat expected to have generated by that time.' + } +] + +function SolarLegend({ item }: { item: SolarLegendItem }) { + return ( + + + ) +} + +function latestValueAt(points: ChartPoint[], at: number): number | null { + return points.reduce((latest, point) => point.x <= at ? point.y : latest, null) +} + +function formatPower(value: number | null): string { + return value === null ? '—' : `${value.toFixed(2)} kW` +} + +function formatEnergy(value: number | null): string { + return value === null ? '—' : `${value.toFixed(2)} kWh` +} + +function formatDifference(value: number | null): string { + if (value === null) return '—' + return `${value > 0 ? '+' : ''}${value.toFixed(2)} kWh` +} + +function formatPercentage(value: number | null): string { + return value === null ? '—' : `${value.toFixed(0)}%` +} + +function makeSolarOverlayPlugin(now: number, nowColour: string): Plugin<'line'> { + return { + id: 'predbat-solar-overlays', + beforeDatasetsDraw(chart) { + const { ctx, chartArea, scales } = chart + const nowPixel = scales.x.getPixelForValue(now) + + ctx.save() + ctx.fillStyle = 'rgba(15, 23, 42, 0.07)' + ctx.fillRect(chartArea.left, chartArea.top, Math.max(0, nowPixel - chartArea.left), chartArea.bottom - chartArea.top) + ctx.restore() + }, + afterDatasetsDraw(chart) { + const { ctx, chartArea, scales } = chart + const nowPixel = scales.x.getPixelForValue(now) + + ctx.save() + ctx.strokeStyle = nowColour + ctx.lineWidth = 1.5 + ctx.setLineDash([5, 4]) + ctx.beginPath() + ctx.moveTo(nowPixel, chartArea.top) + ctx.lineTo(nowPixel, chartArea.bottom) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = nowColour + ctx.font = '600 11px sans-serif' + ctx.fillText('Now', nowPixel + 5, chartArea.top + 13) + ctx.restore() + } + } +} + +/** Render measured solar power and Predbat's current and historical forecasts. */ +export default function SolarChart({ data }: SolarChartProps) { + const canvasRef = useRef(null) + const [view, setView] = useStoredState('predbat-chart-solar-view', 'power', ['power', 'accuracy']) + const [powerDays, setPowerDays] = useStoredState('predbat-chart-solar-power-range', 1, [1, 3, 7]) + const [accuracyDays, setAccuracyDays] = useStoredState('predbat-chart-solar-accuracy-range', 7, [1, 3, 7]) + const [comparisons, setComparisons] = useState>(new Set(['raw', 'range'])) + const [themeRevision, setThemeRevision] = useState(0) + const days = view === 'power' ? powerDays : accuracyDays + const generatedAt = Date.parse(data.generated_at) + const allPoints = useMemo(() => ({ + actual: toChartPoints(data.series.actual), + forecastHistory: toChartPoints(data.series.forecast_history), + calibratedHistory: toChartPoints(data.series.forecast_history_calibrated), + forecast: toChartPoints(data.series.forecast), + low: toChartPoints(data.series.forecast_low), + high: toChartPoints(data.series.forecast_high), + calibrated: toChartPoints(data.series.forecast_calibrated), + energyActual: toChartPoints(data.series.energy_actual), + energyForecast: toChartPoints(data.series.energy_forecast) + }), [data.series]) + const forecastEnd = useMemo( + () => Math.max(...allPoints.forecast.map((point) => point.x)), + [allPoints.forecast] + ) + const powerWindow = useMemo( + () => getSolarPowerWindow(data.generated_at, powerDays, forecastEnd), + [data.generated_at, forecastEnd, powerDays] + ) + const window = useMemo( + () => view === 'power' ? powerWindow : getSolarChartWindow(data.generated_at, days), + [data.generated_at, days, powerWindow, view] + ) + const actualPoints = pointsInWindow(allPoints.actual, window) + const calibratedPoints = pointsInWindow( + combineSolarForecast(allPoints.calibratedHistory, allPoints.calibrated, generatedAt), + window + ) + const rawPoints = pointsInWindow( + combineSolarForecast(allPoints.forecastHistory, allPoints.forecast, generatedAt), + window + ) + const lowPoints = pointsInWindow(allPoints.low, window) + const highPoints = pointsInWindow(allPoints.high, window) + const energyActualPoints = pointsInWindow(allPoints.energyActual, window) + const energyForecastPoints = pointsInWindow(allPoints.energyForecast, window) + const currentActual = latestValueAt(allPoints.actual, generatedAt) + const currentForecast = latestValueAt(allPoints.calibrated, generatedAt) + const energyActualInView = sumDailyCumulative(energyActualPoints) + const energyForecastInView = sumDailyCumulative(energyForecastPoints) + const accuracy = solarAccuracy(energyActualInView, energyForecastInView) + const comparisonAvailability: Record = { + raw: rawPoints.length > 0, + range: lowPoints.length > 0 || highPoints.length > 0 + } + + function setRangeDays(rangeDays: number) { + if (view === 'power') { + setPowerDays(rangeDays) + } else { + setAccuracyDays(rangeDays) + } + } + + useEffect(() => { + const observer = new MutationObserver(() => setThemeRevision((revision) => revision + 1)) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!canvasRef.current) { + return + } + + const styles = getComputedStyle(document.documentElement) + const textColour = styles.getPropertyValue('--color-text-secondary').trim() || '#4b5563' + const gridColour = styles.getPropertyValue('--color-border').trim() || '#e5e7eb' + const actualColour = styles.getPropertyValue('--color-flow-solar').trim() || '#f59e0b' + const forecastColour = styles.getPropertyValue('--color-current').trim() || '#2563eb' + const nowColour = styles.getPropertyValue('--color-warning').trim() || '#f59e0b' + const datasets: ChartDataset<'line', ChartPoint[]>[] = view === 'accuracy' + ? [ + { + label: 'Actual energy', + data: energyActualPoints, + borderColor: actualColour, + backgroundColor: actualColour, + borderWidth: 3, + pointRadius: 0, + tension: 0.2 + }, + { + label: 'Forecast energy', + data: energyForecastPoints, + borderColor: forecastColour, + backgroundColor: forecastColour, + borderWidth: 3, + borderDash: [6, 4], + pointRadius: 0, + tension: 0.2 + } + ] + : [ + { + label: 'Actual generation', + data: actualPoints, + borderColor: actualColour, + backgroundColor: actualColour, + borderWidth: 3, + pointRadius: 0, + tension: 0.2 + }, + { + label: 'Calibrated forecast', + data: calibratedPoints, + borderColor: forecastColour, + backgroundColor: forecastColour, + borderWidth: 3, + pointRadius: 0, + tension: 0.2 + } + ] + + if (view === 'power' && comparisons.has('raw') && comparisonAvailability.raw) { + datasets.push({ + label: 'Original forecast', + data: rawPoints, + borderColor: '#64748b', + backgroundColor: '#64748b', + borderWidth: 1.5, + borderDash: [6, 4], + pointRadius: 0, + tension: 0.2 + }) + } + if (view === 'power' && comparisons.has('range') && comparisonAvailability.range) { + datasets.push( + { + label: 'Lower estimate', + data: lowPoints, + borderColor: '#a855f7', + backgroundColor: '#a855f7', + borderWidth: 1.5, + borderDash: [3, 4], + pointRadius: 0, + tension: 0.2 + }, + { + label: 'Upper estimate', + data: highPoints, + borderColor: '#16a34a', + backgroundColor: '#16a34a', + borderWidth: 1.5, + borderDash: [3, 4], + pointRadius: 0, + tension: 0.2 + } + ) + } + + const configuration: ChartConfiguration<'line', ChartPoint[]> = { + type: 'line', + data: { datasets }, + plugins: [makeSolarOverlayPlugin(generatedAt, nowColour)], + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + parsing: false, + normalized: true, + interaction: { intersect: false, mode: 'nearest' }, + scales: { + x: { + type: 'linear', + min: window.start, + max: window.end, + grid: { color: gridColour }, + ticks: { + color: textColour, + maxTicksLimit: days === 1 ? 9 : 8, + callback(value) { + return new Intl.DateTimeFormat(undefined, days === 1 + ? { hour: '2-digit', minute: '2-digit' } + : { weekday: 'short', day: 'numeric', hour: '2-digit' } + ).format(Number(value)) + } + } + }, + y: { + beginAtZero: true, + grid: { color: gridColour }, + ticks: { color: textColour, callback: (value) => `${value} ${view === 'accuracy' ? 'kWh' : 'kW'}` } + } + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title(items) { + return new Intl.DateTimeFormat(undefined, { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }).format(Number(items[0]?.parsed.x)) + }, + label(item) { + return `${item.dataset.label}: ${Number(item.parsed.y).toFixed(2)} ${view === 'accuracy' ? 'kWh' : 'kW'}` + } + } + } + } + } + } + + const chart = new Chart(canvasRef.current, configuration) + return () => chart.destroy() + }, [actualPoints, calibratedPoints, comparisonAvailability.range, comparisonAvailability.raw, comparisons, days, energyActualPoints, energyForecastPoints, generatedAt, highPoints, lowPoints, rawPoints, themeRevision, view, window]) + + function toggleComparison(key: ComparisonKey) { + setComparisons((current) => { + const next = new Set(current) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + } + + const visibleLegendItems = view === 'accuracy' + ? accuracyLegendItems + : legendItems.filter(({ key }) => { + if (key === 'raw') return comparisons.has('raw') && comparisonAvailability.raw + if (key === 'low' || key === 'high') return comparisons.has('range') && comparisonAvailability.range + return true + }) + + return ( +
+
+
+

{view === 'power' ? 'Solar power forecast' : 'Solar forecast accuracy'}

+

{view === 'power' + ? 'Predbat’s future solar scenarios, with measured inverter output for context.' + : 'Historical cumulative forecast energy compared with the energy actually generated.'}

+
+ +
+
+ + +
+
+ {[1, 3, 7].map((rangeDays) => ( + + ))} +
+
+
+ +
+ {view === 'power' ? ( + <> +
Generating now{formatPower(currentActual)}
+
Forecast now{formatPower(currentForecast)}
+
Actual peak in view{formatPower(peakSolarPower(actualPoints))}
+
Forecast peak in view{formatPower(peakSolarPower(calibratedPoints))}
+ + ) : ( + <> +
Generated in view{formatEnergy(energyActualInView)}
+
Forecast in view{formatEnergy(energyForecastInView)}
+
Difference in view{formatDifference(accuracy.difference)}
+
+ + Forecast achieved in view + Total actual energy in the selected days as a percentage of the total calibrated forecast for the same period. + + {formatPercentage(accuracy.achieved)} +
+ + )} +
+ + {view === 'power' && days > 1 && powerWindow.forecastDays < days && ( +

+ {powerWindow.forecastDays === 0 + ? 'No forecast days are currently available.' + : `Only ${powerWindow.forecastDays} ${powerWindow.forecastDays === 1 ? 'day' : 'days'} of forecast data ${powerWindow.forecastDays === 1 ? 'is' : 'are'} available.`} + {' '}Historical data fills the remaining {days - powerWindow.forecastDays} {days - powerWindow.forecastDays === 1 ? 'day' : 'days'}. +

+ )} + +
+ {visibleLegendItems.map((item) => )} +
+ +
+ +
+ + {view === 'power' &&
+ Compare forecast scenarios +
+ + +
+
} +
+ ) +} diff --git a/frontend/src/components/StatusCard.css b/frontend/src/components/StatusCard.css new file mode 100644 index 000000000..02fc9fa1c --- /dev/null +++ b/frontend/src/components/StatusCard.css @@ -0,0 +1,319 @@ +.status-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-text); + border-radius: 16px; + padding: 1.5rem; + margin: 1.5rem 0; + box-shadow: var(--shadow-card); +} + +.status-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.status-card-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.status-card-version { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.status-card-version-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.6rem; +} + +.status-card-update { + display: inline-flex; + align-items: center; + gap: 0.3rem; + min-height: 1.75rem; + padding: 0.25rem 0.55rem; + color: var(--color-success); + background: color-mix(in srgb, var(--color-success) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--color-success) 45%, transparent); + border-radius: 999px; + font: inherit; + font-size: 0.72rem; + font-weight: 700; + cursor: pointer; +} + +.status-card-update:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-success) 20%, transparent); +} + +.status-card-update:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.status-card-update:disabled { + opacity: 0.65; + cursor: wait; +} + +.status-card-label, +.status-card-detail > span { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); +} + +.status-card-status-section { + margin-top: 1.25rem; +} + +.status-card-status-row { + display: flex; + align-items: center; + gap: 0.55rem; + margin-top: 0.25rem; +} + +.status-dot { + width: 0.7rem; + height: 0.7rem; + flex-shrink: 0; + border-radius: 50%; + transform: translateY(3px); +} + +.status-ok { + background: var(--color-success); +} + +.status-warning { + background: var(--color-warning); +} + +.status-error { + background: var(--color-danger); +} + + +.status-card-details { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +.status-card-detail { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0 1rem; + min-width: 0; +} + +.status-card-detail:first-child { + padding-left: 0; +} + +.status-card-detail:last-child { + padding-right: 0; +} + +.status-card-detail + .status-card-detail { + border-left: 1px solid var(--color-border); +} + +.status-card-detail span { + font-size: 0.72rem; + color: var(--color-text-muted); +} + +.status-card-detail strong { + font-size: 0.9rem; + font-weight: 500; +} + +.status-card-controls { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +.status-control-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0 1.25rem; +} + +/* Remove padding from the outer edges */ +.status-control-row:first-child { + padding-left: 0; +} + +.status-control-row:last-child { + padding-right: 0; +} + +/* Divider between controls */ +.status-control-row + .status-control-row { + border-left: 1px solid var(--color-border); +} + +.status-control-text { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.status-control-label { + font-size: 0.9rem; + font-weight: 600; +} + +.status-control-description { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.status-card-select { + width: 100%; + max-width: 240px; + padding: 0.45rem 0.6rem; + color: var(--color-text); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 6px; +} + +/* Toggle switch */ +.toggle-switch { + position: relative; + display: inline-block; + width: 42px; + height: 24px; + flex-shrink: 0; + cursor: pointer; +} + +/* Hide the native checkbox visually */ +.toggle-switch input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +/* Switch track */ +.toggle-slider { + position: absolute; + inset: 0; + background: var(--color-border); + border-radius: 999px; + transition: background 0.2s ease; +} + +/* Switch handle */ +.toggle-slider::before { + content: ''; + position: absolute; + width: 18px; + height: 18px; + left: 3px; + top: 3px; + background: var(--color-surface); + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: transform 0.2s ease; +} + +/* Enabled state */ +.toggle-switch input:checked + .toggle-slider { + background: var(--color-success); +} + +/* Move handle when enabled */ +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(18px); +} + +/* Keyboard focus */ +.toggle-switch input:focus-visible + .toggle-slider { + outline: 2px solid var(--color-success); + outline-offset: 2px; +} + +@media (max-width: 700px) { + .status-card { + padding: 1rem; + margin: 1rem; + } + + .status-card-details { + grid-template-columns: repeat(2, 1fr); + gap: 1rem 0; + } + + .status-card-detail:nth-child(odd) { + padding-left: 0; + padding-right: 1rem; + border-left: none; + } + + .status-card-detail:nth-child(even) { + padding-left: 1rem; + padding-right: 0; + } + + .status-card-controls { + grid-template-columns: 1fr; + } + + .status-control-row, + .status-control-row:first-child, + .status-control-row:last-child { + padding: 0.75rem 0; + } + + .status-control-row + .status-control-row { + border-left: none; + border-top: 1px solid var(--color-border); + } + + .status-control-row:first-child { + padding-top: 0; + } + + .status-control-row:last-child { + padding-bottom: 0; + } +} + +@media (max-width: 400px) { + .status-card { + padding: 0.5rem; + margin: 0.5rem; + } + + .status-card-details { + grid-template-columns: 1fr; + gap: 1rem 0; + } + + .status-card-detail + .status-card-detail { + padding-left: 0; + border-left: none; + } +} diff --git a/frontend/src/components/StatusCard.tsx b/frontend/src/components/StatusCard.tsx new file mode 100644 index 000000000..628ac9ea1 --- /dev/null +++ b/frontend/src/components/StatusCard.tsx @@ -0,0 +1,230 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCircleArrowUp } from '@fortawesome/free-solid-svg-icons' + +import './StatusCard.css' + +type StatusCardProps = { + status: string + mode: string + version: string + latestVersion: string | null + updateAvailable: boolean + updating: boolean + + lastUpdated: string | null + + configOk: boolean + + active: boolean + readOnly: boolean + debugEnabled: boolean + + onModeChange: (value: string) => void + onActiveChange: (value: boolean) => void + onReadOnlyChange: (value: boolean) => void + onDebugChange: (value: boolean) => void + onUpdate: () => void +} + +// Relative labels refresh when the parent supplies the next status poll. +function formatTimeAgo(value: string | null) { + if (!value) return 'Unknown' + + const date = new Date(value) + const now = new Date() + + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000) + + if (seconds < 60) { + return 'Just now' + } + + const minutes = Math.floor(seconds / 60) + + if (minutes < 60) { + return `${minutes} min ago` + } + + const hours = Math.floor(minutes / 60) + + if (hours < 24) { + return `${hours}h ago` + } + + const days = Math.floor(hours / 24) + + return `${days}d ago` +} + +function getStatusHealth(status: string) { + const normalized = status.toLowerCase() + + if (normalized.includes('error') || normalized.includes('unhealthy')) { + return 'error' + } + + if (normalized.includes('warn')) { + return 'warning' + } + + return 'ok' +} + +function StatusCard({ + status, + mode, + version, + latestVersion, + updateAvailable, + updating, + lastUpdated, + configOk, + active, + readOnly, + debugEnabled, + onModeChange, + onActiveChange, + onReadOnlyChange, + onDebugChange, + onUpdate +}: StatusCardProps) { + const statusHealth = getStatusHealth(status) + + return ( +
+ {/* Card heading */} +
+

Predbat Status

+ +
+ Predbat {version} + + {updateAvailable && ( + + )} +
+
+ + {/* Current status */} +
+ Status + +
+ + + {status} +
+
+ + {/* Runtime/status information */} +
+ {/* Mode */} +
+ Mode + + +
+ + {/* Config health */} +
+ Config + + + {configOk ? 'OK' : 'Error'} + +
+ + {/* Last update */} +
+ Plan Updated + + {formatTimeAgo(lastUpdated)} +
+ +
+ + {/* Interactive controls */} +
+ {/* Predbat calculation/activity state */} +
+
+ Predbat Active + + {active ? 'Calculating' : 'Idle'} +
+ + +
+ + {/* Read-only mode */} +
+
+ Read Only + + {readOnly ? 'Enabled' : 'Disabled'} +
+ + +
+ + {/* Debug output */} +
+
+ Debug + + + {debugEnabled ? 'Enabled' : 'Disabled'} + +
+ + +
+
+
+ ) +} + +export default StatusCard diff --git a/frontend/src/components/theme.ts b/frontend/src/components/theme.ts new file mode 100644 index 000000000..060a26cfb --- /dev/null +++ b/frontend/src/components/theme.ts @@ -0,0 +1,14 @@ +export type ThemePreference = 'light' | 'dark' | 'auto' +export type ResolvedTheme = Exclude + +export function readThemePreference(stored: string | null): ThemePreference { + return stored === 'light' || stored === 'dark' || stored === 'auto' ? stored : 'auto' +} + +export function resolveTheme(preference: ThemePreference, systemDark: boolean): ResolvedTheme { + return preference === 'auto' ? (systemDark ? 'dark' : 'light') : preference +} + +export function getNextThemePreference(preference: ThemePreference): ThemePreference { + return preference === 'light' ? 'dark' : preference === 'dark' ? 'auto' : 'light' +} diff --git a/frontend/src/editor.worker.ts b/frontend/src/editor.worker.ts new file mode 100644 index 000000000..4d246b65b --- /dev/null +++ b/frontend/src/editor.worker.ts @@ -0,0 +1 @@ +import 'monaco-editor/esm/vs/editor/editor.worker.js' diff --git a/frontend/src/hooks/useStoredState.ts b/frontend/src/hooks/useStoredState.ts new file mode 100644 index 000000000..1744a8dd2 --- /dev/null +++ b/frontend/src/hooks/useStoredState.ts @@ -0,0 +1,28 @@ +import { useEffect, useState, type Dispatch, type SetStateAction } from 'react' + +type StoredValue = number | string + +export function resolveStoredValue(stored: string | null, fallback: T, allowed: readonly T[]): T { + return allowed.find((value) => String(value) === stored) ?? fallback +} + +/** Keep a validated chart preference in local storage. */ +export function useStoredState(key: string, fallback: T, allowed: readonly T[]): [T, Dispatch>] { + const [value, setValue] = useState(() => { + try { + return resolveStoredValue(localStorage.getItem(key), fallback, allowed) + } catch { + return fallback + } + }) + + useEffect(() => { + try { + localStorage.setItem(key, String(value)) + } catch { + // Browsers may disable storage; the in-memory preference still works. + } + }, [key, value]) + + return [value, setValue] +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 000000000..fa6172fb0 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,158 @@ +@import './styles/theme.css'; + +* { + box-sizing: border-box; +} + +:root { + font-family: + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Helvetica, + Arial, + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +:where(button, input, select, textarea) { + font: inherit; +} + +:where(button, a, input, select, textarea, summary) { + touch-action: manipulation; + -webkit-tap-highlight-color: color-mix(in srgb, var(--color-current) 18%, transparent); +} + +:where(button, a, input, select, textarea, summary):focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +:where(button, input, select, textarea):disabled { + cursor: not-allowed; +} + +:where(h1, h2, h3, h4, h5, h6) { + scroll-margin-top: 5rem; + text-wrap: balance; +} + +:where(table, time, output, [data-numeric]) { + font-variant-numeric: tabular-nums; +} + +.visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +/* + * Keep the entire document on the application theme. + * + * Use min-height rather than height so pages longer than one + * viewport continue using the correct background colour. + */ +html, +body, +#root { + margin: 0; + min-width: 100%; + min-height: 100%; + background: var(--color-bg); + color: var(--color-text); +} + +html { + min-height: 100%; +} + +body { + min-height: 100vh; + min-height: 100dvh; + line-height: 1.5; +} + +#root { + min-height: 100vh; + min-height: 100dvh; +} + +@media (max-width: 700px) { + html, + body, + #root { + width: 100%; + max-width: 100%; + overflow-x: clip; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +main { + width: 100%; + max-width: 1200px; + margin: 1.5rem auto; +} + +.summary-item { + display: flex; + align-items: center; + gap: 16px; + max-width: 280px; + padding: 20px; + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-text-secondary); + border-radius: 12px; +} + +.summary-item__icon { + font-size: 32px; +} + +.summary-item__content { + display: flex; + flex-direction: column; +} + +.summary-item__label { + font-size: 14px; + color: var(--color-text-secondary); +} + +.summary-item__value { + font-size: 28px; + line-height: 1.2; +} + +.summary-item__detail { + margin-top: 4px; + font-size: 13px; + color: var(--color-text-secondary); +} + +.battery-bar-fill { + height: 100%; + border-radius: inherit; + transition: width 0.3s ease; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 000000000..2caec890d --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/frontend/src/pages/AnnualPage.css b/frontend/src/pages/AnnualPage.css new file mode 100644 index 000000000..0ce91c0c3 --- /dev/null +++ b/frontend/src/pages/AnnualPage.css @@ -0,0 +1,49 @@ +.app-content > main:has(> .annual-page) { + display: flex; + max-width: none; + height: 100dvh; + margin-block: 0; + padding: 1.5rem clamp(0.75rem, 2vw, 2rem); + overflow: hidden; +} + +.annual-page { + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; + min-width: 0; + min-height: 0; +} + +.annual-page-header h1, +.annual-page-header p { + margin: 0; +} + +.annual-page-header h1 { + color: var(--color-text); + font-size: 1.5rem; +} + +.annual-page-header p { + margin-top: 0.2rem; + color: var(--color-text-muted); + font-size: 0.8rem; +} + +.annual-frame { + flex: 1; + width: 100%; + min-height: 0; + border: 1px solid var(--color-border); + border-radius: 10px; + background: var(--color-surface); +} + +@media (max-width: 700px) { + .app-content > main:has(> .annual-page) { + height: calc(100dvh - 52px); + padding: 1rem 0.75rem; + } +} diff --git a/frontend/src/pages/AnnualPage.tsx b/frontend/src/pages/AnnualPage.tsx new file mode 100644 index 000000000..78c7c3ff1 --- /dev/null +++ b/frontend/src/pages/AnnualPage.tsx @@ -0,0 +1,43 @@ +import { useEffect, useRef } from 'react' + +import './AnnualPage.css' + +/** Host Predbat's complete What If tool inside the modern dashboard shell. */ +export default function AnnualPage() { + const frameRef = useRef(null) + + function styleEmbeddedPage() { + const document = frameRef.current?.contentDocument + if (!document) return + + document.querySelector('.menu-bar')?.remove() + document.querySelectorAll('a[href="./annual"]').forEach((link) => { + link.href = './legacy_annual' + }) + document.documentElement.classList.toggle('dark-mode', window.document.documentElement.dataset.theme === 'dark') + document.body.classList.toggle('dark-mode', window.document.documentElement.dataset.theme === 'dark') + + if (!document.getElementById('modern-annual-frame-style')) { + const style = document.createElement('style') + style.id = 'modern-annual-frame-style' + style.textContent = 'body { padding-top: 0 !important; margin: 0.75rem !important; }' + document.head.append(style) + } + } + + useEffect(() => { + const observer = new MutationObserver(styleEmbeddedPage) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, []) + + return ( +
+
+

What If

+

Model the annual cost, savings and payback for different solar, battery and tariff choices.

+
+