v7.10.0: Solar clipping awareness for DC-coupled hybrid inverters - #58
v7.10.0: Solar clipping awareness for DC-coupled hybrid inverters#58pookey wants to merge 4 commits into
Conversation
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.
|
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 ;) |
Code reviewFound 4 issues:
Lines 135 to 138 in b09a8f2
bess-manager/core/bess/dp_battery_algorithm.py Lines 134 to 144 in b09a8f2
bess-manager/core/bess/models.py Lines 153 to 170 in b09a8f2
bess-manager/core/bess/dp_battery_algorithm.py Lines 222 to 225 in b09a8f2 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
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.
|
Updated this branch with an additional fix discovered after deploying the clipping feature. Bug: The DP was modelling 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 Backward compatible: no solar / nighttime periods are unchanged. |
|
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? |
* 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>
Summary
battery.inverter_ac_capacity_kwis 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.inverter_ac_capacity_kw = 0(default) gives identical behaviour to v7.9.5.Changes
config.yaml: newbattery.inverter_ac_capacity_kwandbattery.solar_panel_dc_capacity_kwoptionssettings.py: newBatterySettingsfields loaded from configmodels.py:EnergyDatagainsdc_excess_to_batteryandsolar_clippedfieldsdp_battery_algorithm.py:split_solar_forecast(), DC-aware_calculate_reward,_run_dynamic_programming,optimize_battery_schedule, and_create_idle_schedulebattery_system_manager.py: wires upsplit_solar_forecastin_run_optimizationapi_dataclasses.py:dcExcessToBatteryandsolarClippedexposed in/api/dashboardper-period response;inverterAcCapacityKwandsolarPanelDcCapacityKwexposed in/api/settings/batteryapi.py: quarterly-to-hourly aggregation sums the new clipping fieldsDemonstration:
We can see headroom being left in the. battery during the peak.
Test plan
blackandruffcleaninverter_ac_capacity_kw: 5.0andsolar_panel_dc_capacity_kw: 6.7in config, run optimization, verify schedule defers grid charging to after peak clipping hoursdcExcessToBatteryandsolarClippedappear in/api/dashboardperiod datainverterAcCapacityKwandsolarPanelDcCapacityKwappear in/api/settings/battery🤖 Generated with Claude Code