Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to BESS Battery Manager will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Configurable consumption forecast strategy via `home.consumption_strategy` setting. Supports `sensor` (default, HA 48h average), `fixed` (flat rate from config), and `influxdb_7d_avg` (7-day rolling average from InfluxDB power sensor data at 15-minute resolution).
- `home.timezone` setting for timezone-aware schedule calculations.
- XGBoost ML energy consumption predictor with `ml_prediction` strategy option. Trains on InfluxDB historical data, uses weather forecasts from HA, and generates 96 quarter-hourly predictions. Includes ML Report dashboard page showing model metrics, feature importance, and forecast comparison.

### Changed

- Docker base image switched from Alpine 3.19 to Debian Bookworm to support xgboost and scikit-learn wheel installation.

### Note

The ML predictor is experimental. In testing so far, predictions are roughly on par with or slightly worse than a simple 7-day average (`influxdb_7d_avg`). It adds significant startup time (model retrain on boot) and heavy dependencies (xgboost, scikit-learn). It is included as an optional feature for experimentation — the `influxdb_7d_avg` strategy is recommended for production use.

## [7.4.2] - 2026-03-07

### Fixed
Expand Down
12 changes: 7 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,26 @@ LABEL \
org.label-schema.vcs-url="https://github.com/johanzander/bess-manager"

