Calculations¶
EMS Mode Decision (EMSController._decide / _commit)¶
The controller evaluates five mutually-exclusive modes, in this priority order, on every tick:
1. IDLE — battery_soc_entity missing OR battery_power_entity stale (sensor_max_age_sec)
2. PROTECT_BATTERY — soc < battery_min_soc
OR (previously PROTECT_BATTERY AND soc < battery_min_soc + battery_soc_hysteresis_pct)
3. IDLE — soc ≥ battery_max_soc ("battery full")
4. EXPORT_SURPLUS — (pv_w − load_w) > pv_surplus_threshold_w AND export hold active (see below)
PV_CHARGING — (pv_w − load_w) > pv_surplus_threshold_w AND export hold NOT active
5. GRID_CHARGING — price cheap AND grid-charge conditions met (see below)
6. IDLE — none of the above ("no action")
Every condition is evaluated fail closed: if a required sensor value is missing or stale, the controller takes the safe path (no grid charging, no withholding of PV charging) instead of guessing.
SoC sensor unavailable
If battery_soc_entity returns no value, the controller immediately and without delay (urgent) switches to IDLE and hands control back to the inverter's own self-use logic. This is the central safety measure against uncontrolled charging when the battery state of charge is unknown.
The staleness check here does not run on battery_soc_entity itself — it runs on battery_power_entity (default sensor_max_age_sec = 300 s). Reason: SoC is a coarse percentage that can legitimately stay on the exact same value for hours, or — in winter with grid charging off — for days; a timeout on "time since the SoC last changed" would eventually be wrong for every possible value. battery_power_entity comes from the same BMS/inverter connection and fluctuates continuously as long as that connection is alive, so its freshness proves the SoC reading is current, not merely that it happened to change recently.
PV Surplus¶
Only computed when both power sensors report a current value (no older than sensor_max_age_sec); otherwise surplus_w is treated as unknown and the controller proceeds to step 5 (grid charging). If surplus_w > pv_surplus_threshold_w (default 200 W), the export hold (next section) decides whether the mode becomes PV Charging or Export Surplus.
Grid-Friendly Export Hold (_should_hold_pv_charge, mode EXPORT_SURPLUS)¶
Optional strategy (pv_export_priority_enabled, off by default): exports PV surplus to the grid instead of charging the battery immediately, for as long as the Solcast remaining-today forecast stays above what the battery still needs. Every one of the following must hold for the hold (hold = True, mode EXPORT_SURPLUS) to apply instead of charging — any failed condition immediately triggers PV_CHARGING:
1. pv_export_priority_enabled == true
2. now.hour < pv_charge_backstop_hour (default 14:00 — always charge after this)
3. bat_soc ≥ pv_export_min_soc_pct (default 30 % — always charge below this)
4. bat_kwh_free > 0.05 kWh (there's practically still room)
5. solcast_remaining_today_kwh available AND not stale (forecast_max_age_sec)
target = bat_kwh_free × pv_charge_margin_factor # default factor 1.2
hyst = clamp(pv_charge_hysteresis_frac, 0.0, 0.5) # default 0.10 (±10 %)
threshold = target × (1 − hyst) if currently EXPORT_SURPLUS # easier to stay in the hold
= target × (1 + hyst) otherwise # harder to enter it
hold = solcast_remaining_today_kwh > threshold
The threshold is deliberately asymmetric (hysteresis): entering the export hold requires the remaining forecast to be clearly above the need; leaving it only requires a smaller drop. This keeps the mode from flapping around the trigger point.
While the export hold is active, InverterController.apply_mode() sets the charge current to export_hold_charge_current_a (default 0 A = charging blocked entirely) and leaves the discharge current at maximum, so a passing cloud is still covered from the battery instead of by importing from the grid.
Grid-Charge Decision (_should_grid_charge, mode GRID_CHARGING)¶
1. electricity_price_entity available AND not stale (price_max_age_sec)
2. price_eur_kwh < cheap_rate_threshold_eur
3. bat_kwh_free > grid_charge_min_free_kwh (default 1.0 kWh — otherwise not worth it)
If solcast_remaining_today_kwh is available (not stale) AND > grid_charge_min_free_kwh:
should_grid_charge = bat_kwh_free > solcast_remaining_today_kwh × pv_charge_margin_factor
+ grid_charge_min_free_kwh
Otherwise (today's forecast missing, stale, or simply spent — e.g. in the evening):
# Since v2.0.1: check tomorrow's forecast before falling back to a
# blind time-of-day decision — if it alone covers the battery's need,
# buying grid energy tonight isn't worth it.
If solcast_tomorrow_kwh is available (not stale):
If bat_kwh_free ≤ solcast_tomorrow_kwh × pv_charge_margin_factor + grid_charge_min_free_kwh:
should_grid_charge = False # tomorrow's sun will refill it
# Neither today nor tomorrow has a usable value:
# "No more sun can arrive today, and nothing reliable is known about
# tomorrow either" — only charge inside the configured dark window
should_grid_charge = grid_charge_dark_start_hour ≤ now.hour < grid_charge_dark_end_hour
(default 21:00–06:00; allowed to wrap past midnight)
Why this fallback was needed
solcast_remaining_today_kwh falls back to None/0 for two different reasons: legitimately in the evening, once no more sun is expected for the rest of the day, and incorrectly when Solcast (depending on your plan) goes several hours without a new value and forecast_max_age_sec (default 8 h since v2.0.1, previously 3 h) is exceeded — even though the last known value is usually still correct after the midnight rollover. Both cases used to fall straight into the dark window, even when solcast_tomorrow_entity already showed a forecast more than large enough for the next day. The check only ever narrows the decision (it can prevent grid charging, never trigger it in addition) and leaves every existing safety gate (SoC floor, PROTECT_BATTERY) untouched.
No longer tied to the internal consumption model
Earlier miniEMS versions used the internal consumption forecast (ConsumptionModel, section below) as a fallback for the grid-charge decision whenever Solcast was unavailable. That is no longer the case since the grid-friendly PV strategy (Phase 7): with no usable Solcast forecast (today or tomorrow), the decision relies exclusively on the dark window. ConsumptionModel.predicted_pv_kwh and .predicted_load_kwh currently feed into no control decision at all — they are dashboard-display values only (see "Consumption & PV Prediction" further down this page).
Inverter Write Confirmation (InverterController, since v2.0.1)¶
A service call Home Assistant accepts (HTTP 200) is not proof the inverter applied it — some Deye/Solarman bridges only confirm a written value (charge current, discharge current, grid-charge switch) on their next poll, observed to lag by up to ~25 minutes.
per channel (charge current, discharge current, grid-charge switch), independently:
when the target changes: mark the channel "unconfirmed"
while unconfirmed:
if the real HA state == target: mark the channel "confirmed"
else, if ≥ INVERTER_WRITE_CONFIRM_TIMEOUT_SEC (30 s) since the last send:
resend the service call
write_unconfirmed (0–3) is a live count of how many of the three channels are currently unconfirmed, and drops back to 0 once the real state catches up — separate from write_errors, which only counts outright HTTP/connection failures. Both surface as warnings in the dashboard banner.
Battery Protection Hysteresis¶
Enter: soc < battery_min_soc → PROTECT_BATTERY (immediate)
Leave: only once soc ≥ battery_min_soc + battery_soc_hysteresis_pct (default 2 %)
Without this deadband, a SoC hovering right around battery_min_soc would flip the mode back and forth between PROTECT_BATTERY and something else on every tick.
Mode Debouncing (_commit, mode_dwell_sec)¶
A newly proposed mode is not applied immediately — it must be requested continuously for a while first, unless the decision is marked urgent (SoC protection, a missing/stale SoC sensor, or an export hold released by a safety guard):
If decision.mode == current mode:
no change, pending reset
If decision.urgent OR mode_dwell_sec ≤ 0:
apply immediately
Otherwise:
If decision.mode ≠ previously proposed mode:
new proposal, start timer at now
waited = now − pending_since
If waited < mode_dwell_sec:
keep current mode (still waiting)
Else:
apply the mode
Default mode_dwell_sec = 300 s. This prevents rapid switching on short price or PV fluctuations, without delaying real emergencies (empty battery, sensor failure).
Battery State (BatteryModel)¶
free_to_charge_kwh = max(0, (max_soc − soc) / 100 × capacity_kwh)
useable_kwh = max(0, (soc − min_soc) / 100 × capacity_kwh)
capacity_kwh defaults to battery_capacity_kwh (fixed config value), but is replaced on every tick by the live sensor battery_capacity_entity if configured and it reports a plausible value:
This plausibility band prevents a mis-scaled sensor (e.g. an Ah reading instead of kWh) from feeding an absurd capacity into the charge/decision logic — outside the band, the fixed config value is used instead.
Cost & Savings — Complete Reference (CostOptimizer)¶
CostOptimizer.record_tick() is called once per EMS tick (default 30 s).
All accumulators are keyed by calendar date and flushed to SQLite after every
tick. Values are stored at 6 decimal-place precision. On restart,
today's accumulators are restored from SQLite before the first tick.
Prerequisite: Spike Filtering¶
Every power reading is validated by SensorValidator before use.
A sample is rejected (replaced by the last accepted value) when:
If no prior value exists for a sensor, the first reading is always accepted.
Interval Duration¶
Grid Import & Cost (today_grid_import_kwh, today_grid_cost_eur)¶
kWh — Source A (preferred)¶
When grid_import_energy_entity is configured (default: sensor.deye8k_today_energy_import),
the inverter's own daily import counter is used directly. The value is set each tick:
kWh — Source B (calculated fallback)¶
When grid_import_energy_entity is empty or unavailable, kWh is accumulated from
grid_power_w only when grid_power_w > 0 (net import):
kwh_imported = (grid_power_w / 1000) × hours
grid_import_kwh += kwh_imported ← accumulated each tick
Cost — always accumulated per tick¶
Grid cost cannot be derived from a daily total sensor because it requires the spot price at each individual interval. It is always accumulated from ticks:
price_eur_kwh is the current dynamic spot price from electricity_price_entity.
PV Savings (today_pv_savings_eur)¶
Represents the electricity cost avoided by using PV instead of buying from the grid. Only the portion of PV that directly covers house load is counted — PV exported to the grid is excluded here (see Feed-in below).
pv_to_load_w = clamp(pv_power_w, 0, load_power_w)
kwh_pv_used = (pv_to_load_w / 1000) × hours
pv_used_kwh += kwh_pv_used
pv_savings_eur += kwh_pv_used × price_eur_kwh
The valuation uses the current spot price, so cheap-rate PV contributes less savings than peak-rate PV.
Total Load Cost (today_load_total_kwh, today_load_cost_eur)¶
Hypothetical cost if the entire house load had been purchased from the grid at the current spot price, regardless of actual source (PV, battery, grid).
Since v2.0.4: a three-stage cascade, not two sources
For grid_import_kwh, feed_in_kwh and load_total_kwh this order applies as
of v2.0.4 — the Source A described below has become the middle stage:
| Stage | Source | Day boundary |
|---|---|---|
| 1 | lifetime counter minus anchor (*_total_entity) |
our local midnight |
| 2 | inverter's daily counter (*_energy_entity) |
the inverter's clock |
| 3 | tick accumulation from power | our local midnight |
Reason: the inverter resets its daily counters 4min54s after local midnight (measured across all four counters simultaneously). During that window stage 2 still reported yesterday's closing total, and the override wrote it into the new day. Stage 1 derives the daily delta itself and cuts the day where cost is cut. The daily counter stays in use as the anchor bootstrap after a mid-day restart. Details: Tageswechsel & Energiezählung (German).
Which figures are commensurable
Since stage 1, kWh totals and € totals no longer cover the same window: the kWh come from a hardware counter and therefore also cover add-on downtime, while every € value is still accumulated per tick and covers uptime only.
On a continuously running instance the divergence is minutes and negligible. On
one that restarts often it is not: a quotient such as today_load_cost_eur /
today_load_total_kwh came out at 0.1370 €/kWh there — below the cheapest
tariff and therefore impossible. Anyone forming a ratio across the two needs to
know this.
kWh – Source A (preferred, since v2.0.3)¶
If load_consumption_entity is configured (default:
sensor.deye8k_today_load_consumption), the inverter's own daily counter is
used directly. The value is set on every tick:
kWh – Source B (calculated fallback)¶
If load_consumption_entity is empty or unavailable:
Cost – always accumulated per tick¶
Always ≥ today_grid_cost_eur because PV and battery reduce actual grid
purchases.
Why Source A only exists here since v2.0.3
Before v2.0.3, today_load_total_kwh — unlike grid import and feed-in — had no
hardware counter to fall back on and was purely tick-based. Every add-on restart left
a gap that was never made up. Measured live: after a single restart, the value was
already ~1.1 kWh (≈23%) below the inverter's own daily total
(sensor.deye8k_today_load_consumption).
Grid-to-Battery Charging Cost (today_grid_charge_cost_eur)¶
Derived from the power balance — mode-independent, no EMS state needed.
The Deye sign convention is: battery_power_w > 0 = discharging,
battery_power_w < 0 = charging.
battery_charge_w = max(0, −battery_power_w) # positive when charging
pv_surplus_w = max(0, pv_power_w − load_power_w)
grid_charge_w = max(0, battery_charge_w − pv_surplus_w)
kwh_gc = (grid_charge_w / 1000) × hours
grid_charge_kwh += kwh_gc
grid_charge_cost_eur += kwh_gc × price_eur_kwh
grid_charge_w is the portion of battery charging power that cannot be
covered by excess PV — therefore it must have come from the grid. This
power-based estimate is the basis for today_grid_charge_cost_eur; it is
independent of the balance-based Scenario 2 calculation further down.
Feed-in Revenue (today_feed_in_revenue_eur)¶
Source A — HA sensor (preferred)¶
When feed_in_energy_entity is configured (default: sensor.deye8k_today_energy_export),
the inverter's own daily export counter is used directly. The sensor resets at midnight
and provides a cumulative kWh total. On each tick the value is set, not accumulated:
feed_in_kwh = feed_in_energy_entity ← read directly from HA each tick
feed_in_revenue = feed_in_kwh × feed_in_tariff_eur_kwh
Source B — calculated fallback¶
When feed_in_energy_entity is empty or the entity is unavailable, feed-in is
derived from grid_power_w only when grid_power_w < 0 (net export):
feed_in_w = max(0, −grid_power_w)
kwh_exported = (feed_in_w / 1000) × hours
feed_in_kwh += kwh_exported ← accumulated each tick
feed_in_revenue += kwh_exported × feed_in_tariff_eur_kwh
Both sources use the fixed feed-in tariff (feed_in_tariff_eur_kwh,
default 0.08 €/kWh), not the spot price.
Balance-Based Grid Charging, Efficiency & ROI (Scenario 2, optional)¶
Only active when the corresponding advanced sensors are configured — see the "Advanced Sensors for Balance-Based Cost Calculation" section in Configuration. Complements — does not replace — the power-based calculation above; all values are None/absent as long as the required inputs are missing.
Balance-Based Grid Charge Amount (today_grid_charge_kwh_bilanz)¶
Computed from the inverter's daily total sensors instead of instantaneous power — more robust against short measurement gaps:
grid_charge_energy = today_energy_import − today_load_consumption + today_battery_discharge
today_grid_charge_kwh_bilanz = max(0, grid_charge_energy)
Only computed when battery_discharge_entity and grid_import_energy_entity are available (today_load_consumption comes from the internally tracked load_total_kwh accumulator).
today_grid_charge_cost_bilanz_eur applies the power-based accumulator's average price per kWh to this amount:
today_grid_charge_cost_bilanz_eur = today_grid_charge_kwh_bilanz
× (today_grid_charge_cost_eur / today_grid_charge_kwh)
(0 when today_grid_charge_kwh == 0)
Inverter Efficiency (today_efficiency_pct)¶
η = (today_production_entity − today_losses_entity) / today_production_entity
(None when today_production_entity ≤ 0 or either entity is missing)
Grid-Charge ROI (today_grid_charge_roi_eur)¶
usable_kwh = today_grid_charge_kwh_bilanz × η
saving_eur = usable_kwh × avg_discharge_tariff_eur_kwh
roi_eur = saving_eur − today_grid_charge_cost_eur
Only computed when today_grid_charge_kwh_bilanz > 0, η > 0, and avg_discharge_tariff_eur_kwh > 0 is configured (default 0.0 = disabled). avg_discharge_tariff_eur_kwh is the assumed price the discharged energy would otherwise have cost — comparing the cost of grid charging against the value of the later, otherwise more expensive, grid draw it displaces.
Effect on today_cost_without_grid_charge¶
When the balance-based cost estimate is available, it is preferred over the power-based one for this metric:
gc_cost_for_subtraction = today_grid_charge_cost_bilanz_eur if available,
else today_grid_charge_cost_eur
today_cost_without_grid_charge = max(0, today_grid_cost_eur − gc_cost_for_subtraction)
Derived Metrics (computed in ems_controller.py)¶
These are calculated once per tick from the accumulated values above and added to the status dict.
| Entity | Formula | Meaning |
|---|---|---|
today_cost_without_grid_charge |
max(0, grid_cost_eur − gc_cost_for_subtraction) |
What the grid bill would have been if the battery had never been charged from the grid (see Scenario 2 precedence above) |
today_cost_fix_price_tariff |
load_total_kwh × fix_price + daily_base_price_eur |
What today's load would cost at the fixed reference tariff plus base fee (default 0.30 €/kWh, daily_base_price_eur default 0) |
Weekly Aggregation (in-memory)¶
Computed in CostOptimizer from the in-memory daily buckets without a DB
query:
week_grid_cost_eur = Σ grid_cost_eur[d] for all d where (today − d).days < 7
week_pv_savings_eur = Σ pv_savings_eur[d] for all d where (today − d).days < 7
The rolling window is exactly 7 calendar days (today + 6 prior days).
Monthly / Yearly Aggregation (SQLite)¶
CostOptimizer.summary_with_db() queries the daily_stats table:
-- Month
SELECT SUM(grid_cost_eur), SUM(pv_savings_eur), SUM(load_cost_eur)
FROM daily_stats WHERE date LIKE 'YYYY-MM-%'
-- Year
SELECT SUM(grid_cost_eur), SUM(pv_savings_eur), SUM(load_cost_eur)
FROM daily_stats WHERE strftime('%Y', date) = 'YYYY'
| Entities | Source |
|---|---|
month_grid_cost_eur, month_pv_savings_eur, month_load_cost_eur |
Calendar-month SUM from DB |
year_grid_cost_eur, year_pv_savings_eur, year_load_cost_eur |
Calendar-year SUM from DB |
Price Tier Consumption¶
Each tick, load_kwh is allocated to exactly one of three rate buckets
based on the current spot price. The three buckets always sum to
today_load_total_kwh for the day.
Tier Assignment¶
if price_eur_kwh < cheap_rate_threshold_eur:
kwh_low_rate += load_kwh # cheap
elif price_eur_kwh < medium_rate_threshold_eur:
kwh_medium_rate += load_kwh # medium
else:
kwh_high_rate += load_kwh # high
Tier Boundaries¶
| Tier | Condition | Config key | Default |
|---|---|---|---|
low |
price < cheap_rate_threshold_eur |
cheap_rate_threshold_eur |
0.10 €/kWh |
medium |
cheap_rate ≤ price < medium_rate_threshold_eur |
medium_rate_threshold_eur |
0.20 €/kWh |
high |
price ≥ medium_rate_threshold_eur |
medium_rate_threshold_eur |
0.20 €/kWh |
Both thresholds are configurable on the Settings page.
Daily Entities¶
| Entity | Accumulator | Resets |
|---|---|---|
today_kwh_low_rate |
in-memory, restored from DB on restart | midnight |
today_kwh_medium_rate |
in-memory, restored from DB on restart | midnight |
today_kwh_high_rate |
in-memory, restored from DB on restart | midnight |
Monthly Aggregation¶
month_kwh_high_rate, month_kwh_medium_rate, month_kwh_low_rate are
SQLite SUMs of the daily kwh_high_rate, kwh_medium_rate, kwh_low_rate
columns, queried via store.query_month().
Consumption & PV Prediction (ConsumptionModel)¶
Computed once per EMS tick in consumption_model.py.
Data source: SQLite daily history (store.py) + optional HA weather forecast.
Dashboard display only, no control effect
Both prediction values (predicted_load_kwh, predicted_pv_kwh) are computed
regardless of whether Solcast is configured, and are used purely for the
dashboard display. The actual grid-charge and export decisions rely
exclusively on the Solcast remaining-today forecast (see the "Grid-Charge
Decision" section further up this page) — this model no longer feeds into
that logic at all.
Predicted Load (predicted_load_kwh)¶
If weather_entity configured AND forecast available:
target_temp = tomorrow's forecast value the model keeps as "night temp"
(actually: tomorrow's daytime high from the HA forecast — the
naming is historical, see the source comment in consumption_model.py)
similar_days = days in last 60 d with |avg_temp − target| ≤ 4 °C
If len(similar_days) ≥ 3:
predicted_load = median(load_total_kwh of similar_days) → source: "historical"
Else:
→ temperature fallback rules (see below) → source: "fallback"
Else:
→ temperature fallback rules → source: "fallback"
Temperature Fallback Rules¶
| Condition | Predicted Load |
|---|---|
| Night temp < 0 °C and day temp < 0 °C | 30 kWh |
| Night temp < 0 °C and day temp < 10 °C | 20 kWh |
| Night temp > 0 °C and day temp < 15 °C | 10 kWh |
| Otherwise (e.g. mild/warm days outside the three rules) | Median of available historical days, else 0.0 kWh |
Predicted PV Yield (predicted_pv_kwh)¶
peaks = [peak_pv_w | last 14 days, peak_pv_w > 100 W]
p75 = 75th percentile(peaks), nearest-rank: sorted list, index ⌊n × 0.75⌋
With forecast:
clear_frac = avg(1 − cloud_coverage / 100) across all forecast slots
daylight_h = astronomical day length for HA latitude + current month
pv_factor = clear_frac × min(1.0, daylight_h / 12.0)
Without forecast:
pv_factor = 0.5 (neutral assumption)
daylight_h = approximation for 51°N + current month
predicted_pv = max(0, (p75 / 1000) × pv_factor × daylight_h)
Returns 0.0 kWh if no peak PV value above 100 W was recorded in the last 14 days (e.g. right after installation).
Day-length formula (daylight_hours_approx in weather_client.py):
day_of_year = (month − 1) × 30 + 15
decl = 23.45° × sin(360° × (284 + day_of_year) / 365)
cos_ha = −tan(lat) × tan(decl) [clamped to −1 … 1]
daylight_h = 2 × arccos(cos_ha) / 15
Weather Data Cache¶
WeatherClient caches the result of weather.get_forecasts for 30 minutes.
The HA latitude is read once from http://supervisor/core/api/config and cached.