-
Notifications
You must be signed in to change notification settings - Fork 10
fix: release VPP control when IDLE at the reserve floor (#592) #619
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
c05b58e
fix: release VPP control when IDLE at the reserve floor (#592)
johanzander 72ce967
Merge remote-tracking branch 'origin/main' into fix/issue-592-vpp-idl…
johanzander 23031e7
Merge branch 'main' into fix/issue-592-vpp-idle-at-floor
johanzander 0e611bc
fix: wire at_reserve_floor through display and simulator, guard unrea…
johanzander c2c6fa3
Merge remote-tracking branch 'origin/fix/issue-592-vpp-idle-at-floor'…
johanzander 70c426b
Merge remote-tracking branch 'origin/main' into fix/issue-592-vpp-idl…
johanzander 43295e6
test: re-pin the VPP baseline commands, plans untouched
johanzander 900f5e5
fix: read the entering SoE, and drop the getattr masking it
johanzander f34f54a
docs: say what the VPP baseline actually shows
johanzander File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| """IDLE at the reserve floor releases VPP control so the BMS can sleep (#592). | ||
|
|
||
| Reported behaviour: during a long overnight IDLE with the battery already at | ||
| its minimum SoC, the inverter was held in `battery_first` (`vpp_power=+1`, | ||
| remote control enabled) and that command was re-asserted every period, so the | ||
| inverter was never handed back and its BMS never slept. | ||
|
|
||
| `battery_first` is right whenever IDLE is *holding energy back* for a later | ||
| peak (#466) -- it keeps self-consumption on grid/solar instead of draining the | ||
| battery, which IDLE's own DP cost model (`_idle_battery_flows`) never credits. | ||
| At the reserve floor there is nothing left to hold, so the hold buys nothing | ||
| and costs the BMS its sleep. | ||
|
|
||
| **These tests drive the real production write path** | ||
| (`BatterySystemManager._apply_period_schedule`), not `_intent_to_vpp` with | ||
| hand-built arguments. That is deliberate: the mapping alone could be correct | ||
| while the floor flag never reaches it -- the branch would be dead in | ||
| production and a unit test on the mapping would still pass. What is asserted | ||
| here is the command that actually lands on the inverter. | ||
|
|
||
| Flow-neutrality of the swap (the reason this needs no VPP baseline re-pin) is | ||
| proved separately in | ||
| `test_vpp_simulator_branches.py::TestIdleAtReserveFloor`. | ||
| """ | ||
|
|
||
| from types import SimpleNamespace | ||
|
|
||
| from core.bess.battery_system_manager import BatterySystemManager | ||
| from core.bess.price_manager import MockSource | ||
| from core.bess.tests.conftest import MockHomeAssistantController | ||
|
|
||
| PERIOD = 12 # 03:00 -- the overnight idle stretch from the report | ||
|
|
||
|
|
||
| def _make_vpp_bsm( | ||
| soc: float, | ||
| ) -> tuple[BatterySystemManager, MockHomeAssistantController]: | ||
| controller = MockHomeAssistantController() | ||
| controller.settings["battery_soc"] = soc | ||
| bsm = BatterySystemManager( | ||
| controller=controller, | ||
| price_source=MockSource([1.0] * 96), | ||
| addon_options={ | ||
| "inverter": { | ||
| "platform": "solax_modbus_growatt_min", | ||
| "control_mode": "vpp", | ||
| } | ||
| }, | ||
| ) | ||
| intents = ["IDLE"] * 96 | ||
| bsm._inverter_controller.strategic_intents = intents | ||
| bsm._inverter_controller.current_schedule = SimpleNamespace(actions=[0.0] * 96) | ||
| return bsm, controller | ||
|
|
||
|
|
||
| def _last_vpp_command(controller: MockHomeAssistantController) -> dict: | ||
| return controller.calls["growatt_vpp_periods"][-1] | ||
|
|
||
|
|
||
| class TestIdleAtReserveFloorReleasesControl: | ||
| def test_idle_at_the_floor_releases_the_inverter(self): | ||
| """At min SoC the written command must release remote control, so the | ||
| inverter reverts to its own self-use and stops being commanded.""" | ||
| bsm, controller = _make_vpp_bsm(soc=10.0) | ||
| assert ( | ||
| bsm.battery_settings.min_soc == 10.0 | ||
| ), "fixture assumes the default 10% floor; the SoC above must equal it" | ||
|
|
||
| bsm._apply_period_schedule(PERIOD) | ||
|
|
||
| command = _last_vpp_command(controller) | ||
| assert command["power_pct"] == 0 | ||
| assert command["remote_control_enabled"] is False | ||
|
|
||
| def test_idle_above_the_floor_still_holds_battery_first(self): | ||
| """#466 must survive #592: with energy still banked for the morning | ||
| peak, IDLE holds battery_first exactly as before.""" | ||
| bsm, controller = _make_vpp_bsm(soc=50.0) | ||
|
|
||
| bsm._apply_period_schedule(PERIOD) | ||
|
|
||
| command = _last_vpp_command(controller) | ||
| assert command["power_pct"] == 1 | ||
| assert command["remote_control_enabled"] is True | ||
|
|
||
| def test_released_control_stops_re_asserting_every_period(self): | ||
| """The actual mechanism behind "the BMS never sleeps": with remote | ||
| control enabled `_apply_period_vpp` rewrites every period to refresh | ||
| the inverter's fallback timer (#404). Once released there is nothing | ||
| to refresh, so the writes must stop rather than continue silently.""" | ||
| bsm, controller = _make_vpp_bsm(soc=10.0) | ||
|
|
||
| for period in range(PERIOD, PERIOD + 4): | ||
| bsm._apply_period_schedule(period) | ||
|
|
||
| assert len(controller.calls["growatt_vpp_periods"]) == 1, ( | ||
| "a released inverter must be written once, not re-commanded every " | ||
| "period -- re-asserting is what kept the BMS awake" | ||
| ) | ||
|
|
||
| def test_hold_still_re_asserts_every_period_above_the_floor(self): | ||
| """Guard rail on the test above: the every-period refresh is correct | ||
| and must be preserved wherever remote control is genuinely active, | ||
| otherwise the fallback timer would lapse mid-hold (#404).""" | ||
| bsm, controller = _make_vpp_bsm(soc=50.0) | ||
|
|
||
| for period in range(PERIOD, PERIOD + 4): | ||
| bsm._apply_period_schedule(period) | ||
|
|
||
| assert len(controller.calls["growatt_vpp_periods"]) == 4 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
self.controller.get_battery_soc()can returnNone("unavailable"/"unknown" HA sensor state — seeha_api_controller.py's_get_sensor_value, typedfloat | None). Multiplying that intocurrent_soeraises an uncaughtTypeErrorinstead of degrading gracefully.This codebase already has the fix for this exact fallibility:
_get_current_battery_soc()a few hundred lines up validatessoc is not None and 0 <= soc <= 100before using it._at_reserve_floor()should do the same (or reuse it) rather than reading the sensor raw.This matters more than a normal null-check nit because of where this method is now called from: the retry closure in
_schedule_period_retry(line ~2870) has no surrounding try/except at all, andapply_discharge_inhibit(line ~3492) runs as a bare every-minute APScheduler job with none either — both bypass_runtime_failure_trackerentirely on this exception, unlike every other failure path in this file. And since this is computed unconditionally in_apply_period_schedulefor every platform (not just Growatt VPP), it's a new crash risk for Solis/Huawei/SolaX/TOU-Growatt installs too, not just the VPP path this PR targets.