Skip to content

Commit 41e59a3

Browse files
fix(enphase): age livestream readings out instead of dropping them
Clearing the reading on a failed read made a single missed connection flip all four sensors onto the bucket fallback, which lags 15-30 minutes - a bigger visible step than simply holding the last measurement a little longer. A reading now stays in use for ENPHASE_LIVE_MAX_AGE_MINUTES and is ignored after that, so a blip is absorbed while genuinely old data still stops being presented as current. Readings are stamped on arrival because DataMsg.timestamp is a constant and the payload cannot date itself. They remain in-memory only, so nothing survives a restart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 91ae498 commit 41e59a3

3 files changed

Lines changed: 44 additions & 17 deletions

File tree

apps/predbat/enphase.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@
6868
# 1 would be the just-closed bucket, which the cloud is still back-filling; 2 is settled.
6969
ENPHASE_SETTLED_BUCKETS = 2
7070
ENPHASE_LIVESTREAM_TIMEOUT = 15 # seconds to wait for a livestream message before giving up
71+
# How long a livestream reading stays usable. Holding it over a missed poll avoids flipping the
72+
# sensors onto the 15-30 minute bucket fallback for a single blip, but it is instantaneous data
73+
# with no timestamp of its own, so it must not be published indefinitely either.
74+
ENPHASE_LIVE_MAX_AGE_MINUTES = 15
7175
LIVESTREAM_BOOTSTRAP = "/pv/aws_sigv4/livestream.json" # returns the AWS IoT endpoint, topic and authorizer credentials
7276

7377
# live_power is deliberately absent: livestream readings are instantaneous and carry no usable
@@ -603,6 +607,8 @@ async def publish_data(self, site_id):
603607
# instantaneous, where the buckets are a 15-minute average and load is only ever a residual.
604608
# The bucket values above stay as the fallback for when the stream is unavailable.
605609
live = self.live_power.get(site_id) or {}
610+
if live and (now_ts - live.get("read_ts", 0)) > ENPHASE_LIVE_MAX_AGE_MINUTES * 60:
611+
live = {} # too old to present as current; fall back to the bucket values below
606612
if live:
607613
pv_power = live.get("pv", pv_power)
608614
# The livestream reports grid negative while exporting, the opposite of Predbat's sign.
@@ -1207,12 +1213,11 @@ async def get_live_power(self, site_id):
12071213
"""
12081214
reading = await self._fetch_live_power(site_id)
12091215
if reading:
1216+
# Stamped on arrival: DataMsg.timestamp is a constant, so the payload cannot date itself.
1217+
reading["read_ts"] = datetime.now(timezone.utc).timestamp()
12101218
self.live_power[site_id] = reading
1211-
else:
1212-
# Drop any previous reading rather than let publish_data republish it. These values are
1213-
# instantaneous, so a stale measurement presented as current is worse than falling back
1214-
# to the (lagging but genuinely current) bucket values.
1215-
self.live_power.pop(site_id, None)
1219+
# A failure deliberately leaves any previous reading alone - publish_data ages it out after
1220+
# ENPHASE_LIVE_MAX_AGE_MINUTES rather than dropping to the lagging buckets over one blip.
12161221
return reading
12171222

12181223
async def _fetch_live_power(self, site_id):

apps/predbat/tests/test_enphase_api.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -669,9 +669,13 @@ def test_gateway_serial_read_from_today():
669669
assert gateway_serial({}) is None
670670

671671

672+
BUCKET_START = 1783724400 # local midnight of the day the /today buckets belong to
673+
FROZEN_NOW_TS = BUCKET_START + int(82.5 * 900) # inside index 82, so the settled bucket read is 80
674+
675+
672676
def _publish_with_buckets(api, production=250, imp=100, exp=0, charge=200, discharge=0):
673677
"""Publish a site from /today buckets; the defaults give pv 1000 W, importing 400 W, charging 800 W."""
674-
start = 1783724400
678+
start = BUCKET_START
675679

676680
def bucket(value):
677681
"""Return a 96-slot Wh array with the bucket read by publish_data set to value."""
@@ -693,7 +697,7 @@ class _Fixed(original):
693697
@classmethod
694698
def now(cls, tz=None):
695699
"""Return a time inside index 82, so the settled bucket publish_data reads is 80."""
696-
return original.fromtimestamp(start + int(82.5 * 900), tz)
700+
return original.fromtimestamp(FROZEN_NOW_TS, tz)
697701

698702
enphase_module.datetime = _Fixed
699703
try:
@@ -711,7 +715,7 @@ def test_publish_prefers_the_measured_livestream_reading():
711715
the 15-minute buckets on every count.
712716
"""
713717
api = MockEnphaseAPI()
714-
api.live_power["12345"] = {"pv": 4632.8, "battery": 32.0, "grid": -2686.1, "load": 1978.7, "soc": 100}
718+
api.live_power["12345"] = {"pv": 4632.8, "battery": 32.0, "grid": -2686.1, "load": 1978.7, "soc": 100, "read_ts": FROZEN_NOW_TS - 60}
715719
published = _publish_with_buckets(api)
716720
assert published == {"pv": 4632.8, "grid": 2686.1, "battery": 32.0, "load": 1978.7} # grid flipped to +export
717721