# Install requirements for add-on
RUN apk add --no-cache \
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
py3-pip \
python3-pip \
python3-venv \
python3-dev \
gcc \
musl-dev \
bash \
nodejs \
npm
npm \
&& rm -rf /var/lib/apt/lists/*

# Set working directory
WORKDIR /app

# Copy Python application files from backend directory
COPY backend/app.py backend/api.py backend/api_conversion.py backend/api_dataclasses.py backend/log_config.py backend/requirements.txt ./

# Copy core directory
# Copy core directory and ML module
COPY core/ /app/core/
COPY ml/ /app/ml/

# Build and copy frontend
WORKDIR /tmp/frontend
Expand Down
68 changes: 67 additions & 1 deletion backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ async def get_battery_settings():
settings = bess_controller.system.get_settings()
battery_settings = settings["battery"]
estimated_consumption = settings["home"].default_hourly
consumption_strategy = settings["home"].consumption_strategy

# Create APIBatterySettings using existing method
api_settings = APIBatterySettings.from_internal(
battery_settings, estimated_consumption
battery_settings, estimated_consumption, consumption_strategy
)
return api_settings.__dict__

Expand Down Expand Up @@ -2249,3 +2250,68 @@ async def dismiss_all_runtime_failures():
except Exception as e:
logger.error(f"Error dismissing all runtime failures: {e}")
raise HTTPException(status_code=500, detail=str(e)) from e


@router.get("/api/ml-report")
async def get_ml_report():
"""Return ML model report: metrics, feature importance, predictions vs yesterday."""
import json
from pathlib import Path

from app import bess_controller

system = bess_controller.system
strategy = system.home_settings.consumption_strategy
is_active = strategy in ("ml_prediction", "influxdb_7d_avg")

try:
from ml.config import load_config

ml_cfg = load_config(app_options=system._addon_options)
report_path = Path(ml_cfg["model_path"]).with_suffix(".report.json")
except Exception as e:
logger.warning("Could not load ML config for report: %s", e)
return {"isActive": is_active, "modelAvailable": False}

if not report_path.exists():
return {"isActive": is_active, "modelAvailable": False}

with open(report_path) as f:
report = json.load(f)

predictions = system._ml_forecast_cache

forecast_date = (
system._ml_forecast_cache_date.isoformat()
if system._ml_forecast_cache_date
else None
)

yesterday_profile = None
week_avg_profile = None
try:
from ml.data_fetcher import fetch_history_context

history = fetch_history_context(ml_cfg)
yesterday_profile = history["yesterday_profile"]
week_avg_profile = history["week_avg_profile"]
except Exception as e:
logger.warning("Could not fetch history context for ML report: %s", e)

return {
"isActive": is_active,
"activeStrategy": strategy,
"modelAvailable": True,
"lastTrained": report["trained_at"],
"trainSize": report["train_size"],
"testSize": report["test_size"],
"metrics": convert_keys_to_camel_case(report["metrics"]),
"baselines": {
k: convert_keys_to_camel_case(v) for k, v in report["baselines"].items()
},
"featureImportance": report["feature_importance"],
"forecastDate": forecast_date,
"predictions": predictions,
"yesterdayProfile": yesterday_profile,
"weekAvgProfile": week_avg_profile,
}
6 changes: 5 additions & 1 deletion backend/api_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,12 @@ class APIBatterySettings:
efficiencyCharge: float # % - charging efficiency
efficiencyDischarge: float # % - discharge efficiency
estimatedConsumption: float # kWh - estimated daily consumption
consumptionStrategy: str # consumption forecast strategy

@classmethod
def from_internal(cls, battery, estimated_consumption: float) -> APIBatterySettings:
def from_internal(
cls, battery, estimated_consumption: float, consumption_strategy: str = "sensor"
) -> APIBatterySettings:
"""Convert from internal snake_case to canonical camelCase."""
return cls(
totalCapacity=battery.total_capacity,
Expand All @@ -115,6 +118,7 @@ def from_internal(cls, battery, estimated_consumption: float) -> APIBatterySetti
efficiencyCharge=battery.efficiency_charge,
efficiencyDischarge=battery.efficiency_discharge,
estimatedConsumption=estimated_consumption,
consumptionStrategy=consumption_strategy,
)

def to_internal_update(self) -> dict:
Expand Down
14 changes: 13 additions & 1 deletion backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def __init__(self):
self.ha_controller,
price_source=None, # Let system manager auto-select based on config
energy_provider_config=energy_provider_config,
addon_options=options,
)

# Create scheduler with increased misfire grace time to avoid unnecessary warnings
Expand Down Expand Up @@ -254,6 +255,15 @@ def prepare_next_day():
misfire_grace_time=30, # Allow 30 seconds of misfire before warning
)

# ML model daily retrain at 23:00 — retrain only, predictions are
# generated at 23:55 in _handle_special_cases after cache is cleared
if self.system._addon_options.get("ml"):
self.scheduler.add_job(
self.system._retrain_ml_model,
CronTrigger(hour=23, minute=0),
misfire_grace_time=120,
)

# Charging power adjustment (every 5 minutes)
self.scheduler.add_job(
self.system.adjust_charging_power,
Expand Down Expand Up @@ -324,7 +334,7 @@ def _apply_settings(self, options):
)

# Required home settings
required_home_keys = ["consumption", "currency"]
required_home_keys = ["consumption", "currency", "consumption_strategy"]
for key in required_home_keys:
if key not in home_config:
raise ValueError(
Expand All @@ -346,6 +356,8 @@ def _apply_settings(self, options):
"home": {
"defaultHourly": home_config["consumption"],
"currency": home_config["currency"],
"consumptionStrategy": home_config["consumption_strategy"],
"timezone": home_config.get("timezone", "Europe/Stockholm"),
},
"price": {
"area": electricity_price_config["area"],
Expand Down
3 changes: 3 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ pyyaml
watchdog
numpy
pandas
scikit-learn
xgboost
astral
10 changes: 5 additions & 5 deletions build.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"build_from": {
"aarch64": "ghcr.io/home-assistant/aarch64-base:3.19",
"amd64": "ghcr.io/home-assistant/amd64-base:3.19",
"armhf": "ghcr.io/home-assistant/armhf-base:3.19",
"armv7": "ghcr.io/home-assistant/armv7-base:3.19",
"i386": "ghcr.io/home-assistant/i386-base:3.19"
"aarch64": "ghcr.io/home-assistant/aarch64-base-debian:bookworm",
"amd64": "ghcr.io/home-assistant/amd64-base-debian:bookworm",
"armhf": "ghcr.io/home-assistant/armhf-base-debian:bookworm",
"armv7": "ghcr.io/home-assistant/armv7-base-debian:bookworm",
"i386": "ghcr.io/home-assistant/i386-base-debian:bookworm"
},
"squash": false,
"args": {}
Expand Down
54 changes: 54 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ options:
home:
consumption: 3.5 # default hourly consumption in kWh
currency: "SEK" # currency for price display and calculations
consumption_strategy: "sensor" # "sensor" (HA 48h avg), "fixed", "influxdb_7d_avg", or "ml_prediction"
timezone: "Europe/Stockholm" # IANA timezone for schedule calculations
max_fuse_current: 25 # Maximum fuse current in amperes
voltage: 230 # Line voltage in volts
safety_margin_factor: 1.0 # Safety margin for power calculations (100% - safe with 5min monitoring)
Expand Down Expand Up @@ -97,6 +99,31 @@ options:
48h_avg_grid_import: "sensor.48h_average_grid_import_power" # 48h average consumption
solar_forecast_today: "sensor.solcast_pv_forecast_forecast_today" # Today's solar forecast
solar_forecast_tomorrow: "sensor.solcast_pv_forecast_forecast_tomorrow" # Tomorrow's solar forecast
ml:
location:
latitude: 59.3293
longitude: 18.0686
timezone: "Europe/Stockholm"
weather_entity: "weather.forecast_home"
feature_sensors:
outdoor_temperature: "your_temperature_sensor_id"
derived_features:
hour_of_day: true
day_of_week: false
daylight_hours: false
history_context:
yesterday_same_hour: true
yesterday_total: true
week_avg_same_hour: true
recent_24h_mean: true
training:
days_of_history: 30
test_split: 0.2
model_params:
n_estimators: 200
max_depth: 6
learning_rate: 0.1
min_child_weight: 3
schema:
influxdb:
url: str
Expand All @@ -106,6 +133,8 @@ schema:
home:
consumption: float
currency: str
consumption_strategy: str
timezone: str
max_fuse_current: int
voltage: int
safety_margin_factor: float
Expand Down Expand Up @@ -167,3 +196,28 @@ schema:
output_power: str
self_power: str
system_power: str
ml:
location:
latitude: float
longitude: float
timezone: str
weather_entity: str
feature_sensors:
outdoor_temperature: str
derived_features:
hour_of_day: bool
day_of_week: bool
daylight_hours: bool
history_context:
yesterday_same_hour: bool
yesterday_total: bool
week_avg_same_hour: bool
recent_24h_mean: bool
training:
days_of_history: int
test_split: float
model_params:
n_estimators: int
max_depth: int
learning_rate: float
min_child_weight: int
Loading