Skip to content

v7.10.0: Solar clipping awareness for DC-coupled hybrid inverters - #58

Closed
pookey wants to merge 4 commits into
johanzander:mainfrom
pookey:feat/solar-clipping-johanzander
Closed

v7.10.0: Solar clipping awareness for DC-coupled hybrid inverters#58
pookey wants to merge 4 commits into
johanzander:mainfrom
pookey:feat/solar-clipping-johanzander

Conversation

@pookey

@pookey pookey commented Mar 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Problem: The optimizer had no awareness of inverter AC output limits. On a 6.7 kW DC panel / 5 kW AC inverter setup, the battery would fill before peak solar hours, permanently wasting free DC-excess energy that the inverter cannot convert to AC.
  • Solution: When battery.inverter_ac_capacity_kw is configured, the Solcast forecast is split into AC solar (≤ inverter limit) and DC-excess solar. The DP algorithm absorbs DC excess before its AC-side charge/discharge decision. Because DC-excess energy has zero grid cost (only cycle cost), backward induction naturally keeps battery headroom open during clipping hours — no heuristics needed.
  • Backward compatible: inverter_ac_capacity_kw = 0 (default) gives identical behaviour to v7.9.5.

Changes

  • config.yaml: new battery.inverter_ac_capacity_kw and battery.solar_panel_dc_capacity_kw options
  • settings.py: new BatterySettings fields loaded from config
  • models.py: EnergyData gains dc_excess_to_battery and solar_clipped fields
  • dp_battery_algorithm.py: split_solar_forecast(), DC-aware _calculate_reward, _run_dynamic_programming, optimize_battery_schedule, and _create_idle_schedule
  • battery_system_manager.py: wires up split_solar_forecast in _run_optimization
  • api_dataclasses.py: dcExcessToBattery and solarClipped exposed in /api/dashboard per-period response; inverterAcCapacityKw and solarPanelDcCapacityKw exposed in /api/settings/battery
  • api.py: quarterly-to-hourly aggregation sums the new clipping fields
  • 5 new behaviour-based tests (all passing)

Demonstration:

We can see headroom being left in the. battery during the peak.

image

Test plan

  • All unit tests pass (rebased cleanly on johanzander/main)
  • black and ruff clean
  • Set inverter_ac_capacity_kw: 5.0 and solar_panel_dc_capacity_kw: 6.7 in config, run optimization, verify schedule defers grid charging to after peak clipping hours
  • Verify dcExcessToBattery and solarClipped appear in /api/dashboard period data
  • Verify inverterAcCapacityKw and solarPanelDcCapacityKw appear in /api/settings/battery

🤖 Generated with Claude Code

The optimizer previously had no awareness of inverter AC output limits, so it
would grid-charge or solar-store the battery before peak solar hours, wasting
free DC-excess energy that the inverter could not convert to AC.

When `battery.inverter_ac_capacity_kw` is configured, the Solcast forecast is
split into AC solar (≤ inverter limit) and DC-excess solar (the portion that
flows directly to the battery on the DC bus). The DP algorithm receives both
and automatically absorbs DC excess before evaluating AC-side charge/discharge
decisions. Because DC-excess energy carries zero grid cost (only cycle cost),
backward induction naturally reserves battery headroom for clipping hours over
grid charging — no special heuristics needed.

New EnergyData fields `dc_excess_to_battery` and `solar_clipped` expose
captured vs lost DC excess per period for dashboard visibility. When
`inverter_ac_capacity_kw = 0` (default), behaviour is unchanged.
@pookey

pookey commented Mar 14, 2026

Copy link
Copy Markdown
Contributor Author

This is still in testing - I'll not be able to verify it's effectiveness until there's another funny day in the UK, so might be a few years ;)

@pookey

pookey commented Mar 14, 2026

Copy link
Copy Markdown
Contributor Author

Code review

Found 4 issues:

  1. config.yaml schema fields should be optionalinverter_ac_capacity_kw and solar_panel_dc_capacity_kw are defined as float (required) in the HA add-on schema, but should be float? (optional). Home Assistant validates the schema before Python runs, so existing users upgrading without these fields will fail to start the add-on. The Python-side .get() defaults in settings.py are unreachable if HA rejects the config first. Other optional fields already use float? (e.g., solar_forecast_tomorrow: str?).

