Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,16 @@
"restore": False,
"manual_rate": True,
},
{
"name": "manual_load_adjust",
"friendly_name": "Manual load adjustment",
"type": "select",
"options": ["off"],
"icon": "mdi:state-machine",
"default": "off",
"restore": False,
"manual_rate": True,
},
{
"name": "manual_import_value",
"friendly_name": "Manual import value",
Expand All @@ -956,6 +966,17 @@
"icon": "mdi:currency-usd",
"default": 0,
},
{
"name": "manual_load_value",
"friendly_name": "Manual load adjustment value",
"type": "input_number",
"min": -10,
"max": 10,
"step": 0.1,
"unit": "kWh",
"icon": "mdi:currency-usd",
"default": 0.5,
},
{
"name": "manual_api",
"friendly_name": "Manual API controls",
Expand Down
5 changes: 5 additions & 0 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def step_data_history(
load_scaling_dynamic=None,
base_offset=None,
flip=False,
load_adjust={},
):
"""
Create cached step data for historical array
Expand Down Expand Up @@ -126,6 +127,9 @@ def step_data_history(
if load_forecast:
for offset in range(step):
load_extra += self.get_from_incrementing(load_forecast, minute_absolute, backwards=False)
if load_adjust:
load_extra += load_adjust.get(minute_absolute, 0) * step / 30.0 # The kWh figure is for the 30 minute period in question so divide by 30 and times by step
load_extra = max(load_extra, -value) # Don't allow going to negative load values
values[minute] = dp4((value + load_extra) * scaling_dynamic * scale_today * scale_fixed)

# Simple divergence model keeps the same total but brings PV/Load up and down every 5 minutes
Expand Down Expand Up @@ -2266,6 +2270,7 @@ def fetch_config_options(self):
self.manual_api = self.api_select_update("manual_api")
self.manual_import_rates = self.manual_rates("manual_import_rates", default_rate=self.get_arg("manual_import_value"))
self.manual_export_rates = self.manual_rates("manual_export_rates", default_rate=self.get_arg("manual_export_value"))
self.manual_load_adjust = self.manual_rates("manual_load_adjust", default_rate=self.get_arg("manual_load_value"))

# Update list of config options to save/restore to
self.update_save_restore_list()
Expand Down
23 changes: 13 additions & 10 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,9 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,

load_forecast = str(load_forecast)

if minute in self.manual_load_adjust:
load_forecast += " ⅎ"

if plan_debug and load_forecast10 > 0.0:
load_forecast += " (%s)" % (str(load_forecast10))

Expand Down Expand Up @@ -1408,22 +1411,22 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
html += cell_style + " "
html += "bgcolor=" + state_color + ">" + state + "</td>"
html += "<td bgcolor=#FFFFFF> " + show_limit + "</td>"
html += "<td bgcolor=" + pv_color + ">" + str(pv_forecast) + pv_symbol + "</td>"
html += "<td bgcolor=" + load_color + ">" + str(load_forecast) + "</td>"
html += "<td id=pv bgcolor=" + pv_color + ">" + str(pv_forecast) + pv_symbol + "</td>"
html += "<td id=load data-minute=" + str(minute) + " bgcolor=" + load_color + ">" + str(load_forecast) + "</td>"
if plan_debug:
html += "<td bgcolor=" + clipped_color + ">" + clipped_str + "</td>"
html += "<td id=clip bgcolor=" + clipped_color + ">" + clipped_str + "</td>"
if plan_debug and self.load_forecast:
html += "<td bgcolor=" + extra_color + ">" + str(extra_forecast) + "</td>"
html += "<td id=extra bgcolor=" + extra_color + ">" + str(extra_forecast) + "</td>"
if self.num_cars > 0: # Don't display car charging data if there's no car
html += "<td bgcolor=" + car_color + ">" + car_charging_str + "</td>"
html += "<td id=car bgcolor=" + car_color + ">" + car_charging_str + "</td>"
if self.iboost_enable:
html += "<td bgcolor=" + iboost_color + ">" + iboost_amount_str + " </td>"
html += "<td bgcolor=" + soc_color + ">" + str(soc_percent) + soc_sym + "</td>"
html += "<td bgcolor=" + cost_color + ">" + str(cost_str) + "</td>"
html += "<td bgcolor=#FFFFFF>" + str(total_str) + "</td>"
html += "<td id=soc bgcolor=" + soc_color + ">" + str(soc_percent) + soc_sym + "</td>"
html += "<td id=cost bgcolor=" + cost_color + ">" + str(cost_str) + "</td>"
html += "<td id=total_cost bgcolor=#FFFFFF>" + str(total_str) + "</td>"
if self.carbon_enable:
html += "<td bgcolor=" + carbon_intensity_color + ">" + str(carbon_intensity) + " </td>"
html += "<td bgcolor=" + carbon_color + "> " + str(carbon_str) + " </td>"
html += "<td id=carbon bgcolor=" + carbon_intensity_color + ">" + str(carbon_intensity) + " </td>"
html += "<td id=total_carbon bgcolor=" + carbon_color + "> " + str(carbon_str) + " </td>"
html += "</tr>\n"

# End of plan costs
Expand Down
2 changes: 2 additions & 0 deletions apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
load_forecast=self.load_forecast,
load_scaling_dynamic=self.load_scaling_dynamic,
cloud_factor=self.metric_load_divergence,
load_adjust=self.manual_load_adjust,
)
load_minutes_step10 = self.step_data_history(
self.load_minutes,
Expand All @@ -801,6 +802,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
load_forecast=self.load_forecast,
load_scaling_dynamic=self.load_scaling_dynamic,
cloud_factor=min(self.metric_load_divergence + 0.5, 1.0) if self.metric_load_divergence else None,
load_adjust=self.manual_load_adjust,
)
pv_forecast_minute_step = self.step_data_history(self.pv_forecast_minute, self.minutes_now, forward=True, cloud_factor=self.metric_cloud_coverage)
pv_forecast_minute10_step = self.step_data_history(self.pv_forecast_minute10, self.minutes_now, forward=True, cloud_factor=min(self.metric_cloud_coverage + 0.2, 1.0) if self.metric_cloud_coverage else None, flip=True)
Expand Down
1 change: 1 addition & 0 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ def reset(self):
self.manual_api = []
self.manual_import_rates = {}
self.manual_export_rates = {}
self.manual_load_adjust = {}
self.config_index = {}
self.dashboard_index = []
self.dashboard_index_app = {}
Expand Down
13 changes: 12 additions & 1 deletion apps/predbat/userinterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -1159,9 +1159,14 @@ def manual_select(self, config_item, value):
if "_import" in item["name"]:
# Manual import rate
self.manual_rates(config_item, new_value=item_value, default_rate=self.get_arg("manual_import_value"))
else:
elif "_export" in item["name"]:
# Manual export rate
self.manual_rates(config_item, new_value=item_value, default_rate=self.get_arg("manual_export_value"))
elif "_load" in item["name"]:
# Manual load rate
self.manual_rates(config_item, new_value=item_value, default_rate=self.get_arg("manual_load_value"))
else:
self.log("Warn: Manual rate sensor {} not recognised".format(config_item))
else:
self.manual_times(config_item, new_value=item_value)

Expand Down Expand Up @@ -1294,6 +1299,12 @@ def manual_rates(self, config_item, exclude=[], new_value=None, default_rate=0):
start_time = datetime.strptime(rate_time, "%H:%M:%S")
except (ValueError, TypeError):
start_time = None

try:
rate_value = float(rate_value)
except (ValueError, TypeError):
rate_value = default_rate

if start_time:
minutes = start_time.hour * 60 + start_time.minute
if minutes < minutes_now:
Expand Down
126 changes: 123 additions & 3 deletions apps/predbat/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -1573,7 +1573,7 @@ async def html_plan(self, request):
// Reload the page to show the updated plan
setTimeout(() => location.reload(), 1000);
} else {
alert('Error setting import override: ' + (data.message || 'Unknown error'));
alert('Error setting rate override: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
Expand All @@ -1585,6 +1585,67 @@ async def html_plan(self, request):

}

// Handle rate override option function
function handleLoadOverride(time, adjustment, action, clear) {
console.log("Load override:", time, "Adjustment:", adjustment, "Action:", action);
// Create a form data object to send the override parameters
const formData = new FormData();
formData.append('time', time);
formData.append('rate', adjustment);
formData.append('action', action);
// Send the override request to the server
fetch('./rate_override', {
method: 'POST',
body: formData
})
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error('Failed to set rate override');
})
.then(data => {
if (data.success) {
// Show success message
const messageElement = document.createElement('div');
if (clear) {
messageElement.textContent = `Manual load adjustment cleared for ${time}`;
} else {
messageElement.textContent = `Load adjustment set to ${adjustment} for ${time}`;
}
messageElement.style.position = 'fixed';
messageElement.style.top = '65px';
messageElement.style.right = '10px';
messageElement.style.padding = '10px';
messageElement.style.backgroundColor = '#4CAF50';
messageElement.style.color = 'white';
messageElement.style.borderRadius = '4px';
messageElement.style.zIndex = '1000';
document.body.appendChild(messageElement);

// Auto-remove message after 3 seconds
setTimeout(() => {
messageElement.style.opacity = '0';
messageElement.style.transition = 'opacity 0.5s';
setTimeout(() => messageElement.remove(), 500);
}, 3000);

// Reload the page to show the updated plan
setTimeout(() => location.reload(), 1000);
} else {
alert('Error setting load adjustment override: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Error setting load adjustment override: ' + (error.message || 'Unknown error'));
});
// Close dropdown after selection
closeDropdowns();

}


// Handle option selection
function handleTimeOverride(time, action) {
console.log("Time override:", time, "Action:", action);
Expand Down Expand Up @@ -1658,6 +1719,7 @@ async def html_plan(self, request):
time_pattern = r"<td id=time.*?>((?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) \d{2}:\d{2})</td>"
import_pattern = r"<td id=import data-minute=(\S+) data-rate=(\S+)(.*?)>(.*?)</td>"
export_pattern = r"<td id=export data-minute=(\S+) data-rate=(\S+)(.*?)>(.*?)</td>"
load_pattern = r"<td id=load data-minute=(\S+) (.*?)>(.*?)</td>"

# Counter for creating unique IDs for dropdowns
dropdown_counter = 0
Expand All @@ -1670,6 +1732,7 @@ async def html_plan(self, request):
manual_all_times = manual_charge_times + manual_export_times + manual_demand_times + manual_freeze_charge_times + manual_freeze_export_times
manual_import_rates = self.base.manual_rates("manual_import_rates")
manual_export_rates = self.base.manual_rates("manual_export_rates")
manual_load_adjust = self.base.manual_rates("manual_load_adjust")

# Function to replace time cells with cells containing dropdowns
def add_button_to_time(match):
Expand Down Expand Up @@ -1772,10 +1835,61 @@ def add_button_to_import(match, is_import=True):

return button_html

def add_button_to_load(match):
"""
Add load rate button to load cells
"""
nonlocal dropdown_counter
dropdown_id = f"dropdown_{dropdown_counter}"
input_id = f"load_input_{dropdown_counter}"
dropdown_counter += 1

load_minute = match.group(1)
load_tag = match.group(2).strip()
load_text = match.group(3).strip()
load_minute_to_time = self.base.midnight_utc + timedelta(minutes=int(load_minute))
load_minute_str = load_minute_to_time.strftime("%a %H:%M")
override_active = False
if int(load_minute) in manual_load_adjust:
override_active = True
load_adjust = manual_load_adjust[int(load_minute)]

button_html = f"""<td {load_tag} class="clickable-time-cell {'override-active' if override_active else ''}" onclick="toggleForceDropdown('{dropdown_id}')">
{load_text}
<div class="dropdown">
<div id="{dropdown_id}" class="dropdown-content">
"""
if override_active:
action = "Clear Load"
button_html += f"""<a onclick="handleLoadOverride('{load_minute_str}', '{load_adjust}', '{action}', true)">{action}</a>"""
else:
# Add input field for custom rate entry
default_adjust = self.base.get_arg("manual_load_value", 0.0)
action = "Set Load"
button_html += f"""
<div style="padding: 12px 16px;">
<label style="display: block; margin-bottom: 5px; color: inherit;">{action} {load_minute_str} Adjustment:</label>
<input type="number" id="{input_id}" step="0.1" value="{default_adjust}"
style="width: 80px; padding: 4px; margin-bottom: 8px; border-radius: 3px;">
<br>
<button onclick="handleLoadOverride('{load_minute_str}', document.getElementById('{input_id}').value, '{action}', false)"
style="padding: 6px 12px; border-radius: 3px; font-size: 12px;">
Set Load Adjustment
</button>
</div>
"""
button_html += f"""
</div>
</div>
</td>"""

return button_html

# Process the HTML plan to add buttons to time cells
processed_html = re.sub(time_pattern, add_button_to_time, html_plan)
processed_html = re.sub(import_pattern, add_button_to_import, processed_html)
processed_html = re.sub(export_pattern, add_button_to_export, processed_html)
processed_html = re.sub(load_pattern, add_button_to_load, processed_html)

text += processed_html + "</body></html>\n"
return web.Response(content_type="text/html", text=text)
Expand Down Expand Up @@ -5757,11 +5871,11 @@ async def html_rate_override(self, request):
rate = 0.0

# Log the override request
self.log(f"Rate override requested: {action} at {time_str}")
self.log(f"Rate override requested: {action} at {time_str} value {rate}")

# Validate inputs
if not time_str or not action:
self.log("ERROR: Missing required parameters for rate override")
self.log("ERROR: Missing required parameters for override")
return web.json_response({"success": False, "message": "Missing required parameters"}, status=400)

now_utc = self.base.now_utc
Expand All @@ -5785,6 +5899,12 @@ async def html_rate_override(self, request):
item = self.base.config_index.get("manual_export_value", {})
await self.base.ha_interface.set_state_external(item.get("entity", None), rate)
await self.base.async_manual_select("manual_export_rates", selection_option)
elif action == "Set Load":
item = self.base.config_index.get("manual_load_value", {})
await self.base.ha_interface.set_state_external(item.get("entity", None), rate)
await self.base.async_manual_select("manual_load_adjust", selection_option)
elif action == "Clear Load":
await self.base.async_manual_select("manual_load_adjust", clear_option)
else:
self.log("ERROR: Unknown action for rate override")
return web.json_response({"success": False, "message": "Unknown action"}, status=400)
Expand Down
14 changes: 12 additions & 2 deletions docs/customisation.md
Original file line number Diff line number Diff line change
Expand Up @@ -603,12 +603,22 @@ hold at the current level. The grid may be used if solar is not enough to cover
The **select.predbat_manual_freeze_export** selector is used to force Predbat to freeze export during a 30-minute slot, this implies the battery will not charge but will
still discharge for the house load. Any solar will be exported to the grid.

The **select.predbat_manual_import_rates** selected is used to override the import rates for a 30-minute slot, the rate selected will be that configured in **input_number.predbat_manual_import_value**
The **select.predbat_manual_import_rates** selector is used to override the import rates for a 30-minute slot, the rate selected will be that configured in **input_number.predbat_manual_import_value**
which can be adjusted prior to making a selection. As with the other selectors the selection can be cleared by selecting the option in square brackets or by using **off**

The **select.predbat_manual_export_rates** selected is used to override the export rates for a 30-minute slot, the rate selected will be that configured in **input_number.predbat_manual_export_value**
If this selector is used in an automation you can set the time and rate together by making a selection in the format HH:MM:SS=rate e.g. 12:30:00=29.5

The **select.predbat_manual_export_rates** selector is used to override the export rates for a 30-minute slot, the rate selected will be that configured in **input_number.predbat_manual_export_value**
which can be adjusted prior to making a selection. As with the other selectors the selection can be cleared by selecting the option in square brackets or by using **off**

If this selector is used in an automation you can set the time and rate together by making a selection in the format HH:MM:SS=rate e.g. 12:30:00=29.5

The **select.predbat_manual_load_adjust** selector is used to make adjustments to the predicted load for a 30-minute slot, the adjustment in kWh (which is added to the predicted load) will be that
configured in **input_number.predbat_manual_load_value** which can be adjusted prior to making a selection. As with the other selectors the selection can be cleared by selecting the option in
square brackets or by using **off**

If this selector is used in an automation you can set the time and rate together by making a selection in the format HH:MM:SS=adjustment e.g. 12:30:00=0.5

When you use the manual override features you can only select times in the next 18 hours, the overrides will be removed once their time
slot expires (they do not repeat).

Expand Down