Skip to content

Commit

Permalink
New release: complete refactor of methodology and logic around price …
Browse files Browse the repository at this point in the history
…based grid assist/pass-thru mode and added a more reliable method of manual toggling the feature on and off regardless of price threshold in .env config, add grid monitoring and critical message types for alerts, fixed unrecoverable tibber api errors.
  • Loading branch information
JoshuaDodds committed Dec 15, 2024
1 parent c0bfd1a commit 731f4a9
Show file tree
Hide file tree
Showing 8 changed files with 119 additions and 51 deletions.
2 changes: 1 addition & 1 deletion lib/clients/mqtt_client_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def _on_message(_client, _userdata, msg):
logmsg = f"{' '.join(topic.rsplit('/', 3)[1:3])}: {value}"
logging.debug(logmsg)

if topic and value:
if topic and value is not None:
# capture and dispatch events which should update Domoticz
if topic in DzEndpoints['system0']:
domoticz_update(topic, value, logmsg)
Expand Down
7 changes: 4 additions & 3 deletions lib/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ def dotenv_config(env_variable):
"c1_daily_yield": f"N/{systemId0}/solarcharger/282/History/Daily/0/Yield",

# AC Out Metrics
"ac_out_power": f"N/{systemId0}/vebus/276/Ac/Out/P",
"ac_out_power": f"N/{systemId0}/vebus/276/Ac/Out/P",

# AC In Metrics
"ac_in_power": f"N/{systemId0}/vebus/276/Ac/ActiveIn/P",
"ac_in_connected": f"N/{systemId0}/vebus/276/Ac/ActiveIn/Connected",
"ac_in_power": f"N/{systemId0}/vebus/276/Ac/ActiveIn/P",

# Control
"ac_power_setpoint": f"N/{systemId0}/settings/0/Settings/CGwacs/AcPowerSetPoint",
Expand All @@ -58,7 +59,7 @@ def dotenv_config(env_variable):
"trigger_ess_charge_scheduling": f"Cerbomoticzgx/EnergyBroker/RunTrigger",
"system_shutdown": f"Cerbomoticzgx/system/shutdown",
"ess_net_metering_enabled": f"Cerbomoticzgx/system/EssNetMeteringEnabled",
"ess_net_metering_overridden": f"Cerbomoticzgx/system/EssNetMeteringOverridden",
"ess_net_metering_overridden": f"Cerbomoticzgx/system/EssNetMeteringOverridden", # When this is toggled on, DynESS will not operate with automated buy/sell decisions
"ess_net_metering_batt_min_soc": f"Cerbomoticzgx/system/EssNetMeteringBattMinSoc",

# Tibber
Expand Down
75 changes: 40 additions & 35 deletions lib/energy_broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from paho.mqtt import publish
from lib.constants import logging, systemId0, PythonToVictronWeekdayNumberConversion, dotenv_config, cerboGxEndpoint
from lib.helpers import get_seasonally_adjusted_max_charge_slots, calculate_max_discharge_slots_needed, publish_message
from lib.helpers import get_seasonally_adjusted_max_charge_slots, calculate_max_discharge_slots_needed, publish_message, round_up_to_nearest_10
from lib.tibber_api import lowest_48h_prices, lowest_24h_prices
from lib.notifications import pushover_notification
from lib.tibber_api import publish_pricing_data
Expand All @@ -29,7 +29,7 @@ def schedule_tasks():

# Grid Charging Scheduled Tasks
scheduler.every().day.at("09:30").do(set_charging_schedule, caller="TaskScheduler()", silent=True)
scheduler.every().day.at("21:30").do(set_charging_schedule, caller="TaskScheduler()", silent=True, schedule_type="48h")
scheduler.every().day.at("21:30").do(set_charging_schedule, caller="TaskScheduler()", silent=True)


def retrieve_latest_tibber_pricing():
Expand Down Expand Up @@ -131,50 +131,55 @@ def manage_sale_of_stored_energy_to_the_grid() -> None:
f"Stopped energy export at {batt_soc}% and a current price of {round(tibber_price_now, 3)}")


def manage_grid_usage_based_on_current_price(price: float = None) -> None:
inverter_mode = int(STATE.get("inverter_mode"))
def adjust_grid_setpoint(watts, override_ess_net_mettering):
target_watts = int(round_up_to_nearest_10(watts))
ac_power_setpoint(watts=target_watts, override_ess_net_mettering=override_ess_net_mettering, silent=True)
return target_watts


