Skip to content

Commit 2d862f8

Browse files
Fix for dual day/night rate tariff from octopus, misc bug fixes (#2724)
* Typo * Fix unit test * Fix to log file flicker with filter * Web tidy * Fix fetch crash Support day/night tariff * [pre-commit.ci lite] apply automatic fixes --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent e3229b8 commit 2d862f8

6 files changed

Lines changed: 234 additions & 79 deletions

File tree

‎apps/predbat/fetch.py‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -758,13 +758,14 @@ def minute_data(
758758
if minute in mdata:
759759
last_sample_minute = minute
760760
break
761-
last_sample_value = mdata[last_sample_minute]
762-
last_but_one_minute_sample = mdata[last_sample_minute + 1] if (last_sample_minute + 1) in mdata else last_sample_value
763-
step = last_sample_value - last_but_one_minute_sample
764-
if last_sample_minute < 10 * 60 and last_sample_minute > 0:
765-
for minute in range(last_sample_minute):
766-
if minute not in mdata:
767-
mdata[minute] = dp4(newest_state + step * (last_sample_minute - minute))
761+
if last_sample_minute > 0:
762+
last_sample_value = mdata[last_sample_minute]
763+
last_but_one_minute_sample = mdata[last_sample_minute + 1] if (last_sample_minute + 1) in mdata else last_sample_value
764+
step = last_sample_value - last_but_one_minute_sample
765+
if last_sample_minute < 10 * 60 and last_sample_minute > 0:
766+
for minute in range(last_sample_minute):
767+
if minute not in mdata:
768+
mdata[minute] = dp4(newest_state + step * (last_sample_minute - minute))
768769

769770
# Fill from last sample until now
770771
for minute in range(60 * 24 * days):

‎apps/predbat/gecloud.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,6 @@ async def async_automatic_config(self, devices):
682682
has_pause_battery = True
683683

684684
self.log("GECloud: Auto-config detected features - charge power percent: {}, pause battery: {}, pause start time: {}, discharge target soc: {}".format(has_charge_power_percent, has_pause_battery, has_pause_start_time, has_discharge_target_soc))
685-
print("GECloud: Auto-config detected features - charge power percent: {}, pause battery: {}, pause start time: {}, discharge target soc: {}".format(has_charge_power_percent, has_pause_battery, has_pause_start_time, has_discharge_target_soc))
686685

687686
self.base.args["inverter_type"] = ["GEC" for _ in range(num_inverters)]
688687
self.base.args["num_inverters"] = num_inverters

‎apps/predbat/octopus.py‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@
2626
DATE_STR_FORMAT = "%Y-%m-%d"
2727
DATE_TIME_STR_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
2828

29+
# Hard-wired smart meter day/night rate times
30+
OCTOPUS_NIGHT_RATE_START_HOUR = 0
31+
OCTOPUS_NIGHT_RATE_START_MINUTE = 30
32+
OCTOPUS_NIGHT_RATE_END_HOUR = 7
33+
OCTOPUS_NIGHT_RATE_END_MINUTE = 30
34+
35+
OCTOPUS_DAY_RATE_START_HOUR = 7
36+
OCTOPUS_DAY_RATE_START_MINUTE = 30
37+
OCTOPUS_DAY_RATE_END_HOUR = 0
38+
OCTOPUS_DAY_RATE_END_MINUTE = 30
39+
2940

3041
def is_active(now_utc, activeFrom, activeTo):
3142
if not activeFrom:
@@ -673,6 +684,50 @@ async def async_get_saving_sessions(self, account_id):
673684
else:
674685
return response_data.get("savingSessions", {})
675686

687+
async def async_get_day_night_rates(self, url):
688+
"""
689+
Get day and night rates from Octopus
690+
"""
691+
mdata = []
692+
self.log("Info: Octopus tariff has day and night rates, fetching both")
693+
url_day = url.replace("standard-unit-rates", "day-unit-rates")
694+
url_night = url.replace("standard-unit-rates", "night-unit-rates")
695+
result_day = await self.fetch_url_cached(url_day)
696+
result_night = await self.fetch_url_cached(url_night)
697+
# is_night_rate = self.__is_between_times(rate, "00:30:00", "07:30:00", True)
698+
# Find the current day rate by scanning all the values looking at valid from date and picking the latest that is before now
699+
current_day_rate = None
700+
current_night_rate = None
701+
for rate in result_day:
702+
valid_from_stamp = rate.get("valid_from", "")
703+
# Convert from string to datetime
704+
valid_from_stamp = datetime.strptime(valid_from_stamp, DATE_TIME_STR_FORMAT)
705+
if valid_from_stamp <= self.now_utc:
706+
current_day_rate = rate.get("value_inc_vat", None)
707+
for rate in result_night:
708+
valid_from_stamp = rate.get("valid_from", "")
709+
# Convert from string to datetime
710+
valid_from_stamp = datetime.strptime(valid_from_stamp, DATE_TIME_STR_FORMAT)
711+
if valid_from_stamp <= self.now_utc:
712+
current_night_rate = rate.get("value_inc_vat", None)
713+
self.log("Info: Current day rate {} night rate {}".format(current_day_rate, current_night_rate))
714+
if current_day_rate is not None and current_night_rate is not None:
715+
# Now create a combined list of rates, start from 2 days back and go forward 3 days with the day and night rates
716+
night_start_time = self.now_utc.replace(hour=OCTOPUS_NIGHT_RATE_START_HOUR, minute=OCTOPUS_NIGHT_RATE_START_MINUTE, second=0, microsecond=0) - timedelta(days=2)
717+
night_end_time = night_start_time.replace(hour=OCTOPUS_NIGHT_RATE_END_HOUR, minute=OCTOPUS_NIGHT_RATE_END_MINUTE)
718+
day_start_time = night_start_time.replace(hour=OCTOPUS_DAY_RATE_START_HOUR, minute=OCTOPUS_DAY_RATE_START_MINUTE)
719+
day_end_time = night_start_time.replace(hour=OCTOPUS_DAY_RATE_END_HOUR, minute=OCTOPUS_DAY_RATE_END_MINUTE) + timedelta(days=1)
720+
for day in range(8):
721+
# Night rate
722+
mdata.append({"valid_from": night_start_time.strftime(DATE_TIME_STR_FORMAT), "valid_to": night_end_time.strftime(DATE_TIME_STR_FORMAT), "value_inc_vat": current_night_rate})
723+
# Day rate
724+
mdata.append({"valid_from": day_start_time.strftime(DATE_TIME_STR_FORMAT), "valid_to": day_end_time.strftime(DATE_TIME_STR_FORMAT), "value_inc_vat": current_day_rate})
725+
night_start_time += timedelta(days=1)
726+
night_end_time += timedelta(days=1)
727+
day_start_time += timedelta(days=1)
728+
day_end_time += timedelta(days=1)
729+
return mdata
730+
676731
async def async_download_octopus_url(self, url):
677732
"""
678733
Download octopus rates directly from a URL
@@ -683,7 +738,7 @@ async def async_download_octopus_url(self, url):
683738
while url and pages < 3:
684739
self.requests_total += 1
685740
r = requests.get(url)
686-
if r.status_code not in [200, 201]:
741+
if r.status_code not in [200, 201, 400]:
687742
self.failures_total += 1
688743
self.log("Warn: Error downloading Octopus data from URL {}, code {}".format(url, r.status_code))
689744
return {}
@@ -694,9 +749,28 @@ async def async_download_octopus_url(self, url):
694749
self.failures_total += 1
695750
self.log("Warn: Error downloading Octopus data from URL {} (JSONDecodeError)".format(url))
696751
return {}
752+
753+
if r.status_code == 400:
754+
detail = data.get("detail", "")
755+
if "This tariff has day and night rates" in detail:
756+
self.log("Info: Octopus tariff has day and night rates, fetching both")
757+
mdata = await self.async_get_day_night_rates(url)
758+
if mdata:
759+
return mdata
760+
else:
761+
self.failures_total += 1
762+
self.log("Warn: Error downloading Octopus data from URL {} (No Results)".format(url))
763+
return {}
764+
else:
765+
self.failures_total += 1
766+
self.log("Warn: Error downloading Octopus data from URL {} (400) - {}".format(url, detail))
767+
return {}
768+
697769
if "results" in data:
698770
mdata += data["results"]
699771
else:
772+
detail = data.get("detail", "")
773+
700774
self.failures_total += 1
701775
self.log("Warn: Error downloading Octopus data from URL {} (No Results)".format(url))
702776
return {}

‎apps/predbat/unit_test.py‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import glob
1616
from datetime import datetime, timedelta
1717
import argparse
18+
import asyncio
1819

1920
try:
2021
import requests
@@ -379,7 +380,7 @@ def run_nordpool_test(my_predbat):
379380
if min(fixed.values()) != max(fixed.values()):
380381
print("ERROR: Fixed rates can not change")
381382
failed = True
382-
if len(fixed) > 5 * 24 * 60:
383+
if len(fixed) > 6 * 24 * 60:
383384
print("ERROR: Fixed rates too long got {}".format(len(fixed)))
384385
failed = True
385386

@@ -9133,6 +9134,49 @@ def run_test_units(my_predbat):
91339134
return failed
91349135

91359136

9137+
def test_download_octopus_url_wrapper(my_predbat):
9138+
"""
9139+
Wrapper to run the async test function
9140+
"""
9141+
return asyncio.run(test_download_octopus_url(my_predbat))
9142+
9143+
9144+
async def test_download_octopus_url(my_predbat):
9145+
"""
9146+
Test the download_octopus_url function
9147+
"""
9148+
print("**** Running download_octopus_url tests ****")
9149+
failed = False
9150+
9151+
# Test URL for VAR-22-11-01 tariff
9152+
test_url = "https://api.octopus.energy/v1/products/VAR-22-11-01/electricity-tariffs/E-2R-VAR-22-11-01-A/standard-unit-rates/"
9153+
9154+
# Test the download function
9155+
api = OctopusAPI("", "", my_predbat)
9156+
api.now_utc = my_predbat.now_utc
9157+
rates_data = await api.async_download_octopus_url(test_url)
9158+
9159+
# Basic validation checks
9160+
if not rates_data:
9161+
print("ERROR: No rate data downloaded from URL {}".format(test_url))
9162+
failed = True
9163+
else:
9164+
print("Successfully downloaded {} rate points from VAR-22-11-01 tariff".format(len(rates_data)))
9165+
pdata = my_predbat.minute_data(rates_data, my_predbat.forecast_days + 1, my_predbat.midnight_utc, "value_inc_vat", "valid_from", backwards=False, to_key="valid_to")
9166+
if len(pdata) < 24 * 60:
9167+
print("ERROR: Expecting at least {} minutes of rate data got {}".format(24 * 60, len(pdata)))
9168+
failed = True
9169+
else:
9170+
print("Successfully processed {} minutes of rate data".format(len(pdata)))
9171+
night_rate = pdata.get(60)
9172+
day_rate = pdata.get(600)
9173+
if night_rate == day_rate:
9174+
print("ERROR: Expecting different night and day rates got {} and {}".format(night_rate, day_rate))
9175+
failed = True
9176+
9177+
return failed
9178+
9179+
91369180
def main():
91379181
# Parse command line arguments
91389182
parser = argparse.ArgumentParser(description="Predbat unit tests")
@@ -9179,6 +9223,8 @@ def main():
91799223
if args.perf_only:
91809224
return failed
91819225

9226+
if not failed:
9227+
failed |= test_download_octopus_url_wrapper(my_predbat)
91829228
if not failed:
91839229
failed |= test_plugin_startup_order(my_predbat)
91849230

‎apps/predbat/web.py‎

Lines changed: 7 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import shutil
2020
import html as html_module
2121
import time
22-
from web_helper import get_header_html, get_plan_css, get_editor_js, get_editor_css, get_log_css, get_charts_css, get_apps_css, get_html_config_css, get_apps_js, get_components_css, get_logfile_js
22+
from web_helper import get_header_html, get_plan_css, get_editor_js, get_editor_css, get_log_css, get_charts_css, get_apps_css, get_html_config_css, get_apps_js, get_components_css, get_logfile_js, get_entity_toggle_js, get_entity_control_css
2323

2424
from utils import calc_percent_limit, str2time, dp0, dp2
2525
from config import TIME_FORMAT, TIME_FORMAT_DAILY
@@ -815,37 +815,8 @@ def get_entity_control_html(self, entity_id, days):
815815
html = '<div class="entity-edit-container" style="margin-top: 15px; padding: 15px; border: 1px solid var(--border-color, #ddd); border-radius: 5px; background-color: var(--background-secondary, #f9f9f9);">'
816816

817817
# Add CSS for dark mode support
818-
html += """
819-
<style>
820-
.entity-edit-container {
821-
--border-color: #ddd;
822-
--background-secondary: #f9f9f9;
823-
--text-color: #333;
824-
--text-secondary: #666;
825-
--input-background: #fff;
826-
}
818+
html += get_entity_control_css()
827819

828-
body.dark-mode .entity-edit-container {
829-
--border-color: #555;
830-
--background-secondary: #2d2d2d;
831-
--text-color: #e0e0e0;
832-
--text-secondary: #bbb;
833-
--input-background: #3d3d3d;
834-
color: var(--text-color);
835-
}
836-
837-
body.dark-mode .entity-edit-container input[type="number"],
838-
body.dark-mode .entity-edit-container select {
839-
background-color: var(--input-background) !important;
840-
border-color: var(--border-color) !important;
841-
color: var(--text-color) !important;
842-
}
843-
844-
body.dark-mode .entity-edit-container span {
845-
color: var(--text-color) !important;
846-
}
847-
</style>
848-
"""
849820
html += f'<form method="post" action="./entity" style="display: flex; align-items: center; gap: 10px;">'
850821
html += f'<input type="hidden" name="entity_id" value="{entity_id}">'
851822
html += f'<input type="hidden" name="days" value="{days}">'
@@ -860,37 +831,7 @@ def get_entity_control_html(self, entity_id, days):
860831
html += f"</div>"
861832

862833
# Add JavaScript for toggle functionality
863-
html += f"""
864-
<script>
865-
function toggleEntitySwitch(button, entityId, days) {{
866-
// Toggle the visual state
867-
button.classList.toggle('active');
868-
869-
// Determine the new value
870-
const newValue = button.classList.contains('active') ? 'on' : 'off';
871-
872-
// Create form data
873-
const formData = new FormData();
874-
formData.append('entity_id', entityId);
875-
formData.append('days', days);
876-
formData.append('value', newValue);
877-
878-
// Submit the form
879-
fetch('./entity', {{
880-
method: 'POST',
881-
body: formData
882-
}}).then(response => {{
883-
if (response.redirected) {{
884-
window.location.href = response.url;
885-
}}
886-
}}).catch(error => {{
887-
console.error('Error:', error);
888-
// Revert the toggle on error
889-
button.classList.toggle('active');
890-
}});
891-
}}
892-
</script>
893-
"""
834+
html += get_entity_toggle_js()
894835

895836
elif control_type == "number":
896837
min_val = self.base.get_state_wrapper(entity_id=entity_id, attribute="min", default=None)
@@ -1118,11 +1059,15 @@ async def html_api_get_log(self, request):
11181059
"""
11191060
try:
11201061
logfile = "predbat.log"
1062+
logfile_1 = "predbat.1.log"
11211063
logdata = ""
11221064

11231065
if os.path.exists(logfile):
11241066
with open(logfile, "r") as f:
11251067
logdata = f.read()
1068+
if os.path.exists(logfile_1):
1069+
with open(logfile_1, "r") as f:
1070+
logdata = f.read() + "\n" + logdata
11261071

11271072
# Get query parameters
11281073
args = request.query

0 commit comments

Comments
 (0)