Source code for juham.automation.energycostcalculator

import datetime
from typing import Any
import json
from influxdb_client_3 import Point
from juham.base import Base, MqttMsg
from juham.base.jmqtt import JMqtt


[docs] class EnergyCostCalculator(Base): """The EnergyCostCalculator class calculates the net energy balance between produced and consumed energy for Time-Based Settlement (TBS). It performs the following functions: * Subscribes to 'spot' and 'power' MQTT topics. * Calculates the net energy and the rate of change of the net energy per hour and per day (24h) * Publishes the calculated values to the MQTT net energy balance topic. * Stores the data in a time series database. This information helps other home automation components optimize energy usage and minimize electricity bills. """ topic_in_spot = Base.mqtt_root_topic + "/spot" topic_in_powerconsumption = Base.mqtt_root_topic + "/powerconsumption" topic_out_net_energy_balance = ( Base.mqtt_root_topic + "/net_energy_balance" ) # hourly energy balance energy for time based settlement to_joule_coeff = 1.0 / (1000.0 * 3600) energy_balancing_interval: float = 3600 def __init__(self, name="ecc"): super().__init__(name) self.current_ts = 0 self.net_energy_balance_cost_hour = 0 self.net_energy_balance_cost_day = 0 self.net_energy_balance_start_hour = self.elapsed_seconds_in_hour( self.timestamp() ) self.net_energy_balance_start_day = self.elapsed_seconds_in_day( self.timestamp() ) self.spots = [] # @override
[docs] def on_connect(self, client: object, userdata: Any, flags: int, rc: int): super().on_connect(client, userdata, flags, rc) if rc == 0: self.subscribe(self.topic_in_spot) self.subscribe(self.topic_in_powerconsumption)
# @override
[docs] def on_message(self, client: object, userdata: Any, msg: MqttMsg): """Handle MQTT message. Args: client (object) : client userdata (any): user data msg (MqttMsg): mqtt message """ ts_now = self.timestamp() m = json.loads(msg.payload.decode()) if msg.topic == self.topic_in_spot: self.on_spot(m) elif msg.topic == self.topic_in_powerconsumption: self.on_powerconsumption(ts_now, m) else: self.error(f"Unknown event {msg.topic}")
[docs] def on_spot(self, spot: dict): """Stores the received per hour electricity prices to spots list. Args: spot (list): list of hourly spot prices """ for s in spot: self.spots.append( {"Timestamp": s["Timestamp"], "PriceWithTax": s["PriceWithTax"]} )
[docs] def map_prices_to_joules(self, price: float): """Convert the given electricity price in kWh to Watt seconds (J) Args: price (float): electricity price given as kWh Returns: Electricity price per watt second (J) """ return price * self.to_joule_coeff
[docs] def get_prices(self, ts_prev: float, ts_now: float): """Fetch the electricity prices for the given two subsequent time stamps. Args: ts_prev (float): previous time ts_now (float): current time Returns: Electricity prices for the given interval """ prev_price = None current_price = None for i in range(0, len(self.spots) - 1): r0 = self.spots[i] r1 = self.spots[i + 1] ts0 = r0["Timestamp"] ts1 = r1["Timestamp"] if ts_prev >= ts0 and ts_prev <= ts1: prev_price = r0["PriceWithTax"] if ts_now >= ts0 and ts_now <= ts1: current_price = r0["PriceWithTax"] if prev_price is not None and current_price is not None: return prev_price, current_price self.error("PANIC: run out of spot prices") return 0.0, 0.0
[docs] def calculate_net_energy_cost(self, ts_prev: float, ts_now: float, energy: float): """Given time interval as start and stop Calculate the cost over the given time period. Positive values indicate revenue, negative cost. Args: ts_prev (timestamp): beginning time stamp of the interval ts_now (timestamp): end of the interval energy (float): energy consumed during the time interval Returns: Cost or revenue """ cost = 0 prev = ts_prev while prev < ts_now: elapsed_seconds = ts_now - prev if elapsed_seconds > self.energy_balancing_interval: elapsed_seconds = self.energy_balancing_interval now = prev + elapsed_seconds start_per_kwh, stop_per_kwh = self.get_prices(prev, now) start_price = self.map_prices_to_joules(start_per_kwh) stop_price = self.map_prices_to_joules(stop_per_kwh) if abs(stop_price - start_price) < 1e-24: cost = cost + energy * elapsed_seconds * start_price self.debug( f"Energy cost {str(cost)} e = {str(energy)} J x {str(start_price)} e/J x {str(elapsed_seconds)} s" ) else: # interpolate cost over energy balancing interval boundary elapsed = now - prev if elapsed < 0.00001: self.debug( f"Cost over hour boundary {str(cost)} but elapsed seconds is 0s" ) return 0.0 ts_0 = self.quantize(self.energy_balancing_interval, now) t1 = (ts_0 - prev) / elapsed t2 = (now - ts_0) / elapsed cost = ( cost + energy * ((1.0 - t1) * start_price + t2 * stop_price) * elapsed_seconds ) self.debug( f"Cost over hour boundary {str(cost)} e = {str(energy)} J x {str(start_price)} e/J x {str(t1)} s + {str(stop_price)}e x {str(t2)} s" ) prev = prev + elapsed_seconds return cost
[docs] def on_powerconsumption(self, ts_now: float, m: dict): """Calculate net energy cost and update the hourly consumption attribute accordingly. Args: ts_now (float): time stamp of the energy consumed m (dict): Juham MQTT message holding energy reading """ power = m["real_total"] if not self.spots: self.info("Waiting for electricity prices...") elif self.current_ts == 0: self.net_energy_balance_cost_hour = 0.0 self.net_energy_balance_cost_day = 0.0 self.current_ts = ts_now self.net_energy_balance_start_hour = self.quantize( self.energy_balancing_interval, ts_now ) self.info( f"Energy cost calculator reset at {self.timestampstr(self.net_energy_balance_start_hour) }" ) else: # calculate cost dp = self.calculate_net_energy_cost(self.current_ts, ts_now, power) self.net_energy_balance_cost_hour = self.net_energy_balance_cost_hour + dp self.net_energy_balance_cost_day = self.net_energy_balance_cost_day + dp # calculate and publish energy balance dt = ts_now - self.current_ts # time elapsed since previous call balance = dt * power # energy consumed/produced in this slot self.info( f"Net balance cost {self.net_energy_balance_cost_hour * 100.0}, today {self.net_energy_balance_cost_day * 100.0} cents" ) self.publish_net_energy_balance(ts_now, self.name, balance, power) self.record_energycost( ts_now, self.name, self.net_energy_balance_cost_hour, self.net_energy_balance_cost_day, ) # Check if the current energy balancing interval has ended # If so, reset the net_energy_balance attribute for the next interval if ( ts_now - self.net_energy_balance_start_hour > self.energy_balancing_interval ): self.info( f"Energy balance interval {self.net_energy_balance_start_hour} ... {self.timestampstr(ts_now)} completed with cost {self.net_energy_balance_cost_hour} e, resetting" ) self.net_energy_balance_cost_hour = 0.0 self.net_energy_balance_start_hour = ts_now if ts_now - self.net_energy_balance_start_day > 24 * 3600: self.info( f"Day {self.net_energy_balance_start_hour} ... {self.timestampstr(ts_now)} completed with cost {self.net_energy_balance_cost_day} e, resetting" ) self.net_energy_balance_cost_day = 0.0 self.net_energy_balance_start_day = ts_now self.current_ts = ts_now
[docs] def record_energycost( self, ts_now: float, site: str, cost_hour: float, cost_day: float ): """Record energy cost/revenue to data storage. Positive values represent revenue whereas negative cost. Args: ts_now (float): timestamp site (str): site cost_hour (float): cumulative cost or revenue per hour. cost_day (float): cost or revenue per day. """ try: point = ( Point("energycost") .tag("site", site) .field("cost", cost_hour) .field("cost_day", cost_day) .time(self.epoc2utc(ts_now)) ) self.write(point) except Exception as e: self.error( f"Cannot write energycost at {self.timestampstr(ts_now)}", str(e) )
[docs] def publish_net_energy_balance( self, ts_now: float, site: str, energy: float, power: float ): """Publish the net energy balance for the current energy balancing interval, as well as the real-time power at which energy is currently being produced or consumed (the rate of change of net energy). Args: ts_now (float): timestamp site (str): site energy (float): cost or revenue. power (float) : momentary power (rage of change of energy) """ msg = {"power": power, "energy": energy, "ts": ts_now} self.publish(self.topic_out_net_energy_balance, json.dumps(msg), 1, True)