def manage_grid_usage_based_on_current_price(price: float = None, power: any = None) -> None:
"""
Manages and allows automatic or manual toggle of a "passthrough" mode control loop which matches power consumption
to a grid setpoint to allow consumption from grid while having a fallback to battery in case of grid instability.
"""
ess_net_metering_overridden = STATE.get('ess_net_metering_overridden') or False
price = price if price is not None else STATE.get('tibber_price_now')
vehicle_plugged = True if STATE.get('tesla_plug_status') == "Plugged" else False
vehicle_home = STATE.get('tesla_is_home')
vehicle_soc = STATE.get('tesla_battery_soc')
vehicle_soc_setpoint = STATE.get('tesla_battery_soc_setpoint')
vehicle_is_charging = STATE.get('tesla_is_charging')
grid_charging_enabled = STATE.get('grid_charging_enabled') or False
grid_charging_enabled_by_price = STATE.get('grid_charging_enabled_by_price') or False

# TODO: This mode puts the system in a state where it will not recover automatically if grid power is lost.
# Because of that, some additional checks should be added here before switching to this mode. For
# example, is there someone home who can manually intervene if the grid has issues? Are they awake?
# an additional option could be to install a zigbee 4P circuit breaker on AC IN and monitor for
# issues on the grid or the breaker being thrown open in order to automatically switch the inverters back
# to the correct mode and restore power to the loads.
if not ess_net_metering_overridden:
# if energy is below SWITCH_TO_GRID_PRICE_THRESHOLD, switch to using the grid and try to charge vehicle
if price <= SWITCH_TO_GRID_PRICE_THRESHOLD and not grid_charging_enabled and inverter_mode == 3:
logging.info(f"Energy cost is {round(price, 3)} cents per kWh. Switching to grid energy.")

Utils.set_inverter_mode(mode=1)
if vehicle_plugged and vehicle_home and (vehicle_soc < vehicle_soc_setpoint) and not vehicle_is_charging:
STATE.set('tesla_charge_requested', 'True')
# Manual Mode Setpoint Management: used when grid assist has been manually toggled on
if ess_net_metering_overridden and grid_charging_enabled and not grid_charging_enabled_by_price and power:
setpoint = adjust_grid_setpoint(power, override_ess_net_mettering=True)
logging.debug(f"Setpoint adjusted to: {setpoint}")
return

pushover_notification("Tibber Price Alert",
f"Energy cost is {round(price, 3)} cents per kWh. Switching to grid energy.")
return
# Auto Mode State Change: Toggle grid charging based on price and send a single notification on state change
if not ess_net_metering_overridden or grid_charging_enabled_by_price:
if price <= SWITCH_TO_GRID_PRICE_THRESHOLD and not grid_charging_enabled_by_price:
logging.info(f"Energy cost is {round(price, 3)} cents per kWh. Switching to grid energy.")
pushover_notification(
"Auto Grid Assist On",
f"Energy cost is {round(price, 3)} cents per kWh. Switching to grid energy."
)
STATE.set('grid_charging_enabled_by_price', True)

# reverse the above action when energy is no longer free
if price >= SWITCH_TO_GRID_PRICE_THRESHOLD and not grid_charging_enabled and inverter_mode == 1:
elif price > SWITCH_TO_GRID_PRICE_THRESHOLD and grid_charging_enabled_by_price:
logging.info(f"Energy cost is {round(price, 3)} cents per kWh. Switching back to battery.")
pushover_notification(
"Auto Grid Assist Off",
f"Energy cost is {round(price, 3)} cents per kWh. Switching back to battery."
)
STATE.set('grid_charging_enabled_by_price', False)
ac_power_setpoint(watts="0.0", override_ess_net_mettering=False, silent=False)

Utils.set_inverter_mode(mode=3)
STATE.set('tesla_charge_requested', 'False')

pushover_notification("Tibber Price Alert",
f"Energy cost is {round(price, 3)} cents per kWh. Switching back to battery.")
# Auto Mode Setpoint Management: Manage setpoints if grid charging has been enabled by price threshold targets
if grid_charging_enabled_by_price and power:
setpoint = adjust_grid_setpoint(power, ess_net_metering_overridden)
logging.debug(f"Setpoint adjusted to: {setpoint}")

return

def publish_mqtt_trigger():
""" Triggers the event_handler to call set_charging_scheudle() function"""
""" Triggers the event_handler to call set_charging_schedule() function"""
publish_message("Cerbomoticzgx/EnergyBroker/RunTrigger", payload=f"{{\"value\": {time.localtime().tm_hour}}}", retain=False)