@@ -728,18 +732,34 @@ def test_live_power_is_never_persisted():
728732
assert "live_power" not in ENPHASE_CACHE_KEYS
729733

730734

731-
def test_failed_live_read_drops_the_previous_reading():
732-
"""A failed livestream read clears the last reading rather than leaving it to be republished.
735+
def test_failed_live_read_keeps_the_recent_reading():
736+
"""A failed read leaves the last reading in place so a blip does not flip the sensors.
733737
734-
Falling back to the (lagging but current) bucket values beats presenting a stale measurement
735-
as though it were live.
738+
The bucket fallback lags 15-30 minutes, so bouncing onto it for one missed cycle would be a
739+
bigger step than simply holding the measurement a little longer.
736740
"""
737741
api = MockEnphaseAPI()
738742
api.today["12345"] = {"serial": "122530006866"}
739-
api.live_power["12345"] = {"pv": 4632.8, "battery": 32.0, "grid": -2686.1, "load": 1978.7, "soc": 100}
743+
reading = {"pv": 4632.8, "battery": 32.0, "grid": -2686.1, "load": 1978.7, "soc": 100, "read_ts": FROZEN_NOW_TS - 60}
744+
api.live_power["12345"] = dict(reading)
740745
# No canned response for the bootstrap, so it 404s and the read fails
741746
assert run_async(api.get_live_power("12345")) is None
742-
assert "12345" not in api.live_power
747+
assert api.live_power["12345"] == reading
748+
749+
750+
def test_live_reading_older_than_the_window_falls_back_to_buckets():
751+
"""Once a reading passes ENPHASE_LIVE_MAX_AGE it is ignored in favour of the bucket values.
752+
753+
Livestream readings are instantaneous and carry no usable timestamp of their own, so an old one
754+
must not keep being published as though it were current.
755+
"""
756+
from enphase import ENPHASE_LIVE_MAX_AGE_MINUTES
757+
758+
api = MockEnphaseAPI()
759+
api.live_power["12345"] = {"pv": 4632.8, "battery": 32.0, "grid": -2686.1, "load": 1978.7, "soc": 100, "read_ts": FROZEN_NOW_TS - (ENPHASE_LIVE_MAX_AGE_MINUTES * 60 + 1)}
760+
published = _publish_with_buckets(api)
761+
assert published["pv"] == 1000.0 # bucket value, not the stale 4632.8
762+
assert published["grid"] == -400.0
743763

744764

745765
def test_grid_power_is_positive_when_exporting():
@@ -750,7 +770,7 @@ def test_grid_power_is_positive_when_exporting():
750770
exporting, and the /today buckets give import - export), so both paths have to be flipped.
751771
"""
752772
api = MockEnphaseAPI()
753-
api.live_power["12345"] = {"pv": 4632.8, "battery": 32.0, "grid": -2686.1, "load": 1978.7, "soc": 100}
773+
api.live_power["12345"] = {"pv": 4632.8, "battery": 32.0, "grid": -2686.1, "load": 1978.7, "soc": 100, "read_ts": FROZEN_NOW_TS - 60}
754774
published = _publish_with_buckets(api)
755775
assert published["grid"] == 2686.1 # exporting 2.7 kW -> positive
756776

