From c4a35d66b704c82b4eed766ec7565f54759e826e Mon Sep 17 00:00:00 2001 From: Nathan Marlor Date: Thu, 1 Dec 2022 12:44:22 +0000 Subject: [PATCH 1/3] Simplify battery model by extracting schedule and utils~ --- custom_components/foxess_em/__init__.py | 20 +- .../foxess_em/battery/battery_controller.py | 55 ++-- .../foxess_em/battery/battery_model.py | 250 +++++------------- .../foxess_em/battery/battery_util.py | 29 ++ .../foxess_em/battery/schedule.py | 67 +++++ .../foxess_em/util/peak_period_util.py | 64 +++++ 6 files changed, 281 insertions(+), 204 deletions(-) create mode 100755 custom_components/foxess_em/battery/battery_util.py create mode 100755 custom_components/foxess_em/battery/schedule.py create mode 100755 custom_components/foxess_em/util/peak_period_util.py diff --git a/custom_components/foxess_em/__init__.py b/custom_components/foxess_em/__init__.py index 1f138e1..04beae3 100755 --- a/custom_components/foxess_em/__init__.py +++ b/custom_components/foxess_em/__init__.py @@ -8,6 +8,8 @@ import logging from datetime import time +from custom_components.foxess_em.battery.schedule import Schedule +from custom_components.foxess_em.util.peak_period_util import PeakPeriodUtils from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config from homeassistant.core import CoreState @@ -82,10 +84,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): fox_client = FoxApiClient(session, fox_username, fox_password) # Initialise controllers and services + peak_utils = PeakPeriodUtils(eco_start_time, eco_end_time) + forecast_controller = ForecastController(hass, solcast_client) average_controller = AverageController( hass, eco_start_time, eco_end_time, house_power, aux_power ) + schedule = Schedule(hass) battery_controller = BatteryController( hass, forecast_controller, @@ -97,6 +102,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): eco_start_time, eco_end_time, battery_soc, + schedule, + peak_utils, ) fox_service = FoxCloudService(fox_client) charge_service = ChargeService( @@ -120,7 +127,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): } await _refresh_controllers( - hass, average_controller, forecast_controller, battery_controller + hass, average_controller, forecast_controller, battery_controller, schedule ) hass.services.async_register( @@ -136,11 +143,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True -async def _refresh_controllers(hass: HomeAssistant, average, forecast, battery): +async def _refresh_controllers( + hass: HomeAssistant, + average: AverageController, + forecast: ForecastController, + battery: BatteryController, + schedule: Schedule, +): """Refresh all controllers""" if hass.state is CoreState.running: # Prime history for sensor creation + schedule.load() await average.async_refresh() await forecast.async_refresh() await battery.async_refresh() @@ -151,7 +165,7 @@ async def _refresh_controllers(hass: HomeAssistant, average, forecast, battery): else: hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, - _refresh_controllers(hass, average, forecast, battery), + _refresh_controllers(hass, average, forecast, battery, schedule), ) diff --git a/custom_components/foxess_em/battery/battery_controller.py b/custom_components/foxess_em/battery/battery_controller.py index 76dfa7c..1061171 100755 --- a/custom_components/foxess_em/battery/battery_controller.py +++ b/custom_components/foxess_em/battery/battery_controller.py @@ -3,6 +3,9 @@ from datetime import datetime from datetime import time +from custom_components.foxess_em.battery.battery_util import BatteryUtils +from custom_components.foxess_em.battery.schedule import Schedule +from custom_components.foxess_em.util.peak_period_util import PeakPeriodUtils from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_track_state_change @@ -31,10 +34,15 @@ def __init__( eco_start_time: time, eco_end_time: time, battery_soc: str, + schedule: Schedule, + peak_utils: PeakPeriodUtils, ) -> None: UnloadController.__init__(self) CallbackController.__init__(self) self._hass = hass + self._schedule = schedule + self._peak_utils = peak_utils + self._battery_utils = BatteryUtils(capacity, min_soc) self._model = BatteryModel( hass, min_soc, @@ -44,6 +52,9 @@ def __init__( eco_start_time, eco_end_time, battery_soc, + schedule, + peak_utils, + self._battery_utils, ) self._forecast_controller = forecast_controller self._average_controller = average_controller @@ -87,11 +98,11 @@ def update_callback(self) -> None: def charge_to_perc(self) -> int: """Calculate percentage target""" - return self._model.charge_to_perc(self.min_soc()) + return self._battery_utils.charge_to_perc(self.min_soc()) def get_schedule(self): """Return charge schedule""" - return self._model.get_schedule() + return self._schedule.get_all() def raw_data(self): """Return raw data in dictionary form""" @@ -103,11 +114,23 @@ def state_at_eco_start(self) -> float: def dawn_charge_needs(self) -> float: """Dawn charge needs""" - return self._model.dawn_charge() + return self._schedule_info()["dawn"] def day_charge_needs(self) -> float: """Day charge needs""" - return self._model.day_charge() + return self._schedule_info()["day"] + + def charge_total(self) -> float: + """Total kWh required to charge""" + return self._schedule_info()["total"] + + def min_soc(self) -> float: + """Total kWh required to charge""" + return self._schedule_info()["min_soc"] + + def _schedule_info(self) -> float: + """Schedule info""" + return self._schedule.get(self._peak_utils.next_eco_start_time()) def next_dawn_time(self) -> datetime: """Day charge needs""" @@ -117,14 +140,6 @@ def todays_dawn_time_str(self) -> datetime: """Day charge needs""" return self._model.todays_dawn_time().isoformat() - def charge_total(self) -> float: - """Total kWh required to charge""" - return self._model.total_charge() - - def min_soc(self) -> float: - """Total kWh required to charge""" - return self._model.min_soc() - def battery_last_update(self) -> datetime: """Battery last update""" return self._last_update @@ -143,21 +158,29 @@ def forecast_last_update_str(self) -> str: def set_boost(self, status: bool) -> None: """Set boost on/off""" - self._model.set_boost_full_charge_status("boost_status", status) + self._schedule.set_boost( + self._peak_utils.next_eco_start_time(), "boost_status", status + ) self.refresh() def boost_status(self) -> bool: """Boost status""" - return self._model.get_boost_full_charge_status("boost_status") + return self._schedule.get_boost( + self._peak_utils.next_eco_start_time(), "boost_status" + ) def set_full(self, status: bool) -> None: """Set full charge on/off""" - self._model.set_boost_full_charge_status("full_status", status) + self._schedule.set_boost( + self._peak_utils.next_eco_start_time(), "full_status", status + ) self.refresh() def full_status(self) -> bool: """Full status""" - return self._model.get_boost_full_charge_status("full_status") + return self._schedule.get_boost( + self._peak_utils.next_eco_start_time(), "full_status" + ) def battery_depleted(self) -> datetime: """Time battery capacity is 0""" diff --git a/custom_components/foxess_em/battery/battery_model.py b/custom_components/foxess_em/battery/battery_model.py index a875e86..cf54be5 100755 --- a/custom_components/foxess_em/battery/battery_model.py +++ b/custom_components/foxess_em/battery/battery_model.py @@ -6,13 +6,15 @@ from datetime import timedelta import pandas as pd +from custom_components.foxess_em.battery.battery_util import BatteryUtils +from custom_components.foxess_em.battery.schedule import Schedule +from custom_components.foxess_em.util.peak_period_util import PeakPeriodUtils from homeassistant.core import HomeAssistant from ..util.exceptions import NoDataError _LOGGER = logging.getLogger(__name__) -_MAX_PERC = 100 -_SCHEDULE = "sensor.foxess_em_schedule" + _BOOST = 1 _FULL = 1000 @@ -30,6 +32,9 @@ def __init__( eco_start_time: time, eco_end_time: time, battery_soc: str, + schedule: Schedule, + peak_utils: PeakPeriodUtils, + battery_utils: BatteryUtils, ) -> None: self._hass = hass self._model = None @@ -41,31 +46,14 @@ def __init__( self._eco_start_time = eco_start_time self._eco_end_time = eco_end_time self._battery_soc = battery_soc - self._schedule = {} + self._schedule = schedule + self._peak_utils = peak_utils + self._battery_utils = battery_utils def ready(self) -> bool: """Model status""" return self._ready - def battery_capacity_remaining(self) -> float: - """Usable capacity remaining""" - battery_state = self._hass.states.get(self._battery_soc) - if battery_state is None: - raise NoDataError("Battery state is invalid") - if battery_state.state in ["unknown", "unavailable"]: - raise NoDataError("Battery state is unknown") - - battery_soc = int(battery_state.state) - battery_capacity = (battery_soc / 100) * self._capacity - - return battery_capacity - (self._min_soc * self._capacity) - - def charge_to_perc(self, charge: float) -> float: - """Convert kWh to percentage of charge""" - perc = ((charge / self._capacity) + self._min_soc) * 100 - - return min(_MAX_PERC, round(perc, 0)) - def raw_data(self): """Return raw data in dictionary form""" now = datetime.now().astimezone() @@ -91,8 +79,6 @@ def refresh_battery_model(self, forecast: pd.DataFrame, load: pd.DataFrame) -> N """Calculate battery model""" now = datetime.now().astimezone() - self._schedule = self._get_schedule() - load_forecast = self._merge_dataframes(load, forecast) if self._model is None: @@ -104,12 +90,12 @@ def refresh_battery_model(self, forecast: pd.DataFrame, load: pd.DataFrame) -> N available_capacity = self._capacity - (self._min_soc * self._capacity) - battery = self.battery_capacity_remaining() - last_eco_start = self._last_eco_start_time_str(now) - if last_eco_start in self._schedule: + battery = self._battery_capacity_remaining() + last_schedule = self._schedule.get(self._peak_utils.last_eco_start_time(now)) + if last_schedule is not None: # grab the min soc from the last eco start calc, including boost - min_soc = self._schedule[last_eco_start]["min_soc"] - elif self._in_between(now.time(), self._eco_start_time, self._eco_end_time): + min_soc = last_schedule["min_soc"] + elif self._peak_utils.in_peak(now.time()): # no history and in an eco period, recalulate without knowing boost _, min_soc = self._charge_totals(load_forecast, now, battery) @@ -122,12 +108,7 @@ def refresh_battery_model(self, forecast: pd.DataFrame, load: pd.DataFrame) -> N load_forecast, period, battery, boost ) battery += total - elif ( - self._in_between( - period.time(), self._eco_start_time, self._eco_end_time - ) - and battery < min_soc - ): + elif self._peak_utils.in_peak(period.time()) and battery < min_soc: # hold SoC in off-peak period battery = min_soc else: @@ -160,7 +141,7 @@ def _charge_totals( second=0, microsecond=0, ) - eco_end_time = self._next_eco_end_time(eco_start) + eco_end_time = self._peak_utils.next_eco_end_time(eco_start) next_eco_start = eco_start + timedelta(days=1) # grab all peak values peak = model[ @@ -171,36 +152,51 @@ def _charge_totals( forecast_sum = peak.pv_estimate.sum() load_sum = peak.load.sum() dawn_load = self._dawn_load(model, eco_end_time) - dawn_charge = self.dawn_charge_needs(dawn_load) - day_charge = self.day_charge_needs(forecast_sum, load_sum) - max_charge = self.ceiling_charge_total(max([dawn_charge, day_charge])) + dawn_charge = self._dawn_charge_needs(dawn_load) + day_charge = self._day_charge_needs(forecast_sum, load_sum) + max_charge = self._battery_utils.ceiling_charge_total( + max([dawn_charge, day_charge]) + ) min_soc = ( max_charge if boost == 0 - else self.ceiling_charge_total(max([battery, max_charge]) + boost) + else self._battery_utils.ceiling_charge_total( + max([battery, max_charge]) + boost + ) ) - total = self.ceiling_charge_total(max([0, min_soc - battery])) + total = self._battery_utils.ceiling_charge_total(max([0, min_soc - battery])) # store in dataframe for retrieval later - eco_str = eco_start.isoformat() - self._schedule[eco_str] = { - "eco_start": eco_start, - "eco_end": self._next_eco_end_time(eco_start), - "battery": battery, - "load": load_sum, - "forecast": forecast_sum, - "dawn": dawn_charge, - "day": day_charge, - "total": total, - "min_soc": min_soc, - "boost": boost, - "boost_status": self.get_boost_full_charge_status( - "boost_status", eco_start - ), - "full_status": self.get_boost_full_charge_status("full_status", eco_start), - } - _LOGGER.debug(f"Schedule: {self._schedule[eco_str]}") + self._schedule.upsert( + eco_start, + { + "eco_start": eco_start, + "eco_end": self._peak_utils.next_eco_end_time(eco_start), + "battery": battery, + "load": load_sum, + "forecast": forecast_sum, + "dawn": dawn_charge, + "day": day_charge, + "total": total, + "min_soc": min_soc, + "boost": boost, + }, + ) + return total, min_soc + def _battery_capacity_remaining(self) -> float: + """Usable capacity remaining""" + battery_state = self._hass.states.get(self._battery_soc) + if battery_state is None: + raise NoDataError("Battery state is invalid") + if battery_state.state in ["unknown", "unavailable"]: + raise NoDataError("Battery state is unknown") + + battery_soc = int(battery_state.state) + battery_capacity = (battery_soc / 100) * self._capacity + + return battery_capacity - (self._min_soc * self._capacity) + def _update_model_forecasts(self, future: pd.DataFrame, now: datetime): # keep original values including load, pv, grid etc hist = self._model[ @@ -212,47 +208,14 @@ def _update_model_forecasts(self, future: pd.DataFrame, now: datetime): def _get_total_additional_charge(self, period: datetime): """Get all additional charge""" - boost = self.get_boost_full_charge_status("boost_status", period) - full = self.get_boost_full_charge_status("full_status", period) + boost = self._schedule.get_boost(period, "boost_status") + full = self._schedule.get_boost(period, "full_status") boost = _BOOST if boost is True else 0 full = _FULL if full is True else 0 return max([boost, full]) - def set_boost_full_charge_status(self, charge_type: str, full: bool): - """Setup boosts""" - self._schedule[self._next_eco_start_time_str()][charge_type] = full - - def get_boost_full_charge_status(self, charge_type: str, period: datetime = None): - """Get additional charge""" - eco_str = period or self._next_eco_start_time() - eco_str = eco_str.isoformat() - - if eco_str not in self._schedule: - return False - elif charge_type not in self._schedule[eco_str]: - return False - else: - return self._schedule[eco_str][charge_type] - - def _get_schedule(self): - """Get persisted schedule from states""" - - schedule = self._hass.states.get(_SCHEDULE) - - if schedule is not None and "schedule" in schedule.attributes: - return schedule.attributes["schedule"] - else: - return {} - - def _in_between(self, now: time, start: time, end: time): - """In between two times""" - if start <= end: - return start < now <= end - else: # over midnight e.g., 23:30-04:15 - return now > start or now <= end - def _merge_dataframes(self, load: pd.DataFrame, forecast: pd.DataFrame): """Merge load and forecast dataframes""" load = load.groupby(load["time"]).mean() @@ -272,34 +235,12 @@ def _merge_dataframes(self, load: pd.DataFrame, forecast: pd.DataFrame): def state_at_eco_start(self) -> float: """State at eco end""" - eco_time = self._next_eco_start_time().replace(second=0, microsecond=0) + eco_time = self._peak_utils.next_eco_start_time().replace( + second=0, microsecond=0 + ) eco_time -= timedelta(minutes=1) return self._model[self._model["period_start"] == eco_time].battery.iloc[0] - def dawn_charge(self): - """Dawn charge required""" - return self._charge_info()["dawn"] - - def day_charge(self): - """Day charge required""" - return self._charge_info()["day"] - - def total_charge(self): - """Day charge required""" - return self._charge_info()["total"] - - def get_schedule(self): - """Return charge schedule""" - return self._schedule - - def min_soc(self): - """Day charge required""" - return self._charge_info()["min_soc"] - - def _charge_info(self): - """Charge info""" - return self._schedule[self._next_eco_start_time_str()] - def _dawn_load(self, model: pd.DataFrame, eco_end_time: datetime) -> float: """Dawn load""" dawn_time = self._dawn_time(model, eco_end_time) @@ -323,23 +264,14 @@ def _dawn_time(self, model: pd.DataFrame, date: datetime) -> datetime: else: return dawn.iloc[0].period_start.to_pydatetime() - def dawn_charge_needs(self, dawn_load: float) -> float: + def _dawn_charge_needs(self, dawn_load: float) -> float: """Dawn charge needs""" return round(dawn_load + self._dawn_buffer, 2) - def day_charge_needs(self, forecast: float, house_load: float) -> float: + def _day_charge_needs(self, forecast: float, house_load: float) -> float: """Day charge needs""" return round((house_load + self._day_buffer) - forecast, 2) - def ceiling_charge_total(self, charge_total: float) -> float: - """Ceiling total charge""" - available_capacity = round( - self._capacity - (self._min_soc * self._capacity), - 2, - ) - - return round(min(available_capacity, charge_total), 2) - def next_dawn_time(self) -> datetime: """Calculate dawn time""" now = datetime.now().astimezone() @@ -360,7 +292,7 @@ def todays_dawn_time(self) -> datetime: def battery_depleted_time(self) -> datetime: """Time battery capacity is 0""" - if self.battery_capacity_remaining() == 0: + if self._battery_capacity_remaining() == 0: # battery is already empty, prevent constant time updates and set sensor to unknown return None @@ -378,7 +310,7 @@ def battery_depleted_time(self) -> datetime: def peak_grid_import(self) -> float: """Grid usage required to next eco start""" now = datetime.now().astimezone() - eco_start = self._next_eco_start_time() + eco_start = self._peak_utils.next_eco_start_time() grid_use = self._model[ (self._model["grid"] < 0) @@ -394,7 +326,7 @@ def peak_grid_import(self) -> float: def peak_grid_export(self) -> float: """Grid usage required to next eco start""" now = datetime.now().astimezone() - eco_start = self._next_eco_start_time() + eco_start = self._peak_utils.next_eco_start_time() grid_export = self._model[ (self._model["grid"] > 0) @@ -406,55 +338,3 @@ def peak_grid_export(self) -> float: return 0 return round(grid_export.grid.sum(), 2) - - def _next_eco_start_time_str(self) -> datetime: - """Next eco start time""" - return self._next_eco_start_time().isoformat() - - def _next_eco_start_time(self) -> datetime: - """Next eco start time""" - now = datetime.now().astimezone() - eco_start = now.replace( - hour=self._eco_start_time.hour, - minute=self._eco_start_time.minute, - second=0, - microsecond=0, - ) - if now > eco_start: - eco_start += timedelta(days=1) - - return eco_start - - def _last_eco_start_time_str(self, period: datetime) -> datetime: - """Last eco start time string""" - return self._last_eco_start_time(period).isoformat() - - def _last_eco_start_time(self, period: datetime) -> datetime: - """Last eco start time""" - eco_start = period.replace( - hour=self._eco_start_time.hour, - minute=self._eco_start_time.minute, - second=0, - microsecond=0, - ) - if eco_start > period: - eco_start -= timedelta(days=1) - - return eco_start - - def _next_eco_end_time_str(self, period: datetime) -> datetime: - """Next eco end time string""" - return self._next_eco_end_time(period).isoformat() - - def _next_eco_end_time(self, period: datetime) -> datetime: - """Next eco end time""" - eco_end = period.replace( - hour=self._eco_end_time.hour, - minute=self._eco_end_time.minute, - second=0, - microsecond=0, - ) - if period > eco_end: - eco_end += timedelta(days=1) - - return eco_end diff --git a/custom_components/foxess_em/battery/battery_util.py b/custom_components/foxess_em/battery/battery_util.py new file mode 100755 index 0000000..a57a141 --- /dev/null +++ b/custom_components/foxess_em/battery/battery_util.py @@ -0,0 +1,29 @@ +"""Battery controller""" +import logging + +_LOGGER = logging.getLogger(__name__) +_MAX_PERC = 100 + + +class BatteryUtils: + """Battery Utils""" + + def __init__(self, capacity: float, min_soc: float) -> None: + """Init""" + self._capacity = capacity + self._min_soc = min_soc + + def charge_to_perc(self, charge: float) -> float: + """Convert kWh to percentage of charge""" + perc = ((charge / self._capacity) + self._min_soc) * 100 + + return min(_MAX_PERC, round(perc, 0)) + + def ceiling_charge_total(self, charge_total: float) -> float: + """Ceiling total charge""" + available_capacity = round( + self._capacity - (self._min_soc * self._capacity), + 2, + ) + + return round(min(available_capacity, charge_total), 2) diff --git a/custom_components/foxess_em/battery/schedule.py b/custom_components/foxess_em/battery/schedule.py new file mode 100755 index 0000000..02555e5 --- /dev/null +++ b/custom_components/foxess_em/battery/schedule.py @@ -0,0 +1,67 @@ +"""Battery controller""" +import logging +from datetime import datetime +from typing import Any + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) +_SCHEDULE = "sensor.foxess_em_schedule" + + +class Schedule: + """Schedule""" + + def __init__(self, hass: HomeAssistant) -> None: + """Get persisted schedule from states""" + self._hass = hass + self._schedule = {} + + def load(self) -> None: + """Load schedule from state""" + schedule = self._hass.states.get(_SCHEDULE) + + if schedule is not None and "schedule" in schedule.attributes: + self._schedule = schedule.attributes["schedule"] + else: + self._schedule = {} + + def upsert(self, index: datetime, params: dict) -> None: + """Update or insert new item""" + _LOGGER.debug(f"Updating schedule {index}: {params}") + + index = index.isoformat() + if index in self._schedule: + self._schedule[index].update(params) + else: + self._schedule[index] = params + + def get_all(self) -> dict[str, dict[str, Any]] | None: + """Retrieve schedule item""" + return self._schedule + + def get(self, index: datetime) -> dict[str, Any] | None: + """Retrieve schedule item""" + index = index.isoformat() + + if index in self._schedule: + return self._schedule[index] + else: + return None + + def get_boost(self, index: datetime, charge_type: str) -> bool: + """Retrieve schedule item""" + index = index.isoformat() + + if index not in self._schedule: + return False + elif charge_type not in self._schedule[index]: + return False + else: + return self._schedule[index][charge_type] + + def set_boost(self, index: datetime, charge_type: str, status: bool) -> bool: + """Set boost status""" + index = index.isoformat() + + self._schedule[index][charge_type] = status diff --git a/custom_components/foxess_em/util/peak_period_util.py b/custom_components/foxess_em/util/peak_period_util.py new file mode 100755 index 0000000..2b1c0d9 --- /dev/null +++ b/custom_components/foxess_em/util/peak_period_util.py @@ -0,0 +1,64 @@ +""""Datetime utilities""" +from datetime import datetime +from datetime import time +from datetime import timedelta + + +class PeakPeriodUtils: + """Peak Period Utils""" + + def __init__(self, eco_start_time: datetime, eco_end_time: datetime) -> None: + """Init""" + self._eco_start_time = eco_start_time + self._eco_end_time = eco_end_time + + def in_peak(self, period: time): + """In peak period""" + return self._in_between(period, self._eco_start_time, self._eco_end_time) + + def _in_between(self, now: time, start: time, end: time): + """In between two times""" + if start <= end: + return start < now <= end + else: # over midnight e.g., 23:30-04:15 + return now > start or now <= end + + def next_eco_start_time(self) -> datetime: + """Next eco start time""" + now = datetime.now().astimezone() + eco_start = now.replace( + hour=self._eco_start_time.hour, + minute=self._eco_start_time.minute, + second=0, + microsecond=0, + ) + if now > eco_start: + eco_start += timedelta(days=1) + + return eco_start + + def last_eco_start_time(self, period: datetime) -> datetime: + """Last eco start time""" + eco_start = period.replace( + hour=self._eco_start_time.hour, + minute=self._eco_start_time.minute, + second=0, + microsecond=0, + ) + if eco_start > period: + eco_start -= timedelta(days=1) + + return eco_start + + def next_eco_end_time(self, period: datetime) -> datetime: + """Next eco end time""" + eco_end = period.replace( + hour=self._eco_end_time.hour, + minute=self._eco_end_time.minute, + second=0, + microsecond=0, + ) + if period > eco_end: + eco_end += timedelta(days=1) + + return eco_end From e03e1361eb9d3f861dae29db8ac59d6167ccac0a Mon Sep 17 00:00:00 2001 From: Nathan Marlor Date: Thu, 1 Dec 2022 13:36:17 +0000 Subject: [PATCH 2/3] Ordered methods by external/internal --- custom_components/foxess_em/__init__.py | 1 - .../foxess_em/battery/battery_controller.py | 2 - .../foxess_em/battery/battery_model.py | 152 +++++++++--------- 3 files changed, 75 insertions(+), 80 deletions(-) diff --git a/custom_components/foxess_em/__init__.py b/custom_components/foxess_em/__init__.py index 04beae3..58cb4d0 100755 --- a/custom_components/foxess_em/__init__.py +++ b/custom_components/foxess_em/__init__.py @@ -100,7 +100,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): dawn_buffer, day_buffer, eco_start_time, - eco_end_time, battery_soc, schedule, peak_utils, diff --git a/custom_components/foxess_em/battery/battery_controller.py b/custom_components/foxess_em/battery/battery_controller.py index 1061171..6c0b353 100755 --- a/custom_components/foxess_em/battery/battery_controller.py +++ b/custom_components/foxess_em/battery/battery_controller.py @@ -32,7 +32,6 @@ def __init__( dawn_buffer: float, day_buffer: float, eco_start_time: time, - eco_end_time: time, battery_soc: str, schedule: Schedule, peak_utils: PeakPeriodUtils, @@ -50,7 +49,6 @@ def __init__( dawn_buffer, day_buffer, eco_start_time, - eco_end_time, battery_soc, schedule, peak_utils, diff --git a/custom_components/foxess_em/battery/battery_model.py b/custom_components/foxess_em/battery/battery_model.py index cf54be5..2b77b46 100755 --- a/custom_components/foxess_em/battery/battery_model.py +++ b/custom_components/foxess_em/battery/battery_model.py @@ -30,7 +30,6 @@ def __init__( dawn_buffer: float, day_buffer: float, eco_start_time: time, - eco_end_time: time, battery_soc: str, schedule: Schedule, peak_utils: PeakPeriodUtils, @@ -44,7 +43,6 @@ def __init__( self._dawn_buffer = dawn_buffer self._day_buffer = day_buffer self._eco_start_time = eco_start_time - self._eco_end_time = eco_end_time self._battery_soc = battery_soc self._schedule = schedule self._peak_utils = peak_utils @@ -184,6 +182,81 @@ def _charge_totals( return total, min_soc + def state_at_eco_start(self) -> float: + """State at eco end""" + eco_time = self._peak_utils.next_eco_start_time().replace( + second=0, microsecond=0 + ) + eco_time -= timedelta(minutes=1) + return self._model[self._model["period_start"] == eco_time].battery.iloc[0] + + def next_dawn_time(self) -> datetime: + """Calculate dawn time""" + now = datetime.now().astimezone() + + dawn_today = self._dawn_time(self._model, now) + dawn_tomorrow = self._dawn_time(self._model, now + timedelta(days=1)) + + if now > dawn_today: + return dawn_tomorrow + else: + return dawn_today + + def todays_dawn_time(self) -> datetime: + """Calculate dawn time""" + now = datetime.now().astimezone() + return self._dawn_time(self._model, now) + + def battery_depleted_time(self) -> datetime: + """Time battery capacity is 0""" + + if self._battery_capacity_remaining() == 0: + # battery is already empty, prevent constant time updates and set sensor to unknown + return None + + battery_depleted = self._model[ + (self._model["battery"] == 0) + & (self._model["period_start"] > datetime.now().astimezone()) + ] + + if len(battery_depleted) == 0: + # battery runs past our model, return the last result + return self._model.iloc[-1].period_start + + return battery_depleted.iloc[0].period_start + + def peak_grid_import(self) -> float: + """Grid usage required to next eco start""" + now = datetime.now().astimezone() + eco_start = self._peak_utils.next_eco_start_time() + + grid_use = self._model[ + (self._model["grid"] < 0) + & (self._model["period_start"] > now) + & (self._model["period_start"] < eco_start) + ] + + if len(grid_use) == 0: + return 0 + + return round(abs(grid_use.grid.sum()), 2) + + def peak_grid_export(self) -> float: + """Grid usage required to next eco start""" + now = datetime.now().astimezone() + eco_start = self._peak_utils.next_eco_start_time() + + grid_export = self._model[ + (self._model["grid"] > 0) + & (self._model["period_start"] > now) + & (self._model["period_start"] < eco_start) + ] + + if len(grid_export) == 0: + return 0 + + return round(grid_export.grid.sum(), 2) + def _battery_capacity_remaining(self) -> float: """Usable capacity remaining""" battery_state = self._hass.states.get(self._battery_soc) @@ -233,14 +306,6 @@ def _merge_dataframes(self, load: pd.DataFrame, forecast: pd.DataFrame): return load_forecast - def state_at_eco_start(self) -> float: - """State at eco end""" - eco_time = self._peak_utils.next_eco_start_time().replace( - second=0, microsecond=0 - ) - eco_time -= timedelta(minutes=1) - return self._model[self._model["period_start"] == eco_time].battery.iloc[0] - def _dawn_load(self, model: pd.DataFrame, eco_end_time: datetime) -> float: """Dawn load""" dawn_time = self._dawn_time(model, eco_end_time) @@ -271,70 +336,3 @@ def _dawn_charge_needs(self, dawn_load: float) -> float: def _day_charge_needs(self, forecast: float, house_load: float) -> float: """Day charge needs""" return round((house_load + self._day_buffer) - forecast, 2) - - def next_dawn_time(self) -> datetime: - """Calculate dawn time""" - now = datetime.now().astimezone() - - dawn_today = self._dawn_time(self._model, now) - dawn_tomorrow = self._dawn_time(self._model, now + timedelta(days=1)) - - if now > dawn_today: - return dawn_tomorrow - else: - return dawn_today - - def todays_dawn_time(self) -> datetime: - """Calculate dawn time""" - now = datetime.now().astimezone() - return self._dawn_time(self._model, now) - - def battery_depleted_time(self) -> datetime: - """Time battery capacity is 0""" - - if self._battery_capacity_remaining() == 0: - # battery is already empty, prevent constant time updates and set sensor to unknown - return None - - battery_depleted = self._model[ - (self._model["battery"] == 0) - & (self._model["period_start"] > datetime.now().astimezone()) - ] - - if len(battery_depleted) == 0: - # battery runs past our model, return the last result - return self._model.iloc[-1].period_start - - return battery_depleted.iloc[0].period_start - - def peak_grid_import(self) -> float: - """Grid usage required to next eco start""" - now = datetime.now().astimezone() - eco_start = self._peak_utils.next_eco_start_time() - - grid_use = self._model[ - (self._model["grid"] < 0) - & (self._model["period_start"] > now) - & (self._model["period_start"] < eco_start) - ] - - if len(grid_use) == 0: - return 0 - - return round(abs(grid_use.grid.sum()), 2) - - def peak_grid_export(self) -> float: - """Grid usage required to next eco start""" - now = datetime.now().astimezone() - eco_start = self._peak_utils.next_eco_start_time() - - grid_export = self._model[ - (self._model["grid"] > 0) - & (self._model["period_start"] > now) - & (self._model["period_start"] < eco_start) - ] - - if len(grid_export) == 0: - return 0 - - return round(grid_export.grid.sum(), 2) From 6261699d70325438d57c86871e456fd36babebfe Mon Sep 17 00:00:00 2001 From: Nathan Marlor Date: Thu, 1 Dec 2022 13:40:41 +0000 Subject: [PATCH 3/3] Simplify naming --- .../foxess_em/battery/battery_controller.py | 10 +++++----- .../foxess_em/battery/battery_model.py | 14 ++++++------- .../foxess_em/util/peak_period_util.py | 20 +++++++++---------- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/custom_components/foxess_em/battery/battery_controller.py b/custom_components/foxess_em/battery/battery_controller.py index 6c0b353..82bce8c 100755 --- a/custom_components/foxess_em/battery/battery_controller.py +++ b/custom_components/foxess_em/battery/battery_controller.py @@ -128,7 +128,7 @@ def min_soc(self) -> float: def _schedule_info(self) -> float: """Schedule info""" - return self._schedule.get(self._peak_utils.next_eco_start_time()) + return self._schedule.get(self._peak_utils.next_eco_start()) def next_dawn_time(self) -> datetime: """Day charge needs""" @@ -157,27 +157,27 @@ def forecast_last_update_str(self) -> str: def set_boost(self, status: bool) -> None: """Set boost on/off""" self._schedule.set_boost( - self._peak_utils.next_eco_start_time(), "boost_status", status + self._peak_utils.next_eco_start(), "boost_status", status ) self.refresh() def boost_status(self) -> bool: """Boost status""" return self._schedule.get_boost( - self._peak_utils.next_eco_start_time(), "boost_status" + self._peak_utils.next_eco_start(), "boost_status" ) def set_full(self, status: bool) -> None: """Set full charge on/off""" self._schedule.set_boost( - self._peak_utils.next_eco_start_time(), "full_status", status + self._peak_utils.next_eco_start(), "full_status", status ) self.refresh() def full_status(self) -> bool: """Full status""" return self._schedule.get_boost( - self._peak_utils.next_eco_start_time(), "full_status" + self._peak_utils.next_eco_start(), "full_status" ) def battery_depleted(self) -> datetime: diff --git a/custom_components/foxess_em/battery/battery_model.py b/custom_components/foxess_em/battery/battery_model.py index 2b77b46..ada2ba5 100755 --- a/custom_components/foxess_em/battery/battery_model.py +++ b/custom_components/foxess_em/battery/battery_model.py @@ -89,7 +89,7 @@ def refresh_battery_model(self, forecast: pd.DataFrame, load: pd.DataFrame) -> N available_capacity = self._capacity - (self._min_soc * self._capacity) battery = self._battery_capacity_remaining() - last_schedule = self._schedule.get(self._peak_utils.last_eco_start_time(now)) + last_schedule = self._schedule.get(self._peak_utils.last_eco_start(now)) if last_schedule is not None: # grab the min soc from the last eco start calc, including boost min_soc = last_schedule["min_soc"] @@ -139,7 +139,7 @@ def _charge_totals( second=0, microsecond=0, ) - eco_end_time = self._peak_utils.next_eco_end_time(eco_start) + eco_end_time = self._peak_utils.next_eco_end(eco_start) next_eco_start = eco_start + timedelta(days=1) # grab all peak values peak = model[ @@ -168,7 +168,7 @@ def _charge_totals( eco_start, { "eco_start": eco_start, - "eco_end": self._peak_utils.next_eco_end_time(eco_start), + "eco_end": self._peak_utils.next_eco_end(eco_start), "battery": battery, "load": load_sum, "forecast": forecast_sum, @@ -184,9 +184,7 @@ def _charge_totals( def state_at_eco_start(self) -> float: """State at eco end""" - eco_time = self._peak_utils.next_eco_start_time().replace( - second=0, microsecond=0 - ) + eco_time = self._peak_utils.next_eco_start() eco_time -= timedelta(minutes=1) return self._model[self._model["period_start"] == eco_time].battery.iloc[0] @@ -228,7 +226,7 @@ def battery_depleted_time(self) -> datetime: def peak_grid_import(self) -> float: """Grid usage required to next eco start""" now = datetime.now().astimezone() - eco_start = self._peak_utils.next_eco_start_time() + eco_start = self._peak_utils.next_eco_start() grid_use = self._model[ (self._model["grid"] < 0) @@ -244,7 +242,7 @@ def peak_grid_import(self) -> float: def peak_grid_export(self) -> float: """Grid usage required to next eco start""" now = datetime.now().astimezone() - eco_start = self._peak_utils.next_eco_start_time() + eco_start = self._peak_utils.next_eco_start() grid_export = self._model[ (self._model["grid"] > 0) diff --git a/custom_components/foxess_em/util/peak_period_util.py b/custom_components/foxess_em/util/peak_period_util.py index 2b1c0d9..6f996a0 100755 --- a/custom_components/foxess_em/util/peak_period_util.py +++ b/custom_components/foxess_em/util/peak_period_util.py @@ -16,14 +16,7 @@ def in_peak(self, period: time): """In peak period""" return self._in_between(period, self._eco_start_time, self._eco_end_time) - def _in_between(self, now: time, start: time, end: time): - """In between two times""" - if start <= end: - return start < now <= end - else: # over midnight e.g., 23:30-04:15 - return now > start or now <= end - - def next_eco_start_time(self) -> datetime: + def next_eco_start(self) -> datetime: """Next eco start time""" now = datetime.now().astimezone() eco_start = now.replace( @@ -37,7 +30,7 @@ def next_eco_start_time(self) -> datetime: return eco_start - def last_eco_start_time(self, period: datetime) -> datetime: + def last_eco_start(self, period: datetime) -> datetime: """Last eco start time""" eco_start = period.replace( hour=self._eco_start_time.hour, @@ -50,7 +43,7 @@ def last_eco_start_time(self, period: datetime) -> datetime: return eco_start - def next_eco_end_time(self, period: datetime) -> datetime: + def next_eco_end(self, period: datetime) -> datetime: """Next eco end time""" eco_end = period.replace( hour=self._eco_end_time.hour, @@ -62,3 +55,10 @@ def next_eco_end_time(self, period: datetime) -> datetime: eco_end += timedelta(days=1) return eco_end + + def _in_between(self, now: time, start: time, end: time): + """In between two times""" + if start <= end: + return start < now <= end + else: # over midnight e.g., 23:30-04:15 + return now > start or now <= end