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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .claude/agents/bess-analyst.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,38 @@ You are a BESS (Battery Energy Storage System) analyst. Your role is to analyze
3. Trace the cost basis tracking through charge/discharge
4. Verify price data fed to optimizer

### Debugging DC Clipping / Solar Capture

When `battery.inverter_ac_capacity_kw > 0` in config, the system is clipping-aware:

- `split_solar_forecast()` splits raw solar into `ac_solar` (≤ inverter limit) and `dc_excess`
- DC excess is fed to `optimize_battery_schedule(dc_excess_solar=...)` and `_run_dynamic_programming`
- In `_calculate_reward`, DC excess is absorbed into battery **before** the AC optimization decision
- `EnergyData.dc_excess_to_battery` = DC excess captured; `EnergyData.solar_clipped` = DC excess lost
- `EnergyData.battery_charged` = AC-side charging only (does NOT include DC excess)
- `EnergyData.solar_production` = AC solar only (capped at inverter limit), NOT raw DC production
- DC excess has **zero grid cost**, only cycle cost in cost basis
- DC wear cost applies regardless of AC action (idle, charge, or discharge) — it's a physical process
- The DP naturally keeps battery headroom for clipping hours because DC energy is cheaper than grid
- Even the idle fallback schedule (`_create_idle_schedule`) absorbs DC excess automatically
- When disabled (`inverter_ac_capacity_kw = 0`), behavior is identical to pre-clipping code
- `validate_energy_balance()` checks AC-side only; DC excess is self-balancing by definition (dc_excess_to_battery + solar_clipped = total DC excess)

#### API Visibility

- `/api/dashboard` exposes `dcExcessToBattery` and `solarClipped` as `FormattedValue` fields per period
- `/api/settings/battery` exposes `inverterAcCapacityKw` and `solarPanelDcCapacityKw`
- Both fields appear in today's data (actual and predicted) and tomorrow's data
- Values are zero when clipping is disabled or solar doesn't exceed inverter AC limit
- Quarter-hourly values are summed when aggregating to hourly resolution

#### Common Clipping Debugging Steps

1. Check `inverterAcCapacityKw` is set > 0 in `/api/settings/battery` response
2. Check solar forecast — clipping only occurs when per-period solar > `inverter_ac_capacity_kw * period_duration_hours`
3. If `dcExcessToBattery` is always 0 but clipping should occur, check that `split_solar_forecast` is being called in `battery_system_manager.py._run_optimization`
4. If `solarClipped` is high, battery may be reaching max SOE before peak clipping hours — check if the optimizer is keeping enough headroom

### Debugging Schedule Issues

1. Read `growatt_schedule.py` TOU conversion logic
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ All notable changes to BESS Battery Manager will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [7.10.0] - 2026-03-14

### Added