@@ -2120,7 +2140,8 @@ def run_enphase_api_tests(my_predbat):
21202140
test_publish_prefers_the_measured_livestream_reading()
21212141
test_publish_falls_back_to_buckets_without_a_livestream_reading()
21222142
test_live_power_is_never_persisted()
2123-
test_failed_live_read_drops_the_previous_reading()
2143+
test_failed_live_read_keeps_the_recent_reading()
2144+
test_live_reading_older_than_the_window_falls_back_to_buckets()
21242145
test_grid_power_is_positive_when_exporting()
21252146
test_grid_power_from_buckets_is_positive_when_exporting()
21262147
test_published_power_satisfies_the_predbat_energy_balance()

docs/components.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,8 @@ Connects Predbat to the Enphase Enlighten cloud for monitoring and battery contr
586586
- Predbat controls the battery by writing Enphase schedules: charge windows become charge-from-grid (CFG) schedules with a target SOC, export windows become discharge-to-grid (DTG) schedules, freeze-export windows use restrict-battery-discharge (RBD) schedules, and the reserve is set through the battery profile. `automatic_config` requires both CFG and DTG support and fails configuration if either is missing
587587
- On a successful write, Predbat optimistically updates its local cache and moves on rather than waiting to re-read the cloud - the periodic schedule/profile re-read (every 30 minutes) corrects the cache later if a write didn't actually land
588588
- The PV, grid, battery and load power sensors come from the Enlighten livestream: once per cycle Predbat connects to Enphase's AWS IoT broker over MQTT, takes one measured reading and disconnects. These are separately metered channels, so they are instantaneous and the house load is a real measurement rather than a calculation
589-
- If the livestream is unavailable, all four fall back to the same 15-minute energy bucket of the cloud's intra-day data, so they still agree with each other and a power-flow display still balances. The cloud keeps back-filling a bucket for several minutes after it closes, so Predbat reads a bucket that has settled - which means the fallback values lag real time by roughly 15 to 30 minutes
589+
- A livestream reading stays in use for up to 15 minutes, so a single failed connection does not disturb the sensors. Past that they fall back rather than keep presenting an old measurement as current, and readings are never carried across a restart
590+
- In that fallback, all four come from the same 15-minute energy bucket of the cloud's intra-day data, so they still agree with each other and a power-flow display still balances. The cloud keeps back-filling a bucket for several minutes after it closes, so Predbat reads a bucket that has settled - which means the fallback values lag real time by roughly 15 to 30 minutes
590591
- In that fallback, load power is the energy-balance residual (PV + grid + battery), which is how the Enphase cloud derives its own consumption figure. Because it is a small difference between much larger numbers, it becomes unreliable while the battery is charging or discharging hard - it is clamped at zero so it can never show a negative house load, but treat it as indicative only during battery activity. The energy (`*_today`) sensors are unaffected either way and remain accurate
591592
- **Predbat owns the battery schedules**: it drives exactly one window per direction, so unless it is in read-only mode it deletes any other CFG/DTG/RBD schedule it finds on the site, including ones you created in the Enlighten app. Do not add your own battery schedules while Predbat is in write mode - the Enphase cloud rejects any overlapping schedule with an HTTP 409 conflict, which would stop Predbat from controlling the battery. Set Predbat to read-only mode if you want to manage schedules yourself
592593
- A window that is no longer needed is deleted rather than disabled, because the Enphase cloud ignores a request to disable a schedule (it reports success but keeps enforcing the window)

0 commit comments

Comments
 (0)