This document describes the full EMS architecture, algorithms, and lessons learned. It is intended as a reference for building a standalone ha_ems component.
The EMS is embedded within a Felicity inverter integration but the scheduling logic is inverter-agnostic. It consists of:
| Layer | File | Responsibility |
|---|---|---|
| Coordinator | coordinator.py |
10-second update loop: reads prices, PV forecast, battery SOC; runs schedule optimizer; determines energy state; writes inverter registers |
| Sensors | sensor.py |
Exposes coordinator data as HA entities (price, schedule status, charge likelihood, etc.) |
| Selectors/Numbers | select.py, number.py |
Dashboard controls (Grid Mode, Price Mode, Power Level, SOC limits, etc.) |
| EMS Card | frontend/ha_felicity_ems.js |
LitElement card with canvas chart, client-side simulation, interactive controls |
| Type Handler | type_specific_handler.py |
Translates logical commands (charge/discharge/idle) into model-specific Modbus writes |
Nordpool Entity ──┐
PV Forecast ──┤
Battery SOC ──┼──▶ Coordinator ──▶ Schedule Optimizer ──▶ Energy State
Consumption ──┤ │ │
Grid Current ──┘ │ ▼
│ _transition_to_state()
│ Write inverter registers
▼
Sensor entities ──▶ EMS Card (frontend)
│ │
│ Client-side simulation
│ (mirrors coordinator logic
│ for live preview)
▼
HA History API ──▶ Past slot coloring
On first install, the EMS is completely inactive. No inverter registers are written for power management or economic rules. The integration only reads sensor data.
| Setting | Default | Effect |
|---|---|---|
| Grid Mode | off |
No charge/discharge decisions |
| Price Mode | manual |
User-set threshold (inactive until Grid Mode enabled) |
| Safe Power Management | auto |
Inactive (auto = only active when Grid Mode is on) |
| Nordpool Entity | not configured | No price data available |
| Forecast Entity | not configured | No PV forecast available |
Go to Settings -> Integrations -> Felicity Inverter -> Configure and set:
- Nordpool Entity: Select your Nordpool or energy price sensor (must have
device_class: monetary). Supports 15-min (96 slots), 30-min (48 slots), or hourly (24 slots) granularity. - Nordpool Override (optional): An alternative price entity that takes precedence over the primary.
Without a price entity, the EMS has no price data and cannot make charge/discharge decisions.
Use the Grid Mode selector entity on your dashboard:
from_grid— Charge battery from the grid during cheap price slotsto_grid— Sell battery energy back to the grid during expensive price slotsboth— Automatic arbitrage: charge at cheap prices AND sell at expensive prices within the same day
This is the main on/off switch for the EMS. When set to off, no economic rules are activated on the inverter.
Use the Power Level number entity (1-10) to set how many kW the inverter should use for charging/discharging. This value is written to the inverter's econ_rule_1_power register.
Use the Price Mode selector entity:
manual— You control the price threshold via the Price Threshold Level slider (1-10). Level 1 = cheapest prices only, level 10 = almost always active.auto— The schedule optimizer automatically selects the cheapest (or most expensive) time slots based on battery state, PV forecast, and consumption estimate.
In Configure, set:
- Forecast Entity: A Forecast.Solar or Solcast sensor showing today's expected kWh
- Forecast Entity Tomorrow: Tomorrow's forecast (if available as separate entity)
This allows the EMS to factor in expected solar production when calculating how much grid energy is needed.
In Configure, set:
- Consumption Override Entity: A P1 meter, utility meter, or template sensor that provides daily kWh consumption. The EMS builds a 7-day rolling average from this and uses it instead of the manual estimate.
Set Grid Mode to off. This:
- Stops all charge/discharge state transitions
- Stops writing
econ_rule_1_enable,econ_rule_1_soc,econ_rule_1_voltage, etc. - Sets
econ_rule_1_enableto 0 (idle) on the next cycle - Safe Power Management (if set to
auto) also becomes inactive
Price data, slot calculations, and informational sensors continue to update (read-only).
- Set Grid Mode to
off - Set Safe Power Management to
off - (Optional) Remove the Nordpool entity from Configure
This ensures zero register writes related to EMS. The integration only reads sensor data. An external EMS component can safely manage the inverter without conflicts.
Set Safe Power Management to off while keeping Grid Mode active. The EMS will still make charge/discharge decisions but will not monitor grid current or adjust the power level for safety. Use this only if another system handles overcurrent protection.
| Entity | Options | Default | Description |
|---|---|---|---|
| Grid Mode | off / from_grid / to_grid / both |
off |
Main EMS switch. from_grid = charge from grid, to_grid = sell to grid, both = charge cheap + sell expensive. |
| Price Mode | manual / auto |
manual |
manual = user sets price level, auto = optimizer picks best slots. |
| Safe Power Management | auto / on / off |
auto |
Controls amperage-based power limiting. auto = active only when Grid Mode is on. on = always active. off = never active (for external EMS). |
| Entity | Range | Default | Description |
|---|---|---|---|
| Power Level | 1-10 | 5 | Charge/discharge power in kW. Written to econ_rule_1_power. |
| Price Threshold Level | 1-10 | 5 | Where the price threshold sits between min and max price. Only used in manual mode. 1 = only cheapest, 10 = almost all slots. |
| Voltage Level | 48-60 V (48V) / 300-448 V (HV) | 58 | Max battery voltage during charging. The inverter stops charging when battery voltage reaches this level. Range auto-adjusts based on battery system voltage. |
| Discharge Min Voltage | 48-60 V (48V) / 300-448 V (HV) | 50 | Min battery voltage during discharging. The inverter stops discharging when battery voltage drops to this level. Range auto-adjusts based on battery system voltage. |
| Battery Charge Max Level | 30-100% | 100 | Target SOC for charging. Charging stops when SOC reaches this level. |
| Battery Discharge Min Level | 10-70% | 20 | Minimum SOC for discharging. Discharging stops when SOC drops to this level. |
| Battery Capacity | 1-100 kWh | 10 | Total usable battery capacity. Used by the schedule optimizer for energy calculations. |
| Efficiency Factor | 0.70-1.00 | 0.90 | Round-trip charge/discharge efficiency. Accounts for conversion losses. |
| Daily Consumption Estimate | 0-100 kWh | 10 | Fallback daily consumption estimate. Replaced by 7-day rolling average when consumption data is available. |
| Reserve Target | 0-100% | 0 | Fixed minimum battery reserve floor. 0 = dynamic (discharge_min + overnight reserve). When set > 0, overrides the dynamic calculation with a fixed percentage. Useful for grid-unstable areas where you always want a high battery level. |
| Max Amperage Per Phase | 10-63 A | 16 | Grid current safety limit. Used by Safe Power Management to prevent breaker trips. |
| Option | Description |
|---|---|
| Nordpool Entity | Primary energy price sensor. Required for any EMS functionality. |
| Nordpool Override | Alternative price entity (takes precedence when set). |
| Forecast Entity | PV forecast sensor (today's total kWh). Forecast.Solar or Solcast. |
| Forecast Entity Tomorrow | Tomorrow's PV forecast (separate entity). |
| Consumption Override Entity | Daily consumption sensor (P1 meter / utility meter). Feeds the 7-day rolling average. |
Every ~10 seconds the coordinator runs an update cycle:
┌─────────────────────────────────────────────────────────────┐
│ UPDATE CYCLE (~10s) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Read inverter registers (Modbus) │
│ 2. Read price data from Nordpool entity │
│ 3. Read PV forecast (if configured) │
│ 4. Read battery SOC │
│ │
│ ┌──────────────┐ │
│ │ New day? │ │
│ └──────┬───────┘ │
│ yes │ no │
│ ▼ │ │
│ ┌─────────────────┐ │ │
│ │ MIDNIGHT RESET │ │ │
│ │ • Record daily │ │ │
│ │ consumption │ │ │
│ │ • Calc deficit │ │ │
│ │ • Reset → idle │ │ │
│ └─────────────────┘ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ price_mode? │ │
│ └────┬────────┬───┘ │
│ manual │ │ auto │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌──────────────────────┐ │
│ │ Calc threshold │ │ Run schedule │ │
│ │ from user level │ │ optimizer │ │
│ │ (1-10) │ │ (select cheapest / │ │
│ │ │ │ most expensive │ │
│ │ │ │ slots for the day) │ │
│ └────────┬────────┘ └──────────┬───────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────────────────────┐ │
│ │ _determine_energy_state() │ │
│ │ → "charging" / "discharging" │ │
│ │ / "idle" │ │
│ └───────────────┬────────────────┘ │
│ │ │
│ ┌──────────┴───────────┐ │
│ │ State changed? │ │
│ └──────┬──────────┬────┘ │
│ yes │ │ no │
│ ▼ └─── (skip) │
│ ┌───────────────────────┐ │
│ │ _transition_to_state()│ │
│ │ Write inverter regs │ │
│ └───────────────────────┘ │
│ │
│ (parallel) Safe Power Management │
│ → monitor grid amps, adjust econ_rule_1_power │
│ │
└─────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ _determine_energy_state(battery_soc) │
├──────────────────────────────────────────────────────────────┤
│ │
│ grid_mode == "off"? ──yes──▶ return "idle" │
│ │ no │
│ ▼ │
│ battery_soc unknown? ──yes──▶ return "idle" │
│ │ no │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ price_mode == "auto"? │ │
│ └────────┬────────────────────┬───────────┘ │
│ yes │ │ no (manual) │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌───────────────────────────────┐ │
│ │ AUTO MODE │ │ MANUAL MODE │ │
│ │ │ │ │ │
│ │ Look up current │ │ grid_mode includes │ │
│ │ slot_idx in │ │ "from_grid" or "both"? │ │
│ │ scheduled_slots │ │ AND price < threshold? │ │
│ │ │ │ AND SOC < charge_max? │ │
│ │ "charge" slot │ │ ──yes──▶ "charging" │ │
│ │ + SOC < max? │ │ │ │
│ │ → "charging" │ │ grid_mode includes │ │
│ │ │ │ "to_grid" or "both"? │ │
│ │ "discharge" slot │ │ AND price > threshold? │ │
│ │ + SOC > min? │ │ AND SOC > discharge_min? │ │
│ │ → "discharging" │ │ ──yes──▶ "discharging" │ │
│ │ │ │ │ │
│ │ otherwise │ │ otherwise │ │
│ │ → "idle" │ │ → "idle" │ │
│ └──────────────────┘ └───────────────────────────────┘ │
│ │
│ Manual mode also uses hysteresis (5% of price spread) │
│ to prevent oscillation near threshold. │
│ │
└──────────────────────────────────────────────────────────────┘
The schedule optimizer runs every update cycle and determines which time slots should be used for charging or discharging. The key concept is solar-first: grid energy is only purchased when solar cannot cover the overnight reserve.
All three scheduling modes use a forward-looking SOC projection to make smarter decisions. Instead of only looking at the current battery level (snapshot), the algorithm simulates the battery trajectory through all remaining time slots, accounting for consumption drain and PV production (scaled by the confidence factor).
Helper functions (ems.py):
| Function | Purpose |
|---|---|
_calculate_pv_confidence() |
Compares actual PV produced so far vs forecast expected by now. Returns 0.1–1.0 confidence factor. |
_project_soc_trajectory() |
Simulates battery kWh slot-by-slot: subtracts consumption, adds PV (× confidence), clamps at battery capacity. Returns per-slot projection, min SOC, and max SOC. |
How it improves each mode:
-
from_grid / both (charge side): Uses
min_projected_socto detect future shortfalls. If the battery is currently above reserve but will dip below it later (e.g., evening consumption after PV stops), the predictive deficit catches this and schedules cheap grid charging proactively.snapshot_deficit = max(0, reserve_target - current_kwh - net_pv) predictive_deficit = max(0, reserve_target - min_projected_soc) energy_deficit = max(snapshot_deficit, predictive_deficit) -
to_grid / both (sell side): Uses
max_projected_socto account for PV that will boost the battery later. If PV will push the battery well above reserve, more energy can be safely sold.snapshot_sellable = max(0, current_kwh - reserve_target) × efficiency predictive_sellable = max(0, max_projected_soc - reserve_target) × efficiency sellable = max(snapshot_sellable, predictive_sellable)
Key scenario this solves: Battery at 72% SOC on a cloudy day with cheap night prices. The snapshot sees 72% > reserve and schedules no charging. The trajectory projects that evening consumption will drain the battery below reserve — and schedules cheap night slots to prevent this.
The unified charge slot selection (select_unified_charge_slots) applies a PV-aware headroom constraint: the remaining battery capacity available for grid charging is reduced by the expected net PV surplus, since that PV energy will also fill the battery.
raw_headroom = charge_max_kwh - current_kwh
pv_fill = max(0, net_pv_surplus)
effective_headroom = max(0, raw_headroom - pv_fill)
max_today_slots = floor(effective_headroom / kwh_per_slot)
Exceptions:
- Negative-price slots are exempt from headroom cap — charging during negative prices is always profitable
- Deficit slots are exempt — if the battery needs energy to reach reserve, those slots are always allowed
This prevents paying for grid energy that can't be stored because PV will fill the remaining battery capacity during the day.
After selecting charge/discharge slots, a forward SOC simulation validates the schedule slot-by-slot. This catches timing issues that aggregate calculations miss — for example, selling at 7am when PV doesn't peak until noon, or charging slots 1-6 when the battery is already 90% full.
How it works (_validate_schedule_soc in ems.py):
1. Simulate battery SOC forward through all remaining slots:
- For each slot: soc += pv_per_slot - consumption_per_slot
- If charge slot: soc += energy_per_slot × efficiency
- If discharge slot: soc -= energy_per_slot
2. Check bounds BEFORE clamping:
- If soc < min_kwh → violation (low)
- If soc > battery_capacity → violation (high)
3. On violation, prune the least valuable offending slot:
- Low violation: drop the discharge slot with lowest price
(least profitable to sell) at or before the violation
- High violation: drop the charge slot with highest price
(most expensive to buy) at or before the violation
- Negative-price charge slots are exempt from overflow pruning
(we're being paid to charge — wasting some energy is still profitable)
4. Re-simulate and repeat until no violations remain
Applied to all three modes:
from_grid: validates charge slots don't overflow the batteryto_grid: validates discharge slots don't drain below reserveboth: validates combined charge+discharge schedule respects both bounds
Key scenario this prevents: Battery at 40% SOC with expensive morning prices (7-8am) and PV peaking at noon. Without validation, the scheduler might sell at 7am because aggregate calculations show enough total energy. The per-slot check catches that 7am sell would breach the minimum before PV arrives at noon.
By default, the reserve target is computed dynamically:
reserve_target = min(battery_capacity, discharge_min_kwh + overnight_reserve)
The Reserve Target setting (reserve_target_pct) overrides this with a fixed floor:
If reserve_target_pct = 0 (default):
→ Dynamic: discharge_min + overnight_reserve (varies by consumption and PV)
If reserve_target_pct > 0:
→ Fixed: max(reserve_target_pct% × capacity, discharge_min%)
Use cases for a fixed reserve target:
- Grid-unstable areas: Set to 80% to keep the battery topped up, using grid only when PV falls short
- Backup power priority: Set to 70% to always have enough reserve for outages
- Default (0): Dynamic calculation adapts to actual consumption and season — best for most users
Important: A high reserve target reduces sellable energy in to_grid and both modes, since sellable = max_projected - reserve_target. Setting reserve_target_pct=80% on a 60 kWh battery means only energy above 48 kWh is available for selling.
1. Calculate self-consumption reserve (sunset → sunrise):
- sunset_hour = last hour with PV forecast > 0.1 kWh (default: 19)
- sunrise_hour = first hour with PV forecast > 0.1 kWh (default: 7)
- overnight_hours = (24 - sunset) + sunrise
- reserve_kwh = consumption_per_hour × overnight_hours
2. Calculate reserve target:
- min_kwh = discharge_min% × battery_capacity
- If reserve_target_pct > 0:
reserve_target = max(reserve_target_pct% × capacity, min_kwh)
- Else (default):
reserve_target = min(battery_capacity, min_kwh + reserve_kwh)
The target ensures that AFTER overnight drain the battery still
sits at min_kwh. It is NOT charge_max — only enough to survive
overnight while respecting the minimum SOC floor.
3. Calculate battery shortfall:
- battery_shortfall = max(0, reserve_target - current_kwh)
4. Calculate net PV surplus (see "PV Surplus Model" below):
- Only counts hours where PV production > house consumption
- Scaled by PV confidence factor (actual vs forecast)
5. Calculate grid energy deficit:
- snapshot_deficit = max(0, battery_shortfall - net_pv)
- Project SOC trajectory through remaining slots (PV × confidence − consumption)
- predictive_deficit = max(0, reserve_target - min_projected_soc)
- energy_deficit = max(snapshot, predictive) + yesterday's deficit carryover
6. Select charge slots:
a. Always include negative-price slots (paid to charge)
b. Sort remaining by price ascending
c. Pick cheapest N slots to cover remaining deficit
d. threshold = highest price among selected slots
7. Unified two-day optimization (when tomorrow's prices available):
a. Project midnight battery from actual state + today's charge - drain
b. Calculate tomorrow's deficit:
reserve_target + daytime_gap - projected_midnight
where daytime_gap = max(0, consumption - pv_tomorrow)
c. Total deficit = today's + tomorrow's
d. Merge today remaining + tomorrow all slots → sort by price
e. Pick cheapest N slots from combined pool to cover total deficit
f. Safety: if battery drops below min_kwh before tomorrow's first
selected slot, swap expensive tomorrow → cheap today slots
1. Calculate reserve floor:
- min_kwh = discharge_min% × battery_capacity
- reserve_target = min(battery_capacity, min_kwh + self_consumption_reserve)
(includes overnight reserve to protect self-sufficiency)
2. Calculate sellable energy (predictive):
- snapshot_sellable = max(0, current_kwh - reserve_target) × efficiency
- Project SOC trajectory through remaining slots (PV × confidence − consumption)
- predictive_sellable = max(0, max_projected_soc - reserve_target) × efficiency
- sellable = max(snapshot_sellable, predictive_sellable)
NOTE: PV that will boost battery later makes more energy safely sellable
3. Select discharge slots:
a. Exclude negative-price slots (never sell at a loss)
b. Sort remaining by price descending
c. Pick most expensive N slots to sell all sellable energy
┌────────────────────────────────────────────────────────────────┐
│ BOTH MODE — SELF-SUFFICIENCY FIRST │
├────────────────────────────────────────────────────────────────┤
│ │
│ PHASE 0 — SELF-CONSUMPTION RESERVE │
│ ├─ Estimate sunset hour (last hour with PV > 0.1 kWh) │
│ ├─ Estimate sunrise tomorrow (first hour with PV today) │
│ ├─ overnight_hours = (24 - sunset) + sunrise │
│ └─ reserve = consumption_per_hour × overnight_hours │
│ │
│ PHASE 1 — CHARGE SIDE (grid only if solar can't cover) │
│ ├─ reserve_target = discharge_min + overnight_reserve │
│ │ (capped at battery_capacity) │
│ ├─ snapshot_deficit = reserve_target − current_kwh − net_pv │
│ ├─ Project SOC trajectory (PV × confidence − consumption) │
│ ├─ predictive_deficit = reserve_target − min_projected_soc │
│ ├─ energy_deficit = max(snapshot, predictive) │
│ │ Catches future shortfalls even if battery is OK right now │
│ ├─ Always include negative-price slots (paid to charge) │
│ └─ Fill remaining deficit with cheapest non-negative slots │
│ │
│ PHASE 2 — DISCHARGE SIDE (only sell true surplus) │
│ ├─ reserve_floor = discharge_min + overnight_reserve │
│ ├─ snapshot_sellable = (current_kwh − reserve_floor) × eff │
│ ├─ Project SOC trajectory → max_projected_soc │
│ ├─ predictive_sellable = (max_projected − reserve_floor) × eff│
│ ├─ sellable = max(snapshot, predictive) │
│ │ PV boost later → more safely sellable now │
│ └─ Select most expensive positive-price slots │
│ │
│ PHASE 3 — PROFITABILITY FILTER │
│ ├─ Remove any discharge slot that overlaps a charge slot │
│ ├─ min_sell_price = max_buy_price / (eff × eff) │
│ └─ Only keep discharge slots where price ≥ min_sell_price │
│ │
│ PHASE 4 — ANTI-CONFLICT GUARD (real-time) │
│ ├─ Before activating discharge, check grid power direction │
│ └─ If house is importing >200W → suppress discharge (idle) │
│ (prevents selling battery while buying from grid) │
│ │
│ Example: battery=30kWh, reserve=15kWh, discharge_min=6kWh │
│ ├─ reserve_floor = 6 + 15 = 21 kWh │
│ ├─ sellable = (30 − 21) × 0.9 = 8.1 kWh │
│ └─ Only sell 8.1 kWh at profitable prices │
│ (remaining 21 kWh = overnight drain + min SOC floor) │
│ │
└────────────────────────────────────────────────────────────────┘
In all modes (from_grid, to_grid, both), the charge target is the overnight reserve (enough to survive until tomorrow's solar), NOT charge_max (filling the battery to 100%). This means:
- On sunny days with good forecast: deficit is 0, no grid charging needed
- On cloudy days: deficit increases, more cheap grid slots selected
- charge_max is only used as a SOC cap during actual charging (inverter stops at charge_max), not as a scheduling target
The deficit calculation uses per-hour solar production data (from Forecast.Solar wh_hours or Solcast detailedHourly) to accurately determine how much solar surplus is available to charge the battery.
Why this matters: A flat calculation like PV_total - consumption_total is misleading:
Example day: consumption = 2 kWh/hour (flat), PV varies by hour
Hour: 06 07 08 09 10 11 12 13 14 15 16 17 18 19
PV: 0 1 2 4 6 7 7 6 4 2 1 0 0 0 = 40 kWh
Load: 2 2 2 2 2 2 2 2 2 2 2 2 2 2 = 28 kWh
(14h × 2)
Flat model: net_pv = 40 - 28 = 12 kWh surplus
Hourly model: surplus per hour (only positive values):
Hour: 06 07 08 09 10 11 12 13 14 15 16 17 18 19
Diff: -2 -1 0 +2 +4 +5 +5 +4 +2 0 -1 -2 -2 -2
Surplus: 0 0 0 2 4 5 5 4 2 0 0 0 0 0 = 22 kWh
The hourly model yields MORE surplus because it correctly recognizes
that solar peak hours produce enough to charge the battery, even
though evening hours have no sun.
When hourly PV data is unavailable, the system falls back to the flat model.
Problem discovered: On cloudy days the PV forecast can be wildly optimistic (e.g., forecast says 24.7 kWh but actual production at midday is 0 kWh). The scheduler trusts the forecast surplus and schedules too few grid charge slots, leaving the battery well below the min SOC target.
Solution: Scale the forecast by a confidence factor based on actual-vs-expected production:
pv_confidence = actual_produced_so_far / forecast_expected_by_now
Example at 13:00:
- Forecast expected by now: 12 kWh (sum of hourly forecast for hours 0-12)
- Actual PV today: 0 kWh
- Confidence: 0.0 → floored to 0.1
Without confidence: net_pv = 10 kWh → deficit = 0 → no grid charging
With confidence: net_pv = 1 kWh → deficit = 8 kWh → 4 charge slots
Rules:
- Only activates when >1 kWh was expected by now (avoids early-morning noise)
- Floored at 0.1 (never completely ignores forecast — weather can improve)
- Capped at 1.0 (if actual exceeds forecast, don't over-estimate)
- Sunny days: confidence ≈ 1.0, no change
- Cloudy days: confidence drops, more grid slots scheduled
This is critical for reliable operation. Without it, a single cloudy day can leave the battery dangerously low.
Problem: Some TREX-25/50 installations have solar panels connected via the generator/micro-inverter port instead of the dedicated PV inputs. In these setups:
- PV registers (
pv1-4_day_energy) always read 0 kWh - The inverter doesn't know the generator-port power is solar
- The confidence factor permanently drops to 0.1 (floor), because
actual / expected = 0 / X = 0 - The scheduler over-estimates the energy deficit and over-schedules grid charging
- Likelihood shows "tight" or "at_risk" when solar is actually producing fine
Detection: The inverter has a genmode register (address 8759) with options: Generator, Smart Load, Micro Inv. When set to Micro Inv, the generator port is being used for solar micro-inverters. Additionally, if PV registers read ~0 but generator_day_cost_energy > 0, solar is clearly flowing through the generator port.
Solution: The pv_actual_today_kwh property now falls back to generator_day_cost_energy when:
- PV string registers read near-zero (< 0.1 kWh), AND
generator_day_cost_energy> 0
This applies to all inverter types (TREX-5/10 and TREX-25/50). The backend collects PV energy from whichever register type exists, then checks the generator fallback if the total is near-zero — regardless of model.
This ensures the PV confidence factor works correctly even when solar enters via the generator port. The generator energy register (address 4586, 0.1 kWh precision) tracks daily energy just like PV day energy would.
Card generator_as_pv setting (default: true):
Both the inverter card and EMS card expose a generator_as_pv config option (checkbox in the card editor: "Treat generator port as PV (micro-inverter solar)"). When enabled:
- Inverter card: Real-time PV power display uses
total_generator_active_powerwhen PV registers read near-zero. Generator display shows 0 W to avoid double-counting. SVG flow animations follow accordingly. - EMS card: PV Today display falls back to
generator_day_cost_energyeven when the backendpv_actual_today_kwhattribute returns 0 (the frontend applies its own secondary check).
When disabled (for users with actual diesel generators), both cards show raw PV and generator values without merging.
Available generator registers (TREX-25/50):
| Register | Address | Description |
|---|---|---|
generator_day_cost_energy |
4586 | Daily energy through gen port (kWh) — used as PV actual fallback |
total_generator_power |
4498 | Current total power through gen port (kW) |
phase_a/b/c_generator_active_power |
4464-4466 | Per-phase gen power (kW) |
genmode |
8759 | Port mode: Generator / Smart Load / Micro Inv |
For ha_ems: This is an important edge case. Any generic EMS must handle the scenario where the inverter's PV measurement point doesn't cover all solar sources. Consider:
- A config option to specify alternative PV actual entities (e.g., a separate energy meter on the solar array) — implemented as
generator_as_pvcard setting - Auto-detection when PV reads 0 but other power sources show solar-like patterns (daytime-only, follows irradiance curve)
- A flag to disable the confidence factor entirely if no reliable PV actual measurement exists
- Both real-time power (inverter card) and daily energy (EMS card) must handle the fallback independently
Each update cycle (~10 seconds):
- Read current price from Nordpool entity
- Calculate price threshold from min/avg/max prices using the user's Price Threshold Level (1-10)
- Apply hysteresis band around the threshold to prevent oscillation:
- Margin = 5% of price spread (max_price - min_price)
- To enter charging: price must drop below
threshold - margin - To enter discharging: price must rise above
threshold + margin - To stay in current state: price only needs to remain past
threshold(no margin) - Dead zone between
threshold +/- margin-> remains in current state or idle
- SOC limits also apply:
- Charging stops when SOC reaches Battery Charge Max Level
- Discharging stops when SOC drops to Battery Discharge Min Level
- Write economic rule registers on state change
Price ───────────────────────────────────────────────────────▶
charge dead discharge
zone zone zone
◀──────────────────▶ ◀──────────▶ ◀──────────────────▶
──────────────────|────────────|─────────|──────────────────
threshold threshold threshold
− margin + margin
• Entering charge requires price < threshold − margin
• Entering discharge requires price > threshold + margin
• Once in a state, stays until price crosses raw threshold
• If price is in dead zone and idle: stays idle (no flip-flop)
When active, each update cycle:
- Read the highest grid current across all phases
- Compare against Max Amperage Per Phase setting:
- > 95%: Reduce power level by 2 kW (emergency)
- > 80%: Reduce power level by 1 kW (caution)
- < 70%: Recover power level by 1 kW (up to user's Power Level)
- 0 or no current: Jump directly to user's Power Level
- Write
econ_rule_1_powerregister when the level changes
Grid current ─────────────────────────────────────────────────▶
0A 70% 80% 95% max
│ │ │ │ │
│ JUMP TO │ RECOVER │ HOLD │ REDUCE │
│ user │ +1 kW/ │ (no │ -1 kW │
│ power │ cycle │ change) │ │
│ level │ │ │ >95%: │
│ │ │ │ -2 kW │
The EMS card is a LitElement-based HA Lovelace card (ha_felicity_ems.js) that provides a complete dashboard for monitoring and controlling the EMS. It includes a canvas-based price chart, interactive controls, and a client-side schedule simulation for live preview.
┌─────────────────────────────────────────────────────────┐
│ [████████░░] 65% / 60 kWh CHARGING ACTIVE │
├─────────────────────────────────────────────────────────┤
│ PRICE THRESHOLD LIKELIHOOD │
│ 0.220 €/kWh 0.253 €/kWh on_track │
├─────────────────────────────────────────────────────────┤
│ Today's Schedule [Today] [Tomorrow] │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Canvas chart: price bars per slot │ │
│ │ - Green bars = charge slots │ │
│ │ - Orange bars = discharge slots │ │
│ │ - Grey bars = idle │ │
│ │ - Red dashed line = price threshold │ │
│ │ - White border = current slot │ │
│ │ - Past: dim colors from actual HA history │ │
│ └─────────────────────────────────────────────────┘ │
│ ⚡ 4 charge 📡 0 sell ↓ 9.0 kWh planned 🔋 19.3 res│
├─────────────────────────────────────────────────────────┤
│ PV Today Remaining Forecast Today Tomorrow │
│ 22.8 kWh 9.5 kWh 39.6 kWh 37.7 kWh │
├─────────────────────────────────────────────────────────┤
│ Grid Mode Price Mode Max SOC Min SOC │
│ [from_grid] [auto ] [100% ] [35% ] │
│ │
│ Power 7.5 kW Price Level 5/10 │
│ ═══════●════ ═══════════●══════ │
│ │
│ Safe: 7.5 kW Est: 38.5 kWh/d │
└─────────────────────────────────────────────────────────┘
- 10-segment visual bar in the header
- Color-coded: green (>50%), orange (20-50%), red (<20%)
- Shows SOC % and total capacity
The chart displays one bar per price slot (supports 15-min, 30-min, or hourly granularity).
Bar coloring for Today view:
| Slot Type | Color | Description |
|---|---|---|
| Past + actually charged | Dim green (0.3 alpha) | From HA energy_state history |
| Past + actually discharged | Dim orange (0.3 alpha) | From HA energy_state history |
| Past + idle/no action | Dim grey (0.2 alpha) | No significant charge/discharge |
| Current slot (charge) | Bright green + white border | Active now |
| Current slot (discharge) | Bright orange + white border | Active now |
| Current slot (idle) | Light blue + white border | Active now |
| Future charge | Green (#4CAF50) | Scheduled to charge |
| Future discharge | Orange (#FF9800) | Scheduled to sell |
| Future negative price | Blue (#2196F3) | Will charge (paid to take energy) |
| Future idle | Grey (0.4 alpha) | No action planned |
Tomorrow view uses slightly softer (0.6 alpha) versions of the same colors.
Chart elements:
- Red dashed threshold line with value label
- Zero line when negative prices exist
- Hour markers on x-axis (adaptive spacing)
- Min/max price labels on y-axis
The card fetches the energy_state entity's history via the HA REST API (history/period/...) to determine what actually happened in past slots. This is throttled to once per 60 seconds.
For each past slot:
- Fetch all state changes that overlap the slot's time window
- Calculate time-weighted duration of each state (charging, discharging, idle)
- Mark the slot if charging or discharging exceeded 10% of slot duration
- Color the bar accordingly (dim green for charged, dim orange for discharged)
This provides visual feedback on what the system actually did vs. what was planned.
The card includes a full JavaScript reimplementation of the coordinator's schedule optimizer. This enables live preview — as the user drags sliders or changes dropdowns, the chart updates instantly without waiting for HA state updates.
Mirrored logic:
- Solar-first reserve targeting (same as coordinator)
- Negative price handling
- Round-trip profitability filter for
bothmode - All three grid modes (from_grid, to_grid, both)
Simulation parameters come from the schedule_status entity attributes:
sim_params.battery_capacity_kwhsim_params.battery_soc_pctsim_params.battery_charge_max_pctsim_params.battery_discharge_min_pctsim_params.efficiencysim_params.net_pv_kwh(already confidence-adjusted by coordinator)sim_params.consumption_est_kwhself_consumption_reserveyesterday_deficit_kwhslot_granularity_min
Override parameters (local to card, used during slider drag):
powerKw— from power sliderpriceLevel— from price level sliderchargeMax/dischargeMin— from SOC dropdownsgridMode— from grid mode dropdown
Tomorrow simulation differs: assumes battery starts at discharge_min % (worst-case overnight), uses tomorrow's forecast PV minus daily consumption estimate, and all slots are treated as future.
Dropdowns (4-column grid):
- Grid Mode: off / from_grid / to_grid / both
- Price Mode: manual / auto
- Max SOC: 100% down to 30% in 5% steps
- Min SOC: 10% up to 70% in 5% steps
Sliders (2-column grid):
- Power Level: 1-10 kW in 0.5 kW steps
- Price Threshold Level: 1-10
Sliders use a preview + commit pattern:
- On drag: update local override, re-run simulation, redraw chart (instant)
- On release: send value to HA via service call
- After 2 seconds: clear local override to sync with actual HA state
- Primary:
schedule_statusentity attributeslot_schedule/slot_schedule_tomorrow - Fallback: Read directly from Nordpool entity attributes (
today,prices_today,raw_todayfor today;tomorrow,prices_tomorrow,raw_tomorrowfor tomorrow)
The card resolves entity IDs from a device_id. It uses hass.entities to find all entities belonging to the configured device, then matches by suffix (e.g., _energy_state, _current_price). A regex fallback handles entity IDs with extra words inserted (e.g., sensor.xxx_pv_generated_energy_inquiry_day matches key pv_generated_energy_day).
Shows four PV values:
- PV Today: Actual production from inverter registers (TREX-5/10:
pv_generated_energy_dayin Wh; TREX-25/50: sum ofpv1-4_day_energyin kWh) - Remaining: Estimated remaining forecast for rest of day
- Forecast Today: Total forecast for today
- Tomorrow: Forecast for tomorrow
When pv_actual_today_kwh is not available as a schedule_status attribute, the card falls back to reading the entity directly.
Generator-port solar fallback (PV Today):
The PV Today value uses a multi-level fallback:
pv_actual_today_kwhattribute fromschedule_statussensor (backend)- If null: direct entity read (
pv_generated_energy_dayfor TREX-5/10, or sum ofpv1-4_day_energyfor TREX-25/50) - If near-zero AND
generator_as_pvenabled:generator_day_cost_energyentity
Step 3 also applies as a secondary check when step 1 returns 0 (not null) — ensuring generator-port solar is always captured when the backend attribute hasn't been updated yet.
When the EMS decides on a state change, _transition_to_state() writes the economic rule registers:
| Register | Charging | Discharging | Idle |
|---|---|---|---|
econ_rule_1_enable |
1 | 2 | 0 |
econ_rule_1_soc |
Battery Charge Max Level | Battery Discharge Min Level | (not written) |
econ_rule_1_voltage |
Voltage Level (max) | Discharge Min Voltage (min) | (not written) |
econ_rule_1_power |
Safe Max Power (watts) | Safe Max Power (watts) | (not written) |
econ_rule_1_start_day |
today | today | (not written) |
econ_rule_1_stop_day |
today | today | (not written) |
| Aspect | TREX-5 / TREX-10 | TREX-25 / TREX-50 |
|---|---|---|
| Enable register | Single econ_rule_1_enable @ 8568 (0/1/2) |
econ_rule_1_grid_charge_enable @ 8713 + mode registers |
| Power unit | Watts (direct) | Kilowatts (divide by 1000) |
| Power registers | econ_rule_1_power only |
econ_rule_1_power + grid_peak_shaving_power |
| Voltage scaling | x10 (58V -> 580) | x10 (58V -> 580) |
| Date registers | Written (start_day / stop_day) | Ignored (not applicable) |
| Voltage range | 48-60 V (dynamic) | 48-500 V |
| Battery SOC source | Single battery register | min(bat1_soc, bat2_soc) — conservative |
econ_rule_1_enable = 1 → reg 8568 = 1
econ_rule_1_soc = 100 → reg 8575 = 100
econ_rule_1_voltage = 58 → reg 8574 = 580 (×10)
econ_rule_1_power = 3000 → reg 8576 = 3000 (watts)
econ_rule_1_start_day → reg 8571
econ_rule_1_stop_day → reg 8572
econ_rule_1_enable = 1 → reg 8713 = 1 (grid_charge_enable)
peak_shaving_enable = 1
econ_rule_1_soc = 100 → reg 8718 = 100
econ_rule_1_voltage = 58 → reg 8717 = 580 (×10)
econ_rule_1_power = 3000 → reg 8719 = 3 (kW, ÷1000)
reg 8521 = 3 (grid_peak_shaving)
econ_rule_1_enable = 2 → reg 8713 = 1 (grid_charge_enable)
reg 8521 = 0 (peak_shaving = 0)
peak_shaving_enable = 0
econ_rule_1_soc = 20 → reg 8718 = 20
econ_rule_1_voltage = 50 → reg 8717 = 500
econ_rule_1_power = 3000 → reg 8719 = 3 (kW)
econ_rule_1_enable = 0 → reg 8713 = 0 (grid_charge OFF)
reg 8521 = 0 (peak_shaving OFF)
(SOC, voltage, power not written for idle)
These sensors update regardless of EMS state:
| Sensor | Description |
|---|---|
| Current Price | Current electricity price from Nordpool entity |
| Min Price | Lowest price today |
| Max Price | Highest price today |
| Price Threshold | Calculated threshold (manual: from level, auto: from optimizer) |
| Available Slots | Number of remaining time slots at or below the current price threshold |
| Available Energy Capacity | How much energy (kWh) those slots could provide |
| Charge Likelihood | Whether the battery target will likely be met (uses scheduled energy, not just threshold slots): on_track (≥120%) / tight (100-120%) / at_risk (50-100%) / insufficient (<50%) / nothing_to_sell |
| Schedule Status | Current optimizer state: manual / active / waiting / off / no_action_needed |
| Energy State | Current inverter state: charging / discharging / idle / unknown |
| PV Forecast Today | Today's solar production forecast (kWh) |
| PV Forecast Remaining | Estimated remaining solar for rest of day |
| PV Forecast Tomorrow | Tomorrow's solar forecast |
| Safe Max Power | Current power level after Safe Power Management adjustment |
| Weekly Avg Consumption | 7-day rolling average daily consumption (kWh). Persisted to disk. |
The schedule_status sensor carries rich attributes used by the EMS card:
{
"slot_schedule": [...], # Today's price slots with actions
"slot_schedule_tomorrow": [...], # Tomorrow's price slots
"slot_granularity_min": 60, # Minutes per slot
"scheduled_charge_slots": 4,
"scheduled_discharge_slots": 0,
"grid_energy_planned_kwh": 9.0,
"self_consumption_reserve": 19.3,
"yesterday_deficit_kwh": 0.0,
"pv_actual_today_kwh": 22.8,
"sim_params": { # For client-side simulation
"battery_capacity_kwh": 60,
"battery_soc_pct": 85,
"battery_charge_max_pct": 100,
"battery_discharge_min_pct": 35,
"efficiency": 0.90,
"net_pv_kwh": 5.2, # Already confidence-adjusted
"consumption_est_kwh": 38.5
}
}At midnight each day:
- Record today's energy consumption (from override entity or inverter registers) for the rolling average
- Calculate yesterday's deficit (how far short of the charge target)
- Carry the deficit forward to the next day's energy target (capped by battery headroom)
- Reset the energy state to idle
- Begin a new scheduling cycle
The 7-day consumption rolling average is persisted to disk using Home Assistant's Store helper (saved to .storage/). On reboot or update:
- The stored history (up to 7 days) is loaded from disk
- The weekly average is immediately recalculated
- No data is lost — you do NOT need to wait another week
The average works with as few as 1 day of data (divides by actual number of entries, not always 7).
Data priority for daily consumption:
- Consumption Override Entity (P1 meter / utility meter) — most accurate
- Inverter daily energy registers (daily_energy_consumed, daily_load_energy, etc.)
- Falls back to
Daily Consumption Estimatesetting if neither source provides data
Issue: The scheduler assumed PV forecast was accurate and subtracted predicted solar surplus from the grid energy deficit. On cloudy days (0 kWh actual vs 24.7 kWh forecast), this resulted in zero grid charging, leaving the battery far below min SOC.
Fix: PV confidence factor scales forecast by actual / expected_by_now. See "PV Confidence Factor" section above.
Issue: Original both mode used charge_max (e.g., 100%) as the target, causing the scheduler to buy grid energy to fill the battery even when solar would handle it.
Fix: Changed target to overnight reserve in all modes. Grid charging only covers the gap between current battery level, expected solar, and overnight needs.
Issue: TREX-25/50 entity IDs include extra words (e.g., sensor.xxx_pv_generated_energy_inquiry_day for key pv_generated_energy_day). The card's exact suffix match failed.
Fix: Added a regex fallback in _getEntityId() that matches key parts in order with optional extra words between them.
Issue: TREX-25/50 has separate bat1_soc / bat2_soc registers; the coordinator was reading the wrong register for SOC.
Fix: Store resolved SOC on coordinator as self.battery_soc, using min(bat1_soc, bat2_soc) for TREX-25/50 (conservative approach).
Issue: safe_max_power was in watts (e.g., 7500) but the energy-per-slot calculation treated it as kW, resulting in 7500 kWh/slot instead of 7.5 kWh/slot. Every slot looked like it could charge the entire battery, so all slots were marked as charge slots.
Fix: Divide by 1000 to convert watts to kW before energy calculations.
Design: A 5% margin of the price spread prevents rapid switching between charging/discharging when the price hovers near the threshold. Once in a state, the system stays until the price clearly crosses the threshold.
Design: When battery is nearly full (>= 95%), skip charge scheduling entirely to avoid unnecessary grid purchases for the last few percent.
Design: Negative prices mean you get paid to consume energy. The scheduler always includes negative-price slots for charging (free energy + payment). It never discharges during negative prices (selling at a loss).
Design: If the battery didn't reach its target yesterday, the deficit carries forward to today's energy target (capped by physical battery headroom). This prevents persistent under-charging across days.
Issue: On cloudy days with cheap night prices, battery at 72% SOC (above reserve), the scheduler saw no deficit and skipped cheap grid slots. By evening, consumption drained the battery below reserve — but the cheap slots were already past.
Fix: Added SOC trajectory projection that simulates battery level through remaining slots. The predictive deficit catches future shortfalls before they happen, using max(snapshot_deficit, predictive_deficit). Both ems.py (standalone) and coordinator.py (runtime) implement this identically.
Issue: On sunny days, the headroom calculation (charge_max - current_kwh) allowed grid charging for the full gap, but PV production would also fill the same space. Result: grid energy paid for but nowhere to store it.
Fix: Subtract net PV surplus from headroom: effective_headroom = raw_headroom - pv_fill. Negative-price slots are exempt (always profitable to charge). Deficit slots are also exempt (reserve protection takes priority).
Problem: The original algorithm optimized each day independently, then tried to patch with defer/precharge logic. This was fragile: it didn't properly shift slots between days, and the card's client-side simulation diverged from the backend.
Solution: _select_unified_charge_slots
When tomorrow's prices are available (typically after ~13:00), the algorithm merges today's remaining slots with ALL of tomorrow's slots into one combined pool, sorted by price. It then picks the cheapest slots from both days to cover the total two-day deficit.
How it works:
- Calculate today's deficit:
reserve_target - current_kwh - net_pv - Project midnight battery:
current_kwh + net_pv + today_charge - drain_to_midnight(clamped to [min_kwh, battery_capacity]) - Calculate tomorrow's deficit:
tomorrow_reserve_target = min(battery_capacity, min_kwh + reserve_kwh)daytime_gap = max(0, consumption_est - pv_forecast_tomorrow)— extra drain on low-PV daystomorrow_deficit = max(0, tomorrow_reserve_target + daytime_gap - projected_midnight)
- Total deficit = today's + tomorrow's
- Combine today remaining + tomorrow all into one pool
- Sort by price, pick cheapest N slots to cover total deficit
- Split result into today_selected and tomorrow_selected
- Safety check: if battery would drop below min_kwh before tomorrow's first selected slot, swap the most expensive tomorrow slots for the cheapest available today slots until the battery survives the bridge
This naturally handles both scenarios:
- Defer: when tomorrow is cheaper, the cheapest slots naturally come from tomorrow's pool
- Pre-charge: when today is cheaper, the cheapest slots naturally come from today's pool
- No special-case logic needed — it's just "pick the cheapest from both days"
Example A — battery low, tomorrow cheaper:
- Battery: 50% of 60 kWh = 30 kWh, min SOC 40% = 24 kWh
- Today's deficit: reserve 43.25 - battery 30 = 13.25 kWh
- Projected midnight: 30 + 13.25 - drain ≈ 35 kWh
- Tomorrow's deficit: reserve 43.25 - projected 35 = 8.25 kWh
- Total: 21.5 kWh → 12 slots needed
- Today prices: 0.27-0.39, tomorrow: 0.10-0.19
- Most slots from tomorrow's cheap pool; safety swap adds today slots if battery can't survive until tomorrow's first slot
Example B — battery full, cheap price now:
- Battery: 99% of 60 kWh = 59.4 kWh
- Today's deficit: reserve 43.25 - 59.4 = 0 kWh
- Projected midnight: 59.4 - drain ≈ 50+ kWh
- Tomorrow's deficit: reserve 43.25 - projected 50 = 0 kWh
- Total: 0 kWh → no charging needed
- Headroom: 60 - 59.4 = 0.6 kWh → can't even fit one slot
- Result: no slots selected despite cheap current price (correct)
Sensor attributes:
tomorrow_planned_slots: number of slots assigned to tomorrow from unified selectiontomorrow_planned_kwh: energy (kWh) assigned to tomorrowtomorrow_precharge_kwh: negative of tomorrow_planned_kwh (for backward compatibility)
Card display:
- Charge count shows
X+Y chargewhere X=today, Y=tomorrow - Planned kWh shows combined total for both days
- Tomorrow view highlights the slots assigned to tomorrow by the unified algorithm
Constraints:
- Only activates when tomorrow's prices are available
- One day of lookahead only (no data beyond tomorrow)
- PV-aware battery headroom cap: today's slots limited by
max_battery - current_kwh - net_pv. Accounts for PV that will also fill the battery. If battery is 99%, no today pre-charge slots are selected regardless of price. Negative-price and deficit slots are exempt. Excess today slots are replaced with next-cheapest tomorrow slots. - Realistic tomorrow start: projects midnight battery from actual state
(
current_kwh + net_pv + today_charge - drain_to_midnight), not worst-case min_kwh - Low-PV day proactive charging:
daytime_gap = max(0, consumption - pv_tomorrow). On days when solar is insufficient to cover consumption, the battery drains during daytime hours. The daytime gap is added to tomorrow's deficit so the algorithm proactively schedules more cheap grid slots. Example: 38 kWh consumption, 4 kWh PV → daytime gap 34 kWh → much more grid charging scheduled in cheap morning slots. - Safety swap ensures battery never drops below min SOC during bridge period
- Without tomorrow data, falls back to today-only optimization
Issue: The aggregate sellable/deficit calculations could produce schedules that violate battery bounds at specific time slots. For example: scheduling a discharge at 7am when the battery is low, even though PV at noon would eventually push it above reserve. The aggregate says "enough total energy" but the battery would actually dip below minimum at 7am.
Fix: Added _validate_schedule_soc() which simulates the battery forward slot-by-slot after scheduling. It iteratively drops the least valuable discharge (on low violation) or most expensive charge (on high violation) until the SOC stays within bounds at every slot. Negative-price charge slots are exempt from overflow pruning. Applied to all three modes: from_grid, to_grid, both.
Inspired by: The VB Sell macro's iterative SOC check at every time slot (ROW_CHECK vs ROW_MIN/ROW_MAX).
Design: The default dynamic reserve (discharge_min + overnight_reserve) optimizes for cost — it charges just enough to survive until tomorrow's solar. In grid-unstable areas, users want higher battery levels regardless of cost efficiency.
reserve_target_pct (default: 0) provides a fixed floor percentage. When set > 0, it overrides the dynamic calculation with max(reserve_target_pct × capacity, discharge_min). Setting it to 80% on a 60 kWh battery means the EMS targets 48 kWh minimum, charging at cheapest available prices and only skipping grid when PV alone can maintain the target.
The EMS automatically handles different price slot granularities:
| Slots/Day | Granularity | Common Source |
|---|---|---|
| 24 | 60 min (hourly) | Nordpool hourly |
| 48 | 30 min | Some markets |
| 96 | 15 min | Intraday markets |
The granularity is detected from the price array length and used throughout: slot energy calculations, chart rendering, current slot detection, and history mapping.