- Solar clipping awareness for DC-coupled hybrid inverters. When `battery.inverter_ac_capacity_kw`
is set, the optimizer splits the Solcast solar forecast into AC-available solar (capped at the
inverter limit) and DC-excess solar (the portion that bypasses AC conversion and flows directly
to the battery on the DC bus). The DP algorithm naturally keeps battery headroom open during
clipping hours because DC-excess energy has zero grid cost — only cycle cost — making it
cheaper to store than grid-charged energy. (thanks [@pookey](https://github.com/pookey))
- New `EnergyData` fields `dc_excess_to_battery` and `solar_clipped` track captured vs lost DC
excess per period for dashboard visibility.
- New `battery.solar_panel_dc_capacity_kw` config setting (informational, not required).
- Idle fallback schedule now absorbs DC excess even when AC optimization is rejected by the
profitability gate, since DC absorption is a physical process independent of AC decisions.
- `dcExcessToBattery` and `solarClipped` exposed in `/api/dashboard` per-period response.
- `inverterAcCapacityKw` and `solarPanelDcCapacityKw` exposed in `/api/settings/battery` response.

### Changed

- `EnergyData.solar_production` represents AC solar only (capped at inverter limit) when clipping
is enabled; `EnergyData.battery_charged` represents AC-side charging only.
- Cost basis for DC-excess energy reflects cycle cost only (no grid cost), so the profitability
check naturally favours discharging DC-charged energy over grid-charged energy.
- When `inverter_ac_capacity_kw = 0` (default), behaviour is identical to previous versions.

## [7.9.5] - 2026-03-14

### Added
Expand Down
10 changes: 10 additions & 0 deletions backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,16 @@ def _aggregate_quarterly_to_hourly(
solarSavings=create_formatted_value(
sum(p.solarSavings.value for p in quarter_periods), "currency", currency
),
dcExcessToBattery=create_formatted_value(
sum(p.dcExcessToBattery.value for p in quarter_periods),
"energy_kwh_only",
currency,
),
solarClipped=create_formatted_value(
sum(p.solarClipped.value for p in quarter_periods),
"energy_kwh_only",
currency,
),
# Use dominant strategic intent with tie-breaking (same logic as Growatt schedule)
strategicIntent=dominant_intent,
directSolar=sum(p.directSolar for p in quarter_periods),
Expand Down
18 changes: 18 additions & 0 deletions backend/api_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ class APIBatterySettings:
estimatedConsumption: float # kWh - estimated daily consumption
consumptionStrategy: str # consumption forecast strategy

# DC clipping settings
inverterAcCapacityKw: float # kW - inverter AC output limit (0 = clipping disabled)
solarPanelDcCapacityKw: float # kW - DC panel capacity (informational)

@classmethod
def from_internal(
cls, battery, estimated_consumption: float, consumption_strategy: str = "sensor"
Expand All @@ -119,6 +123,8 @@ def from_internal(
efficiencyDischarge=battery.efficiency_discharge,
estimatedConsumption=estimated_consumption,
consumptionStrategy=consumption_strategy,
inverterAcCapacityKw=battery.inverter_ac_capacity_kw,
solarPanelDcCapacityKw=battery.solar_panel_dc_capacity_kw,
)

def to_internal_update(self) -> dict:
Expand Down Expand Up @@ -373,6 +379,10 @@ class APIDashboardHourlyData:
solarExcess: FormattedValue # How much solar excess in solar-only scenario
solarSavings: FormattedValue # Savings from solar vs grid-only

# DC clipping flows (zero when clipping is disabled or no clipping occurs)
dcExcessToBattery: FormattedValue # DC excess captured by battery (kWh)
solarClipped: FormattedValue # DC excess lost because battery was full (kWh)

# Raw values for logic only
strategicIntent: str
directSolar: float
Expand Down Expand Up @@ -496,6 +506,14 @@ def safe_format(value, unit_type):
hourly.economic.solar_savings,
"currency",
),
dcExcessToBattery=safe_format(
hourly.energy.dc_excess_to_battery,
"energy_kwh_only",
),
solarClipped=safe_format(
hourly.energy.solar_clipped,
"energy_kwh_only",
),
# Raw values for logic
strategicIntent=hourly.decision.strategic_intent,
directSolar=direct_solar,
Expand Down
6 changes: 5 additions & 1 deletion config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: "BESS Manager"
description: "Battery Energy Storage System optimization and management"
version: "7.9.5"
version: "7.10.0"
slug: "bess_manager"
init: false
arch:
Expand Down Expand Up @@ -42,6 +42,8 @@ options:
max_charge_discharge_power: 15.0 # kW
cycle_cost: 0.50 # Battery wear cost per kWh (excl. VAT) - use your local currency
min_action_profit_threshold: 8.0 # Minimum profit threshold for any battery action (in your currency)
inverter_ac_capacity_kw: 0.0 # Inverter AC output limit in kW. 0 = disabled (no clipping awareness)
solar_panel_dc_capacity_kw: 0.0 # DC panel capacity in kW. Informational only.
temperature_derating:
enabled: false # Enable for outdoor batteries affected by cold weather
weather_entity: "" # HA weather entity for forecast (e.g. "weather.forecast_home")
Expand Down Expand Up @@ -131,6 +133,8 @@ schema:
max_charge_discharge_power: float
cycle_cost: float
min_action_profit_threshold: float
inverter_ac_capacity_kw: float?
solar_panel_dc_capacity_kw: float?
temperature_derating:
enabled: bool?
weather_entity: str?
Expand Down
11 changes: 11 additions & 0 deletions core/bess/battery_system_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
OptimizationResult,
optimize_battery_schedule,
print_optimization_results,
split_solar_forecast,
)
from .dp_schedule import DPSchedule
from .exceptions import (
Expand Down Expand Up @@ -1302,6 +1303,15 @@ def _run_optimization(
n_periods
)

# Split solar into AC-available and DC-excess when inverter limit is configured
dc_excess_solar = None
if self.battery_settings.inverter_ac_capacity_kw > 0:
remaining_solar, dc_excess_solar = split_solar_forecast(
solar_production=remaining_solar,
inverter_ac_capacity_kw=self.battery_settings.inverter_ac_capacity_kw,
period_duration_hours=0.25,
)

# Run DP optimization with strategic intent capture - returns OptimizationResult directly
result = optimize_battery_schedule(
buy_price=buy_prices,
Expand All @@ -1315,6 +1325,7 @@ def _run_optimization(
terminal_value_per_kwh=terminal_value,
currency=self.home_settings.currency,
max_charge_power_per_period=max_charge_power_per_period,
dc_excess_solar=dc_excess_solar,
)

# Add timestamps to period data (algorithm is time-agnostic, operates on relative indices)
Expand Down
Loading