Expand Down
26 changes: 20 additions & 6 deletions lib/event_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

from lib.helpers import get_topic_key, publish_message
from lib.constants import dotenv_config, logging
from lib.victron_integration import regulate_battery_max_voltage
from lib.victron_integration import regulate_battery_max_voltage, ac_power_setpoint
from lib.global_state import GlobalStateClient
from lib.notifications import pushover_notification_critical
from lib.energy_broker import (
manage_sale_of_stored_energy_to_the_grid,
set_charging_schedule,
Expand Down Expand Up @@ -50,11 +51,24 @@ def _unhandled_method(self):
# global state db but will just be uncaught in this event handler.
logging.debug(f"{__name__}: Invalid method or nothing implemented for topic: '{self.mqtt_topic}'")

def ac_in_connected(self):
event = int(self.value)
if event == 0:
logging.info("AC Input: Grid is offline! This should not happen!")
# Ensure Ac Loads are powered by ensuring Inverters on are
Utils.set_inverter_mode(mode=3)
pushover_notification_critical(
"AC Input Gone!",
"Cerbomoticzgx requesting immediate attention: Grid power is offline. Check inverters and breakers!"
)
elif event == 1:
logging.info("AC Input: Grid is online.")

def ac_power_setpoint(self):
if float(self.value) > 0 or float(self.value) < 0:
logging.info(f"AC Power Setpoint changed to {self.value}")
logging.debug(f"AC Power Setpoint changed to {self.value}")
else:
logging.info(f"AC Power Setpoint reset to {self.value}")
logging.debug(f"AC Power Setpoint reset to {self.value}")

def ess_net_metering_batt_min_soc(self):
if self.gs_client.get('ess_net_metering_batt_min_soc'):
Expand Down Expand Up @@ -123,6 +137,7 @@ def tesla_power(self):
publish_message("Tesla/vehicle0/Ac/tesla_load", message=f"{_value}", retain=True)

def ac_out_power(self):
manage_grid_usage_based_on_current_price(price=self.gs_client.get('tibber_price_now'), power=int(self.value))
self.adjust_ac_out_power()

def ac_in_power(self):
Expand All @@ -139,10 +154,9 @@ def grid_charging_enabled(self):

if _value:
grid_import_state = "Enabled"
Utils.set_inverter_mode(mode=1)
else:
grid_import_state = "Disabled"
Utils.set_inverter_mode(mode=3)
ac_power_setpoint(watts="0.0", override_ess_net_mettering=False, silent=False)

logging.info(f"Grid assisted charging toggled to {grid_import_state}")

Expand Down Expand Up @@ -238,4 +252,4 @@ def adjust_ac_out_power(self):

@staticmethod
def trigger_ess_charge_scheduling():
set_charging_schedule(caller=__name__, schedule_type='24h')
set_charging_schedule(caller=__name__, silent=True)
41 changes: 41 additions & 0 deletions lib/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,44 @@ def pushover_notification(topic: str, msg: str) -> bool:
_req = requests.post('https://api.pushover.net/1/messages.json', data=payload, headers={'User-Agent': 'CerbomoticzGx'})
except Exception as e:
logging.info(f"lib.notifications error: {e}")


def pushover_notification_critical(topic: str, msg: str) -> bool:
"""
Sends a critical priority Pushover notification with static parameters.
Parameters:
topic (str): The topic of the notification.
msg (str): The message content.
Returns:
bool: True if the notification was successfully sent, False otherwise.
"""
_id = PushOverConfig.get("id")
_key = PushOverConfig.get("key")

payload = {
"message": f"{topic}: {msg}",
"user": _id,
"token": _key,
"priority": 2, # Critical priority
"title": "Critical Energy Alert", # Static title
"url": "http://192.168.1.163/app", # Static URL
"url_title": "Go to Dashboard", # Static URL title
"sound": "my_siren", # TODO: Does not work for some reason
"retry": 30, # Retry interval in seconds
"expire": 3600, # Expires after 1 hour
}

try:
response = requests.post(
'https://api.pushover.net/1/messages.json',
json=payload, # Send as JSON
headers={'User-Agent': 'CerbomoticzGx', 'Content-Type': 'application/json'}
)
response.raise_for_status()
return True
except requests.RequestException as e:
logging.error(f"lib.notifications error: {e}")
return False

2 changes: 1 addition & 1 deletion lib/tibber_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ async def log_accumulated(data):
logging.info(f"Tibber: Live measurements starting...")
try:
home.start_live_feed(user_agent=f"cerbomoticzgx/{dotenv_config('VERSION')}",
retries=20,
retries=3,
retry_interval=10)
except (TransportClosed, ConnectionClosedError) as e:
logging.info(f"Tibber Error: {e} It seems we have a network/connectivity issue. Attempting a service restart...")
Expand Down
6 changes: 4 additions & 2 deletions lib/victron_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,23 @@
max_voltage = float(dotenv_config('BATTERY_ABSORPTION_VOLTAGE'))
battery_full_voltage = float(dotenv_config('BATTERY_FULL_VOLTAGE'))

def ac_power_setpoint(watts: str = None, override_ess_net_mettering=True):
def ac_power_setpoint(watts: str = None, override_ess_net_mettering=True, silent: bool = False):
# disable net metering overide whenever power setpoint returns to zero
if watts == "0.0":
publish_message(Topics['system0']['ess_net_metering_overridden'], message="False", retain=True)

if watts:
_msg = f"{{\"value\": {watts}}}"
logging.debug(f"Victron Integration: Setting AC Power Set Point to: {watts} watts")

if override_ess_net_mettering:
publish_message(Topics['system0']['ess_net_metering_overridden'], message="True", retain=True)

STATE.set(key='ac_power_setpoint', value=f"{watts}")
publish.single(TopicsWritable['system0']['ac_power_setpoint'], payload=_msg, qos=1, retain=True, hostname=cerboGxEndpoint, port=1883)

if not silent:
logging.info(f"Victron Integration: Set AC Power Set Point to: {watts} watts")

def set_minimum_ess_soc(percent: int = 0):
if percent:
_msg = f"{{\"value\": {percent}}}"
Expand Down
11 changes: 8 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ def init():
# Set shutdown state to false (prevent a looping restart condition)
publish_message("Cerbomoticzgx/system/shutdown", message="False", retain=True)

# set higher than 0 zero cost at startup until actual pricing is retreived or auto sell/auto grid-assist might flap
publish_message(topic='Tibber/home/price_info/now/total', message="0.35", retain=True)
STATE.set('tibber_price_now', "0.35")


def main():
try:
Expand Down Expand Up @@ -105,16 +109,14 @@ def post_startup():
time.sleep(2)
logging.info(f"post_startup() actions executing...")

# set higher than 0 zero cost at startup until actual pricing is retreived
STATE.set('tibber_price_now', 0.15)

# Re-apply previously set Dynamic ESS preferences set in the previous run
logging.info(f"post_startup(): Re-storing previous state if available...")

AC_POWER_SETPOINT = retrieve_message('ac_power_setpoint') or STATE.get('ac_power_setpoint') or '0.0'
DYNAMIC_ESS_BATT_MIN_SOC = retrieve_message('ess_net_metering_batt_min_soc') or STATE.get("ess_net_metering_batt_min_soc") or dotenv_config('DYNAMIC_ESS_BATT_MIN_SOC')
DYNAMIC_ESS_NET_METERING_ENABLED = retrieve_message('ess_net_metering_enabled') or STATE.get("ess_net_metering_enabled") or dotenv_config('DYNAMIC_ESS_NET_METERING_ENABLED')
GRID_CHARGING_ENABLED = retrieve_message('grid_charging_enabled') or STATE.get("grid_charging_enabled") or False
GRID_CHARGING_ENABLED_BY_PRICE = retrieve_message('grid_charging_enabled_by_price') or STATE.get("grid_charging_enabled_by_price") or False
ESS_NET_METERING_OVERRIDDEN = retrieve_message('ess_net_metering_overridden') or STATE.get("ess_net_metering_overridden") or False
TESLA_CHARGE_REQUESTED = retrieve_message('tesla_charge_requested') or STATE.get("tesla_charge_requested") or False

Expand All @@ -124,6 +126,9 @@ def post_startup():
publish_message(topic='Tesla/settings/grid_charging_enabled', message=GRID_CHARGING_ENABLED, retain=True)
STATE.set('grid_charging_enabled', str(GRID_CHARGING_ENABLED))

publish_message(topic='Tesla/settings/grid_charging_enabled_by_price', message=GRID_CHARGING_ENABLED, retain=True)
STATE.set('grid_charging_enabled_by_price', str(GRID_CHARGING_ENABLED_BY_PRICE))

publish_message(topic='Tesla/vehicle0/control/charge_requested', message=TESLA_CHARGE_REQUESTED, retain=True)
STATE.set('tesla_charge_requested', TESLA_CHARGE_REQUESTED)

Expand Down

0 comments on commit 731f4a9

Please sign in to comment.