Repository navigation
Expose solar surplus power and force-export window state as sensors #3791
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
Open
Pezmc
wants to merge
7
commits into
springfall2008:main
Choose a base branch
from
Pezmc:feature/car-charging-solar-surplus
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
99ddb7f
Publish the current solar surplus as a sensor
Pezmc b3808a4
Report an active force export slot as a binary sensor
Pezmc de0993a
Correct the units on the current power sensors in the docs
Pezmc bce4fe9
Document the solar surplus and force export slot sensors
Pezmc 5fca881
Bound the solar surplus by the CT clamp setting and by generation
Pezmc 6c74ec5
🐛 Read the force export slot through export_mode_of
Pezmc 509c40b
📝 Correct the car priority recipe and the surplus refresh rate
Pezmc 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| # ----------------------------------------------------------------------------- | ||
| # Predbat Home Battery System | ||
| # Copyright Trefor Southwell 2026 - All Rights Reserved | ||
| # This application maybe used for personal use only and not for commercial use | ||
| # ----------------------------------------------------------------------------- | ||
| # fmt off | ||
| # pylint: disable=consider-using-f-string | ||
| # pylint: disable=line-too-long | ||
| # pylint: disable=attribute-defined-outside-init | ||
|
|
||
| import copy | ||
|
|
||
| from const import EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE, EXPORT_MODE_TARGET, EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE | ||
| from utils import pack_export_limit | ||
| from tests.test_infra import reset_inverter | ||
|
|
||
| SURPLUS_ENTITY = ".solar_surplus_power" | ||
| EXPORT_SLOT_ENTITY = "_force_export_slot" | ||
|
|
||
|
|
||
| def check_surplus(my_predbat, name, grid_power, battery_power, car_charging_power, car_configured, expect_state, pv_power=20000, car_reported_load=True): | ||
| """Publish the inverter sensors for one power reading and check the surplus state | ||
|
|
||
| pv_power defaults high enough not to clip. Cases exercising the clamp pass their own. | ||
| """ | ||
| my_predbat.grid_power = grid_power | ||
| my_predbat.battery_power = battery_power | ||
| my_predbat.pv_power = pv_power | ||
| my_predbat.car_charging_power = car_charging_power | ||
| my_predbat.car_charging_power_configured = car_configured | ||
| my_predbat.car_energy_reported_load = car_reported_load | ||
| my_predbat.publish_inverter_data() | ||
|
|
||
| entity_id = my_predbat.prefix + SURPLUS_ENTITY | ||
| item = my_predbat.ha_interface.dummy_items.get(entity_id) | ||
| if item is None: | ||
| print("ERROR: {} was not published for {}".format(entity_id, name)) | ||
| return True | ||
| if item.get("state", None) != expect_state: | ||
| print("ERROR: {} state is {} expected {} for {}".format(entity_id, item.get("state", None), expect_state, name)) | ||
| return True | ||
| return False | ||
|
|
||
|
|
||
| def check_export_slot(my_predbat, name, minutes_now, export_window, export_limits, set_export_window, expect_on): | ||
| """Publish the charge/export status for one plan state and check the force export slot sensor""" | ||
| my_predbat.minutes_now = minutes_now | ||
| my_predbat.export_window_best = export_window | ||
| my_predbat.export_limits_best = export_limits | ||
| my_predbat.set_export_window = set_export_window | ||
| my_predbat.set_charge_export_status(False, False, True) | ||
|
|
||
| entity_id = "binary_sensor." + my_predbat.prefix + EXPORT_SLOT_ENTITY | ||
| item = my_predbat.ha_interface.dummy_items.get(entity_id) | ||
| if item is None: | ||
| print("ERROR: {} was not published for {}".format(entity_id, name)) | ||
| return True | ||
| expect_state = "on" if expect_on else "off" | ||
| if item.get("state", None) != expect_state: | ||
| print("ERROR: {} state is {} expected {} for {}".format(entity_id, item.get("state", None), expect_state, name)) | ||
| return True | ||
| return False | ||
|
|
||
|
|
||
| def run_export_slot_tests(my_predbat): | ||
| """ | ||
| binary_sensor.predbat_force_export_slot reports whether the plan has a force export window | ||
| running, so an automation can leave the solar for Predbat to sell. | ||
| """ | ||
| failed = False | ||
| saved = {key: getattr(my_predbat, key) for key in ["minutes_now", "export_window_best", "export_limits_best", "set_export_window"]} | ||
| saved_items = copy.deepcopy(my_predbat.ha_interface.dummy_items) | ||
| window = [{"start": 600, "end": 660}] | ||
|
|
||
| try: | ||
| print("Test: inside a force export window the sensor is on") | ||
| failed |= check_export_slot(my_predbat, "inside window", 630, window, [pack_export_limit(EXPORT_MODE_TARGET, 10)], True, True) | ||
|
|
||
| print("Test: a freeze export window counts, as Predbat is selling the solar") | ||
| failed |= check_export_slot(my_predbat, "freeze export", 630, window, [pack_export_limit(EXPORT_MODE_FREEZE)], True, True) | ||
|
|
||
| print("Test: a window left at the idle limit is not an export slot") | ||
| failed |= check_export_slot(my_predbat, "idle limit", 630, window, [pack_export_limit(EXPORT_MODE_IDLE)], True, False) | ||
|
|
||
| print("Test: the bare numbers an older saved plan decodes into read the same way") | ||
| failed |= check_export_slot(my_predbat, "legacy target", 630, window, [10.0], True, True) | ||
| failed |= check_export_slot(my_predbat, "legacy freeze", 630, window, [EXPORT_LIMIT_FREEZE], True, True) | ||
| failed |= check_export_slot(my_predbat, "legacy idle", 630, window, [EXPORT_LIMIT_IDLE], True, False) | ||
|
|
||
| print("Test: before and after the window the sensor is off") | ||
| failed |= check_export_slot(my_predbat, "before window", 599, window, [10.0], True, False) | ||
| failed |= check_export_slot(my_predbat, "after window", 660, window, [10.0], True, False) | ||
|
|
||
| print("Test: with export control disabled the sensor is off even inside a window") | ||
| failed |= check_export_slot(my_predbat, "export control off", 630, window, [10.0], False, False) | ||
|
|
||
| print("Test: with no windows planned the sensor is off") | ||
| failed |= check_export_slot(my_predbat, "no windows", 630, [], [], True, False) | ||
|
|
||
| print("Test: the charging/exporting/demand sensors still report the commanded state") | ||
| my_predbat.set_charge_export_status(True, False, False) | ||
|
Pezmc marked this conversation as resolved.
|
||
| for entity, expect_state in [("_charging", "on"), ("_exporting", "off"), ("_demand", "off")]: | ||
| entity_id = "binary_sensor." + my_predbat.prefix + entity | ||
| item = my_predbat.ha_interface.dummy_items.get(entity_id) | ||
| if item is None or item.get("state", None) != expect_state: | ||
| print("ERROR: {} state is {} expected {}".format(entity_id, item.get("state", None) if item else None, expect_state)) | ||
| failed = True | ||
| finally: | ||
| for key, value in saved.items(): | ||
| setattr(my_predbat, key, value) | ||
| my_predbat.ha_interface.dummy_items.clear() | ||
| my_predbat.ha_interface.dummy_items.update(saved_items) | ||
|
|
||
| return failed | ||
|
|
||
|
|
||
| def run_solar_surplus_tests(my_predbat): | ||
| """ | ||
| predbat.solar_surplus_power reports the power a load could take now without importing or | ||
| draining the battery, so an automation can run a car or immersion heater on spare solar. | ||
| """ | ||
| reset_inverter(my_predbat) | ||
| failed = False | ||
|
|
||
| saved = {key: getattr(my_predbat, key) for key in ["grid_power", "battery_power", "pv_power", "car_charging_power", "car_charging_power_configured", "car_energy_reported_load"]} | ||
| saved_items = copy.deepcopy(my_predbat.ha_interface.dummy_items) | ||
| try: | ||
| print("Test: with no car charging the surplus is the grid export") | ||
| failed |= check_surplus(my_predbat, "plain export", grid_power=2000, battery_power=0, car_charging_power=0, car_configured=False, expect_state=2.0) | ||
|
|
||
| print("Test: importing from the grid reports no surplus rather than a negative one") | ||
| failed |= check_surplus(my_predbat, "importing", grid_power=-1500, battery_power=0, car_charging_power=0, car_configured=False, expect_state=0.0) | ||
|
|
||
| print("Test: a charging car is added back, so the sensor does not collapse once the car starts") | ||
| failed |= check_surplus(my_predbat, "car charging", grid_power=200, battery_power=0, car_charging_power=7000, car_configured=True, expect_state=7.2) | ||
|
|
||
| # Cloud cover mid-charge: the battery is covering part of the car and the grid has swung | ||
| # to import. Without the subtraction this reads 4.0kW and the car keeps charging off it. | ||
| print("Test: battery discharge is not offered to the car as solar surplus") | ||
| failed |= check_surplus(my_predbat, "battery covering the car", grid_power=-3000, battery_power=3000, car_charging_power=7000, car_configured=True, expect_state=1.0) | ||
|
|
||
| print("Test: battery charging takes the surplus, it is not added back") | ||
| failed |= check_surplus(my_predbat, "battery charging", grid_power=1000, battery_power=-2000, car_charging_power=0, car_configured=False, expect_state=1.0) | ||
|
|
||
| # Outside the CT clamp the grid reading never saw the car, so the add-back would invent a | ||
| # surplus equal to the charger draw and the automation would never turn the car off. | ||
| print("Test: a charger outside the CT clamp is not added back") | ||
| failed |= check_surplus(my_predbat, "outside CT clamp", grid_power=0, battery_power=0, car_charging_power=7000, car_configured=True, expect_state=0.0, car_reported_load=False) | ||
|
|
||
| print("Test: inside the CT clamp the same reading does add the car back") | ||
| failed |= check_surplus(my_predbat, "inside CT clamp", grid_power=0, battery_power=0, car_charging_power=7000, car_configured=True, expect_state=7.0, car_reported_load=True) | ||
|
|
||
| # A grid sensor wired positive-on-import without grid_power_invert reads +8kW while the | ||
| # house imports 8kW. Unclamped the surplus would track the import. | ||
| print("Test: the surplus can never exceed generation") | ||
| failed |= check_surplus(my_predbat, "inverted grid sensor at night", grid_power=8000, battery_power=0, car_charging_power=0, car_configured=False, expect_state=0.0, pv_power=0) | ||
| failed |= check_surplus(my_predbat, "clamped to PV", grid_power=8000, battery_power=0, car_charging_power=0, car_configured=False, expect_state=3.0, pv_power=3000) | ||
|
|
||
| print("Test: a genuine surplus is not clipped by the clamp") | ||
| failed |= check_surplus(my_predbat, "surplus below PV", grid_power=2500, battery_power=0, car_charging_power=0, car_configured=False, expect_state=2.5, pv_power=4000) | ||
|
|
||
| print("Test: the components and whether a charger is configured are published as attributes") | ||
| my_predbat.grid_power = 500 | ||
| my_predbat.battery_power = -250 | ||
| my_predbat.pv_power = 2750 | ||
| my_predbat.car_charging_power = 1500 | ||
| my_predbat.car_charging_power_configured = True | ||
| my_predbat.car_energy_reported_load = True | ||
| my_predbat.publish_inverter_data() | ||
| item = my_predbat.ha_interface.dummy_items.get(my_predbat.prefix + SURPLUS_ENTITY) | ||
| expect = { | ||
| "state": 2.0, | ||
| "friendly_name": "Current solar surplus power", | ||
| "state_class": "measurement", | ||
| "unit_of_measurement": "kW", | ||
| "device_class": "power", | ||
| "icon": "mdi:solar-power", | ||
| "grid_power": 0.5, | ||
| "battery_power": -0.25, | ||
| "pv_power": 2.75, | ||
| "car_charging_power": 1.5, | ||
| "car_charging_power_configured": True, | ||
| "car_charging_power_included": True, | ||
| } | ||
| for key, value in expect.items(): | ||
| if item.get(key, None) != value: | ||
| print("ERROR: {} {} is {} expected {}".format(my_predbat.prefix + SURPLUS_ENTITY, key, item.get(key, None), value)) | ||
| failed = True | ||
|
|
||
| print("Test: the sensor is published even when no car charger is configured") | ||
| my_predbat.car_charging_power = 0 | ||
| my_predbat.car_charging_power_configured = False | ||
| my_predbat.ha_interface.dummy_items.pop(my_predbat.prefix + SURPLUS_ENTITY, None) | ||
| my_predbat.publish_inverter_data() | ||
| item = my_predbat.ha_interface.dummy_items.get(my_predbat.prefix + SURPLUS_ENTITY) | ||
| if item is None: | ||
| print("ERROR: {} was not published with no car charger configured".format(my_predbat.prefix + SURPLUS_ENTITY)) | ||
| failed = True | ||
| elif item.get("car_charging_power_configured", None) is not False: | ||
| print("ERROR: {} car_charging_power_configured is {} expected False".format(my_predbat.prefix + SURPLUS_ENTITY, item.get("car_charging_power_configured", None))) | ||
| failed = True | ||
|
|
||
| print("Test: car_charging_power_included reports whether the add-back was applied") | ||
| my_predbat.car_energy_reported_load = False | ||
| my_predbat.publish_inverter_data() | ||
| item = my_predbat.ha_interface.dummy_items.get(my_predbat.prefix + SURPLUS_ENTITY) | ||
| if item.get("car_charging_power_included", None) is not False: | ||
| print("ERROR: {} car_charging_power_included is {} expected False".format(my_predbat.prefix + SURPLUS_ENTITY, item.get("car_charging_power_included", None))) | ||
| failed = True | ||
| finally: | ||
| for key, value in saved.items(): | ||
| setattr(my_predbat, key, value) | ||
| my_predbat.ha_interface.dummy_items.clear() | ||
| my_predbat.ha_interface.dummy_items.update(saved_items) | ||
|
|
||
| failed |= run_export_slot_tests(my_predbat) | ||
|
|
||
| return failed | ||
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
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.
Minor efficiency note, inherited from the four sensors above but now worth noting while this block lands: on the quick path this entity is written twice per tick —
fetch_inverter_dataalready ends withpublish_inverter_data()(execute.py, its own tail call) andquick_inverter_data_updatethen callsself.publish_inverter_data()again immediately afterfetch_inverter_data(quick=True)returns True. Same double-write as the sibling sensors, so nothing new breaks; folding the quick path to one publish (drop the tail call or the caller's repeat) would halve theset_statetraffic for all six entities.