bess-manager/config.yaml

Lines 135 to 138 in b09a8f2

min_action_profit_threshold: float
inverter_ac_capacity_kw: float
solar_panel_dc_capacity_kw: float
temperature_derating:

  1. split_solar_forecast docstring contradicts implementation — The docstring says inverter_ac_capacity_kw: 0 = no limit, but the implementation computes ac_limit_kwh = inverter_ac_capacity_kw * period_duration_hours, so passing 0 caps all AC solar to zero and routes everything to DC excess. The test test_split_solar_forecast_zero_inverter_limit has a docstring saying "returns all solar as AC (disabled)" but asserts the opposite (ac_solar == [0.0, 0.0, 0.0]). The CHANGELOG also claims "When inverter_ac_capacity_kw = 0 (default), behaviour is identical to previous versions" which would be false if split_solar_forecast were called with 0. The production guard (if inverter_ac_capacity_kw > 0) prevents this from being hit at runtime, but the docstring and test are misleading.

solar_production: Raw solar forecast per period (kWh).
inverter_ac_capacity_kw: Inverter AC output limit in kW. 0 = no limit.
period_duration_hours: Duration of each period in hours.
Returns:
Tuple of (ac_solar, dc_excess) lists, both same length as solar_production.
"""
ac_limit_kwh = inverter_ac_capacity_kw * period_duration_hours
ac_solar = [min(s, ac_limit_kwh) for s in solar_production]
dc_excess = [max(0.0, s - ac_limit_kwh) for s in solar_production]
return ac_solar, dc_excess

  1. validate_energy_balance does not account for dc_excess_to_battery — The balance check computes energy_in = solar_production + grid_imported + battery_discharged and energy_out = home_consumption + grid_exported + battery_charged. With DC clipping, dc_excess_to_battery charges the battery but is excluded from both battery_charged (AC-side only) and solar_production (now AC-capped). Periods with DC excess absorption will produce a false energy imbalance equal to dc_excess_to_battery.

def validate_energy_balance(self, tolerance: float = 0.2) -> tuple[bool, str]:
"""Validate energy balance - always warn and continue, never fail."""
energy_in = self.solar_production + self.grid_imported + self.battery_discharged
energy_out = self.home_consumption + self.grid_exported + self.battery_charged
balance_error = abs(energy_in - energy_out)
if balance_error <= tolerance:
return True, f"Energy balance OK: {balance_error:.3f} kWh error"
else:
logger.warning(
f"Energy balance warning: In={energy_in:.2f}, Out={energy_out:.2f}, "
f"Error={balance_error:.2f} kWh"
)
return (
True,
f"Energy balance warning: {balance_error:.2f} kWh error (continuing)",
)

  1. CYCLE COST POLICY comment now contradicts implementation — The _calculate_reward docstring states "Applied only to charging operations (not discharging)". With the PR changes, when DC excess is absorbed during discharge or idle periods, battery_wear_cost = dc_wear_cost (non-zero), so cycle cost is now applied during non-charging periods too.

CYCLE COST POLICY:
- Applied only to charging operations (not discharging)
- Applied to energy actually stored (after efficiency losses)

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

ipc-zpg and others added 3 commits March 14, 2026 21:43
1. Schema fields: inverter_ac_capacity_kw and solar_panel_dc_capacity_kw
   changed from `float` to `float?` so existing users can upgrade without
   HA rejecting their config before Python runs.

2. split_solar_forecast docstring: removed misleading "0 = no limit" since
   passing 0 would cap AC to zero. Clarified that the caller must guard
   with `if inverter_ac_capacity_kw > 0`. Replaced the zero-limit test
   with a total-preservation test.

3. validate_energy_balance: documented that DC excess bypasses the AC bus
   and is balanced by definition (dc_excess_to_battery + solar_clipped =
   total DC excess). The AC-side balance holds by construction since grid
   flows are derived from the balance equation.

4. Cycle cost policy docstring: updated to reflect that DC wear cost is
   applied regardless of AC action (not only during charging).
Add dcExcessToBattery and solarClipped FormattedValue fields to
/api/dashboard per-period response. Add inverterAcCapacityKw and
solarPanelDcCapacityKw to /api/settings/battery response. Update
bess-analyst agent with API visibility and debugging steps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The DP was treating IDLE (power=0) as a flat hold, but on the Growatt
inverter IDLE maps to load_first mode where excess solar automatically
charges the battery before exporting. This caused the optimizer to
underestimate morning SOE buildup, leaving insufficient headroom for
afternoon DC excess absorption.

Changes:
- _state_transition: when power=0, excess solar auto-charges battery up
  to max_charge_power_kw and available capacity (load_first behavior)
- _calculate_reward: detects IDLE auto-charging from SOE delta, models
  reduced grid export and applies cycle wear cost on auto-charged energy;
  blends auto-charged solar into cost basis at cycle-cost-only rate
- _run_dynamic_programming: computes solar_excess_ac_kw per period
  (capped at temperature-derated limit), passes to _state_transition;
  updates fallback IDLE block and cost basis propagation accordingly
- _create_idle_schedule: added dt parameter, now models solar
  auto-charging in fallback schedule for accurate idle cost accounting

The DP's backward induction now sees that IDLE during morning solar
fills the battery, reducing headroom for free DC excess. When keeping
headroom is more valuable, it chooses discharge/EXPORT_ARBITRAGE (grid_first)
which prevents solar auto-charging on the real inverter — no new intent
types needed.

Backward compatible: no solar or nighttime periods are unchanged.
@pookey

pookey commented Mar 18, 2026

Copy link
Copy Markdown
Contributor Author

Updated this branch with an additional fix discovered after deploying the clipping feature.

Bug: The DP was modelling power=0 (IDLE) as a flat battery hold, but on the Growatt inverter IDLE maps to load_first mode where excess solar automatically charges the battery before exporting. This caused the optimizer to underestimate morning SOE buildup, leaving insufficient headroom for afternoon DC excess absorption.

Evidence (2026-03-18): Periods 30–39 were planned as IDLE, but battery charged from 1.4 → 7.2 kWh (5.8 kWh gain). By period 40 (10:00) battery was at 76% with only 2.1 kWh headroom despite >5 kW solar forecast all afternoon.

Fix: Model IDLE auto-charging in _state_transition, _calculate_reward, _run_dynamic_programming, and _create_idle_schedule. The DP's backward induction now sees that morning IDLE fills the battery, and will schedule discharge/EXPORT_ARBITRAGE (grid_first) when preserving headroom for DC excess is more valuable. No new intent types needed — discharge during solar-excess already maps to EXPORT_ARBITRAGE.

Backward compatible: no solar / nighttime periods are unchanged.

@johanzander

Copy link
Copy Markdown
Owner

Hi @pookey , sorry for not merging this one sooner, and now I have refactored the algorithm (or fixed some issues), so I cannot safely merge the changes. Could you rebase and verify your fix and I will merge it?

@pookey pookey closed this Apr 14, 2026
@pookey
pookey deleted the feat/solar-clipping-johanzander branch April 14, 2026 12:03
ridax67 pushed a commit to ridax67/bess-manager that referenced this pull request Jul 15, 2026
* Fix stale health-check banner and InfluxDB placeholder-config log spam (johanzander#217)

Two bugs from johanzander#201's debug bundle:

1. The dashboard health banner only refreshed at startup, after a
   settings save, or after the setup wizard — never periodically. A
   sensor blip at exactly one of those moments left the banner stuck
   showing an error indefinitely, even after sensors recovered, with
   no way for the user to clear it short of an unrelated settings save
   or a full restart. Added a public refresh_health_check() wrapper on
   BatterySystemManager, a 5-minute scheduler job, and a manual
   "Recheck now" button (POST /api/system-health/recheck) so the
   banner self-corrects without user guesswork.

2. get_sensor_data_batch/get_power_sensor_data_batch only checked for
   empty-string InfluxDB config, not the shipped placeholder
   credentials, so the periodic sensor-collection job kept attempting
   real HTTP connections to the default (unconfigured) InfluxDB URL
   every ~15 minutes for any user who never set it up. Both now use
   the existing is_influxdb_configured() check, which also gained a
   bucket-emptiness check it was missing.

Also converts two pre-existing call sites (settings-save refresh,
setup-wizard completion) from the private _run_health_check() to the
new public wrapper, for consistency with the no-private-cross-class-
calls pattern the wrapper itself establishes.

* docs: backfill missing changelog entries for johanzander#207, johanzander#211, johanzander#217 (johanzander#220)

These PRs shipped fixes but didn't touch CHANGELOG.md, leaving gaps
between what's documented and what actually merged since 9.8.1. Also
converts (#N) references to full markdown links, since plain #N text
only auto-links correctly when read within this same repo.

* fix: unify Battery/Home settings startup/PATCH paths, drop *_STORE_TO_API registries (johanzander#219) (johanzander#224)

BatterySettings.update()/HomeSettings.update() no longer translate camelCase
- snake_case (the settings store's native format) is now the canonical shape
for both the startup path and the PATCH /api/settings path, mirroring the
identical fix already applied to Price settings in johanzander#197/johanzander#216.

Root cause: the startup path filtered store fields through hand-maintained
BATTERY_STORE_TO_API/HOME_STORE_TO_API translation dicts before calling
update_settings(), while the PATCH handler already bypassed them and passed
the raw snake_case store dict directly - working only because
_camel_to_snake() was a no-op on already-snake_case keys. A field present in
the store but missing from the registry (e.g. charging_power_rate,
efficiency_charge, efficiency_discharge) was silently dropped at startup
while continuing to work via PATCH - the same bug class fixed for Price
in johanzander#197 after Belgian user johanzander#126's spot_multiplier revert-on-restart bug.

Code review (medium effort) surfaced and fixed three issues before this
was verified:
- build_system_settings() filtered the battery store section to known
  BatterySettings fields (to exclude the non-dataclass temperature_derating
  key) but passed the home section through completely unfiltered - a stray
  key surviving a partial migration would crash the whole app at startup.
  Added the same _HOME_DATACLASS_FIELDS filter for home.
- _BATTERY_DATACLASS_FIELDS (new) duplicated api.py's pre-existing
  _BATTERY_MODEL_ATTRS - identical dataclass-field derivation computed in
  two files with no test asserting them equal. api.py now imports the one
  definition from api_conversion.py instead of recomputing it.
- BatterySettings.update()/HomeSettings.update() used hasattr(), which also
  matches method names (not just fields) and violates the repo's explicit
  "never use hasattr" rule - replaced with an explicit dataclass-field
  membership check.

Verified locally against a real running backend (podman-compose CI stack):
startup applies the fixture correctly, PATCH persists and echoes battery/
home settings, values survive a container restart, charging_power_rate/
efficiency_charge/efficiency_discharge (previously silently dropped) now
correctly reach BSM, and a stray pre-migration key injected into the store
no longer crashes startup - confirmed to crash without the home filter fix.

Closes johanzander#219

* fix: detect Solcast via entity registry unique_id instead of entity_id (johanzander#223)

Solcast detection previously matched entity_id substrings ("forecast_today"),
which fails when Home Assistant translates entity names to the user's
language. This was already fixed and shipped on beta but never ported to
main because the only call site never passed the entity registry through.

- Add SOLCAST_SUFFIX_MAP mapping unique_id keys to BESS sensor keys
- Pass entity_registry to discover_optional_sensors for Solcast lookup
- Add solcast_solar to the debug bundle exporter's registry filters so
  future locale-detection bug reports are actually diagnosable
- Add Solcast entity_registry entries to mock-HA scenarios that previously
  relied on the removed substring fallback

Split from johanzander#218; items 1 and 2 depend on the spot_multiplier feature port
and are tracked in johanzander#221.

Closes johanzander#218

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix: unify Battery/Home/Price settings startup/PATCH paths, fix live efficiency-field bug (johanzander#197) (johanzander#216)

* fix: unify price settings startup/PATCH paths, drop PRICE_STORE_TO_API (johanzander#197)

PriceSettings.update() translated camelCase to snake_case on every call,
which made it a no-op for the PATCH /api/settings path (already snake_case)
but load-bearing for the startup path, which went through a separate
hand-maintained PRICE_STORE_TO_API registry to build a camelCase dict.
Two divergent paths to the same sink, kept in sync only by an accidental
no-op quirk — a field added to PriceSettings without a matching registry
entry would silently vanish on restart while still working via PATCH
(the root cause class behind johanzander#126's spot_multiplier revert-on-restart bug).

Make snake_case the single canonical format: PriceSettings.update() no
longer translates camelCase, and PRICE_STORE_TO_API (a translation dict)
is replaced by PRICE_REQUIRED_FIELDS (a presence-validation-only set) kept
in sync with the dataclass by a new structural test. The one remaining
camelCase producer (POST /api/setup/complete's live-apply path) is updated
to build snake_case keys directly.

Battery and Home settings have the same historical dual-path pattern but
are intentionally left untouched here, matching the issue's scope — a
candidate follow-up, not a live bug.

* fix: extend settings unification to Battery and Home, fix live efficiency-field bug (johanzander#197)

Battery and Home settings had the identical architecture flaw already
fixed for Price: BatterySettings.update()/HomeSettings.update() translated
camelCase on every call (a no-op for PATCH's already-snake_case dicts, but
load-bearing for the startup path's separate BATTERY_STORE_TO_API/
HOME_STORE_TO_API translation registries). Both now take snake_case only,
matching Price; _camel_to_snake is now dead code and removed.

Investigating Battery surfaced a live bug on main (not beta-only, unlike
the Price example): BATTERY_STORE_TO_API only listed 7 of BatterySettings's
10 fields. charging_power_rate/efficiency_charge/efficiency_discharge are
user-editable via the Settings page and persisted to the store, but
build_system_settings() only ever built its startup output from those 7
keys — so a user's efficiency settings applied live via PATCH, then
silently reverted to class defaults (97%/95%/40%) on every restart.

Fix: BATTERY_REQUIRED_FIELDS (the original 7, presence-validated at
startup, unchanged semantics) is now paired with BATTERY_MODEL_ATTRS
(derived live from the dataclass, all 10 fields — relocated from a local
_BATTERY_MODEL_ATTRS in api.py, now the single source of truth for both
the startup and PATCH paths). build_system_settings() filters the full
battery store section through BATTERY_MODEL_ATTRS instead of only the
required 7, so all persisted fields survive a restart, while
temperature_derating (a non-model key in the same store section, applied
separately at BSM construction) is correctly filtered out rather than
crashing BatterySettings.update().

Home has no equivalent gap (its one excluded field, min_valid, is
genuinely not store-backed, like Price's min_profit/use_actual_price) —
HOME_REQUIRED_FIELDS does double duty as both the required-fields check
and the passthrough filter.

Test coverage per section (Battery/Home, mirroring Price): update()
rejects camelCase, build_system_settings() output is snake_case, a
structural test ties the required-fields registry to the dataclass, and a
round-trip test on a real BatterySystemManager asserts the startup and
PATCH paths converge. The Battery round-trip specifically asserts
charging_power_rate/efficiency_charge/efficiency_discharge survive the
startup path — the regression test for the live bug above — plus a
temperature_derating passthrough test.

Full suite verified: 975 fast + 326 slow tests pass, quality-check.sh
clean. Locally verified via podman-compose (docker-compose.ci.yml, now
usable after johanzander#214 added podman-compose to requirements-dev.txt): PATCHed
efficiency/charging-power-rate and phase_count/default_hourly values
survive a live container restart; a stray temperature_derating value
survives restart without crashing startup.

* fix: filter stale pre-migration keys from Home PATCH path, matching startup (johanzander#197/johanzander#219)

The merged Battery/Home unification (from johanzander#216 + johanzander#224, reconciled after
both PRs independently touched the same files) left one residual gap: the
PATCH /api/settings handler filtered battery through BATTERY_MODEL_ATTRS
but passed home straight through unfiltered, unlike the startup path
(build_system_settings()), which now filters both through their
respective *_MODEL_ATTRS sets.

A stale pre-migration key ('consumption') can coexist with its renamed
successor ('default_hourly') if a migration was ever interrupted — the
rename in settings_store.py only fires when default_hourly is absent, so
once both exist, neither the rename nor cleanup runs on subsequent loads.
The startup path already tolerates this (filters non-model keys out); the
PATCH path did not, and would raise AttributeError inside
HomeSettings.update() — the exact "startup and PATCH must reach BSM
identically" gap this issue is about, just newly surfaced in the Home
column after the merge.

Fix: PATCH's home branch now filters through HOME_MODEL_ATTRS the same
way battery already does, importing it from api_conversion.py.

* feat: port multiplicative spot-price adjustment (spot_multiplier) to main (johanzander#227)

main's ENTSO-e provider (johanzander#208) only supported the additive markup model,
same as Nordpool ((spot + markup) * VAT + fees). Contracts that scale the
raw spot price instead — e.g. Belgian Luminus Dynamic (spot * 1.0175 +
fees) * VAT, export spot * 1.018 + compensation — got systematically
wrong prices. Beta already built spot_multiplier/export_spot_multiplier
for this (v9.10.0b1); this ports it to main using beta's commit as a
reference, folding in a wiring gap that the referenced beta history hit
too (spot_multiplier missing from PRICE_STORE_TO_API silently reverting
to 1.0 on every restart) and the setup wizard's _PRICE_MAP allow-list
gap this issue explicitly flagged, plus use_actual_price which has the
identical pre-existing gap.

Fixes found and applied during code review before this PR opened:
- setup_complete()'s live-update path omitted the two new fields, so a
  wizard-configured multiplier only took effect after an addon restart
- two spots (PricingFormSection's provider-switch handler and
  SetupWizardPage's scan-on-mount) unconditionally overwrote a user's
  saved custom multiplier with a generic default

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix: set working_dir /app/backend in docker-compose.ci.yml to unshadow live mount (johanzander#228)

Dockerfile.dev's WORKDIR is /app, which is also the build-time COPY
destination for backend/*.py. Without an explicit working_dir override,
uvicorn's sys.path[0] resolves to the stale image-baked /app/*.py ahead of
the live-mounted /app/backend/*.py in PYTHONPATH, so editing app.py,
api.py, api_conversion.py etc. and restarting the container silently
keeps running old code. docker-compose.yml's dev setup already works
around this by cd'ing into /app/backend before invoking uvicorn; this
applies the same fix to the CI compose file via working_dir.

Verified locally with podman: confirmed sys.path/cwd, confirmed a live
edit is invisible after restart without the fix and picked up
immediately with it.

Closes johanzander#226

* test: de-duplicate Battery/Home/Price test classes in test_settings_contracts.py (johanzander#229)

Extract a parametrized TestModelAttrsConsistency (Battery + Home) to
replace the two near-identical classes that differed only in dataclass,
constant names, and exclusion set — Price stays separate since it has no
MODEL_ATTRS constant to check against.

Extract _apply_startup()/_apply_patch() helpers to remove the repeated
_bsm() + update_settings() boilerplate shared by all three
*SettingsRoundTrip classes, keeping the one genuinely different line
(Battery's BATTERY_MODEL_ATTRS PATCH filter) inline per class. Also
routes Price's startup test through the existing _full_options() helper
instead of rebuilding the same dict inline.

No behavior change: same 32 tests, same assertions, verified passing
before and after.

Closes johanzander#225

* fix: use correct solar baseline in profitability gate, never substitute a worse fallback (johanzander#235)

The DP optimizer's profitability gate compared its schedule against a
solar-blind baseline (solar_only_cost was hardcoded equal to
grid_only_cost), so on high-solar days with negative injection prices
the gate could reject a genuinely good schedule and fall back to an
all-IDLE plan that can never discharge — reproducing the reported
"battery fills to 100%, exports at negative prices, then sits full
through the evening peak" pattern.

Root cause and fix, in order of discovery:

- solar_only_cost is now the real per-period solar-only-no-battery cost
  (reusing EconomicData.solar_only_cost, already computed per period),
  and the gate compares against it instead of the zero-solar baseline.
- The same hardcode existed a second time in
  BatterySystemManager._create_updated_schedule's "today only" summary
  recompute, which runs on every normal (non-prepare-next-day) schedule
  update — not just when the gate mishandles a day. Fixed the same way.
- Testing against 3 real historical fixtures found the all-IDLE
  fallback itself can cost more than the schedule it replaces: it still
  pays wear cost on passively-absorbed solar but never discharges to
  recoup it. Added a guardrail so the gate only substitutes the
  fallback when it's actually cheaper than the rejected schedule.
- Updated docs/agents/bess-knowledge.md's "Profit threshold" section,
  which described the old unconditional-substitution, grid-baseline
  behavior and is the documented source of truth for the AI chat/issue
  analyst.

Note: for the reporter's own configuration (cycle_cost_per_kwh=0.40 vs
their evening price spread), discharging into the evening peak is
genuinely unprofitable even with the fix — verified against the exact
real debug-bundle data. This fix corrects the gate's math and prevents
it from making things worse; it does not force discharge where the
economics don't support it.

Closes johanzander#231

* docs: trim changelog verbosity, add missing GitHub Release step to release skill (johanzander#230)

- CHANGELOG entries with a PR link should be one-liners, not restate the
  full PR description (found this session while cutting v9.9.0b6 on beta)
- The beta release skill was missing the "create a published GitHub
  Release" step; release-addon.yml only triggers on release:published,
  not on a pushed tag, so following the skill as written would silently
  skip the Docker image build
- Note that beta/main's branch protection no longer requires a manual
  review (removed 2026-07-04 to match main, which has none); merge is
  now gated on CI status checks only

* fix: use currency-appropriate default for battery cycle cost (johanzander#237)

BATTERY_CHARGE_CYCLE_COST (0.40) is a SEK value, but non-Swedish
installs got it verbatim since it's hidden under advanced settings and
users rarely change it. Setup discovery now sets cycle_cost_per_kwh
from a currency map (EUR 0.035, GBP 0.031) alongside the existing
locale-detection logic for currency/VAT, and the wizard mirrors this
so the Battery step shows the right value before the user ever saves.

* fix: show sell price alongside buy price in Battery SOC chart tooltip (johanzander#238)

* fix: show sell price alongside buy price in Battery SOC chart tooltip

BatteryLevelChart's tooltip only ever read hour.buyPrice, so contracts
where sell/export price diverges sharply from buy price (e.g. Belpex
dynamic contracts going negative at midday) gave no visual explanation
for why the optimizer held or exported. Adds a "Show sell price" toggle
(off by default, persisted like the existing dataResolution preference)
that adds a second tooltip row when enabled.

Closes johanzander#232

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: move sell price toggle to Energy Flow chart, not Battery SOC chart

BatteryLevelChart only ever surfaces price in a tooltip on hover; the
actual persistent, always-visible price line users look at lives in
EnergyFlowChart's "Electricity Price" line. Toggling sell price on the
Battery SOC card had no visible effect on the chart users actually
read for price shape. Moves the toggle, tooltip row, and a second
dashed price line to EnergyFlowChart instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: always show sell price in Energy Flow tooltip, gate only the line

The toggle now only controls whether the second dashed price line (and
its legend entry) is drawn on the chart. The tooltip's "Sell Price" row
shows whenever sell price data exists, regardless of the toggle state,
so users always get the number on hover even if they haven't turned on
the visual line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat: add self-resolved health-check recovery notice (johanzander#239)

* feat: add self-resolved health-check recovery notice (johanzander#215)

A sensor that briefly goes ERROR/WARNING and recovers on its own (e.g. an
HA restart) previously left no trace once the live dashboard banner
self-corrected back to OK — if nobody was watching during the window, the
intermittent issue went unnoticed. Track per-component ERROR/WARNING -> OK
transitions and surface them as a dismissible amber banner once all active
issues have cleared, so a self-resolved problem is never silently lost.

- core/bess/health_recovery_tracker.py: new in-memory HealthRecoveryTracker
  (mirrors the existing RuntimeFailureTracker pattern), bounded to 50 entries.
- core/bess/battery_system_manager.py: _run_health_check now diffs the new
  health-check result against the previous cached one and records/clears
  recoveries per component; new get_health_recoveries()/
  acknowledge_health_recoveries() public wrappers.
- backend/api.py: GET /api/health-recoveries, POST
  /api/health-recoveries/acknowledge.
- frontend: new useHealthRecoveries hook; AlertBanner.tsx gains a third
  "recovered" state (only shown once no active critical/warning issues
  remain) and an expandable "show all" toggle for >3 issues; dismiss is no
  longer available while an issue is actively critical/warning (only the
  recovered notice is dismissible), tightening the live banner to match its
  original intent.
- .claude/skills/verify/SKILL.md: persisted the local mock-HA E2E
  verification recipe (podman-compose setup, live sensor toggling via
  /mock/update_sensor, faketime gotcha) discovered while verifying this
  change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: add manual demo script for the health-recovery banner (johanzander#215)

Lets a reviewer reproduce the break/fix/observe cycle used to verify
PR johanzander#239 with one command instead of copy-pasting curl calls, per request
on the PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: give the health-recovery demo a fully clean baseline

The demo previously ran against ci-normal-day.json as-is, which carries
two pre-existing unrelated errors (Nordpool price-date mismatch,
unconfigured energy-monitoring sensors). Those kept the active-issue
(red, non-dismissible) banner showing after the "fix" step, masking the
actual recovered-state banner this script exists to demonstrate.

Now generates a scratch scenario/settings/options fixture set (today's
date instead of the pinned scenario date, added lifetime energy sensors,
InfluxDB left unconfigured) so Battery Control is the only thing that
ever goes wrong — the recovered banner is now actually visible after
`fix`, not just provable via curl.

Scratch files must live under the repo (.demo-scratch/, gitignored), not
/tmp — the podman machine only shares /Users into its VM, so a /tmp path
mounts as missing even though it exists on the host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat: show specific failing sensor(s) and consequence in the health banner

The active-issue and recovered banners only named the top-level component
(e.g. "Battery Control") with a generic description, no indication of the
actual failing sensor/entity, and no timestamp for active issues (recovered
ones already had one, just wasn't rendered). Per feedback, address both the
missing specifics and unclear consequence wording.

- core/bess/health_check.py: new describe_failing_checks(component) — names
  the specific sub-check(s)/entity behind a component's ERROR/WARNING (e.g.
  "Battery Charging Power Rate (number.growatt_battery_charging_power_rate)"),
  shared by the recovery tracker and the dashboard summary API instead of
  duplicating the logic.
- core/bess/battery_system_manager.py: _update_health_recoveries now calls
  the shared helper instead of its own private copy.
- backend/api.py: /api/dashboard-health-summary's critical_issues now carry
  a "detail" field in both the degraded-mode and cached-results code paths.
- frontend/AlertBanner.tsx: renders the specific detail alongside each
  active issue and each recovery; adds an "As of {time}" note to the active
  banners; rewords both banners to state the actual consequence ("cannot
  reliably operate or optimize your battery...") instead of a vague "may
  affect system operation"; recovered banner gets a summary line above the
  per-item list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat: add break-multi/fix-multi to the demo script

Lets a reviewer see the multiple-issues case directly in the browser
instead of taking it on faith: breaks 2 sensors in Battery Control and
1 in Energy Monitoring at once (2 different components, one with 2
failing sub-checks), then restores all 3 to show both recoveries
listed together with their own detail/time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test: add Playwright regression coverage for the health-recovery banner

Per feedback: new UI controls should get an e2e spec covering their logic,
not just component unit tests. Adds e2e/tests/health-recovery.spec.ts,
which runs in the existing CI e2e job (docker-compose.ci.yml, gated on
backend/core/frontend/e2e changes) — verified locally against the real
stack (all 4 pass; full existing e2e suite of 70 tests still passes too).

Covers: breaking a required sensor surfaces it (with the specific failing
entity) in both /api/dashboard-health-summary and the dashboard DOM with no
dismiss button; fixing it records a recovery with the right previousStatus
and detail; acknowledging clears it; a component erroring again drops its
own stale pending recovery. The "recovered" banner's DOM appearance isn't
asserted here — CI's shared ci-normal-day scenario has its own permanently
pinned pre-existing errors (date-mismatch), so hasCriticalErrors never
reliably clears in that shared stack; that path is verified at the API
level instead, which is deterministic regardless of the scenario's other
baseline state.

Also extends scripts/demo_health_recovery.sh with a `fix-partial` command
(restore only one of two broken components) to manually reproduce the
"one recovered, one still failing" case: the recovery is recorded but
stays hidden behind the still-active banner until everything clears.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix: drop beta's session-dismiss banner, superseded by main's recovery notice

Merging main's health-recovery feature (johanzander#239) into beta reintroduced
beta's older onDismiss/dismissedBanner mechanism for active critical
issues, but main's own history intentionally removed dismissibility of
active issues when it added the recovery-tracking design (only the
recovered-issue notice is dismissible now). The reintroduced dismiss
button broke e2e/tests/health-recovery.spec.ts, which asserts active
issues have no dismiss button. Restore AlertBanner.tsx and
DashboardPage.tsx to match main exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants