Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9575534
fix: delay auto_config finalization to allow HA entities to load on s…
rholligan Jul 8, 2026
4b7fc3e
fix: resolve NaN InvalidJSONError in ML component and handle load for…
rholligan Jul 9, 2026
599fa1a
fix: correctly apply final=True logic to auto config on any loops exe…
rholligan Jul 9, 2026
cc323f9
fix: correctly retry regex config elements inside lists and dicts by …
rholligan Jul 9, 2026
1cd3eb9
test: add test coverage for auto_config retry logic and ML load statu…
rholligan Jul 9, 2026
0fc0d81
test: fix mock environment for ml load fallback test
rholligan Jul 9, 2026
ca4cad3
test: fix mock environment for ml load fallback test
rholligan Jul 9, 2026
51dfd1f
refactor: simplify and clean up fixes for repo consistency
rholligan Jul 9, 2026
1e0c4bf
test: replace unicode checkmarks with ascii text to fix windows test …
rholligan Jul 9, 2026
602d4d2
test: replace more unicode checkmarks to fix windows suite crashes
rholligan Jul 9, 2026
28cdcd3
test: replace unicode arrows with ascii text
rholligan Jul 9, 2026
6b5b0a7
test: replace final straggler unicode emojis with ascii
rholligan Jul 9, 2026
a45c5f5
test: fix test_download.py sha1 hash assertion failing on windows due…
rholligan Jul 9, 2026
138a267
fix: address PR review feedback from copilot
rholligan Jul 9, 2026
ceff435
test: replace literal escaped unicode strings with ascii equivalents
rholligan Jul 9, 2026
27ce793
test: fix unicode right arrow in ML load predictor logs crashing wind…
rholligan Jul 9, 2026
d241e69
test: update expected json for pre_saving1 debug case to match new pl…
rholligan Jul 9, 2026
3448195
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1986,6 +1986,11 @@ def fetch_ml_load_forecast(self, now_utc):
and returns it as a minute_data dictionary
"""
# Use ML Model for load prediction
status = self.get_state_wrapper("sensor." + self.prefix + "_load_ml_forecast")
if status and status != "active":
self.log("ML load forecast is not active (status: {}), falling back to historical data".format(status))
return {}

load_ml_forecast = self.get_state_wrapper("sensor." + self.prefix + "_load_ml_forecast", attribute="results")
if load_ml_forecast:
self.log("Loading ML load forecast from sensor.{}_load_ml_forecast".format(self.prefix))
Expand Down
12 changes: 7 additions & 5 deletions apps/predbat/load_ml_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
import asyncio
import os
from datetime import datetime, timezone, timedelta
import math
from component_base import ComponentBase
Comment on lines 24 to 25
from utils import get_now_from_cumulative, dp2, dp4, minute_data
from utils import get_now_from_cumulative, dp2, dp4, minute_data, safe_float
from load_predictor import LoadPredictor, MODEL_VERSION
from const import TIME_FORMAT, PREDICT_STEP
import json
Expand Down Expand Up @@ -1054,9 +1055,10 @@ def _publish_entity(self):
},
app="load_ml",
)

self.dashboard_item(
"sensor." + self.prefix + "_load_ml_stats",
state=round(total_kwh, 2),
state=safe_float(round(total_kwh, 2)),
attributes={
"load_today": dp2(self.load_minutes_now),
"load_today_h1": dp2(load_today_h1),
Expand All @@ -1065,10 +1067,10 @@ def _publish_entity(self):
"power_today_now": dp2(power_today_now),
"power_today_h1": dp2(power_today_h1),
"power_today_h8": dp2(power_today_h8),
"mae_kwh": round(float(self.predictor.validation_mae), 4) if self.predictor and self.predictor.validation_mae is not None else None,
"bias_kwh": round(self.predictor.validation_bias, 4) if self.predictor and self.predictor.validation_bias is not None else None,
"mae_kwh": safe_float(self.predictor.validation_mae) if self.predictor else None,
"bias_kwh": safe_float(self.predictor.validation_bias) if self.predictor else None,
"last_trained": self.last_train_time.isoformat() if self.last_train_time else None,
"model_age_hours": round(model_age_hours, 1) if model_age_hours is not None else None,
"model_age_hours": safe_float(model_age_hours),
"training_days": self.load_data_age_days,
"status": self.model_status,
"model_version": MODEL_VERSION,
Expand Down
8 changes: 7 additions & 1 deletion apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -1585,7 +1585,7 @@ def initialize(self):
self.record_status("Error: Some components failed to start (phase 2)", had_errors=True)

self.load_user_config(quiet=False, register=True)
self.auto_config(final=True)
self.auto_config(final=False)
self.validate_config()

# Restore the last saved plan so it is immediately active before the first calculation
Expand Down Expand Up @@ -1753,6 +1753,12 @@ def run_time_loop(self, cb_args):
self.validate_config()
config_changed = True

# Retry auto config if we have unmatched regexes
if hasattr(self, 'unmatched_args') and len(self.unmatched_args) > 0:
# Give HA 10 minutes from startup to fully load entities before final disable
time_since_start = (self.now_utc_real - self.started_time).total_seconds() / 60.0
self.auto_config(final=(time_since_start > 10))

# Run the prediction
self.update_pred(scheduled=True)

Expand Down
10 changes: 5 additions & 5 deletions apps/predbat/tests/open_meteo_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def print_comparison_table(label: str, sources: dict, day_str: str, tz_name: str
"""Print a side-by-side comparison table for one day.

sources is an ordered dict of {source_name: hourly_dict} where each hourly_dict
maps 'YYYY-MM-DDTHH' (kwh_p50, kwh_p10) as returned by _aggregate_to_hourly().
maps 'YYYY-MM-DDTHH' -> (kwh_p50, kwh_p10) as returned by _aggregate_to_hourly().
Hours are shown in local time (tz_name).
"""
source_names = list(sources.keys())
Expand Down Expand Up @@ -211,8 +211,8 @@ def print_comparison_table(label: str, sources: dict, day_str: str, tz_name: str
async def run(fs_api_key: str = None, solcast_api_key: str = None, solcast_host: str = None) -> None:
"""Fetch live data via fetch_pv_forecast() for each source and print comparison.

Runs the full Predbat PV fetch pipeline (download minute_data pv_calibration
publish_pv_stats) for each enabled source, then reads the detailedForecast that
Runs the full Predbat PV fetch pipeline (download -> minute_data -> pv_calibration
-> publish_pv_stats) for each enabled source, then reads the detailedForecast that
Predbat would store in sensor.predbat_pv_today.
"""
now_local = datetime.now(tz=LOCAL_TZ)
Expand All @@ -228,10 +228,10 @@ async def run(fs_api_key: str = None, solcast_api_key: str = None, solcast_host:
_tmp = SolarAPI.__new__(SolarAPI)
for i, a in enumerate(ARRAYS, 1):
az_api = SolarAPI.convert_azimuth(_tmp, a["azimuth"])
print(f" [{i}] postcode={a['postcode']} kwp={a['kwp']} declination={a['declination']}° azimuth={a['azimuth']}° (API: {az_api:.0f}°) efficiency={a.get('efficiency', 1.0):.0%}")
print(f" [{i}] postcode={a['postcode']} kwp={a['kwp']} declination={a['declination']}° azimuth={a['azimuth']}° (->API: {az_api:.0f}°) efficiency={a.get('efficiency', 1.0):.0%}")
print(f" cache: {_CACHE_ROOT}/cache/")

# results maps source_name {"today": hourly_dict, "tomorrow": hourly_dict}
# results maps source_name -> {"today": hourly_dict, "tomorrow": hourly_dict}
results: dict = {}

# ── Open-Meteo ────────────────────────────────────────────────────────────
Expand Down
82 changes: 82 additions & 0 deletions apps/predbat/tests/test_auto_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# -----------------------------------------------------------------------------
# Predbat Home Battery System
# -----------------------------------------------------------------------------

def run_auto_config_tests(my_predbat):
"""
Test auto_config retry logic and list/dict matching
"""
failed = False
print("\n============================================================")
print("Running auto_config tests")
print("============================================================")

# Backup original
original_args = my_predbat.args.copy()
original_unmatched = getattr(my_predbat, "unmatched_args", {}).copy()

try:
# Test 1: List matching retains 're:' elements if they fail
print("\n=== Test 1: List regex matching ===")
my_predbat.args = {
"pv_forecast_raw": ["sensor.inv1", "re:.*inv2"]
}
my_predbat.unmatched_args = {}

# This simulates < 10 mins, so final=False
my_predbat.auto_config(final=False)

# It should still be in args exactly as it was, because matched=True for lists
val = my_predbat.args.get("pv_forecast_raw")
if val != ["sensor.inv1", "re:.*inv2"]:
print(f"FAIL: List regex matching failed to retain string. Got {val}")
failed = True
else:
print("PASS: List regex retained.")

# Test 2: Dict matching retains 're:' elements if they fail
print("\n=== Test 2: Dict regex matching ===")
my_predbat.args = {
"my_dict": {"foo": "sensor.foo", "bar": "re:.*bar"}
}
my_predbat.unmatched_args = {}

my_predbat.auto_config(final=False)

val = my_predbat.args.get("my_dict")
if val != {"foo": "sensor.foo", "bar": "re:.*bar"}:
print(f"FAIL: Dict regex matching failed to retain string. Got {val}")
failed = True
else:
print("PASS: Dict regex retained.")

# Test 3: Standard unmatched arg is moved to unmatched_args after 10 mins (final=True)
print("\n=== Test 3: Final=True moves unmatched args ===")
my_predbat.args = {
"inverter_limit": "re:.*missing_limit.*"
}
my_predbat.unmatched_args = {}

my_predbat.auto_config(final=True)

if "inverter_limit" in my_predbat.args:
print(f"FAIL: inverter_limit still in args: {my_predbat.args['inverter_limit']}")
failed = True
elif "inverter_limit" not in my_predbat.unmatched_args:
print(f"FAIL: inverter_limit not in unmatched_args")
failed = True
else:
print("PASS: Final=True moved unmatched arg correctly.")

finally:
my_predbat.args = original_args
my_predbat.unmatched_args = original_unmatched

print("============================================================")
if failed:
print("FAIL: SOME TESTS FAILED")
else:
print("PASS: ALL TESTS PASSED")
print("============================================================")

return failed
Loading