diff --git a/custom_components/foxess_em/battery/battery_controller.py b/custom_components/foxess_em/battery/battery_controller.py index 0cde5a5..8909414 100755 --- a/custom_components/foxess_em/battery/battery_controller.py +++ b/custom_components/foxess_em/battery/battery_controller.py @@ -94,51 +94,31 @@ def charge_to_perc(self) -> int: self.charge_total() + self.state_at_eco_start() ) - def state_at_dawn(self) -> float: - """Battery state at dawn""" - dawn = self._model.state_at_dawn() - return round(dawn, 2) - - def state_at_eco_end(self) -> float: - """Battery state at end of eco period""" - eco_end = self._model.state_at_eco_end() - return round(eco_end, 2) - def state_at_eco_start(self) -> float: """Battery state at start of eco period""" - battery_value = self._model.state_at_eco_start() - return round(battery_value, 2) - - def todays_dawn_time(self) -> datetime: - """Return todays dawn time""" - return self._model.todays_dawn_time() - - def todays_dawn_time_str(self) -> str: - """Return todays dawn time in ISO format""" - return self._model.todays_dawn_time().isoformat() - - def next_dawn_time(self) -> datetime: - """Return next dawn time""" - return self._model.next_dawn_time() + return round(self._model.state_at_eco_start(), 2) def dawn_charge_needs(self) -> float: """Dawn charge needs""" - return self._model.dawn_charge_needs() + return self._model.dawn_charge() def day_charge_needs(self) -> float: """Day charge needs""" - house_load = self._average_controller.average_peak_house_load() - forecast_today = self._forecast_controller.total_kwh_forecast_today() - forecast_tomorrow = self._forecast_controller.total_kwh_forecast_tomorrow() + return self._model.day_charge() - return self._model.day_charge_needs( - forecast_today, forecast_tomorrow, house_load - ) + def next_dawn_time(self) -> datetime: + """Day charge needs""" + return self._model.next_dawn_time() + + 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""" + eco_start = self.state_at_eco_start() if self._full: - return self._model.ceiling_charge_total(float("inf")) + return self._model.ceiling_charge_total(float("inf"), eco_start) dawn_charge = self.dawn_charge_needs() day_charge = self.day_charge_needs() @@ -147,7 +127,7 @@ def charge_total(self) -> float: if self._boost: total += _BOOST - total = self._model.ceiling_charge_total(total) + total = self._model.ceiling_charge_total(total, eco_start) return total diff --git a/custom_components/foxess_em/battery/battery_model.py b/custom_components/foxess_em/battery/battery_model.py index 7d0b95a..0291002 100755 --- a/custom_components/foxess_em/battery/battery_model.py +++ b/custom_components/foxess_em/battery/battery_model.py @@ -67,104 +67,110 @@ def refresh_battery_model(self, forecast: pd.DataFrame, load: pd.DataFrame) -> N load = load.groupby(load["time"]).mean() load["time"] = load.index.values - now = datetime.utcnow() - - forecast = forecast[ - (forecast["date"] >= now.date()) - & (forecast["date"] <= (now + timedelta(days=2)).date()) - ] + # limit forecast values to future only + forecast = forecast[forecast["date"] >= datetime.utcnow().date()] + # reset indexes load.reset_index(drop=True, inplace=True) forecast.reset_index(drop=True, inplace=True) + # merge load and forecast to produce a delta merged = pd.merge(load, forecast, how="right", on=["time"]) merged["delta"] = merged["pv_estimate"] - merged["load"] merged = merged.reset_index(drop=True) - merged = merged.sort_values(by="period_start") + # set global model + self._model = merged + battery = self.battery_capacity_remaining() - battery_states = [] - grid_usage = [] available_capacity = self._capacity - (self._min_soc * self._capacity) for index, _ in merged.iterrows(): - if merged.iloc[index]["period_start"] >= datetime.now().astimezone(): - delta = merged.iloc[index]["delta"] - new_state = battery + delta - battery = max([0, min([available_capacity, new_state])]) - battery_states.append(battery) - if new_state <= 0 or new_state >= available_capacity: - # import (-) or excess (+) - grid_usage.append(delta) + period = merged.iloc[index]["period_start"].to_pydatetime() + + if period > datetime.now().astimezone(): + if period.time() == self._eco_start_time: + # landed on the start of the eco period + dawn_charge, day_charge = self._charge_totals(period, index) + battery += max([dawn_charge, day_charge]) + # store in dataframe for retrieval later + merged.at[index, "charge_dawn"] = dawn_charge + merged.at[index, "charge_day"] = day_charge + merged.at[index, "battery"] = battery + elif ( + period.time() > self._eco_start_time + and period.time() <= self._eco_end_time + ): + # still in eco period, don't update the battery + merged.at[index, "battery"] = merged.at[index - 1, "battery"] else: - # battery usage - grid_usage.append(0) - else: - grid_usage.append(0) - battery_states.append(0) - - merged["battery"] = battery_states - merged["grid"] = grid_usage + delta = merged.iloc[index]["delta"] + new_state = battery + delta + battery = max([0, min([available_capacity, new_state])]) + merged.at[index, "battery"] = battery + if new_state <= 0 or new_state >= available_capacity: + # import (-) or excess (+) + merged.at[index, "grid"] = delta + else: + # battery usage + merged.at[index, "grid"] = 0 - self._model = merged self._ready = True - def state_at_eco_start(self) -> float: - """State at eco end""" - return self._state_at_datetime(self._next_eco_start_time()) + def _charge_totals(self, period, index): + """Return charge totals for dawn/day""" + # calculate start/end of the next peak period + eco_end = self._next_eco_end_time(period) + next_eco_start = period + timedelta(days=1) + # grab all peak values + peak = self._model[ + (self._model["period_start"] > eco_end) + & (self._model["period_start"] < next_eco_start) + ] + # sum forecast and house load + forecast_sum = peak.pv_estimate.sum() + load_sum = peak.load.sum() + dawn_load = self._dawn_load(eco_end) + eco_start = self._model.iloc[index - 1].battery + dawn_charge = self.dawn_charge_needs(dawn_load, eco_start) + day_charge = self.day_charge_needs(forecast_sum, load_sum, eco_start) + _LOGGER.debug( + f"Period: {period.date()} - EcoStart: {eco_start} Dawn: {dawn_charge} Day: {day_charge}" + ) + return dawn_charge, day_charge - def state_at_eco_end(self) -> float: + def state_at_eco_start(self) -> float: """State at eco end""" - return self._state_at_datetime(self._next_eco_end_time()) + eco_time = self._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 state_at_dawn(self) -> float: - """State at eco end""" - return self._state_at_datetime(self.next_dawn_time()) + def dawn_charge(self): + """Dawn charge required""" + return self._charge_info().iloc[0].charge_dawn - def dawn_load(self) -> float: - """Dawn load""" - dawn_date = datetime.now().astimezone() - if self._is_after_todays_eco_start(): - dawn_date += timedelta(days=1) + def day_charge(self): + """Day charge required""" + return self._charge_info().iloc[0].charge_day - eco_time = dawn_date.replace( - hour=self._eco_end_time.hour, - minute=self._eco_end_time.minute, - second=0, - microsecond=0, - ) + def _charge_info(self): + """Charge info""" + return self._model[self._model["period_start"] == self._next_eco_start_time()] - dawn_time = self._dawn_time(dawn_date) + def _dawn_load(self, eco_end_time) -> float: + """Dawn load""" + dawn_time = self._dawn_time(eco_end_time) dawn_load = self._model[ - ( - (self._model["period_start"] > eco_time) - & (self._model["period_start"] < dawn_time) - ) + (self._model["period_start"] > eco_end_time) + & (self._model["period_start"] < dawn_time) ] load_sum = abs(dawn_load.delta.sum()) return round(load_sum, 2) - def next_dawn_time(self) -> datetime: - """Calculate dawn time""" - now = datetime.now().astimezone() - - dawn_today = self._dawn_time(now) - dawn_tomorrow = self._dawn_time(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(now) - def _dawn_time(self, date: datetime) -> datetime: """Calculate dawn time""" filtered = self._model[self._model["date"] == date.date()] @@ -174,53 +180,56 @@ def _dawn_time(self, date: datetime) -> datetime: # Solar never reaches house load... return mid-day return date.replace(hour=12, minute=0, second=0, microsecond=0) else: - return self._model.iloc[dawn["period_start"].idxmin()].period_start + return dawn.iloc[0].period_start.to_pydatetime() - def dawn_charge_needs(self) -> float: + def dawn_charge_needs(self, dawn_load, eco_start) -> float: """Dawn charge needs""" - - eco_start = self.state_at_eco_start() - dawn_load = self.dawn_load() - dawn_charge_needs = eco_start - dawn_load - dawn_buffer_top_up = self._dawn_buffer - dawn_charge_needs + dawn_buffer = self._dawn_buffer - dawn_charge_needs - ceiling = self.ceiling_charge_total(dawn_buffer_top_up) + ceiling = self.ceiling_charge_total(dawn_buffer, eco_start) return round(ceiling, 2) def day_charge_needs( - self, - forecast_today: float, - forecast_tomorrow: float, - house_load: float, + self, forecast: float, house_load: float, eco_start: float ) -> float: """Day charge needs""" - if self._is_after_todays_eco_start(): - forecast = forecast_tomorrow - else: - forecast = forecast_today - - day_charge_needs = (self.state_at_eco_start() - house_load) + forecast + day_charge_needs = (eco_start - house_load) + forecast day_buffer_top_up = self._day_buffer - day_charge_needs - ceiling = self.ceiling_charge_total(day_buffer_top_up) + ceiling = self.ceiling_charge_total(day_buffer_top_up, eco_start) return round(ceiling, 2) - def ceiling_charge_total(self, charge_total: float) -> float: + def ceiling_charge_total(self, charge_total: float, eco_start: float) -> float: """Ceiling total charge""" available_capacity = round( - self._capacity - - (self._min_soc * self._capacity) - - self.state_at_eco_start(), + self._capacity - (self._min_soc * self._capacity) - eco_start, 2, ) return min(available_capacity, charge_total) + def next_dawn_time(self) -> datetime: + """Calculate dawn time""" + now = datetime.now().astimezone() + + dawn_today = self._dawn_time(now) + dawn_tomorrow = self._dawn_time(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(now) + def battery_depleted_time(self) -> datetime: """Time battery capacity is 0""" @@ -265,25 +274,6 @@ def peak_grid_export(self) -> float: return round(grid_export.grid.sum(), 2) - def _state_at_datetime(self, state_time: datetime) -> float: - """Battery and forecast remaining meets load until dawn""" - state_time = state_time.replace(second=0, microsecond=0) - return self._model[self._model["period_start"] == state_time].battery.iloc[0] - - def _next_eco_end_time(self) -> datetime: - """Next eco end time""" - now = datetime.now().astimezone() - eco_end = now.replace( - hour=self._eco_end_time.hour, - minute=self._eco_end_time.minute, - second=0, - microsecond=0, - ) - if now > eco_end: - eco_end += timedelta(days=1) - - return eco_end - def _next_eco_start_time(self) -> datetime: """Next eco start time""" now = datetime.now().astimezone() @@ -298,13 +288,15 @@ def _next_eco_start_time(self) -> datetime: return eco_start - def _is_after_todays_eco_start(self) -> bool: - """Is current time after eco period start""" - now = datetime.now().astimezone() - eco_start = now.replace( - hour=self._eco_start_time.hour, - minute=self._eco_start_time.minute, + def _next_eco_end_time(self, period) -> 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, ) - return now > eco_start + if period > eco_end: + eco_end += timedelta(days=1) + + return eco_end diff --git a/custom_components/foxess_em/battery/battery_sensor.py b/custom_components/foxess_em/battery/battery_sensor.py index df76597..19efed1 100755 --- a/custom_components/foxess_em/battery/battery_sensor.py +++ b/custom_components/foxess_em/battery/battery_sensor.py @@ -23,33 +23,6 @@ "Target %:": "charge_to_perc", }, ), - "state_at_eco_start": SensorDescription( - key="state_at_eco_start", - device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - name="Capacity: Eco Start", - icon="mdi:meter-electric", - should_poll=False, - state_attributes={}, - ), - "state_at_eco_end": SensorDescription( - key="state_at_eco_end", - device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - name="Capacity: Eco End", - icon="mdi:meter-electric", - should_poll=False, - state_attributes={}, - ), - "state_at_dawn": SensorDescription( - key="state_at_dawn", - device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - name="Capacity: Dawn", - icon="mdi:sun-clock", - should_poll=False, - state_attributes={}, - ), "next_dawn_time": SensorDescription( key="next_dawn_time", device_class=SensorDeviceClass.TIMESTAMP, @@ -59,6 +32,15 @@ should_poll=False, state_attributes={"Todays Dawn:": "todays_dawn_time_str"}, ), + "state_at_eco_start": SensorDescription( + key="state_at_eco_start", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + name="Capacity: Eco Start", + icon="mdi:meter-electric", + should_poll=False, + state_attributes={}, + ), "last_update": SensorDescription( key="battery_last_update", device_class=SensorDeviceClass.TIMESTAMP, diff --git a/custom_components/foxess_em/const.py b/custom_components/foxess_em/const.py index 5e7a493..a47d45d 100755 --- a/custom_components/foxess_em/const.py +++ b/custom_components/foxess_em/const.py @@ -19,7 +19,7 @@ # Configuration and options SOLCAST_API_KEY = "key" SOLCAST_SCAN_INTERVAL = "scan_interval" -# SOLCAST_URL = "https://091dad86-9afd-43a4-b29a-530be493c37a.mock.pstmn.io" +# SOLCAST_URL = "https://8877cafb-8d67-4284-b745-de66c8c184e5.mock.pstmn.io" SOLCAST_URL = "https://api.solcast.com.au" FOX_USERNAME = "fox_username" FOX_PASSWORD = "fox_password" diff --git a/custom_components/foxess_em/forecast/solcast_api.py b/custom_components/foxess_em/forecast/solcast_api.py index ca0c51d..c00416b 100755 --- a/custom_components/foxess_em/forecast/solcast_api.py +++ b/custom_components/foxess_em/forecast/solcast_api.py @@ -86,7 +86,7 @@ async def async_get_data(self, site_id: str) -> dict: return history_estimates + live_estimates async def _fetch_data( - self, api_key: str, site_id: str, solcast_url: str, path="error", hours=50 + self, api_key: str, site_id: str, solcast_url: str, path="error", hours=120 ) -> dict[str, Any]: """fetch data via the Solcast API."""