The Battery Energy Storage System (BESS) Manager is a Home Assistant add-on that optimizes battery storage systems for cost savings through price-based arbitrage and solar integration. The system uses dynamic programming optimization to generate optimal daily battery schedules at 15-minute (quarterly) resolution while adapting to real-time conditions.
For the reasoning model and economics behind the algorithm's decisions — not just its implementation — see ALGORITHM_EXPLAINED.md.
- Event-Driven Design: Hourly updates and schedule adaptations based on real measurements
- Component Separation: Clear boundaries between data collection, optimization, and control
- Deterministic Operation: Explicit failure modes, no fallbacks or defaults
- Data Immutability: Historical data is immutable, predictions are versioned
Purpose: Main coordinator that orchestrates all components and provides the primary API.
Key Responsibilities:
- Initialize and configure system components
- Create and update battery schedules using dynamic programming optimization
- Apply scheduled settings to Growatt inverter via Home Assistant
- Coordinate hourly updates and real-time adaptations
- Manage system settings and configuration
Key Methods:
def update_battery_schedule(current_period: int, prepare_next_day: bool = False) -> None
def adjust_charging_power() -> None
def update_settings(settings: dict) -> None
def get_current_daily_view(current_period: int | None = None) -> DailyView
def start() -> NonePurpose: Collects energy data from Home Assistant sensors with validation and flow calculation.
Key Responsibilities:
- Collect quarterly (15-minute) energy measurements from InfluxDB and real-time sensors
- Calculate detailed energy flows (solar-to-home, grid-to-battery, etc.)
- Validate energy balance and detect sensor anomalies
- Reconstruct historical data during system startup
Data Sources:
- InfluxDB for historical cumulative sensor data
- Home Assistant API for real-time readings
- Sensor abstraction layer for device independence
Purpose: Centralized interface to Home Assistant with sensor abstraction.
Key Responsibilities:
- Manage sensor configuration and entity ID mapping
- Provide unified API for reading sensor values and controlling devices
- Handle different sensor types (power, energy, state)
- Support sensor validation and health checking
- Control Growatt inverter settings (battery modes, TOU schedules)
Sensor Abstraction:
- All sensor access uses method names, not entity IDs
- Configurable sensor mapping for different hardware setups
- Centralized validation and error handling
Purpose: Core algorithm that generates optimal battery schedules.
Algorithm Flow:
- Discretization: Battery state of energy (SOE) and power levels are discretized into fine-grained steps (0.025 kWh / 0.1 kW -- halved in #512 after a full-corpus benchmark showed the coarser 0.05 kWh / 0.2 kW grid left 0.01-0.36 SEK/day unrealized on most fixtures)
- Backward Induction: Starting from the last period, work backwards evaluating all feasible actions (charge/discharge/idle) at each (period, SOE) cell
- Reward + Future Value: For each action, compute the immediate reward (grid cost savings minus cycle cost) plus the optimal future value from the resulting SOE state
- Policy Extraction: Forward-simulate from the initial SOE, following the optimal action at each step to produce the final schedule
- All-IDLE Safety Net: Unconditionally compute an all-IDLE schedule and swap it in only if its cost is cheaper than the optimized schedule's — a plain O(1) comparison, not a configurable/horizon-scaled threshold. This catches numerical residual from SOE discretization; it is not an economic profitability gate. (An earlier threshold-based gate was removed in the "Bellman-optimality guardrail removal" refactor — the DP's backward induction already finds the Bellman-optimal schedule, so an extra economic veto was redundant. See
core/bess/dp_battery_algorithm.py:1514-1536.)
Inputs:
- Variable-length electricity price forecast at 15-minute resolution (from current period through end of available data; may span into the next day when tomorrow's prices are available)
- Battery parameters (capacity, limits, cycle cost)
- Consumption predictions (one entry per period, matching price array length)
- Solar production forecast (one entry per period, matching price array length)
- Current battery state and cost basis
- Home electrical settings (fuse current, voltage, phase count) when
power_monitoring_enabled— derives a per-period grid-import cap that constrains total import (load + battery charging), forcing the battery to cover a load spike via discharge rather than importing past the house's fuse limit. Seedocs/agents/bess-knowledge.md's "grid import (fuse) cap" section (#429).
Outputs:
- Battery actions (charge/discharge/idle) for each period in the horizon
- Expected battery SOC progression at 15-minute resolution
- Economic analysis (costs, savings, decision reasoning)
Purpose: Creates complete daily views combining actual and predicted data at quarterly resolution.
Key Responsibilities:
- Merge historical actuals with current predictions
- Provide always-complete quarterly data for today (92–100 periods) for UI/API
- Recalculate total daily savings from combined data
- Mark data sources (actual vs predicted) for each period
Data Integration:
- Historical data from HistoricalDataStore (immutable)
- Predicted data from ScheduleStore (latest optimization)
- Real-time current state for seamless transitions
Purpose: Immutable storage of actual energy events that occurred.
Data Model:
class PeriodData:
period: int # Period index (0-95 for normal day)
energy: EnergyData # Actual measured flows
timestamp: datetime
data_source: str = "actual"
economic: EconomicData
decision: DecisionDataKey Features:
- Immutable once recorded
- Complete energy flow tracking
- Physics validation (energy balance)
- Supports data reconstruction after system restart
Purpose: Versioned storage of optimization results throughout the day.
Storage Model:
class StoredSchedule:
timestamp: datetime
optimization_period: int
optimization_result: OptimizationResultKey Features:
- Stores complete optimization results with metadata
- Tracks when and why each optimization was created
- Enables debugging and analysis of optimization decisions
- Supports multiple optimizations per day as conditions change
Purpose: Converts optimization results to inverter-specific commands.
Base class InverterController provides shared intent-to-control mapping, hourly settings aggregation, and the abstract schedule interface. Four subclasses implement hardware-specific logic:
- GrowattMinController — Growatt MIN/MID/MOD (AC-coupled, cloud). Groups quarterly periods into TOU intervals (max 9 segments). Only creates segments for battery-first/grid-first; idle periods use load-first default. Writes via
growatt_server.update_time_segmentservice call. - SolaxModbusGrowattController — Growatt MIN/MID/MOD (AC-coupled, local Modbus). Subclasses
GrowattMinController— identical scheduling algorithm. Overrides only the I/O layer: writes viaselect.select_option(4 per slot) +button.press(1 per slot). Reads via entity state queries. - GrowattSphController — Growatt SPH (DC-coupled). Uses separate charge/discharge period lists (max 3 each) with global power and SOC settings per write call. Writes via
growatt_server.write_ac_charge_times/write_ac_discharge_times. - SolaxController — SolaX (Modbus VPP). Issues per-period active-power commands instead of storing a persistent TOU schedule. Idle/solar periods disable VPP; charge/discharge periods set a watt target with autorepeat.
Per-period control (shared across all platforms): At each 15-minute period boundary, _write_period_to_hardware() issues generic HA entity calls:
switch.turn_on/switch.turn_off— grid charge enable/disablenumber.set_value(orinput_number.set_valueif the configured entity is a user-providedinput_number.*helper) — charge/discharge power rate
These resolve to platform-specific entities via the sensor config (e.g. grid_charge → switch.rkm…_charge_from_grid on Growatt cloud, or switch.solax_charger_switch on solax_modbus).
Entity suffix maps (ENTITY_SUFFIX_MAP and SOLAX_ENTITY_SUFFIX_MAP in ha_api_controller.py) define the full mapping from unique_id suffixes to BESS sensor keys. See docs/INVERTER_PLATFORMS.md for the user-facing entity reference.
Purpose: Manages electricity price data and calculations.
Key Responsibilities:
- Fetch electricity spot prices for current day and next day (Nordpool or Octopus Energy)
- Calculate retail buy/sell prices with markup, VAT, additional costs
- Support multiple price areas (Nordpool SE1-SE4, Octopus Agile UK)
- Provide price forecasts for optimization
Price Calculation:
buy_price = (spot_price + markup) * vat_multiplier + additional_costs
sell_price = spot_price * export_rate - tax_reductionPurpose: Real-time power monitoring and charging adjustment.
Key Responsibilities:
- Monitor electrical phase loading to prevent circuit overload
- Calculate available charging power based on current consumption
- Dynamically adjust battery charging power to stay within fuse limits
- Provide safety margins for electrical system protection
1. Sensor Collection
└── SensorCollector reads InfluxDB + real-time sensors
└── Calculate energy flows and validate balance
2. Historical Recording
└── Record completed hour in HistoricalDataStore
└── Immutable storage of what actually happened
3. Optimization
└── Run DP algorithm for remaining periods
└── Store new schedule in ScheduleStore
4. Hardware Application
└── InverterController converts to hardware-specific schedule
└── Apply settings to inverter via HomeAssistantAPIController
5. View Generation
└── DailyViewBuilder merges actual + predicted data
└── Generate complete 24-hour view for UI/API
1. Component Initialization
└── Load configuration and settings
└── Initialize all managers and controllers
2. Historical Reconstruction
└── SensorCollector queries InfluxDB for today's data
└── Rebuild HistoricalDataStore with actual measurements
3. Initial Optimization
└── First scheduled update runs fresh optimization
└── Apply schedule to hardware
4. Service Start
└── Begin hourly update cycle
└── Start power monitoring and charging adjustment
The DP algorithm uses backward induction to find the globally optimal battery schedule. Starting from the last period and working backwards, it evaluates all possible battery actions (charge/discharge/idle) at each period and selects the action that minimizes total electricity cost over the remaining horizon.
State space: Discretized battery state of energy (SOE) levels.
Actions: Discretized charge/discharge power levels, filtered by physical constraints (available energy, remaining capacity, power limits, temperature derating).
Temperature derating: on cold days, max charge power can be capped below the configured limit via a weather-forecast-driven derating curve (core/bess/battery_system_manager.py:1917-1966, _get_temperature_derated_charge_limits). If the weather entity isn't configured, this silently returns no derating rather than failing — an installation without a weather entity behaves as if derating doesn't exist, by design, not by error.
charging_power_rate limitation: this settings-page value only seeds the initial charge-power target before the first control cycle; every cycle after that, adjust_charging_power() (core/bess/battery_system_manager.py:2037-2064) derives the actual hardware rate from INTENT_TO_CONTROL (always 0% or 100%), never re-reading the configured percentage. Tracked as a known bug in TODO.md, not a documented feature.
Discharge inhibit: an externally detected binary_sensor (entity ID suffix _charging/_is_charging) can force discharge_rate to 0 independent of the DP schedule, polled every minute (core/bess/battery_system_manager.py:3089-3113) and applied at schedule-write time (core/bess/battery_system_manager.py:2578-2582). This is the most common reason observed hardware behavior diverges from the planned schedule.
Transition: Each action updates SOE accounting for charging/discharging efficiency losses, and updates the cost basis of stored energy. Cost basis is a weighted average, not FIFO — there is no queue of cost "layers". On charge: new_cost_basis = (soe * cost_basis + new_energy_cost) / next_soe (core/bess/dp_battery_algorithm.py:496-498). On discharge, cost_basis is left unchanged.
Objective: Minimize net electricity cost (grid import cost minus export revenue) while accounting for battery cycle degradation costs and a terminal value for energy remaining at end of horizon.
Output: For each period, the algorithm produces the optimal battery action, the resulting detailed energy flows (solar-to-home, grid-to-battery, etc.), economic data (costs, savings), and the strategic intent classification.
All-IDLE safety net: See the "All-IDLE Safety Net" step above — this is a numerical residual check, not an economic profit threshold.
The system decomposes measured energy totals into detailed flows (e.g., solar-to-battery, grid-to-battery) using energy conservation, but flows are clamped to measured grid totals (grid_imported/grid_exported) rather than derived by pure subtraction — pure subtraction can invent flows out of cross-sensor noise (fixed in PR #342). See core/bess/models.py (EnergyData._calculate_detailed_flows, ~lines 90-146) and core/bess/energy_flow_calculator.py (~lines 176-183) for the current formulas, e.g.:
solar_to_battery = max(0, solar_production - export_to_grid
- self_consumption + battery_discharged)
solar_to_battery = min(solar_to_battery, battery_charged, solar_production)Home consumption gets solar first (free), then grid; battery charges from solar first (free), then grid (paid) — but the split is reconciled against measured grid import/export, not assumed from production figures alone.
A related noise source survives even after #342's cap: a battery_to_grid
residual can still appear when its governing aggregate (battery_discharged)
is itself nonzero, indistinguishable from ordinary lifetime-counter
quantization (0.1 kWh resolution) rather than a real export — and it
corrupts the observational infer_intent_from_flows classifier's
BATTERY_EXPORT label. Fixed in #350: any battery_to_grid below the 0.1
kWh floor folds back into battery_to_home, but only when
battery_to_home > 0 (the battery was already covering a genuine home
deficit) — when battery_to_home == 0, any nonzero export stays a real
export, since it has no other channel to have come from.
The system classifies battery action intent using the battery power action as the primary discriminator, with energy flows as secondary input. Classification is performed by classify_strategic_intent(power, energy_data) in strategic_intent.py:
- Discharging (power < −0.1 kW):
- BATTERY_EXPORT:
battery_to_grid > 0.1 kWh - LOAD_SUPPORT: otherwise (discharge serves home load)
- BATTERY_EXPORT:
- Charging (power > 0.1 kW):
- GRID_CHARGING:
grid_to_battery > solar_to_battery(grid is dominant charge source) - SOLAR_STORAGE: otherwise (solar is dominant charge source)
- GRID_CHARGING:
- Near-zero power (fallthrough for passive flows):
- SOLAR_STORAGE:
battery_charged > 0.01 kWh(passive solar charging) - LOAD_SUPPORT:
battery_discharged > 0.01 kWh(small residual discharge) - SOLAR_EXPORT:
grid_exported > 0.01 kWhandsolar_to_grid > 0.01 kWh(solar surplus exporting, battery idle) - IDLE: no significant battery activity
- SOLAR_STORAGE:
The InverterController converts action intents into hardware-specific schedules. Each intent maps to an inverter battery mode and control parameters (shown below for Growatt MIN; other inverters use the same intent mapping with different hardware commands):
| Intent | Battery Mode | Grid Charge | Discharge Rate |
|---|---|---|---|
| GRID_CHARGING | battery_first | On | 0% |
| SOLAR_STORAGE | load_first | Off | 0% |
| LOAD_SUPPORT | load_first | Off | action-derived |
| BATTERY_EXPORT | grid_first | Off | action-derived |
| SOLAR_EXPORT | load_first | Off | 0% |
| IDLE | load_first | Off | 0% |
Why SOLAR_STORAGE and IDLE share the same inverter settings: Both use load_first because solar energy serving the home directly is always more valuable than routing it through the battery (which incurs cycle cost). If prices are cheap enough to justify prioritizing battery charging over home load, the DP algorithm uses GRID_CHARGING instead, which enables AC grid-to-battery charging via battery_first mode. Using battery_first without grid_charge would cause unnecessary grid imports by routing solar to the battery first while the grid serves the home.
Why SOLAR_EXPORT uses load_first (not grid_first): Solar exports naturally in load_first when generation exceeds consumption — no special inverter mode is needed. SOLAR_EXPORT exists as a distinct intent purely for UI display (distinguishing "solar actively exporting" from "nothing happening"). Using grid_first for battery-idle periods would lock the inverter in a mode that prevents the battery from supporting house load during temporary solar deficits.
VPP-style control is the exception to this table (SolaxModbusGrowattController control_mode="vpp", SolaxController): these platforms have no charge_rate register at all (supports_charge_rate_control=False), so load_first vs. grid_first isn't selected via a persistent TOU mode the way it is here — it's chosen per-period from signals threaded through InverterController.apply_period: a block_passive_charging signal (derived from this same table's charge_rate column: 0 → block) distinguishes SOLAR_EXPORT from SOLAR_STORAGE/IDLE, and a strategic_intent signal distinguishes LOAD_SUPPORT from BATTERY_EXPORT — both intents share identical (grid_charge, discharge_rate) values from the table above, but SolaxModbusGrowattController's VPP mode releases control entirely for LOAD_SUPPORT (vpp_remote_control disabled, falling back to the inverter's own load_first self-consumption) rather than forcing a fixed grid_first rate the way BATTERY_EXPORT still does (issue #413). See
docs/INVERTER_PLATFORMS.md's "SOLAR_EXPORT semantics" section and
docs/superpowers/specs/2026-07-20-vpp-passive-charge-block-design.md
for the full VPP-mode design (issue #355).
Why BATTERY_EXPORT requires grid_first: The inverter must route battery discharge toward the grid rather than the home. In load_first, discharge would serve home load first; only grid_first guarantees battery energy reaches the grid.
Schedule generation:
- Group consecutive 15-minute periods that share the same battery mode
- Only create TOU segments for strategic modes (battery_first, grid_first) — load_first is the inverter default and needs no segment
- Enforce hardware constraints: max 9 TOU segments, chronological order, no overlaps
- Preserve past intervals to minimize unnecessary inverter writes
- Diff against segments read fresh from the inverter on every write cycle, never against an in-memory model — a model seeded once at startup drifts from hardware, producing writes that duplicate live segments (rejected by the vendor API) and leaving dropped segments enabled on the battery (#551)
- Only write a segment once its start is within
GrowattMinController.WRITE_HORIZON_MINUTES(45 min). A segment has no effect until it starts, so writing it earlier is pure churn: a marginal period crossing the economic boundary back and forth rewrote the same far-future segment on every cycle. Deferral applies to updates only — an unplanned segment is still disabled promptly, and a pending write is retried each cycle, so a segment is eligible on four cycles with three attempts landing strictly before it takes effect (#554) - A segment already programmed is left alone while the only difference from the plan is an end time whose change is more than
WRITE_HORIZON_MINUTESaway. Step 6 gates on a segment's start, so a window that is already running is never deferred and every nudge the DP makes to its end was written at once, however far out it was. Until the earlier of the two ends, the inverter behaves identically either way, so the write is churn (#589). The deadline still binds: the change is written on the cycle that brings it within the horizon, which is also what clears a window the plan has shortened before its surplus quarter-hour runs
Because at most floor(45/15) + 1 = 4 segments can be within the horizon at once, no realistic plan now reaches the 9-slot limit. The cap in step 3 remains enforced as the inverter's hard contract (segment_id must be 1..9), but the horizon is what binds first.
- On a cycle where the plan is unchanged, re-run the sync anyway (
reconcile_hardware, default no-op; only the differential-update platform implements it, as a straight call to its ownsync_to_hardware). The comparison in step 6 is plan-against-plan, so once a segment is written and the plan stops changing, nothing would look at the inverter again — and issue #551 established that the table drifts on its own, both by dropping a segment and by restoring one the plan does not contain. Re-running the sync covers both directions from the single hardware read it already performs, and re-attempts a write that vanished without raising; one that raises is retried through_hardware_write_pending
The other half of the churn came from segments already running. The plan is rebuilt from the current period each cycle, which used to truncate an in-progress segment's start_time forward to "now" — renaming it every 15 minutes, so the differential update disabled and re-wrote it each time. _group_periods_by_mode now reports the group covering current_period from its true start, keeping a running segment byte-identical across cycles. A stable two-hour window costs 2 writes (one to program, one to clear on expiry) rather than 16.
Settings are managed through the web UI and persisted to /data/bess_settings.json. The only setting that remains in the HA Supervisor-controlled config.yaml (and thus /data/options.json) is the InfluxDB connection.
influxdb:
url: "http://homeassistant.local:8086/api/v2/query"
bucket: "home_assistant/autogen"
username: "your_db_username_here"
password: "your_db_password_here"All other settings are stored in this file and managed via the settings API. Top-level sections:
battery:total_capacity,min_soc,max_soc,max_charge_power_kw,max_discharge_power_kw,cycle_cost_per_kwh,charging_power_rate,efficiency_charge,efficiency_dischargeelectricity_price:area,markup_rate,vat_multiplier,additional_costs,tax_reduction,min_profit,use_actual_pricehome:max_fuse_current,voltage,safety_margin,phase_count,default_hourly,currency,consumption_strategy,power_monitoring_enabledgrowatt: Inverter device ID and integration settingssensors: Entity ID mappings for all Home Assistant sensorsenergy_provider: Price source selection (Nordpool or Octopus Energy) and area configuration
The system supports multiple inverter platforms, each with a dedicated controller subclass:
| Platform ID | Inverter | HA Integration | Control Method | Controller Class |
|---|---|---|---|---|
growatt_min |
Growatt MIC/MIN/MOD/MID | growatt_server (cloud) |
TOU service calls | GrowattMinController |
growatt_solax_modbus |
Growatt MIC/MIN/MOD/MID | solax_modbus (local Modbus) |
TOU entity writes | SolaxModbusGrowattController |
growatt_sph |
Growatt SPH | growatt_server (cloud) |
AC charge/discharge periods | GrowattSphController |
solax |
SolaX | solax_modbus (local Modbus) |
VPP active-power commands | SolaxController |
huawei_solar_luna2000 |
Huawei LUNA2000 | huawei_solar (local Modbus) |
TOU period-list writes | HuaweiController |
The active platform is stored in inverter.platform. Switching platform at runtime calls BatterySystemManager.switch_inverter_platform(), which destroys the current InverterController and creates the correct subclass. No restart is required.
Two platforms make service calls into a vendor integration domain rather than driving entities: Growatt cloud (update_time_segment, write/read_ac_charge_times, write/read_ac_discharge_times) and Huawei (set_tou_periods). Every other service call BESS makes infers its domain from the entity_id prefix — number vs input_number, switch vs select — but these target a device, so there is no prefix to read.
That domain is configuration, not a platform constant. SettingsStore.get_service_domain() resolves it: inverter.service_domain when set, otherwise the platform's entry in PLATFORM_SERVICE_DOMAIN (growatt_server, huawei_solar, or "" for the modbus platforms, which make no vendor calls). The resolved value is held on HomeAssistantAPIController.service_domain and re-synced by BESSController.refresh_service_domain() whenever the inverter section changes.
This is what lets an integration that exposes the same services under its own domain name work as a configuration of an existing platform instead of requiring a new one — see PR #412 (Huawei EMMA via huawei_emma_management, where the EMMA dials out over TLS because a third party owns the Modbus socket). It carries no compatibility guarantee: the payload format is still the platform's (HH:MM-HH:MM/<days>/<+|-> for Huawei), and an integration claiming the domain must implement those services with the same signatures.
SolaxModbusGrowattController subclasses GrowattMinController — the scheduling algorithm (9 TOU slots, differential updates, corruption recovery) is identical. Only the hardware I/O differs: growatt_server uses a single service call per slot, while solax_modbus uses 4 entity writes (select.select_option) plus a button press per slot.
A platform-fixed sibling of the service-domain pattern above: some platforms expose a power flow as one signed sensor instead of two directional entities. Two independent cases, same mechanism:
- Grid. Solis (
grid_power_net) and Huawei (power_meter_active_power) publish one signed grid sensor.SettingsStore.get_grid_power_polarity()resolvesPLATFORM_GRID_POWER_POLARITY("import_positive"forsolis_modbus,"export_positive"forhuawei_solar_luna2000,""elsewhere). Held onHomeAssistantAPIController.grid_power_polarity;get_import_power()/get_export_power()split the single reading by sign. - Battery. Native SolaX (
battery_power_charge, REGISTER_S16) and Huawei (storage_charge_discharge_power, reg 37765) publish one signed battery sensor and no discharge counterpart.SettingsStore.get_battery_power_polarity()resolvesPLATFORM_BATTERY_POWER_POLARITY("charge_positive"for both,""elsewhere). Held onHomeAssistantAPIController.battery_power_polarity;get_battery_charge_power()/get_battery_discharge_power()split the single reading by sign (issue #542).
Neither is user-overridable — polarity is a hardware fact, not configuration. Both splits activate only when the two keys resolve to the same entity_id. settings_store.apply_signed_pair_aliases() arranges that, pointing the derived key (export_power, battery_discharge_power) at the one entity the integration actually publishes, for any platform listed in the corresponding polarity map. It runs inside flatten_sensors(), so the pairing is re-derived on every read of a per-platform sensor map rather than only when discovery writes it: a settings file persisted before the pairing existed would otherwise leave the split off forever, reporting a discharge as negative charge and discharge power as None with no error (issue #604). The legacy flat shape carries no platform and is not aliased, which is safe because _migrate_schema() converts it to the per-platform shape for every platform that could need a split. discover_sensors_from_registry calls the same helper so the derived key lands in platform_sensors and is reconciled off platform_disabled. An explicitly mapped counterpart is never overwritten. BESSController.refresh_power_polarities() re-syncs both polarities after any settings change that can switch platform.
The split lives in the getters, so any caller that resolves a sensor key to an entity ID and reads that entity directly holds the net value instead. The sensor health panel resolves entities that way in get_method_sensor_info, but renders displayValue from calling the getter, so it reports the split value; get_method_sensor_info additionally routes its own current_value through _signed_split_state(), which reuses the same two predicates so the diagnostic field agrees with the getters rather than showing one net value on both directional rows.
Both split helpers are pure and take the direction as a keyword, so the getters and the diagnostic path share one implementation. The battery helper branches on battery_power_polarity explicitly and raises ValueError on any unrecognised value — a typo'd or unimplemented entry in PLATFORM_BATTERY_POWER_POLARITY must fail loudly rather than silently invert every reading. The grid helper is deliberately laxer, treating anything that is not "import_positive" as "export_positive".
Different inverter platforms support different hardware features. The class hierarchy handles behavioral differences (TOU scheduling vs. period lists vs. VPP commands — genuinely different algorithms). Capabilities handle the narrower question: what does code outside the controller need to know about the platform?
Currently only one capability exists: charge_rate_control. It is declared as a ClassVar[bool] on InverterController (default True) and overridden to False by subclasses whose hardware lacks per-period charge/discharge rate registers (SPH, SolaX native). BSM checks this flag to decide whether to initialize the power monitor and whether adjust_charging_power() should run.
# inverter_controller.py (base class)
supports_charge_rate_control: ClassVar[bool] = True
# growatt_sph_controller.py
supports_charge_rate_control: ClassVar[bool] = False
# solax_controller.py
supports_charge_rate_control: ClassVar[bool] = False| Capability | Description | MIN | SPH | SolaX Native | Modbus Growatt MIN |
|---|---|---|---|---|---|
supports_charge_rate_control |
Per-period charge/discharge rate register | Yes | No | No | Yes |
SPH controls charge power globally via write_ac_charge_times(charge_power=100%). SolaX native uses VPP active-power commands. Neither has a per-period register that the power monitor can read/write, so fuse protection cannot function.
The frontend disables UI features based on sensor presence, which correlates with platform capabilities: if the platform lacks charge rate control, the corresponding sensor entity won't exist after discovery. This avoids needing a dedicated capabilities API endpoint — the sensor config already carries the signal.
- Fuse protection toggle: disabled when
battery_charging_power_ratesensor is not configured - InfluxDB consumption strategy: disabled when
local_load_powersensor is not configured - HA Statistics strategy: disabled when
lifetime_load_consumptionsensor is not configured
Sensor-based gating is the right default. A dedicated capabilities API should only be introduced when the frontend needs to gate on something that doesn't map to sensor presence.
The single ClassVar[bool] is sufficient while capabilities are few and boolean. If the number of externally-queried capabilities grows beyond 2–3 flags, consolidate into a frozen PlatformCapabilities dataclass with typed fields (booleans, integers, Literals). The decision criteria: add a capability only when code outside the controller hierarchy needs to branch on it. Internal differences (schedule model, max slots, power control method) belong in the subclass, not the capability surface.
- Add
supports_foo: ClassVar[bool] = TruetoInverterController - Override to
Falseon subclasses that lack the feature - Gate the feature in BSM / frontend as appropriate
- Create an
InverterControllersubclass implementing the abstract methods - Override any
supports_*flags where the platform differs from defaults - Add the platform string to
VALID_PLATFORMSand the factory in_create_inverter_controller() - Add entity suffix map entries to
ha_api_controller.pyfor sensor discovery
On first startup with no sensors configured, or when the user triggers discovery from the setup wizard or settings page, the system runs a multi-stage auto-detection process via HAAPIController.discover_integrations().
The HA WebSocket API (config/entity_registry/list) returns every registered entity with its platform field.
Matching is by exact platform name, so a supported inverter reached through a
different integration is not detected — Huawei LUNA2000/EMMA via
huawei_emma_management rather than the stock huawei_solar, for example.
Detection therefore narrows the wizard's defaults, never its choices:
every platform stays selectable so such a user can pick theirs and map the
sensors by hand (#621).
Detected integrations:
| Category | HA Platform | Detected As |
|---|---|---|
| Inverter | growatt_server |
Growatt |
| Inverter | solax_modbus |
SolaX |
| Inverter | solis_modbus |
Solis |
| Inverter | huawei_solar |
Huawei |
| Price | nordpool |
Nordpool |
| Price | octopus_energy |
Octopus Energy |
| Forecast | solcast_solar |
Solcast solar forecast |
| Forecast | weather |
Weather (temperature derating) |
Nordpool: official vs HACS custom
Both the official HA Nordpool integration and the older HACS custom component (custom_components/nordpool) register entities under the nordpool platform domain, so Stage 1 detection cannot distinguish them. The distinction is made as follows:
- Stage 3 checks
config_entries/getfor a loadednordpoolconfig entry. If found, the official integration is available and itsconfig_entry_idis stored. - The user selects which provider to use in the Setup Wizard or Settings page (radio button: "Nord Pool (official HA integration)" vs "Nord Pool (HACS custom sensor)").
- At runtime, the selected provider determines how prices are fetched:
nordpool_official: Callsnordpool.get_prices_for_dateservice action (requiresconfig_entry_id)nordpool: Reads hourly prices from sensor entity attributes (today/tomorrowlists on a single entity)
The HA REST API /api/states provides all entity IDs and current values. BESS extracts intermediate identifiers from entity naming patterns — these are NOT the final IDs used in service calls, but are needed to look up the actual HA-internal IDs in Stage 3.
- Growatt device serial number (SN): The
growatt_serverintegration creates entity IDs with the inverter serial number as a prefix (e.g.sensor.rkm0d7n04x_state_of_charge_soc). BESS extracts this SN (rkm0d7n04x) via_extract_growatt_device_sn(). The SN is used in Stage 3 as a lookup key into the HA device registry to find the actualdevice_id(a hex string likefbafceb07a1cc74c351ef4310fa430a0) required by service calls. - Nordpool area: Parsed from Nordpool entity IDs (e.g.
sensor.nordpool_kwh_se4_sek_...→SE4) - Phase count: Detected from phase current sensor entities —
current_l1/l2/l3naming, orphase_a/b/con a metering device (#120; huawei_solar gives the meter'sactive_grid_{A,B,C}_currentand the inverter's ownphase_{A,B,C}_currentthe same "Phase A/B/C current" display name, so the phase_a/b/c form is meter-gated). Candidates are grouped by owning device and one group supplies every phase, preferring the most phases, then the explicitcurrent_lNconvention, then a grid-side name (power_meter/grid) over a sub-circuit one, then the lowest group id — never/api/statesorder, which is arbitrary. This keeps a sub-circuit meter from supplying some phases and the grid meter the rest, and keeps an EV-charger or heat-pump submeter from winning outright. Only groups exposing L1 alone or all three phases are eligible, since the wizard accepts a phase count of 1 or 3 andPowerMonitorreads L1 unconditionally — a partial set would configure throttling that raises on every quarter
discover_ha_metadata() queries the HA WebSocket API to resolve the actual identifiers needed for service calls. These IDs are HA-internal and not available via the REST API. Four WebSocket commands are batched in a single connection:
| Command | Purpose |
|---|---|
config_entries/get |
Find config entry IDs by integration domain |
config/device_registry/list |
Resolve device SN → HA device_id |
get_services |
Detect inverter type from registered services |
config/entity_registry/list |
Extract Nordpool area from unique_id |
Resolved identifiers:
- Growatt
device_id(e.g.fbafceb07a1cc74c351ef4310fa430a0): The HA device registry ID. Allgrowatt_serverservice calls (e.g.update_time_segment) require this as theirdevice_idparameter. Resolution strategy (first match wins):- Match the SN from Stage 2 against device
identifierstuples (most reliable) - Match by
config_entry_idbelonging to thegrowatt_serverintegration - Match by device
nameequal to SN (legacy fallback)
- Match the SN from Stage 2 against device
- Nordpool
config_entry_id: Required fornordpool.get_prices_for_dateservice calls. Found by scanning config entries fordomain == "nordpool"withstate == "loaded". - Nordpool area (fallback): If not resolved in Stage 2, extracted from entity registry
unique_idvalues (format"SE4-current_price"). - Inverter type: Determined from registered services and entity markers:
- MIN:
growatt_server.update_time_segmentservice present - SPH:
growatt_server.write_ac_charge_timesservice present - GROWATT_MODBUS:
solax_modbusentities with TOU time slot marker (time_1_enabledunique_id suffix — note: the entity_id containstime_1_activefrom the display name, but detection matches on unique_id) - SOLAX:
solax_modbusentities with VPP marker (remotecontrol_power_controlunique_id suffix)
- MIN:
discover_sensors_from_registry() maps entity registry entries to BESS sensor keys for each detected inverter integration. It runs separately for each platform found in Stage 1 (e.g. growatt_server entities are mapped using ENTITY_SUFFIX_MAP, solax_modbus entities using SOLAX_ENTITY_SUFFIX_MAP). If both are detected, both sets are returned and the user selects which platform to use.
The mapping uses two layers of filtering:
- Platform field (immutable — set by HA core when the integration creates the entity). Only entities belonging to the target integration are considered.
unique_idsuffix matching. Theunique_idis assigned by the integration at entity creation and never changes regardless of user renames. BESS matches suffixes like_state_of_charge_socor_battery_socagainst the suffix map to determine the BESS sensor key.
The result maps each BESS sensor key (e.g. battery_soc) to the corresponding HA entity_id (e.g. sensor.rkm0d7n04x_state_of_charge_soc). This entity_id is what the REST API uses to read state values at runtime.
Disabled entities are never mapped. An entity with disabled_by set exists in the registry but has no state, so any REST read of it returns 404. Enabled matches always win; when a sensor key's only match is disabled, the key is left unmapped and returned in a second dict (platform_disabled, surfaced by /api/setup/discover as disabledSensors / platformDisabledSensors). The wizard blocks on those and names the entity to enable, rather than persisting a mapping that is guaranteed to fail at runtime. This matters because integrations ship useful entities disabled by default — solax_modbus disables all of its Total * lifetime energy counters, which is what made issue #549 present as "Sensor not found (404) … SYSTEM DEGRADED" after a wizard run that appeared to succeed.
Renaming entities in the HA UI (friendly name/label) does not affect discovery. However, if a user changes the actual entity_id and removes the original suffix, the unique_id still matches — so discovery still works. Only if the integration itself changes its unique_id scheme (across versions) would manual remapping via the wizard be needed.
After discovery, the system derives additional configuration hints:
- Currency and VAT: From the Nordpool area code prefix (SE → SEK/1.25, NO → NOK/1.25, DK → DKK/1.25, FI → EUR/1.255, etc.)
- Phase count: From detected phase current sensors
- Inverter type: From WebSocket service inspection (Growatt MIN/SPH), entity registry TOU marker (Growatt via solax_modbus), or entity registry platform (SolaX)
Beyond core inverter and price sensors, discovery also detects:
- Solcast solar forecast: Entity registry entries on the
solcast_solarplatform, matched byunique_idsuffix (robust against non-English HA locale renaming of the entity ID) - Weather: Entities in the
weather.*domain, preferringweather.homewhen multiple exist - Phase currents:
current_l1,current_l2,current_l3(also discovered from meter-sidephase_a/b/cnaming — seediscover_current_sensors) - EV charging inhibit: Binary sensors ending with
_chargingor_is_charging - Consumption forecast: Custom helper sensor for 48-hour average grid import
The setup wizard is a 6-step flow for first-time configuration. It is triggered when no sensor entity IDs are configured.
| Endpoint | Purpose |
|---|---|
GET /api/setup/status |
Returns wizard_needed flag based on whether sensors are configured |
POST /api/setup/discover |
Runs full auto-discovery, returns sensors map, missing sensors, platform hints |
POST /api/setup/confirm |
Persists discovered sensor config to /data/bess_discovered_config.json and applies to live controller |
POST /api/setup/complete |
Atomic save of all wizard data across 6 settings sections. Rejects (400) an energy provider whose own required configuration is empty — nordpool_official needs nordpoolConfigEntryId, nordpool_hacs/entsoe need their entity, octopus needs octopusImportTodayEntity — since such a config can never fetch a price |
- Scan — Calls
/api/setup/discoverto auto-detect integrations and sensors - Review Sensors — Displays discovered sensor mappings, allows manual correction, selects inverter platform. Every platform is selectable regardless of what was detected; the per-platform status dot reports detection, and the auto-detected platform is merely preselected. Blocked while a required sensor is unmapped, or while a required sensor's only entity is disabled in HA (the entities to enable are listed by name)
- Electricity Pricing — Configure price area, provider (Nordpool/Octopus), markup, VAT (pre-filled from discovery hints). Blocked until the selected provider's required configuration is filled in, mirroring the server-side check on
/api/setup/complete - Battery — Set capacity, SOC limits, power rating, cycle cost
- Home — Set consumption, fuse current, voltage, phase count (pre-filled from detected phase count)
- Complete — Calls
/api/setup/completefor atomic save
The complete endpoint performs a single atomic operation that:
- Saves all 6 settings sections (
sensors,battery,home,electricity_price,energy_provider,inverter/growatt) tobess_settings.jsonusing read-modify-write to preserve non-wizard fields - Maps the UI inverter type (MIN/GROWATT_MODBUS/SPH/SOLAX) to canonical platform names and calls
switch_inverter_platform() - Applies live updates to all running components (sensors, battery settings, home settings, price settings)
- Spawns a background thread that backfills historical data from InfluxDB, builds the daily schedule, and re-runs the health check
Frontend (SetupWizardPage)
│
├── [1] POST /api/setup/discover
│ └── HAAPIController.discover_integrations()
│ ├── Entity Registry scan → platform detection
│ ├── Entity States scan → device SN / prefix extraction
│ ├── WebSocket query → internal IDs, inverter type
│ └── Sensor mapping → ENTITY_SUFFIX_MAP matching
│
├── [2] POST /api/setup/confirm
│ └── Persist to /data/bess_discovered_config.json
│ └── Apply sensor config to live ha_controller
│
├── [3] User fills remaining wizard steps (pricing, battery, home)
│
└── [4] POST /api/setup/complete
├── SettingsStore.save_all() → atomic write of 6 sections
├── switch_inverter_platform() → recreate controller
├── update_settings() → apply live changes
└── Background: backfill history + build schedule + health check
After initial setup, the Settings page (SettingsPage.tsx) provides ongoing platform and sensor management through PATCH /api/settings.
Platform switching: When the user changes the inverter platform in the Sensors tab, the backend validates the platform string, calls switch_inverter_platform() to recreate the controller, and re-runs the health check. Both platform configurations can coexist in the settings file — only the active platform's sensors are used at runtime.
Sensor editing: Individual sensor entity IDs can be updated. The backend validates entity ID format ([a-z]+\.[a-z0-9_]+) before applying changes.
Re-discovery: The user can trigger a fresh auto-discovery from the Settings page to update sensor mappings without going through the full wizard again.
The system includes comprehensive health checking:
- Sensor Validation: Required vs optional sensors, data quality checks
- Component Status: Each manager reports operational status
- Energy Balance: Physics validation of measured energy flows
- Optimization Health: Algorithm convergence and result validation
- Hardware Connection: Inverter communication and control verification
Severity model: each component check is governed by two flags —
is_required (is this component critical to the system, used to set the
dashboard's has_critical_errors banner) and required_methods (which
specific sensors within the component must succeed for it to be ERROR
rather than WARNING). determine_health_status()
(core/bess/health_check.py:80-127) combines them into three outcomes: ERROR
only when a required sensor is missing/failing, WARNING when only an
optional sensor fails, and SKIPPED for optional sensors intentionally left
unconfigured — those don't count as a failure at all. perform_health_check()
(core/bess/health_check.py:138-) never reports SKIPPED for a required
sensor: when a required method's primary sensor mapping is not_configured,
it still attempts to call the method, because some methods (e.g.
get_load_consumption_lifetime) derive a value from other sensors when the
direct one isn't mapped — the pre-check only validates the direct mapping,
not whether a fallback path exists. That call resolves to OK (fallback
succeeded), WARNING (returned None), or ERROR (raised); a required sensor
with no working fallback still correctly drives the component to ERROR, it
just does so via one of those three statuses rather than SKIPPED. A
component with is_required=False should never surface as ERROR — if it
does, check whether required_methods was mistakenly passed as "all
methods" instead of derived from is_required
(see TODO.md's "Simplify Health Check Severity Model" for the known
fragility here).
- Complete daily energy flow data (96 quarterly periods or 24 hourly aggregated)
- Resolution parameter:
quarter-hourlyorhourly - Real-time power monitoring
- Economic analysis and savings breakdown
- Battery status and schedule information
- Runtime configuration management
- Validation and error handling
- Live updates without system restart
- Real-time inverter status
- Detailed schedule management
- TOU interval configuration
- Strategic intent monitoring
The system operates on quarterly resolution (15-minute periods) throughout the entire stack:
┌─────────────────────────────────────────────────────────────────┐
│ Price Provider (Nordpool / Octopus Energy) │
│ Provides: 96 quarterly prices (15-min) │
│ Format: Arrays indexed 0-95 for today │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PriceManager │
│ - get_available_prices() → (buy[N], sell[N]) │
│ - Normalises provider data to quarterly arrays (no expansion) │
│ - DST-aware: validates 92-100 periods │
│ - Simple array indexing: index 0 = today 00:00-00:15 │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ BatterySystemManager │
│ - Optimization: variable-length horizon (today + tomorrow) │
│ - Storage: record_period(period_index, period_data) │
│ - Collection: Uses period indices (0-95 normal, 0-91/99 DST) │
│ - InfluxDB: Queries at 15-minute boundaries │
└────────────────────────────┬────────────────────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ HistoricalDataStore │ │ ScheduleStore │
│ dict[int, PeriodData] │ │ Optimization results │
│ - Stores actual data │ │ - Predicted data │
│ - Period index keys │ │ - Strategic intents │
│ - 92-100 periods/day │ │ - Battery actions │
└──────────────────────────┘ └──────────────────────────┘
│ │
└────────────┬────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ DailyViewBuilder │
│ - Merges actual (past) + predicted (future) │
│ - Returns 96 quarterly PeriodData items (today only) │
│ - Simple logic: if i < current_period: actual, else: predicted │
│ - Calculates summary statistics │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ API Layer (FastAPI) │
│ - GET /api/dashboard?resolution=quarter-hourly → today's periods│
│ - GET /api/dashboard?resolution=hourly → 24 aggregated │
│ - Internal data: Always quarterly (96 periods) │
│ - Aggregation: Display-only feature for UI │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ - EnergyFlowChart: Displays quarterly (96) or hourly (24) │
│ - EnergyFlowCards: Shows totals with flow breakdowns │
│ - Resolution toggle: User display preference │
│ - All calculations use actual quarterly data │
└─────────────────────────────────────────────────────────────────┘
Quarterly-First Architecture:
- Internal data structures use one entry per period (92–100 depending on DST)
- The DP optimizer operates on a variable-length horizon (today's remaining periods plus tomorrow's when available)
- Simple integer indices (0-95 for a normal day, 0-91/0-99 for DST transitions)
- Array-based operations (slicing, summing, mapping)
DST Handling:
- Period count varies: 92 (spring), 96 (normal), 100 (fall)
- All components handle variable period counts
- No hardcoded 24-hour assumptions
- Validation uses ranges (92-100) not fixed values
Data Flow:
- Price Provider: Nordpool or Octopus Energy provides quarterly prices
- Optimization: Operates on variable-length arrays (today's remaining periods + tomorrow's when available)
- Storage: Indexes by period_index (0-95)
- InfluxDB: Queries at 15-minute boundaries
- API: Returns quarterly, aggregates only for display
- Frontend: Displays both resolutions as user preference, except the Dashboard's intent color timeline (
BatteryModeTimeline), which always renders at quarter-hourly granularity regardless of that preference — hourly aggregation can average away a genuine intent disagreement between quarters (#486)
- Unit Tests: Individual component validation with synthetic data
- Integration Tests: End-to-end workflow testing with real scenarios
- Optimization Tests: Algorithm correctness with various market conditions
- Hardware Tests: Inverter integration and sensor validation
- Quarterly Tests: DST transitions and period boundary handling
- Historical Scenarios: Real price data from high-volatility days
- Synthetic Patterns: EV charging, seasonal variations, extreme conditions
- Edge Cases: Sensor failures, price anomalies, hardware issues, DST transitions
- Code Quality: Ruff, Black, Pylance compliance
- Type Safety: Strict typing with union operators (
|) - Documentation: Comprehensive docstrings and design documentation
The mock HA environment lets any user-reported issue be reproduced and debugged locally, without access to the user's Home Assistant installation.
Invariant: mock(debug_export) must be indistinguishable from the real HA
installation at the moment the debug export was taken.
/api/export-debug-data ← debug export (markdown file)
from_debug_log.py ← generates scenario JSON
mock-run.sh ← starts Docker Compose
├── mock-ha (FastAPI, serves scenario data as HA REST API)
└── bess-dev (BESS backend, TZ + FAKETIME pinned to export time)
| Field | Used for |
|---|---|
entity_snapshot |
Verbatim /api/states/{entity_id} responses for every sensor BESS reads |
historical_periods |
Actual measured energy flows — seeded directly into the historical store, no InfluxDB needed |
price_data |
Raw quarterly prices for nordpool_official service call responses |
addon_options |
Complete sensor entity IDs, inverter device ID, price provider config |
inverter_tou_segments |
Current inverter memory state for read_time_segments responses |
export_timestamp + timezone |
Pins mock_time so BESS computes the same optimization period |
At startup, BatterySystemManager checks for BESS_HISTORICAL_SEED_FILE. If
set, it loads historical_periods directly into the historical store and skips
InfluxDB backfill entirely. The sensor collector cache is then warmed from live
mock-HA values so runtime collections work correctly. The mock is fully
self-contained — no external database access required.
This design reflects the current quarterly-native implementation as of the latest refactoring, focusing on simplicity and correctness across all time-based operations.