Smart energy management add-on for Home Assistant OS. Its primary goal is to minimise your electricity bill — whether you have a solar battery, a set of schedulable loads (heat pump, EV charger, pool pump, irrigation…), or both. A scikit-learn ML model combined with dynamic tariff rules decides in real time when to charge or discharge the battery, when to shift loads to cheap/solar windows, and how much to pre-charge at night — so you import as little peak-rate energy as possible and export as little solar as possible.
🔒 100% local — no cloud, no telemetry. Everything runs inside the Docker container on your own hardware. Your energy data, sensor readings, and ML model never leave your home network. The add-on only communicates with your local Home Assistant instance and, optionally, a local InfluxDB instance.
Installation: Settings → Add-ons → Add-on store → ⋮ → Repositories → add
https://github.com/hirofairlane/ha-energy-optimizer
| Library | Version | Purpose |
|---|---|---|
| scikit-learn | ≥ 1.3 | GradientBoostingRegressor + Pipeline + StandardScaler for SOC prediction |
| Flask | ≥ 3.0 | Internal API and web panel (port 8765) |
| APScheduler | ≥ 3.10 | Decision cycle and retrain scheduling |
| requests | — | Home Assistant REST API client |
| numpy / pandas | — | Feature engineering and time-series resampling |
- Installation
- Features
- Setup Wizard
- Web panel
- Energy flow diagram
- Battery charging logic
- Savings calculation
- ML model
- Solar terrain correction
- InfluxDB integration
- Configuration reference
- Electricity tariff
- Persistent data
- Changelog
v5 deep dive: the predictive engine introduced in v5.0.0 has its own architecture write-up at docs/architecture-v5.md and a wiring guide for activating the dormant
v5_engine_enabledflag at docs/v5-wiring.md.
Requirements: Home Assistant OS or Supervised (any architecture: amd64, aarch64, armv7).
- In HA go to Settings → Add-ons → Add-on store → ⋮ menu → Repositories and add:
https://github.com/hirofairlane/ha-energy-optimizer - Find Energy Optimizer in the store and click Install.
- Start the add-on and open the web panel — the Setup Wizard will guide you through the rest.
No YAML editing required. All configuration is done through the wizard and the web panel.
| Required | Optional but recommended | |
|---|---|---|
| Battery | Any smart battery with HA entities for SOC + charge/discharge power | Working mode select, cutoff SOC, force-charge switch (Huawei Luna2000 natively supported) |
| Solar | Forecast.Solar or PVforecast integration | Real-time production sensor |
| History | HA Recorder (14-day window) | InfluxDB for 90-day ML training window |
| Schedulable loads | At least one (heat pump, pool pump, EV charger, or any switch.*) |
Multiple loads — each adds a scheduling optimisation layer |
The add-on is useful even with no battery if you have loads you can shift: it will schedule them to coincide with solar surplus or valley-tariff windows.
| Feature | Description |
|---|---|
| Setup Wizard | 8-step guided configuration — auto-discovers your HA entities with ML-based scoring. No YAML editing required |
| **Smart valley charging** | Calculates exactly how much battery to charge from the grid at night to cover tomorrow's peak demand — no more, no less |
| **Energy flow diagram** | Animated SVG showing real-time power flows between Solar, Battery, Grid, and House nodes |
| Live 7-day averages | Each sensor card shows instantaneous value + Ø7d rolling average (solar: daylight hours only) |
| Solar terrain correction | Learns your real production vs HA forecast from InfluxDB history — corrects for local shading automatically |
| Temperature-aware target | Cold days = more heat pump during peak hours → charges more battery |
| Storm protection | Reads weather entity; pre-charges to a configurable reserve when adverse weather is imminent |
| Heat pump control | Adjusts setpoints based on season, indoor temperature, and free solar surplus (SOC ≥ 99%) |
| Pool pump | Runs during solar surplus or valley tariff to meet daily/weekly runtime targets |
| Pool cleaner | Auto-starts with pool pump, auto-stops after 15 min (~1.5 kWh) |
| Dishwasher | Monitored; recommendation to run during surplus or valley |
| ML SOC prediction | GradientBoostingRegressor trained on up to 90 days of history. Dynamic features from wizard config |
| Prediction accuracy chart | Live SOC: actual vs ML-predicted (24h) + 8h forward forecast. Shows MAE badge |
| Solar history charts | 7-day and 12-month Actual vs HA Forecast line charts |
| Daily savings chart | 7-day bar chart with € value labels; counterfactual method |
| Historical averages | Day view KPI cards show all-time Ø below each value. History tab adds a 5-card summary with all-time and last-12-months averages for solar, consumption, export, import, and self-sufficiency |
| Battery ROI calculator | Enter cost + capacity of additional storage — calculates payback period from your actual average daily savings |
| Battery health mode | Three operating modes in Tweaks: ⚡ Bill Reducer (10–95%), ⚖️ Optimized (20–90%), 🛡️ Battery Guard (25–85%). Controls the SOC range used for the nightly charge target |
| Split battery sensors | For inverters that report charge and discharge as two separate positive entities (Deye, Solarman, Growatt…), enable the "Split sensors" toggle in the Battery wizard step and pick the two entity IDs. The add-on combines them automatically (charge − discharge) |
| Telegram instant alerts | Emergency charge, storm mode, forced grid charge |
| Daily summary | HTML email + Telegram report at configurable time |
| Debug section | Tweaks tab shows how each sensor role resolves (wizard → options → fallback) with live HA value |
The wizard is the recommended way to configure the add-on. It runs through 8 steps and auto-discovers your HA entities using a keyword + device class + unit scoring algorithm.
| Step | What it configures |
|---|---|
| Data | History source: InfluxDB v2 (recommended, 90 days) or HA Recorder (14 days) |
| Location | GPS coordinates and timezone — used for solar geometry and ML features |
| Grid | Grid meter sensor (import/export) |
| Solar | Production sensor + Forecast.Solar sensors (today/tomorrow/hourly) |
| Battery | SOC sensor, charge/discharge power, working mode select, charge cutoff, backup SOC, force charge switch |
| Loads | Per-device sub-wizards for each enabled appliance (see Supported loads) |
| Tariff | Contracted power, tariff type, peak/shoulder/valley prices |
| Done | Data quality score summary and save |
The wizard header shows a live quality score (0–100%):
| Score | Meaning |
|---|---|
| ≥ 70% 🟢 | Enough data for reliable ML predictions |
| 40–70% 🟠 | Partial data — predictions will work but with higher uncertainty |
| < 40% 🔴 | Insufficient data — add InfluxDB or wait for recorder history to accumulate |
Score factors: history source (30 pts) + key sensor coverage (25 pts) + sample count bonus (30 pts) + optional sensors (15 pts).
For each sensor role, the wizard queries all HA entities and scores them:
| Signal | Points |
|---|---|
Matching device_class |
+50 |
| Matching unit of measurement | +40 |
| Each matching keyword in entity ID | +25 |
Top candidates are shown as clickable cards. The selected entity is saved to wizard_config.json and takes precedence over options.json for all decisions.
The Loads step shows a card for each appliance type. Select the ones present in your installation — the wizard then walks through a sub-wizard for each one. Each selected device also appears as a small emoji dot in the wizard navigation bar.
| Load | Icon | What you configure | What the engine does |
|---|---|---|---|
| HVAC | 🌡️ | Climate entity or heat/cool setpoint numbers per zone. 24h schedule with Comfort 🟢 / Surplus 🔵 / Minimum ⚫ temperature tiers | Raises/lowers setpoints based on tariff period, solar surplus (SOC ≥ 99%), and indoor temperature. Multi-zone supported |
| Pool pump | 🏊 | Pool switch + optional runtime sensors (daily/weekly hours) | Runs during solar surplus (SOC ≥ 99%) or valley tariff to meet runtime targets |
| Pool cleaner | 🤿 | Cleaner switch entity | Auto-starts with the pool pump, auto-stops after 15 min (~1.5 kWh) |
| Dishwasher | 🍽️ | State sensor + optional switch | Monitors cycle state; recommends (or triggers) start during solar surplus or valley |
| Washing machine | 👕 | State sensor + power meter | Monitors cycle; recommends start during cheapest/greenest window |
| Dryer | 🌀 | State sensor + power meter | Same as washing machine |
| EV Charger | 🚗 | Switch or number entity (Wallbox, OCPP, Zappi…) | Schedules charging during valley tariff or solar surplus |
| Custom | ⚙️ | Any switch.* entity + estimated watts + schedule preference |
Schedules any switch-controlled load (irrigation pump, water heater, etc.) according to the selected window |
When adding a Custom load you choose one of four scheduling modes:
| Mode | When the switch is turned on |
|---|---|
| 🌙 Valley tariff only | During the cheapest grid tariff window (typically 00:00–08:00) |
| ☀️ Solar surplus only | When solar production exceeds house consumption (SOC ≥ 99%) |
| ☀️🌙 Solar + Valley | Either of the above |
| ⏱ Custom hours | A fixed time range you specify (e.g. 10-14,22-06) |
Multiple Custom loads can be added (e.g. irrigation pump + water heater), each with its own entity, wattage, and schedule.
Five tabs, accessible via HA ingress (port 8765, no external port needed):
| Tab | Contents |
|---|---|
| 📊 Dashboard | Live KPIs · 4-card power panel · animated energy flow diagram · battery card with manual charge buttons · smart target reasoning · recent decision log |
| 📈 Charts | SOC actual vs predicted (24h) + MAE · Solar 7d actual vs forecast · Solar 12m actual vs forecast · Daily savings 7d · Power flow 24h |
| ⚡ Tariff | Per-day weekend config · per-hour timeline · price editor · Reset to defaults |
| ⚙️ Setup (Tweaks) | Notification toggles · battery threshold sliders · decision interval · Data Sources connectivity test · Debug: sensor resolution table |
| 🧙 Wizard | Full setup wizard (see above) |
The Tweaks tab is the runtime control panel — all changes take effect immediately without restarting the add-on.
| Section | What it does |
|---|---|
| Battery health mode | Three-button selector controlling the SOC operating range for the nightly charge target (see below). |
| Notifications | Enable/disable email daily summary, Telegram daily summary, and instant Telegram alerts individually. |
| Battery thresholds | Sliders for emergency, low, medium, and storm SOC thresholds. Adjust without editing config.yaml. |
| Decision interval | How often (in minutes) the optimization engine runs. Default 15 min. |
| Data Sources | Connectivity test for InfluxDB and HA Recorder — shows last-read timestamp, row count, and active/fallback status. |
| Debug: sensor resolution | Table that auto-loads when you open the tab and shows exactly how every sensor role was resolved. A Refresh button re-reads live HA values. |
| Mode | SOC range | Description |
|---|---|---|
| ⚡ Bill Reducer | 10% – 95% | Default. Uses full battery capacity every cycle — maximum daily savings. |
| ⚖️ Optimized | 20% – 90% | Sweet spot for most installations: ~95% of savings benefit with moderate cycle protection. |
| 🛡️ Battery Guard | 25% – 85% | Prioritises longevity — recommended for batteries older than 3 years or with visible capacity degradation. |
The selected mode clamps the nightly charge target: the engine will never set a target below the mode's minimum or above its maximum, regardless of the calculated optimal. Saved in /data/setup.json and applied immediately without restart.
| Column | Meaning |
|---|---|
| Role | Internal name (e.g. solar_power, battery_soc) |
| Entity ID | The HA entity resolved for this role |
| Source | wizard (wizard_config.json) · options (config.yaml) · fallback (built-in default) |
| Value | Current state read from HA at refresh time |
| Status | ✓ valid reading · ⚠ entity exists but value is unexpected · ✗ not found or unavailable |
This is the first place to look when a Dashboard card shows 0 W or "unavailable" — it shows whether the problem is entity resolution or a real sensor issue.
A date navigator (← Today →) plus five KPI cards: Solar / Consumed / Exported / Imported / Self-sufficiency. Each card shows the day's total and, below it, the all-time daily average (Ø) computed from all recorded days. Hover over a card for a tooltip explaining the calculation. Below the KPIs: a stacked hourly energy bar chart and a SOC line for the selected day.
A 5-card summary row at the top shows all-time and last-12-months daily averages for all five KPIs — useful for spotting seasonal patterns or tracking improvement over time.
Below the charts, the Battery ROI calculator lets you enter the cost (€) and extra capacity (kWh) of additional storage and calculates:
- Your current average daily savings (from
savings.json) - Estimated extra savings proportional to the added capacity
- Payback period in days or years
The calculator uses a linear proportionality assumption. Real results depend on your tariff, usage patterns, and how often the battery is the limiting factor.
Four cards updated every 30 seconds, each showing instantaneous value + Ø7d rolling average:
| Card | Color | Average logic |
|---|---|---|
| ☀️ Solar | Yellow | Mean only when solar > 0 W (excludes night) |
| ⚡ Grid | Green (export) / Red (import) | Net mean (positive = selling) |
| 🔋 Battery | Green (charging) / Red (discharging) | Net mean |
| 🏠 House | Orange | Mean of consumption samples > 0 |
An animated SVG diagram shows real-time power flows between four nodes:
| Sensor | Convention |
|---|---|
solar |
Always ≥ 0 W |
grid |
Positive = exporting (selling), Negative = importing (buying) |
battery |
Positive = charging (energy IN to battery), Negative = discharging (energy OUT) |
House balance: P_casa = solar − grid − battery
A. Solar → Casa (first priority)
Solar → Battery (surplus charging)
Solar → Grid (remaining export)
B. Battery → Casa (discharge covers remaining house load)
Battery → Grid (excess discharge if any)
C. Grid → Battery (emergency: grid covers what solar can't charge)
Grid → Casa (grid covers remaining house load)
Each line is animated only when its flow exceeds 10 W. Animation speed is proportional to power:
speed = max(0.35, 2.4 − power/1800) seconds per cycle.
At night (00:00–08:00) the tariff is valley (cheapest). Importing from the grid at night is cheap — the battery's job is to store cheap valley electricity so the house can avoid importing expensive peak electricity the next day.
The question the system answers:
"How full does the battery need to be at the start of tomorrow's peak hours so I never need to import from the grid at peak prices?"
1. Solar forecast (terrain-corrected)
solar_forecast_raw = tomorrow's production forecast (kWh)
terrain_factor = median(actual_day / forecast_day) over last 30 days
solar_tomorrow = solar_forecast_raw × terrain_factor
2. Solar during peak hours
~45% of daily production falls in the 10:00–15:00 morning peak window:
solar_during_peak = solar_tomorrow × 0.45
3. Peak consumption estimate
From average grid power during the last 14 nights (22:00–08:00) via InfluxDB:
peak_base_kwh = base_load_kW × 8 (8 peak hours)
4. Temperature correction
| Outdoor temperature | Correction |
|---|---|
| < 5 °C | +3.0 kWh |
| 5–10 °C | +2.0 kWh |
| 10–15 °C | +1.0 kWh |
| 15–25 °C | 0 kWh |
| 25–30 °C | +0.5 kWh |
| > 30 °C | +1.5 kWh |
5. Battery charge target
battery_gap_kwh = max(0, peak_total_kwh − solar_during_peak_kwh)
target_SOC = (battery_gap_kwh / battery_capacity_kWh) × 100 + 5%
target_SOC = clamp(target_SOC, 30%, 95%)
The Dashboard "Smart target" line shows the full breakdown in real time.
| Situation | Action |
|---|---|
| SOC < emergency threshold (default 10%) | Force-charge at any tariff, any time |
| Storm forecast | Pre-charge to storm threshold (default 80%) |
| Valley + SOC below smart target | Charge at configured power |
| Peak tariff | No grid charging under any normal circumstance |
| SOC ≥ 99% (free solar surplus) | Heat pump boost / pool pump starts |
For every 15-minute decision cycle:
# Sensor conventions:
# grid_power < 0 → buying from grid (import)
# grid_power > 0 → selling to grid (export)
# battery_power > 0 → battery charging
# battery_power < 0 → battery discharging
# Without battery: what would the grid meter read?
grid_without_battery = grid_power + battery_power
def energy_cost(g, import_price, export_price):
if g < 0:
return -g × interval_hours × import_price / 1000 # cost of import
else:
return -g × interval_hours × export_price / 1000 # income from export
saving = energy_cost(grid_without_battery) - energy_cost(grid_power)Daily savings are the sum of all interval savings.
scikit-learn is bundled inside the Docker image — no manual installation needed.
A GradientBoostingRegressor wrapped in a scikit-learn Pipeline(StandardScaler → GBR) predicts the battery SOC for the current moment from recent sensor readings. Used as a sanity check and to populate the predicted-SOC line in Charts.
The feature set is built from the sensors configured in the wizard. If a sensor has no history, it is excluded. The feature list is saved alongside the model:
| Feature | Condition |
|---|---|
hour, weekday, month |
Always |
lag1, lag4, roll4 (SOC lags) |
Always |
solar_proxy (geometric sun elevation 0.0–1.0) |
Always |
temp_out, temp_out_lag4 |
If outdoor temp sensor has history |
solar_lag1, solar_roll4 |
If solar sensor has history |
grid_lag1, grid_roll4, grid_abs_lag1 |
If grid sensor has history |
sm_{name}_lag1 |
For each configured sub-meter |
- Source: InfluxDB v2 (wizard config, 90 days) → InfluxDB v1 (options, 60 days) → MariaDB direct (60 days, recorder DB) → HA Recorder REST (14 days)
- Schedule: nightly at 03:00 (
retrain_cronoption) - Pipeline:
StandardScaler → GradientBoostingRegressor(n_estimators=150, max_depth=4) - Validation: 3-fold cross-validation R² shown in Dashboard
- Auto-retrain: triggers when feature version changes
Computed by chaining single-step predictions, updating lag features with each predicted value. Shown as a dashed line in Charts.
HA solar forecast sensors (Forecast.Solar, etc.) use panel orientation and capacity but have no knowledge of local terrain. Hills or buildings cause systematic over-prediction that the system learns and corrects automatically.
Every 6 hours, from 30 days of InfluxDB history:
ratios = [actual_day_D / forecast_day_{D-1} for each day D]
terrain_factor = median(ratios)
terrain_factor = clamp(terrain_factor, 0.30, 1.50)
The median is robust against cloudy-day outliers. Requires at least 7 days of data. Shown as "Terrain factor: XX%" in the Dashboard.
InfluxDB is the primary data source for ML training and multi-day charts. HA Recorder is the fallback.
| Parameter | Default | Notes |
|---|---|---|
influxdb_url |
http://172.30.32.1:8086 |
HA supervisor bridge IP (standard for HAOS) |
influxdb_db |
homeassistant |
|
influxdb_user |
(empty) | Leave empty if auth disabled |
influxdb_password |
(empty) |
Auth is auto-detected: tries with credentials first, retries without if InfluxDB returns 401.
The HA→InfluxDB integration (pre-2023) stores data as:
- Measurement = unit of the sensor (
%,W,kWh,°C) - Tag
entity_id= sensor name without domain prefix (e.g.battery_state_of_capacity)
If your HA recorder is backed by MariaDB or MySQL and the REST history endpoint returns empty results (a known issue for some installations — see GitHub issue #2), the add-on can read history directly from the recorder database, bypassing the REST API.
Configured from the Setup Wizard's first step. The schema is auto-detected:
- Modern (HA ≥ 2023.4):
JOIN states_meta sm ON s.metadata_id = sm.metadata_id WHERE sm.entity_id = ? - Legacy:
WHERE entity_id = ?directly onstates
Connection parameters (kept in wizard_config.json only — no secrets in options):
| Field | Typical value |
|---|---|
| Host / IP | core-mariadb (HA add-on) or your MariaDB server |
| Port | 3306 |
| Database | homeassistant |
| Username / Password | as configured in the recorder db_url |
The Test connection button counts samples for every configured sensor over the last 60 days; if any returns rows, MariaDB is used as the active source on subsequent cycles. Falls back to HA Recorder REST otherwise.
Since v3.0 the Setup wizard (Setup tab → "Configure entities") is the recommended way to fill these. The wizard scans your HA states, scores candidates by name/unit/device_class and proposes the best match for each role. All options can also be set in the HA add-on Configuration UI as a fallback, but the wizard's
/data/wizard_config.jsontakes priority over them for all sensor lookups.
| Option | Default | Description |
|---|---|---|
sensor_battery_soc |
sensor.battery_state_of_capacity |
Battery state of charge (%) — Huawei Modbus standard |
sensor_battery_power |
sensor.battery_charge_discharge_power |
Charge/discharge power (W, +ve = charging) — Huawei Modbus standard |
sensor_grid_power |
(empty) | Grid meter (W, +ve = export, −ve = import) |
sensor_solar_power |
(empty) | Panel output right now (W, always ≥ 0) |
sensor_solar_current_hour |
sensor.energy_current_hour |
Solar production this hour (kWh) — HA Energy dashboard |
sensor_solar_next_hour |
sensor.energy_next_hour |
Solar forecast next hour (kWh) |
sensor_solar_today |
sensor.energy_production_today |
Cumulative production today (kWh) |
sensor_solar_tomorrow |
sensor.energy_production_tomorrow |
Forecast for tomorrow (kWh) |
sensor_temp_outdoor |
(empty) | Outdoor temperature (°C) |
sensor_temp_salon |
(empty) | Indoor temperature (°C) |
sensor_weather |
(empty) | Weather entity (for storm detection) |
| Option | Default | Description |
|---|---|---|
switch_pool |
(empty) | Pool pump switch |
switch_pool_cleaner |
(empty) | Pool cleaner switch |
number_hvac_cool |
(empty) | Heat pump cooling setpoint |
number_hvac_heat |
(empty) | Heat pump heating setpoint |
number_battery_charge_cutoff |
number.battery_grid_charge_cutoff_soc |
Battery grid charge cutoff SOC — Huawei Modbus standard |
number_battery_charge_power |
(empty) | Battery charge power limit |
number_battery_backup_soc |
(empty) | Battery backup SOC |
switch_battery_force_charge |
(empty) | Battery force charge switch |
select_battery_mode |
select.battery_working_mode |
Battery working mode select — Huawei Modbus standard |
sensor_dishwasher_state |
(empty) | Dishwasher state sensor |
| Option | Default | Description |
|---|---|---|
battery_emergency_threshold |
10% | Force-charge below this SOC at any tariff |
battery_low_threshold |
30% | Low battery level |
battery_medium_threshold |
50% | Medium battery level |
battery_storm_threshold |
80% | Pre-charge target when storm is forecast |
battery_capacity_kwh |
10.0 | Total usable battery capacity (kWh) |
| Option | Default | Description |
|---|---|---|
decision_interval_minutes |
15 | How often the optimization cycle runs |
retrain_cron |
0 3 * * * |
Nightly ML retrain schedule |
summer_start_month |
6 | First month of summer mode |
summer_end_month |
9 | Last month of summer mode |
| Option | Default | Description |
|---|---|---|
notify_email_service |
(empty) | HA notify service name for email |
notify_email_target |
(empty) | Recipient email address — set in HA add-on config UI, not in repo |
notify_telegram_service |
(empty) | HA notify service name for Telegram |
notify_daily_time |
23:00 |
Time to send daily summary |
notify_email_enabled |
true | Enable email daily summary |
notify_telegram_daily_enabled |
true | Enable Telegram daily summary |
notify_telegram_alerts_enabled |
true | Enable instant Telegram alerts |
Default prices — Spain 2.0TD, all costs prorated including taxes and IVA:
| Period | Hours | Price |
|---|---|---|
| Peak (Punta) | Weekdays 10–14h, 18–22h | €0.2234/kWh |
| Shoulder (Llano) | Weekdays 08–10h, 14–18h, 22–00h | €0.1483/kWh |
| Valley (Valle) | 00–08h + all day weekends | €0.1147/kWh |
| Export (Excedentes) | — | €0.040/kWh |
All prices and weekend days are editable per-hour in the Tariff tab. Use "↩ Reset to defaults" to restore the above values.
All data lives in /data/ inside the add-on container (persists across restarts and updates):
| File | Contents |
|---|---|
model.pkl |
Trained scikit-learn pipeline + metadata (R², feature list, feature version) |
wizard_config.json |
Entity IDs, hardware selection, HVAC zones, tariff from the setup wizard |
decisions.json |
Last 500 decision cycles — full sensor snapshot, tariff, actions, prediction |
savings.json |
Cumulative kWh avoided at peak + EUR saved since first run |
tariff.json |
Custom tariff configuration (periods, prices, weekend days) |
setup.json |
GUI runtime overrides — notification toggles, threshold sliders |
The daily summary (sent at notify_daily_time to email + Telegram) used to dump every action of the day as a flat table — 200-500 rows, mostly noise. Useless as a "what did the engine do today" briefing. Rewritten as a concise causal narrative.
New _summarize_day_narrative(decisions, sensors) builds one line per relevant decision category, each phrased as WHAT happened and WHY:
⚡ Grid charge → 95% from 02:30 to 07:45: Valley — smart target 95%.
❄️ Cooling active 60 min today (~4 cycles).
🌬️ Cooling skipped 60 cycles — main reason: Cool night forecast (60×).
🏊 Pool: 1.4 h runtime today (13:15–15:00) — Solar surplus.
🌙 Night-forecast gate active: AEMET min 14.0°C (≤ threshold) — surplus saved for the battery.
Both Telegram and email use the same narrative. Email keeps the four KPI tiles (SOC / Solar / Cycles / Savings) on top; the long actions-table is gone.
Drive-by fix on the quiet log mode (v5.0.12): the verdict fingerprint that decides whether to emit [Δ ...] lines used to truncate to 60 chars and could split a number in half, so a 0.1 °C drift in t_outdoor would dirty-flag the verdict every cycle. Replaced with a regex that collapses every number to * before comparing. Symptoms: spurious Δ-spam right after a rebuild stops on this release.
Both changes are pure logging / UX — zero impact on the decision logic itself.
Drive-by 3: ha_set_number, ha_set_select and ha_switch now treat an empty entity_id as a silent no-op (return True) instead of forwarding the call to HA. Previously, when an optional integration entity wasn't present in the wizard (e.g. battery_backup_soc on inverters that don't expose a writable backup-SoC register), the service call went to HA with entity_id="" and came back as a 400/500 once per cycle — 96 spurious WARNINGs per day. Silently skipping is the right semantics: "no entity → nothing to do, success".
Two new endpoints to start collecting subjective comfort data ("¿estoy bien, frío o caliente?") with full sensor snapshot:
POST /api/comfort_feedbackbody{feedback: "frio"|"perfecto"|"calor", note: "..."}→ appends a JSON line to/data/comfort_feedback.jsonlcapturing current sensors (t_indoor/t_outdoor/zones, ΔT_hp, SoC, solar, hour, weekday). Logs as[COMFORT] perfecto @ t_in=21.2 t_out=18.7.GET /api/comfort_feedback?limit=Nreturns the last N entries for review / future calibration.
Designed to be called from a Home Assistant automation tied to an input_select.confort_subjetivo, so logging a feedback is one tap on a Lovelace card. With ~30 samples it becomes feasible to calibrate per-hour temp_comfort to Sergio's actual perception rather than a global default.
If AEMET predicts a cool night ahead (default ≤ 18 °C), don't burn solar surplus on running the heat pump for cooling — the house will free-cool overnight via open windows + radiant-floor inertia. The surplus stays in the battery.
- New wizard role
aemet_night_low(legacy keysensor_aemet_night_low). Maps to a numeric AEMET forecast sensor, typicallysensor.aemet_daily_forecast_temperature_low. - New cycle gate in
_hp_zone_decision()and_hp_legacy_decision(): when surplus is detected andaemet_night_low ≤ cooling_skip_if_night_low_c(default 18.0), the cooling action is skipped with reasonCool night forecast (X.X°C ≤ Y.Y°C): skip cooling, the house will free-cool tonight. Logged as[COOLING-GATE]. - No-op when the role isn't mapped (sensor value None / 0 / placeholder).
- Hierarchy: thermal override > free-cooling-outdoor gate > night-forecast gate (new) > surplus cooling > inactive.
New option (with schema): cooling_skip_if_night_low_c: 18.0.
Each 15-min cycle was emitting ~15-20 INFO lines, almost all of them identical to the previous cycle. Useful when debugging a regression, useless to keep an eye on day-to-day operation. New log_quiet_mode option (default false):
- When enabled, a
_QuietCycleFilterPython logging filter intercepts every INFO record that fires insiderun_cycle()and drops it unless it's the cycle banner or the new structured summary lines. - At the end of the cycle,
_emit_cycle_summary()compares the current verdict per category against the one persisted in/data/log_summary_state.jsonand emits one INFO line per category that changed:[Δ battery] Mid — opportunistic top-up to smart target → Valley, at optimal level [Δ heat_pump:aerotermia_principal] Surplus cooling → Inactive (no surplus) - Once per hour an
[HB]heartbeat compacts all current verdicts into a single line — confirms the addon is alive even when nothing has changed. - WARNING/ERROR records bypass the filter unconditionally. The Activity tab JSON (
decisions.json) is not filtered — full history is preserved for analysis.
To opt in: set log_quiet_mode: true in addon options.
When the addon decided "Surplus cooling" during the solar peak it wrote an aggressive value to the heat-pump cooling setpoint (e.g. Z1CoolingTemp = 16 °C on Sergio's install — the floor of the slider's range). When the surplus window closed, the decision flipped to Inactive (no surplus) and the slider was left untouched. It stayed at 16 °C for hours and overnight, even though the addon was no longer asking for cooling. A manual flip of the heat pump or any ebusd internal loop would then ramp the unit hard against an inappropriate target.
Now the addon detects the edge active → skip (per zone) and writes the configured cooling_off_setpoint_c (default 25.0 — matches the Z1CoolingTemp value from Sergio's 2026-05-07 baseline) to the slider. The transition is logged as:
[HP/aerotermia_principal] active→skip transition → reset number.ebusd_ctls2_z1coolingtemp_tempv to 25.0°C
Steady-state cycles do NOT keep writing — manual edits made by the user on the slider while the addon is in skip are respected until the next surplus arrives.
State persists at /data/hvac_decision_state.json so the addon doesn't lose the "did I just stop cooling?" knowledge through restarts.
New option: cooling_off_setpoint_c (default 25.0).
v5.0.9 honored a manual ON for at least 50 min but then handed control back to decide_pool(). If there happened to be solar surplus at minute 50, the pump kept running. Sergio's actual intent: flip on → run for 50 min → turn off, full stop.
- On manual ON detection the addon now schedules a deferred APScheduler job (same pattern as the limpiafondos auto-off) that flips the switch off at the exact second
T + pool_manual_min_runtime_min. The Activity tab logs it asManual ON timer elapsed — pump auto-off. - Persistent state at
/data/pool_manual_state.jsonnow stores the absolutemanual_off_attimestamp. On addon restart the job is restored if the deadline is still in the future, or fired immediately if it elapsed while the addon was down. - Cycle override during the window still applies (no surprise OFFs from
decide_pool()while the timer runs), but the cycle no longer owns the cutoff — the APScheduler job does. - Flipping the switch off by hand cancels the pending timer cleanly.
Initial implementation that respected a manual ON as a minimum runtime. Behaviour subtly wrong when solar surplus or valley tariff coincided with minute 50; replaced by v5.0.10's exact-duration timer.
In mountain climates with cool nights (Guadarrama is the reference install), running the heat pump for radiant cooling makes no sense once the outdoor air is colder than indoors. Ventilation does the same job for free. v5.0.7 was still happy to fire the thermal override at 23:00 with 16 °C outside.
Two gates added to _hp_zone_decision() / _hp_legacy_decision(), both inert if sensor_temp_outdoor isn't wired (behaviour matches v5.0.7 in that case):
- Override gate. If
t_indoor > hvac_summer_override_cbutt_outdoor ≤ t_indoor − free_cooling_delta_c(default 2 °C), the heat pump is not started. Instead, the addon emits a[COOLING-GATE]log line and sends a Telegram alert telling you to open the windows. The alert is rate-limited to one everynotify_open_windows_cooldown_hhours (default 4). - Surplus gate. If there's solar surplus and
t_outdoor ≤ cooling_outdoor_max_c(default 22 °C), cooling is also skipped — better to keep the surplus charging the battery than to spend it cooling air that's already cold outside.
New options (all live under cfg() with safe defaults):
| key | default | meaning |
|---|---|---|
cooling_outdoor_max_c |
22.0 |
Skip surplus cooling when outdoor is at or below this temperature |
free_cooling_delta_c |
2.0 |
How much colder outdoor must be vs indoor to skip the thermal override |
notify_open_windows_enabled |
true |
Toggle the Telegram alert |
notify_open_windows_cooldown_h |
4.0 |
Minimum hours between repeat alerts |
State for the alert deduplication persists at /data/cooling_gate_state.json so addon rebuilds don't reset the cooldown timer.
After a 24 h audit on Sergio's install we learned that the obvious "did the heat pump run?" signals — aerotermia_consumo_electrico, status01_pumpstate — are silently broken on his ebusd integration (the first stays unavailable, the second sticks at off even when the unit is clearly running). The only sensors that always tell the truth are the two water-loop temperatures: ida (water leaving the unit) and vuelta (water coming back). When they're equal the unit is idle. When ida is colder than vuelta the unit is cooling. When ida is hotter than vuelta it's heating. No ambiguity, no desync.
- New wizard roles
hp_flow_in_tempandhp_flow_out_tempresolve to two existing sensors. Sergio's install maps them tosensor.aerotermia_exterior_status01_temp1andsensor.aerotermia_exterior_status01_temp1_2respectively. - Per-cycle hydraulic log line. When both roles are configured, every
run_cycle()emits:The verdict is[HYDRAULIC] flow_in=13.5°C flow_out=16.5°C ΔT=-3.0°C → COOLINGHEATINGfor ΔT > +1 °C,COOLINGfor ΔT < −1 °C,idleotherwise. The sign convention (ΔT = ida − vuelta) matches Sergio's existing template sensoraerotermia_delta_t_calefaccionso the two read the same direction. - Three new fields in
sensorsanddecisions.json(hp_flow_in_c,hp_flow_out_c,hp_delta_t_c) so the Activity tab and any downstream analysis have the ground truth recorded per cycle. Installs without the roles configured fall through silently — no log line, no extra fields.
Two related bugs in the valley-charge planner reported by @andredp on issue #8, both compounding to under-fill the battery for tomorrow's peak on self-consumption installs.
- Bug A —
max(30, _hmin)shadowsbattery_health_mode. The smart-target final clamp, the emergency target and the recovery target all forced a hardcoded30 %floor on top of the user's configured health-mode minimum, silently overridingbill_reducer(10) andbattery_guard(25→30, paradoxically lower than guard's actual floor on some edges). Three call sites:calculate_optimal_soc(:822),decide_batteryemergency floor anddecide_batterylow-target. All three now read directly from_health_mode_limits()[0]so the Tweak page selection flows end-to-end.bill_reducerusers get smart_target down to 10 % when solar is plentiful tomorrow;battery_guardusers get 25 %. - Bug B — Night consumption baseline ignores battery discharge.
_refresh_consumption_cachecomputedabs(grid_power)averaged over 22:00–08:00. On self-consumption installs the battery covers the load and the grid value is near zero — Sergio's cache had been showing1.1 kW avg nightonly because of occasional pre-2026-05-30 nights when the battery had not yet been topped up. Fix: pullsolar_power,battery_powerandgrid_powertogether, align them on a 15-min grid, and computehouse = solar + batt − grid(with the addon'spositive=exportingconvention for grid andpositive=dischargingfor battery). Falls back to the legacyabs(grid)calculation for installs whose wizard does not have solar/battery wired (grid-only setups). The new log line is explicit:Consumption cache (house = solar + batt − grid): 0.512 kW avg night (892 samples) - Migration. No setup changes needed for installs that already have solar/battery wired in the wizard. Pure-grid setups keep the old code path with no behavioural change. Users on
bill_reducerwho were quietly being floor-clamped at 30 % will see their smart target dip toward 10 % on sunny-forecast days — that is the requested behaviour.
Direct follow-up to a 48 h audit on Sergio's install. After v5.0.3 enabled radiant cooling under "solar surplus", the engine never once actuated the cooling path across two days in a heatwave (SoC stayed in the 80–95 % band, the cooling setpoint was skip-ed every cycle, the aerotermia never started the compressor). Root cause: free_power = (soc >= 99 and batt_power > 0) is a knife-edge condition. SoC sits at 99–100 % only a handful of minutes per day around the peak, and with a 15-minute cycle the engine almost always misses the window.
- New surplus detector with hysteresis. Added
_has_surplus_power()that crosses on atSoC ≥ 95 %with the battery not charging (batt_power ≥ 0) and only drops off whenSoC < 90 %OR the battery starts charging hard (batt_power < −500 W). All three thresholds are tunable viasetup.json(surplus_enter_soc,surplus_exit_soc,surplus_exit_charge_w). The active flag is persisted to/data/surplus_state.jsonso addon restarts don't lose context. - Avoids start/stop chatter. The 5 % SoC and 500 W power gates between entry and exit are wide enough to absorb cycle-to-cycle noise (cloud shading, occasional inverter ramps, opportunistic loads) without bouncing the engine in/out of surplus mode.
- Both decision paths updated.
_hp_zone_decisionand_hp_legacy_decisionnow use the hysteresis detector wherever they previously called the raw(soc>=99 and batt_power>0)check. Reason strings updated from "Free solar power (SOC≥99%)" to "Solar surplus (hysteresis)" so logs match the actual mechanic. - No tuning changes shipped by default. The defaults match the values audited on Sergio's install (95 / 90 / −500), and
hvac_summer_override_cstays at 29. Installs that want different bands set them insetup.jsonand the addon picks them up on the next cycle.
Follow-up to v5.0.2 reported by @andredp on issue #7. The 5.0.2 fix correctly reset battery_backup_soc at peak start, but did so against the hardcoded default of 20 %, silently shadowing the user's selection on the Tweak page → Battery Health Mode card. Effect: even after upgrading, a bill_reducer install kept the floor at 20 instead of 10 (loses ~10 % of usable capacity every peak window), and a battery_guard install dropped to 20 instead of staying at 25 (more aggressive than the user asked for).
- Root cause. The two call sites — the autonomous cycle when
decide_batteryreturnsself_consumption, and the manualPOST /api/battery/self-consumptionendpoint — both invokedset_battery_self_consumption()with no argument, taking the function's hardcoded default ofmin_soc=20. The samebattery_health_modesetting that already drives the charging floors via_health_mode_limits()(bill_reducer→(10,95) / optimized→(20,90) / battery_guard→(25,85)) was not consulted on the release side. - Fix.
set_battery_self_consumption(min_soc=None): when no explicit floor is passed, derive it from_health_mode_limits()[0]. Both call sites continue to call it with no argument, so the user's Tweak selection now flows end-to-end without any additional wiring. - Migration. Anyone who selected a non-default mode and noticed their battery being more conservative than expected at peak will see the floor change after upgrading:
bill_reducer20 → 10,battery_guard20 → 25.optimizedusers are unaffected (mode floor is already 20). Explicit external API callers passing a custommin_sockeep their behaviour.
Adds a conservative summer regime for installs whose cooling side is a high-inertia radiant floor (or any system where overnight cooling is wasteful) and lets users flip seasons manually from a Home Assistant helper instead of relying on the calendar.
- New
season_selectwizard role. Resolves to a Home Assistantinput_select(typicallyinput_select.seasonwith optionssummer/winter, Spanishverano/inviernoalso accepted). When configured, the addon trusts that selector end-to-end —_season()returns its state and_is_summer()derives from it. When not configured, the legacy month-based heuristic (summer_start_month/summer_end_month) keeps working. The selector is also honoured bydecide_pool, so pool hours-per-day vs hours-per-week switch with the same flip as the HVAC regime. - Conservative summer HVAC regime.
_hp_zone_decisionand_hp_legacy_decisionnow refuse to actuate the cooling setpoint unless one of two conditions holds: (a) a clear solar surplus (SOC ≥ 99 % with the battery exporting, or the zone schedule cell issurplus), or (b) indoor temperature crosses the newhvac_summer_override_cthreshold (default 29 °C). Outside those windows the decision is reported asskip: True, the activity feed records the inaction, and nonumber.set_valueorclimate.set_temperatureis sent — critical for radiant floors, which would otherwise be primed cold overnight at valley prices and stay cold through the morning when there's nothing to dump into. - No behaviour change for winter. The heating branch keeps the existing
surplus/comfort/minimumladder; only the summer branch was inverted. - Migration. Nothing required for installs that don't add the new selector — the month-based default keeps the previous behaviour modulo the summer inactivity rule. Installs that do want the radiant regime should: (1) create
input_select.seasonin HA, (2) point the wizard's new "Season selector entity" field at it, (3) optionally tunehvac_summer_override_cinsetup.json. A companion design for the HA side (templates that swap thermostats with the same selector) ships under docs/season-switch.md.
Fixes two bugs in the battery control layer that prevented Energy Optimizer from working on GoodWe inverters and could leave any installation with an inflated minimum discharge SOC after a valley charge. Reported and diagnosed by @andredp on a GoodWe GW3648-EM with the mletenay HACS integration (issue #7).
- Bug A — mandatory mode select.
set_battery_charge_targetandset_battery_self_consumptionunconditionally calledselect.select_optionagainst the configuredbattery_mode_selectentity. The hardcoded fallback was a Huawei Luna2000-specific id (select.battery_working_mode), so a GoodWe install with the wizard field left blank still fired the call against a non-existent entity, got a 400 back, and tainted the whole battery operation asok=False. GoodWe doesn't need a mode select at all —goodwe_fast_charging_switch+goodwe_fast_charging_socare standalone registers that operate independently of the inverter's operation mode. - Bug B —
battery_backup_socnever reset.set_battery_charge_targetraisedbattery_backup_socto the valley charge target (e.g. 60 %) butset_battery_self_consumptionnever lowered it back when the engine flipped to self-consumption at peak start. On Huawei this entity is the minimum charging SOC and the silent inflation was tolerable; on GoodWe it maps togoodwe_battery_soc_protectionand pinned the battery above the valley target all day, defeating the entire optimisation. - Fix. The mode select is now opt-in: the hardcoded Huawei default is removed, and the call is guarded with
if mode_entity:so it only fires when the user explicitly populatesselect_battery_modein the wizard orsetup.json.set_battery_self_consumptionnow also resetsbattery_backup_soctomin_soc, mirroring howbattery_cutoff_socis already reset. - Migration note for Huawei users. If you were relying on the implicit
select.battery_working_modedefault and never set the entity in the wizard, you must add it explicitly under Setup Wizard → Battery → "Mode select entity" after upgrading. If you already configured it (the recommended path documented in the wizard since v3.x), nothing changes.
Hotfix. The nightly retrain (retrain_cron, default 0 3 * * *) was silently failing on every cycle for any installation with data_source: influxdb in the wizard, leaving the model frozen at the SOC predictions it had at install time. Traceback was buried in the addon log — the engine kept producing decisions from a stale model and never surfaced the problem.
- Root cause.
_influx_queryrequestsepoch=msso InfluxDB returnstimeas an integer (Unix milliseconds), but_rows_to_15min_seriesassumed HA-REST style ISO strings and called.replace("Z", …)on the value. Theexceptclause caughtKeyError / ValueError / TypeErrorbut notAttributeError, so the first row aborted the whole training set. - Fix.
_rows_to_15min_seriesnow detects the type oflast_changed(str/int/float) and converts integer timestamps via magnitude thresholds (seconds / milliseconds / nanoseconds).AttributeErroris also added to theexceptso a single malformed row no longer kills the batch. - Impact. Confirmed on a real install: the post-fix retrain produced 8637 samples × 22 features (R² 0.997), versus the previous 14-feature model trained 18 days earlier. The eight extra features are the wizard's
grid_submeters(per-appliance power readings) that had been silently dropped because they were added after the last successful training. - Affects: HAOS installs with
data_source: influxdb(the default for users who configure the InfluxDB step in the wizard). HA-REST-only installs were not affected because theirlast_changedis already a string.
Major release. The decision engine moves from reactive heuristics to a predictive planner with explicit forecasting, deterministic SOC simulation, an iterative planner with convergence guarantees, a four-layer policy pipeline, and full per-cell traceability. The new engine is delivered behind a feature flag (v5_engine_enabled: false by default) so existing v4 installations keep running unchanged until the flag is flipped.
Why a major bump. Although wizard_config.json v3.5+ remains backward-compatible, the cycle's observable behaviour changes radically when the v5 engine is active: forecasts come from quantile heads, decisions are computed from a horizon-aware planner, and every override (capacity budget, peak prohibition, antiflap, degraded mode) is recorded in a structured trace. Calling that anything less than a major bump would be dishonest.
Architecture (under rootfs/usr/bin/eo/). Built strangler-fig style alongside the existing monolithic energy_optimizer.py. The v4 engine is untouched.
eo/forecasters/—ClearSkyModel(deterministic PV baseline, nopvlib),AtmosphericFactorModel(ML residual with P10/P50/P90 quantile heads),SolarForecaster(composition),HouseForecaster(standalone quantile model, not chained to avoid the v3.x autoregressive inflated R²),ForecastQualityTracker(rolling MAE / bias / calibration error per series),traininghelpers.eo/simulator/— pure, side-effect-free 15-min-timestep SOC simulator with an injectablePhysicsModel(todaySingleBatteryPhysicsModel; future EV / multi-battery without touching the simulator). Hard invariants for SOC bounds, charge/discharge mutex, inverter capacity, energy conservation, min-runtime, contradictory actions, debt monotonicity. Soft mode in production, strict mode in tests.eo/planner/— debt-state classifier with the two bug fixes from review (window-mutation truncation + telemetry-coverage scaling), 11-row decision matrix from the spec, utility-score function, iterative convergence loop with plan-hash + 2-cycle detection +MAX_PLANNER_ITERATIONS=5guardrail. Implements theforced_statesinjection insight from the audit round so the simulated trajectory never diverges from execution.eo/policy/— four-layer pipeline (capacity_budget→peak_prohibition→antiflap→degraded_mode). Each layer is a pure transform; the tripleraw_plan / policy_adjusted_plan / execution_planis preserved end-to-end. Three-level degraded mode (forecast MAE high → dropmin_runtime_onlydecisions; AEMET stale → drop everything exceptrule_id=3; sensors stale > 30 min → all deferred loads OFF).eo/scenario/—ScenarioBuildercollapses quantiles into a single coherent Scenario whose risk tolerance is derived from the worst debt state across declared loads.eo/state/— aggregatedSystemStatedataclass (battery + forecast quality + planner history + load debt + antiflap + execution world state) with atomic JSON persistence (tmp + fsync + os.replace).eo/execution/— pure execution engine and cycle orchestrator. Optimistic-hybrid dispatch (no synchronous ACK wait); telemetry-driven reconciliation after restart (telemetry beats persisted state if they diverge).
Tests. 355 pytest cases across the package. Includes the three high-pressure scenarios from the spec review: "Monstruo del Bucle" (oscillating load hits max_iter cap), "Sábado de Gloria" (concurrent loads on weekend valley serialised by capacity budget), "Cap del Fin del Mundo" (unreachable quota produces irreachable debt state and Telegram alert).
Defaults. v5_engine_enabled: false. The flag turns the engine on once the addon-specific wiring is complete (forecasters trained on the user's historical data, data-source bindings for hours-on-per-day rebuilds, entity registry for is_on / send_command). Wiring guide ships in a future point release.
Hotfix release that lays the ground for the upcoming v5.0.0 rewrite while delivering one immediately useful fix.
- Setup integrity checker: detects when the same
entity_idis configured in multiple actuable roles in the wizard (e.g. the sameswitch.*used aspool_switchand as acustom_loads[].switch). This was the root cause of the "blitzwolf switching by itself" symptom reported in #4 — the pool branch issuedturn_onand the custom-load branch issuedturn_offin the very same decision cycle. On startup the add-on now logs every conflict, publishesbinary_sensor.energy_optimizer_setup_conflictto Home Assistant (with the full conflict list as attributes), and fires a Telegram alert if any CRITICAL collision is found. - First module of the new
eopackage:eo.checks.setup_integrityis a pure, unit-tested module (18 pytest cases) introduced as the template for how v5.0.0 modules will be organised. The legacy monolithicenergy_optimizer.pykeeps running unchanged; new functionality grows alongside it (strangler-fig pattern). pytest.iniandtests/directory: development tests now live in the repo and run withpython3 -m pytest tests/(not shipped inside the Docker image).
Audit pass on top of v4.0.0 to close every "black box" — every switch toggle, every number write, every API call the add-on performs is now recorded in decisions.json and visible in the Activity tab. No silent actions.
- Pool cleaner auto-stop: the 15-min APScheduler-deferred turn-off was firing outside the cycle and never reaching
decisions.json. Now wrapped through a new_record_event()helper that persists deferred / out-of-cycle actions to the same history. Taggedscheduler_eventin the UI. - Pool cleaner start: previously bundled implicitly with the pool pump's reason string. Now emitted as its own
pool_cleanerentry with its ownexplanation. - Heat pump dual call (
number.set_value+ optionalclimate.set_temperature): previously only the number write was logged. The climate mirror call now gets its ownclimate_setpointentry, tracked independently with its own ok/explanation. - Manual API endpoints (
/api/battery/charge,/api/battery/self-consumption): when called from outside the engine (UI button, external automation, curl), the action is now recorded as amanual_apievent with the requester IP, the target SOC, and the full reasoning. No more invisible overrides. - Activity tab visual coverage: extended the
tagMapand CSS to includecustom_load,pool_cleaner,climate_setpoint,dishwasher,manual_api,scheduler_event. Previously unknown types rendered as undifferentiated gray pills; now each event family has a distinct colour so external interventions are visually distinguishable from cycle decisions.
Major release. The internal decision engine has been rewritten to (a) respect every user-configurable setting that was previously silently overridden, and (b) explain what it did and why, in every cycle.
The two visible changes for everyone:
- Every decision now ships an
explanationdict withwhat,why, theinputsused, theformulaapplied, the actualcalculation, and thealternatives_rejected. The Activity tab renders an expandable row per decision (click the ▶ in front of any reason) that shows the full reasoning. The legacyreasonshort string is kept on every decision for backwards compatibility with downstream consumers. - The smart battery target SOC adapts to your actual tariff geometry, not a hardcoded Spanish 2.0TD shape. Users with longer/shorter peak windows (Ukraine 16h peak, Australia 4h peak, Germany variable, …) now get targets that match their real consumption profile.
Decision engine fixes, by impact:
calculate_optimal_socpeak-hour scaling. The previouspeak_base_kwh = base_kw × 8hardcode caused systematic 2× errors on tariffs with non-Spanish peak length. Surfaced by @Karplyak in issue #5 (Ukrainian 16h peak ⇒ target stuck at ~36 % when ~70 % was right). Now scales withlen(tariff.peak_hours).calculate_optimal_socsolar-overlap. Previouslysolar_during_peak = solar_tomorrow × 0.45(Spanish heuristic). Now computed as the overlap between the user's actual peak window and a 7-19h "useful sun" window, divided by that window's length.- Temperature adjustment scales with peak length. The HVAC-load bands (0/0.5/1/2/3 kWh) were tuned for an 8h peak. Now multiplied by
peak_h_ratio = len(peak_hours) / 8. decide_batteryrespectsbattery_health_mode. Previously hardcodedtarget_soc: 30/40andif soc >= 95ignored the user's selected profile, meaning Battery Guard (25-85 %) users would "never reach full" according to the engine. Now resolves the floor asmax(30, health_mode_min)and the ceiling ashealth_mode_max.decide_batteryuses configurablebattery_low_threshold. Previously hardcodedif soc < 20ignored the option even though it was configurable. Now readscfg("battery_low_threshold", 30).decide_batteryuses the configuredbattery_charge_powerentity. Previously hardcoded 1500/2000/3000 W. Now reads the wizard's configured charge-power number entity and caps emergency charges to it.- Mid-tariff opportunistic top-up. Previously mid-period only reacted to dangerously low SOC. Now also tops up toward the smart target if mid is the cheapest period available before peak (for tariffs where mid-hours overlap with daytime sun, e.g. Spanish 2.0TD 14-18h).
- Custom loads scheduler actually drives the switches now (issue #4, reported by @Karplyak). The wizard's Loads step lets the user add
custom_loadswith a switch entity, watts estimate, and a scheduling mode (valley,solar,both,hours). The wizard was persisting them correctly towizard_config.json, but no Python code on the engine side was consuming them — the switches were never being turned on/off by the optimizer. Implementeddecide_custom_loads()and wired it into the decision cycle:valley→ on during P3 tariff onlysolar→ on when grid export ≥ load wattage and SOC > 30 %both→ on if either of the abovehours→ on when current hour is inside any range in the user's spec (10-14,22-06style, ranges wrap midnight) Only emits a service call when the desired state differs from the current one, so quiet hours don't spam the bus.
- Wizard "Flip sign" toggle on the Grid step actually does something now. It was being saved to
wizard_config.jsonasgrid_flipbut never read back — users whose meter reports+ve = importing from gridhad a no-op toggle and the engine kept seeing inverted signs (battery decisions + savings counter all biased). Reported by @Karplyak in issue #2 after testing v3.5.3. _influx_wizard_historyquery corrected. Was usingFROM "<entity_short>"while the HA→Influx integration storesmeasurement = unit+entity_idas TAG. The query returned an empty result for every entity and the loader silently fell back toha_history_influx. Worked for setups with the legacyinfluxdb_urloption populated; broken for users configuring InfluxDB only through the wizard. Switched to the sameFROM /.*/ WHERE entity_id = ...pattern as the rest of the code./api/wizard/data-qualitynow countsgrid_submeters. The endpoint iterated only over the wizard'ssensorsdict — the score reported OK while sub-meter feeds (Meross, etc.) used assm_<name>features in ML training were silently uncounted. Built a unifiedall_entitiesdict spanning both, used in the InfluxDB / MariaDB / HA-Recorder loops.
- MariaDB direct query fix (issue #2, reported by @Karplyak): the recorder query was filtering by
last_changed_ts, which HA only populates when the state value actually changes. Power sensors that emit the same reading repeatedly, or rows where only attributes were updated, leavelast_changed_tsNULL and got silently dropped → 0 rows returned. Switched tolast_updated_ts(and the legacylast_updated), which is populated on every write. Hotfix on top of v3.5.2.
- MariaDB / MySQL recorder support (issue #2): wizard now offers a third data source besides InfluxDB and HA Recorder REST. When the HA recorder is backed by MariaDB and the REST endpoint returns nothing (a known issue for some installations), the add-on can read history directly from the recorder DB. New section in the Setup Wizard's first step: host, port, database, user, password, and a Test connection button. Schema is auto-detected (legacy
states.entity_idvs post-2023.4states_metaJOIN). - ML predict feature-name fix: the chained 8 h SOC forecast was passing
temp_outto a model trained withtemp_outdoor, raising a sklearn warning every cycle. Renamed to match the trained feature; dropped two stale features (temp_out_lag4,grid_abs_lag1) that were never trained. - Version drift fix:
ADD_ON_VERSIONwas 3.5.0 whileconfig.yamlwas 3.5.1; aligned both to 3.5.2.
- Savings counter rewritten with counterfactual method: the previous filter (peak hours + battery discharging + grid import > 500 W) was under-counting by ~97 % because it ignored self-consumption in valley/off-peak and the cases where the battery covered 100 % of the load. New logic compares grid cost with battery against a hypothetical no-battery scenario per cycle (
grid_cf = grid + battery). JSON is backwards-compatible:total_eur_savedstays primary,total_kwh_avoided_peakis kept as secondary,total_kwh_throughputadded for diagnostics. Per-cycle delta is capped at ±0.30 € as sanity.
- Single source of truth for version:
ADD_ON_VERSIONinenergy_optimizer.pyis now canonical. Build copiesconfig.yamlto/addon-config.yamland a startup check logs a warning if the two drift, so panel and Supervisor never disagree. - Wizard data-quality fix: the InfluxDB sample-count query was using
entity_idas the measurement name. With the HA→InfluxDB integration the measurement is the unit (%,W,kWh, …) andentity_idis a tag — query corrected accordingly. - Branch consolidation: merges parallel v3.4.1 work that had diverged on a separate machine. No user-facing regressions; per-version entries below remain authoritative for individual features.
- Split battery sensors (Deye/Solarman/Growatt support): The Battery step of the Setup Wizard now has a "Split sensors" toggle for inverters that report charge and discharge as two separate positive-valued entities instead of one signed sensor. Enable the toggle and select the two entities — the engine combines them as
charge − dischargeso the rest of the logic behaves identically. The debug table in Tweaks also adapts to show both entities when split mode is active.
- Average consumption metrics in Charts: Day view KPI cards show all-time daily averages (Ø) below each value with tooltip explaining the calculation. History tab adds a full 5-card summary row with all-time and last-12-months averages for solar, consumption, export, import, and self-sufficiency.
- Battery ROI calculator: New section in the History tab. Enter the cost and capacity of additional storage — the calculator uses your actual average daily savings to estimate payback time and projected annual gain.
- Battery health mode: Three-button selector in the Tweaks tab controls the SOC operating range the engine targets: ⚡ Bill Reducer (10–95%, default), ⚖️ Optimized (20–90%), 🛡️ Battery Guard (25–85%). Affects the nightly charge target clamp. Persisted in
setup.json.
- Removed all personal/installation-specific defaults from
config.yamland Python fallbacks. Add-on now ships clean for any installation.
- Sensor convention corrected:
battery_poweris+ve = charging, −ve = dischargingthroughout display, flow physics, and averages. - House balance:
P_casa = solar − grid − battery. - Flow vectors rewritten with correct priority order including emergency-charge-from-grid path.
- Battery card: green = charging, red = discharging.
- Debug section in Tweaks tab: table showing sensor role resolution (wizard → options → fallback), raw HA state, parsed value, and ✓/⚠/✗ status. Auto-loads when tab opens.
_live_averages_7d(): W-level averages from 15-min decision samples. Solar average excludes night samples./api/debug/sensorsendpoint added.- Physics-based flow animation: 7 computed flow vectors, speed proportional to power.
- Setup Wizard: 8-step guided configuration with entity auto-discovery, data quality thermometer, HVAC multi-zone scheduling, device sub-dots in nav bar.
- Dynamic ML features: feature set built from wizard-configured sensors, saved alongside model.
- Multi-source history: InfluxDB v2 → InfluxDB v1 → HA Recorder cascade.
_wiz()resolution: wizard_config.json → options.json → hardcoded fallback for every sensor role.
- Grid power sign convention fixed.
- Continuous solar proxy (geometric sun elevation 0.0–1.0).
- Solar terrain correction factor (median actual/forecast, 30 days, cached 6h).
- Battery logic reoriented to cover tomorrow's peak demand.
- Temperature correction for heat pump load.
- InfluxDB as primary ML source (365 days) with auth auto-detection.
- Data Sources debug panel in Setup.
- Solar charts: Actual vs HA Forecast (7d + 12m).
- Savings bar chart with € labels and counterfactual method.
- 8-hour chained ML forecast.
- Weather forecast widget (5-day, storm alert).
- HA ingress fix (
X-Ingress-Pathheader). - Tariff editor with per-day weekend configuration.
- Telegram instant alerts.
- 4-tab GUI: Dashboard, Charts, Tariff, Setup.