From a85099e5feb5d6df5590e42062cbf898b9a47a6d Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:39:43 +0100 Subject: [PATCH 1/8] feat: implement dynamic battery price calculation and update configuration settings --- README.md | 4 + src/CONFIG_README.md | 64 +- src/config.py | 49 +- src/eos_connect.py | 3 + src/interfaces/battery_interface.py | 26 +- src/interfaces/battery_price_handler.py | 737 ++++++++++++++++++++++++ src/interfaces/load_interface.py | 17 +- src/web/js/logging.js | Bin 156926 -> 156960 bytes 8 files changed, 890 insertions(+), 10 deletions(-) create mode 100644 src/interfaces/battery_price_handler.py diff --git a/README.md b/README.md index 5fb50671..2b6070bd 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,10 @@ EOS Connect helps you get the most out of your solar and storage systems—wheth - EVCC-specific modes (e.g., fast charge, PV mode). - **Dynamic Charging Curve**: - If enabled, EOS Connect automatically adjusts the maximum battery charging power based on the current state of charge (SOC). This helps to optimize battery health and efficiency by reducing charge power as the battery approaches full capacity. +- **Dynamic Battery Price Calculation**: + - Analyzes historical charging data to determine the real cost of energy stored in the battery. + - Distinguishes between PV surplus and grid charging to provide an accurate weighted average price. + - Helps the optimizer make better decisions about when to discharge the battery based on the actual cost of the stored energy. - **Cost and Solar Optimization**: - Aligns energy usage with real-time electricity prices (e.g., from Tibber, [smartenergy.at](https://www.smartenergy.at/), or [Stromligning.dk](https://stromligning.dk/)) to minimize costs. - Incorporates PV forecasts to prioritize charging during periods of high solar output. diff --git a/src/CONFIG_README.md b/src/CONFIG_README.md index 48ea2c68..c3d999d0 100644 --- a/src/CONFIG_README.md +++ b/src/CONFIG_README.md @@ -8,6 +8,7 @@ - [EOS Server Configuration](#eos-server-configuration) - [Electricity Price Configuration](#electricity-price-configuration) - [Battery Configuration](#battery-configuration) + - [Dynamic Battery Price Calculation](#dynamic-battery-price-calculation) - [PV Forecast Configuration](#pv-forecast-configuration) - [PV Forecast Source](#pv-forecast-source) - [PV Forecast installations](#pv-forecast-installations) @@ -197,19 +198,68 @@ A default config file will be created with the first start, if there is no `conf - **`battery.max_soc_percentage`**: Maximum state of charge for the battery, as a percentage. -- **`battery.price_euro_per_wh_accu`**: - Price for battery in €/Wh - can be used to shift the result over the day according to the available energy (more details follow). - - - - **`battery.price_euro_per_wh_sensor`**: - Sensor/item identifier that exposes the battery price in €/Wh. If `battery.source` is set to `homeassistant` or `openhab` and a sensor/item is configured here, the system will fetch the value from that sensor/item. If no sensor is configured, the static value at `price_euro_per_wh_accu` will be used. For Home Assistant use an entity ID (e.g., `sensor.battery_price`); for OpenHAB use an item name (e.g., `BatteryPrice`). - - **`battery.charging_curve_enabled`**: Enables or disables the dynamic charging curve for the battery. - `true`: The system will automatically reduce the maximum charging power as the battery SOC increases, helping to protect battery health and optimize efficiency. - `false`: The battery will always charge at the configured maximum power, regardless of SOC. - **Default:** `true` +- **`battery.price_euro_per_wh_accu`**: + Price for battery in €/Wh - can be used to shift the result over the day according to the available energy (more details follow). + + +- **`battery.price_euro_per_wh_sensor`**: + Sensor/item identifier that exposes the battery price in €/Wh. If `battery.source` is set to `homeassistant` or `openhab` and a sensor/item is configured here, the system will fetch the value from that sensor/item. If no sensor is configured, the static value at `price_euro_per_wh_accu` will be used. For Home Assistant use an entity ID (e.g., `sensor.battery_price`); for OpenHAB use an item name (e.g., `BatteryPrice`). + +- **`battery.price_calculation_enabled`**: + Enables dynamic battery price calculation by analyzing historical charging events. + - `true`: The system will analyze the last 96 hours (configurable) of power data to determine the real cost of energy stored in the battery. + - `false`: Uses the static price or sensor value. + - **Default:** `false` + +- **`battery.price_update_interval`**: + Interval in seconds between dynamic price recalculations. + - **Default:** `900` (15 minutes) + +- **`battery.price_history_lookback_hours`**: + Number of hours of history to analyze for the price calculation. + - **Default:** `96` + +- **`battery.battery_power_sensor`**: + Home Assistant entity ID or OpenHAB item name for the battery power sensor (in Watts). Positive values must represent charging. + +- **`battery.pv_power_sensor`**: + Home Assistant entity ID or OpenHAB item name for the total PV power sensor (in Watts). + +- **`battery.grid_power_sensor`**: + Home Assistant entity ID or OpenHAB item name for the grid power sensor (in Watts). Positive values represent import from the grid. + +- **`battery.load_power_sensor`**: + Home Assistant entity ID or OpenHAB item name for the household load power sensor (in Watts). + +- **`battery.price_sensor`**: + Home Assistant entity ID or OpenHAB item name for the current electricity price sensor (in €/kWh or ct/kWh). + +- **`battery.charging_threshold_w`**: + Minimum battery power in Watts to consider the battery as "charging" during historical analysis. + - **Default:** `50.0` + +- **`battery.grid_charge_threshold_w`**: + Minimum grid import power in Watts to attribute charging energy to the grid rather than PV surplus. + - **Default:** `100.0` + + + +#### Dynamic Battery Price Calculation + +When `price_calculation_enabled` is set to `true`, the system performs a "forensic" analysis of your battery's charging history to determine the Weighted Average Cost (WAC) of the energy currently stored. + +**How it works:** +1. **Event Detection:** The system scans historical data (default 48h) to identify "charging events" where the battery power was above the `charging_threshold_w`. +2. **Source Attribution:** For each event, it compares battery power with PV production and Grid import. If grid import is significant (above `grid_charge_threshold_w`), the energy is attributed to the grid at the current market price. Otherwise, it is attributed to PV surplus at the `feed_in_price` (opportunity cost). +3. **WAC Calculation:** The total cost of all energy charged into the battery is divided by the total energy charged. This resulting price is then used by the optimizer to decide when it is profitable to discharge the battery. +4. **Efficiency:** To minimize API load, the system uses a two-step fetching strategy: it first fetches low-resolution data to find events, and then high-resolution data only for the specific periods when the battery was actually charging. + --- ### PV Forecast Configuration diff --git a/src/config.py b/src/config.py index c9ae0d43..55e4ba17 100644 --- a/src/config.py +++ b/src/config.py @@ -88,9 +88,19 @@ def create_default_config(self): "max_charge_power_w": 5000, "min_soc_percentage": 5, "max_soc_percentage": 100, + "charging_curve_enabled": True, # enable charging curve "price_euro_per_wh_accu": 0.0, # price for battery in euro/Wh "price_euro_per_wh_sensor": "", # sensor/item providing battery energy cost in €/Wh - "charging_curve_enabled": True, # enable charging curve + "price_calculation_enabled": False, + "price_update_interval": 900, + "price_history_lookback_hours": 96, + "battery_power_sensor": "", + "pv_power_sensor": "", + "grid_power_sensor": "", + "load_power_sensor": "", + "price_sensor": "", + "charging_threshold_w": 50.0, + "grid_charge_threshold_w": 100.0, } ), "pv_forecast_source": CommentedMap( @@ -291,6 +301,43 @@ def create_default_config(self): + " according to the SOC (default: true)", "charging_curve_enabled", ) + config["battery"].yaml_add_eol_comment( + "enable dynamic battery price calculation based on history", + "price_calculation_enabled", + ) + config["battery"].yaml_add_eol_comment( + "interval for price update in seconds - default: 900 (15 min)", + "price_update_interval", + ) + config["battery"].yaml_add_eol_comment( + "hours of history to analyze for price calculation - default: 96", + "price_history_lookback_hours", + ) + config["battery"].yaml_add_eol_comment( + "HA entity ID or OpenHAB item for battery power in W (positive = charging)", + "battery_power_sensor", + ) + config["battery"].yaml_add_eol_comment( + "HA entity ID or OpenHAB item for PV power in W", "pv_power_sensor" + ) + config["battery"].yaml_add_eol_comment( + "HA entity ID or OpenHAB item for grid power in W (positive = import)", + "grid_power_sensor", + ) + config["battery"].yaml_add_eol_comment( + "HA entity ID or OpenHAB item for load power in W", "load_power_sensor" + ) + config["battery"].yaml_add_eol_comment( + "HA entity ID or OpenHAB item for electricity price in €/kWh or ct/kWh", + "price_sensor", + ) + config["battery"].yaml_add_eol_comment( + "minimum battery power to consider as charging (W)", "charging_threshold_w" + ) + config["battery"].yaml_add_eol_comment( + "minimum grid surplus to consider as grid charging (W)", + "grid_charge_threshold_w", + ) # pv forecast source configuration config.yaml_set_comment_before_after_key( diff --git a/src/eos_connect.py b/src/eos_connect.py index 0e19f433..a97ad7c1 100644 --- a/src/eos_connect.py +++ b/src/eos_connect.py @@ -343,6 +343,8 @@ def mqtt_control_callback(mqtt_cmd): battery_interface = BatteryInterface( config_manager.config["battery"], on_bat_max_changed=None, + load_interface=load_interface, + timezone=time_zone, ) price_interface = PriceInterface( @@ -1496,6 +1498,7 @@ def get_controls(): "max_grid_charge_rate": config_manager.config["inverter"][ "max_grid_charge_rate" ], + "stored_energy": battery_interface.get_stored_energy_info(), }, "inverter": { "inverter_special_data": ( diff --git a/src/interfaces/battery_interface.py b/src/interfaces/battery_interface.py index 36450e37..e4356e28 100644 --- a/src/interfaces/battery_interface.py +++ b/src/interfaces/battery_interface.py @@ -40,6 +40,7 @@ import threading import time import requests +from interfaces.battery_price_handler import BatteryPriceHandler logger = logging.getLogger("__main__") logger.info("[BATTERY-IF] loading module ") @@ -63,7 +64,9 @@ class BatteryInterface: Fetches the current SOC of the battery based on the configured source. """ - def __init__(self, config, on_bat_max_changed=None): + def __init__( + self, config, on_bat_max_changed=None, load_interface=None, timezone=None + ): self.src = config.get("source", "default") self.url = config.get("url", "") self.soc_sensor = config.get("soc_sensor", "") @@ -82,6 +85,11 @@ def __init__(self, config, on_bat_max_changed=None): self.soc_fail_count = 0 + # Initialize dynamic price handler + self.price_handler = BatteryPriceHandler( + config, load_interface=load_interface, timezone=timezone + ) + self.update_interval = 30 self._update_thread = None self._stop_event = threading.Event() @@ -189,6 +197,16 @@ def __update_price_euro_per_wh(self): """ Update the battery price from the configured source if needed. """ + # If dynamic price calculation is enabled, use the handler + if self.price_handler and self.price_handler.price_calculation_enabled: + if self.price_handler.update_price_if_needed(): + self.price_euro_per_wh = self.price_handler.get_current_price() + logger.info( + "[BATTERY-IF] Dynamic battery price updated: %.4f €/kWh", + self.price_euro_per_wh * 1000, + ) + return self.price_euro_per_wh + # If top-level source is default, keep configured static price if self.src == "default": return self.price_euro_per_wh @@ -285,6 +303,12 @@ def get_price_euro_per_wh(self): """ return self.price_euro_per_wh + def get_stored_energy_info(self): + """ + Returns detailed information about the stored energy cost analysis. + """ + return self.price_handler.get_analysis_results() + def set_min_soc(self, min_soc): """ Sets the minimum state of charge (SOC) percentage of the battery. diff --git a/src/interfaces/battery_price_handler.py b/src/interfaces/battery_price_handler.py new file mode 100644 index 00000000..73a198de --- /dev/null +++ b/src/interfaces/battery_price_handler.py @@ -0,0 +1,737 @@ +""" +Battery Price Calculation Handler + +This module provides dynamic battery energy price calculation functionality +by analyzing historical charging events and attributing energy sources (PV vs Grid). +""" + +import logging +from datetime import datetime, timedelta, tzinfo +from typing import Dict, List, Optional, Tuple, Any +import pytz + +logger = logging.getLogger("__main__") +logger.info("[BATTERY-PRICE] loading module") + + +class BatteryPriceHandler: + """ + Handler for calculating battery energy prices based on historical charging data. + + The handler uses a forensic analysis approach to determine the cost of energy + currently stored in the battery. It works by: + 1. Identifying historical charging periods from battery power data. + 2. Attributing energy sources (PV surplus vs Grid import) for each period. + 3. Applying historical electricity prices to grid-charged energy. + 4. Calculating a weighted average price (€/Wh) for the total energy charged. + + This approach is more accurate than live-only tracking because it can reconstruct + the cost of energy that was charged hours or days ago. + """ + + # pylint: disable=too-many-instance-attributes + + # Constants for algorithm tuning + MAX_GAP_SECONDS_IDENTIFY = 120 # Gap tolerance when identifying charging events + MAX_GAP_MINUTES_MERGE = 30 # Gap tolerance when merging fetch ranges + BUFFER_HOURS_LOOKBACK = 12 # Extra history to fetch for session reconstruction + DEFAULT_DELTA_SECONDS = 300 # Fallback time delta between points + + def __init__(self, config: Dict[str, Any], load_interface=None, timezone=None): + """ + Initialize the battery price handler. + + Args: + config: Configuration dictionary with battery and sensor settings + load_interface: Optional LoadInterface instance for historical data access + timezone: Optional timezone object + """ + self.config = config + self.load_interface = load_interface + self.timezone: Optional[tzinfo] = timezone + + # Configuration + self.source = config.get("source", "homeassistant") + self.url = config.get("url", "") + self.access_token = config.get("access_token", "") + self.price_calculation_enabled = config.get("price_calculation_enabled", False) + self.price_update_interval = config.get("price_update_interval", 900) # 15 min + self.price_history_lookback_hours = config.get( + "price_history_lookback_hours", 48 + ) + + # Sensor configuration + self.battery_power_sensor = config.get("battery_power_sensor", "") + self.pv_power_sensor = config.get("pv_power_sensor", "") + self.grid_power_sensor = config.get("grid_power_sensor", "") + self.load_power_sensor = config.get("load_power_sensor", "") + self.price_sensor = config.get("price_sensor", "") + + # Battery parameters + self.charge_efficiency = config.get("charge_efficiency", 0.95) + self.capacity_wh = config.get("capacity_wh", 10000) + self.min_soc_percentage = config.get("min_soc_percentage", 10) + self.price_euro_per_wh_accu = config.get("price_euro_per_wh_accu", 0.00004) + + # Thresholds to filter sensor noise and transients + self.charging_threshold_w = config.get("charging_threshold_w", 50.0) + self.grid_charge_threshold_w = config.get("grid_charge_threshold_w", 100.0) + + # State + self.price_euro_per_wh = self.price_euro_per_wh_accu + self.last_price_calculation: Optional[datetime] = None + self.last_analysis_results = { + "stored_energy_price": self.price_euro_per_wh_accu, + "duration_of_analysis": 0, + "charged_energy": 0.0, + "charged_from_pv": 0.0, + "charged_from_grid": 0.0, + "ratio": 0.0, + "charging_sessions": [], + "last_update": None, + } + + # Validation + if self.price_calculation_enabled: + self._validate_configuration() + logger.info( + "[BATTERY-PRICE] Dynamic price calculation enabled (Interval: %ss, Lookback: %sh)", + self.price_update_interval, + self.price_history_lookback_hours, + ) + else: + logger.info( + "[BATTERY-PRICE] Dynamic price calculation is disabled in config" + ) + + def _validate_configuration(self): + """Validate that required configuration is present.""" + required_sensors = [ + self.battery_power_sensor, + self.pv_power_sensor, + self.grid_power_sensor, + self.load_power_sensor, + self.price_sensor, + ] + + missing = [s for s in required_sensors if not s] + if missing: + raise ValueError(f"Missing required sensors: {missing}") + + # If we have a load_interface, we use its connection settings + if not self.load_interface: + if not self.url: + raise ValueError("URL is required when no LoadInterface is provided") + if self.source == "homeassistant" and not self.access_token: + raise ValueError("Access token is required for Home Assistant") + + def calculate_battery_price_from_history( + self, lookback_hours: Optional[int] = None + ) -> Optional[float]: + """ + Calculate battery price by analyzing historical charging data. + + The algorithm performs the following steps: + 1. Fetches battery power history for the lookback period. + 2. Identifies "charging events" where battery power > threshold. + 3. For each event, fetches aligned PV, Grid, and Load power data. + 4. Splits the charging energy into PV-sourced and Grid-sourced: + - PV surplus = max(0, PV - Load) + - PV to Battery = min(Battery_Power, PV surplus) + - Grid to Battery = max(0, Battery_Power - PV to Battery) + 5. Multiplies grid energy by the electricity price at that specific time. + 6. Sums total costs and total energy to find the weighted average. + + Args: + lookback_hours: Hours of history to analyze (overrides config) + + Returns: + Weighted average price in €/Wh, or None if calculation failed + """ + if lookback_hours is None: + lookback_hours = self.price_history_lookback_hours + + try: + logger.info( + "[BATTERY-PRICE] Starting historical analysis (%sh lookback)", + lookback_hours, + ) + + # Fetch historical data (Step 1: Battery Power only) + historical_data = self._fetch_historical_power_data( + lookback_hours, keys=["battery_power"] + ) + if not historical_data or not historical_data.get("battery_power"): + logger.warning("[BATTERY-PRICE] No battery power data available") + self.last_analysis_results["last_update"] = self._get_now_iso() + return None + + # Reconstruct charging events + charging_events = self._identify_charging_periods(historical_data) + logger.info( + "[BATTERY-PRICE] Found %s charging events", len(charging_events) + ) + + if not charging_events: + logger.info( + "[BATTERY-PRICE] No charging events found, keeping current price" + ) + self.last_analysis_results = { + "stored_energy_price": round(self.price_euro_per_wh, 6), + "duration_of_analysis": lookback_hours, + "charged_energy": 0.0, + "charged_from_pv": 0.0, + "charged_from_grid": 0.0, + "ratio": 0.0, + "charging_sessions": [], + "last_update": self._get_now_iso(), + } + return self.price_euro_per_wh + + # Fetch historical data (Step 2: Other sensors for active ranges only) + self._fetch_missing_sensor_data(historical_data, charging_events) + + # Calculate costs per event + results = self._calculate_total_costs( + charging_events, historical_data, lookback_hours + ) + + total_cost = results["total_cost"] + total_energy_charged = results["total_energy_charged"] + + # Weighted average + weighted_price = ( + total_cost / total_energy_charged + if total_energy_charged > 0 + else self.price_euro_per_wh + ) + + # Store results for external reporting (ALWAYS) + self.last_analysis_results = { + "stored_energy_price": round(weighted_price, 6), + "duration_of_analysis": lookback_hours, + "charged_energy": round(total_energy_charged, 1), + "charged_from_pv": round(results["total_pv_energy"], 1), + "charged_from_grid": round(results["total_grid_energy"], 1), + "ratio": round(results["pv_ratio"], 1), + "charging_sessions": results["sessions"], + "last_update": self._get_now_iso(), + } + + if total_energy_charged > 0: + logger.info( + "[BATTERY-PRICE] Final Price %.4f€/kWh (Total Charged %.1fWh)", + weighted_price * 1000, + total_energy_charged, + ) + return weighted_price + + logger.warning("[BATTERY-PRICE] No energy charged in identified events") + return self.price_euro_per_wh + + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("[BATTERY-PRICE] Error in historical calculation: %s", e) + return None + + # pylint: disable=too-many-locals + def _calculate_total_costs( + self, charging_events: List[Dict], historical_data: Dict, lookback_hours: int + ) -> Dict[str, Any]: + """Calculate total cost and energy from all charging events.""" + total_cost = 0.0 + total_energy_charged = 0.0 + total_pv_energy = 0.0 + total_grid_energy = 0.0 + sessions = [] + + now_tz = ( + datetime.now(self.timezone) if self.timezone else datetime.now(pytz.utc) + ) + active_window_start = now_tz - timedelta(hours=lookback_hours) + + for i, event in enumerate(charging_events): + # Skip events that end before our active window + if event["end_time"] < active_window_start: + continue + + event_totals = self._split_energy_sources(event, historical_data) + energy_from_pv = event_totals["pv_to_battery_wh"] + energy_from_grid = event_totals["grid_to_battery_wh"] + battery_in_wh = event_totals["total_battery_wh"] + + # Apply efficiency to the cost + event_cost = event_totals["grid_cost_euro"] / self.charge_efficiency + + total_cost += event_cost + total_energy_charged += battery_in_wh + total_pv_energy += energy_from_pv + total_grid_energy += energy_from_grid + + sessions.append( + { + "start_time": event["start_time"].isoformat(), + "end_time": event["end_time"].isoformat(), + "charged_energy": round(battery_in_wh, 1), + "charged_from_pv": round(energy_from_pv, 1), + "charged_from_grid": round(energy_from_grid, 1), + "ratio": ( + round(energy_from_pv / battery_in_wh * 100, 1) + if battery_in_wh > 0 + else 0 + ), + "cost": round(event_cost, 4), + } + ) + + logger.debug( + "[BATTERY-PRICE] Event %s: %s - %s | " + "Bat In: %.1fWh | PV %.1fWh, Grid %.1fWh | " + "Price: %.4f€/kWh", + i + 1, + self._localize_time(event["start_time"]), + self._localize_time(event["end_time"]), + battery_in_wh, + energy_from_pv, + energy_from_grid, + (event_cost / battery_in_wh * 1000) if battery_in_wh > 0 else 0, + ) + + pv_ratio = ( + (total_pv_energy / total_energy_charged * 100) + if total_energy_charged > 0 + else 0 + ) + logger.info( + "[BATTERY-PRICE] Summary: PV %.1fWh, Grid %.1fWh, Ratio PV %.1f%%, Cost %.4f€", + total_pv_energy, + total_grid_energy, + pv_ratio, + total_cost, + ) + + return { + "total_cost": total_cost, + "total_energy_charged": total_energy_charged, + "total_pv_energy": total_pv_energy, + "total_grid_energy": total_grid_energy, + "pv_ratio": pv_ratio, + "sessions": sessions, + } + + def should_update_price(self) -> bool: + """Check if it's time to update the price.""" + if not self.price_calculation_enabled: + return False + + now = datetime.now(self.timezone) if self.timezone else datetime.now() + if self.last_price_calculation is None: + return True + + time_since_last = (now - self.last_price_calculation).total_seconds() + return time_since_last >= self.price_update_interval + + def update_price_if_needed(self) -> bool: + """Update price if the update interval has passed.""" + if not self.should_update_price(): + return False + + new_price = self.calculate_battery_price_from_history() + if new_price is not None: + self.price_euro_per_wh = new_price + self.last_price_calculation = ( + datetime.now(self.timezone) if self.timezone else datetime.now() + ) + return True + + return False + + def _fetch_historical_power_data( + self, lookback_hours: int, keys: Optional[List[str]] = None + ) -> Optional[Dict]: + """Fetch historical power data.""" + try: + end_time = datetime.now(self.timezone) if self.timezone else datetime.now() + start_time = end_time - timedelta( + hours=lookback_hours + self.BUFFER_HOURS_LOOKBACK + ) + + if self.load_interface: + return self._fetch_via_load_interface(start_time, end_time, keys) + + return self._fetch_via_direct_api(start_time, end_time, keys) + + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("[BATTERY-PRICE] Failed to fetch historical data: %s", e) + return None + + def _fetch_via_load_interface( + self, start_time: datetime, end_time: datetime, keys: Optional[List[str]] = None + ) -> Optional[Dict]: + """Fetch data using LoadInterface.""" + try: + data = {} + all_sensors = [ + (self.battery_power_sensor, "battery_power"), + (self.pv_power_sensor, "pv_power"), + (self.grid_power_sensor, "grid_power"), + (self.load_power_sensor, "load_power"), + (self.price_sensor, "price_data"), + ] + + sensors = all_sensors + if keys: + sensors = [s for s in all_sensors if s[1] in keys] + + for sensor, key in sensors: + try: + sensor_data = self.load_interface.fetch_historical_energy_data( + entity_id=sensor, start_time=start_time, end_time=end_time + ) + converted_data = self._convert_historical_data(sensor_data, key) + data[key] = converted_data + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning("[BATTERY-PRICE] Failed to fetch %s: %s", key, e) + data[key] = [] + + return data + + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("[BATTERY-PRICE] LoadInterface fetch failed: %s", e) + return None + + def _convert_historical_data(self, sensor_data: List[Dict], key: str) -> List[Dict]: + """Convert historical data format to internal format.""" + converted_data = [] + for entry in sensor_data: + try: + timestamp = datetime.fromisoformat( + entry["last_updated"].replace("Z", "+00:00") + ) + # Handle potential units in state string (e.g. "10.5 W") + state_val = entry.get("state") + if isinstance(state_val, str): + state_val = state_val.split()[0] + + value = float(state_val) + # Price conversion logic + if key == "price_data": + if value > 1.0: # Likely in ct/kWh + value = value / 100.0 + converted_data.append({"timestamp": timestamp, "value": value}) + except (ValueError, KeyError, IndexError): + continue + return converted_data + + def _fetch_via_direct_api( + self, start_time: datetime, end_time: datetime, keys: Optional[List[str]] = None + ) -> Optional[Dict]: + """Fetch data directly via API (fallback when LoadInterface is not available).""" + # pylint: disable=unused-argument + # Implementation for direct API fetch if LoadInterface is not available + # For now, return empty dict as it's a fallback + return {} + + def _localize_time(self, dt: datetime) -> str: + """Convert UTC datetime to local timezone and format as string.""" + if dt.tzinfo is None: + dt = pytz.utc.localize(dt) + + local_tz = self.timezone if self.timezone else pytz.timezone("Europe/Berlin") + return dt.astimezone(local_tz).strftime("%Y-%m-%d %H:%M:%S") + + def _identify_charging_periods(self, historical_data: Dict) -> List[Dict]: + """Identify periods when battery was charging.""" + if not historical_data or "battery_power" not in historical_data: + return [] + + charging_events: List[Dict[str, Any]] = [] + battery_data = historical_data["battery_power"] + battery_data.sort(key=lambda x: x["timestamp"]) + + threshold = self.charging_threshold_w + current_event = None + last_charging_time = None + + for point in battery_data: + power = point.get("value", 0) + timestamp = point.get("timestamp") + + if power > threshold: + if current_event is None: + current_event = { + "start_time": timestamp, + "end_time": timestamp, + "power_points": [point], + } + else: + current_event["end_time"] = timestamp + current_event["power_points"].append(point) + last_charging_time = timestamp + elif current_event is not None: + gap = (timestamp - last_charging_time).total_seconds() + if gap < self.MAX_GAP_SECONDS_IDENTIFY: + current_event["end_time"] = timestamp + current_event["power_points"].append(point) + else: + self._close_charging_event( + current_event, charging_events, threshold + ) + current_event = None + last_charging_time = None + + if current_event is not None: + self._close_charging_event(current_event, charging_events, threshold) + + return charging_events + + def _close_charging_event( + self, event: Dict, events_list: List[Dict], threshold: float + ): + """Trim and add a charging event to the list.""" + while ( + len(event["power_points"]) > 0 + and event["power_points"][-1]["value"] <= threshold + ): + event["power_points"].pop() + + if event["power_points"]: + event["end_time"] = event["power_points"][-1]["timestamp"] + events_list.append(event) + + # pylint: disable=too-many-locals + def _split_energy_sources( + self, event: Dict, historical_data: Dict + ) -> Dict[str, float]: + """Split charging energy between PV and grid sources.""" + totals = { + "pv_to_battery_wh": 0.0, + "grid_to_battery_wh": 0.0, + "total_battery_wh": 0.0, + "total_pv_wh": 0.0, + "total_grid_wh": 0.0, + "total_load_wh": 0.0, + "grid_cost_euro": 0.0, + } + + power_points = event["power_points"] + if not power_points: + return totals + + # Indices for stream alignment + indices = {"pv": 0, "grid": 0, "load": 0, "price": 0} + + for i, point in enumerate(power_points): + timestamp = point["timestamp"] + time_hours = self._get_delta_seconds(power_points, i) / 3600.0 + + # Align sensor streams + pv_power = self._get_aligned_value( + historical_data.get("pv_power", []), timestamp, indices, "pv" + ) + grid_power = self._get_aligned_value( + historical_data.get("grid_power", []), timestamp, indices, "grid" + ) + load_power = self._get_aligned_value( + historical_data.get("load_power", []), timestamp, indices, "load" + ) + current_price = self._get_aligned_value( + historical_data.get("price_data", []), + timestamp, + indices, + "price", + fallback_func=self._get_fallback_price, + ) + + pv_to_bat, grid_to_bat = self._calculate_power_split( + point["value"], pv_power, grid_power, load_power + ) + + grid_energy_wh = grid_to_bat * time_hours + totals["pv_to_battery_wh"] += pv_to_bat * time_hours + totals["grid_to_battery_wh"] += grid_energy_wh + totals["total_battery_wh"] += point["value"] * time_hours + totals["total_pv_wh"] += pv_power * time_hours + totals["total_grid_wh"] += grid_power * time_hours + totals["total_load_wh"] += load_power * time_hours + totals["grid_cost_euro"] += (grid_energy_wh / 1000.0) * current_price + + return totals + + def _get_delta_seconds(self, points: List[Dict], index: int) -> float: + """Calculate time delta between points.""" + if index < len(points) - 1: + delta = ( + points[index + 1]["timestamp"] - points[index]["timestamp"] + ).total_seconds() + elif index > 0: + delta = ( + points[index]["timestamp"] - points[index - 1]["timestamp"] + ).total_seconds() + else: + delta = self.DEFAULT_DELTA_SECONDS + + return delta if delta <= 900 else self.DEFAULT_DELTA_SECONDS + + # pylint: disable=too-many-arguments,too-many-positional-arguments + def _get_aligned_value( + self, + data: List[Dict], + timestamp: datetime, + indices: Dict[str, int], + key: str, + fallback_func=None, + ) -> float: + """Find the sensor value closest to the given timestamp.""" + if not data: + return fallback_func(timestamp) if fallback_func else 0.0 + + idx = indices[key] + while idx < len(data) - 1 and data[idx + 1]["timestamp"] <= timestamp: + idx += 1 + indices[key] = idx + return data[idx]["value"] + + def _calculate_power_split( + self, + battery_power: float, + pv_power: float, + grid_power: float, + load_power: float, + ) -> Tuple[float, float]: + """Determine how battery charging power is split between PV and grid.""" + pv_for_load = min(pv_power, load_power) + remaining_load = max(0, load_power - pv_for_load) + grid_for_load = min(grid_power, remaining_load) + pv_surplus = max(0, pv_power - pv_for_load) + grid_surplus = max(0, grid_power - grid_for_load) + + pv_to_battery = min(battery_power, pv_surplus) + remaining_battery = max(0, battery_power - pv_to_battery) + + grid_to_battery = 0.0 + if grid_surplus > self.grid_charge_threshold_w: + grid_to_battery = min(remaining_battery, grid_surplus) + remaining_battery = max(0, remaining_battery - grid_to_battery) + + if remaining_battery > 0: + pv_to_battery += remaining_battery + + return pv_to_battery, grid_to_battery + + def _calculate_circulating_energy(self, current_soc: float) -> float: + """Calculate current circulating energy in the battery.""" + min_soc = self.min_soc_percentage + circulating_soc = max(0, current_soc - min_soc) + return self.capacity_wh * (circulating_soc / 100) + + def _get_fallback_price(self, timestamp: datetime) -> float: + """Get fallback price when historical data is not available.""" + hour = timestamp.hour + if 22 <= hour or hour <= 6: # Night + return 0.15 + if 7 <= hour <= 13: # Morning/day + return 0.25 + return 0.35 + + def get_current_price(self) -> float: + """Get the current calculated battery price.""" + return self.price_euro_per_wh + + def get_analysis_results(self) -> Dict[str, Any]: + """Get the results of the last historical analysis.""" + return self.last_analysis_results + + def get_status(self) -> Dict[str, Any]: + """Get current status of the price calculation.""" + return { + "enabled": self.price_calculation_enabled, + "current_price": self.price_euro_per_wh, + "last_calculation": ( + self.last_price_calculation.isoformat() + if self.last_price_calculation + else None + ), + "next_update_in": self._seconds_until_next_update(), + } + + def _fetch_missing_sensor_data( + self, historical_data: Dict, charging_events: List[Dict] + ): + """Fetch missing sensor data for active time ranges.""" + if not charging_events: + return + + ranges = [] + for event in charging_events: + start = event["start_time"] - timedelta(minutes=5) + end = event["end_time"] + timedelta(minutes=5) + ranges.append((start, end)) + + merged_ranges = self._merge_ranges( + ranges, max_gap_minutes=self.MAX_GAP_MINUTES_MERGE + ) + + sensors = [ + (self.pv_power_sensor, "pv_power"), + (self.grid_power_sensor, "grid_power"), + (self.load_power_sensor, "load_power"), + (self.price_sensor, "price_data"), + ] + + for sensor_id, key in sensors: + all_points = [] + for start, end in merged_ranges: + points = self._fetch_single_sensor_range(sensor_id, key, start, end) + all_points.extend(points) + + all_points.sort(key=lambda x: x["timestamp"]) + historical_data[key] = all_points + + def _merge_ranges( + self, ranges: List[Tuple[datetime, datetime]], max_gap_minutes: int = 30 + ) -> List[Tuple[datetime, datetime]]: + """Merge overlapping or nearby time ranges.""" + if not ranges: + return [] + + sorted_ranges = sorted(ranges, key=lambda x: x[0]) + merged = [] + current_start, current_end = sorted_ranges[0] + + for next_start, next_end in sorted_ranges[1:]: + if next_start <= current_end + timedelta(minutes=max_gap_minutes): + current_end = max(current_end, next_end) + else: + merged.append((current_start, current_end)) + current_start, current_end = next_start, next_end + + merged.append((current_start, current_end)) + return merged + + def _fetch_single_sensor_range( + self, sensor_id: str, key: str, start_time: datetime, end_time: datetime + ) -> List[Dict]: + """Fetch a single sensor for a specific time range.""" + try: + if self.load_interface: + sensor_data = self.load_interface.fetch_historical_energy_data( + entity_id=sensor_id, start_time=start_time, end_time=end_time + ) + return self._convert_historical_data(sensor_data, key) + return [] + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning("[BATTERY-PRICE] Failed to fetch %s for range: %s", key, e) + return [] + + def _seconds_until_next_update(self) -> int: + """Get seconds until next price update.""" + if not self.last_price_calculation: + return 0 + now = datetime.now(self.timezone) if self.timezone else datetime.now() + elapsed = (now - self.last_price_calculation).total_seconds() + return max(0, int(self.price_update_interval - elapsed)) + + def _get_now_iso(self) -> str: + """Get current time in ISO format with timezone.""" + now = datetime.now(self.timezone) if self.timezone else datetime.now() + return now.isoformat() diff --git a/src/interfaces/load_interface.py b/src/interfaces/load_interface.py index 8bb124b2..73745831 100644 --- a/src/interfaces/load_interface.py +++ b/src/interfaces/load_interface.py @@ -175,6 +175,20 @@ def __request_with_retries( time.sleep(sleep_seconds) # get load data from url persistance source + def fetch_historical_energy_data(self, entity_id, start_time, end_time): + """ + Public wrapper to fetch historical energy data from the configured source. + """ + if self.src == "homeassistant": + return self.__fetch_historical_energy_data_from_homeassistant( + entity_id, start_time, end_time + ) + elif self.src == "openhab": + return self.__fetch_historical_energy_data_from_openhab( + entity_id, start_time, end_time + ) + return [] + def __fetch_historical_energy_data_from_openhab( self, openhab_item, start_time, end_time ): @@ -363,7 +377,8 @@ def __process_energy_data(self, data, debug_sensor=None): if len(data["data"]) > 0 and total_duration > 0: # Get the timestamp of the last sample last_sample_time = datetime.fromisoformat(data["data"][-1]["last_updated"]) - # The interval end is the latest timestamp in the interval (should be provided externally) + # The interval end is the latest timestamp in the interval + # (should be provided externally) # If not available, assume the interval is 1 hour after the first sample interval_end = None if "interval_end" in data: diff --git a/src/web/js/logging.js b/src/web/js/logging.js index 6d314194d5195f4d7cf13860fffab49a4ddde39f..07c4c27a338afb250adeba1505722102259ea426 100644 GIT binary patch delta 51 zcmex&l5@c+&JDW+B%K%>8A5>2l_7{Bl0lat07!c>I5W6z-Y8&S$!OaAv2**!PDb9_ E0FWLK3jhEB delta 43 zcmV+`0M!4W$_f6-39z~lv)m9ic?B{6FaR)@fqelTmq5<}CX@b62!mpZw_=I`zS@PC B5wrjR From 3f449b2dbe9547e3f54632a0ac3c1c9b8f7977b8 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Mon, 22 Dec 2025 17:23:11 +0100 Subject: [PATCH 2/8] feat: add battery price analysis and overview features, enhance UI for better interaction --- README.md | 34 +++++++- src/CONFIG_README.md | 2 +- src/web/css/style.css | 39 +++++++++ src/web/index.html | 2 +- src/web/js/battery.js | 195 ++++++++++++++++++++++++++++++++++++++++++ src/web/js/main.js | 23 +++++ src/web/js/ui.js | 19 ++++ 7 files changed, 311 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2b6070bd..a4e40e29 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ EOS Connect helps you get the most out of your solar and storage systems—wheth - [OpenHAB](#openhab) - [PV Forecast](#pv-forecast) - [Energy Price Forecast](#energy-price-forecast) + - [Battery Price Analysis (Historical Cost)](#battery-price-analysis-historical-cost) - [Webpage Example](#webpage-example) - [Provided Data per **EOS connect** API](#provided-data-per-eos-connect-api) - [Web API (REST/JSON)](#web-api-restjson) @@ -310,6 +311,13 @@ EOS Connect supports multiple sources for solar (PV) production forecasts. You c #### Energy Price Forecast Energy price forecasts are retrieved from the chosen source (e.g. tibber, Akkudoktor, Smartenergy, ...). **Note**: Prices for tomorrow are available earliest at 1 PM. Until then, today's prices are used to feed the model. +#### Battery Price Analysis (Historical Cost) +EOS Connect calculates the actual cost of the energy in your battery by looking at your charging history from the last 96 hours. Instead of a fixed estimate, it uses a **Weighted Average Cost (WAC)**: +- **Smart Tracking**: It automatically identifies if energy came from your solar panels (0€) or the grid. +- **Live Pricing**: For grid charging, it uses the exact electricity price at that time. +- **Session History**: Shows you exactly when, how long, and at what cost your battery was charged. +- **Easy Visualization**: A dedicated "Battery Overview" dashboard shows all these details in simple charts and tables. + > **Note:** > All data collection, forecasting, and optimization cycles are now driven by the `time_frame` setting in your configuration. > For more precise and responsive optimization, set `time_frame: 900` for a 15-minute cycle. @@ -320,6 +328,10 @@ Energy price forecasts are retrieved from the chosen source (e.g. tibber, Akkudo The dashbaord of **EOS connect** is available at `http://localhost:8081`. +- **Main Dashboard**: Real-time overview of PV, Load, Battery, and Optimization states. +- **Battery Overview**: Detailed analysis of battery costs, charging sessions, and PV/Grid ratios. Accessible via the main menu or by clicking the battery SOC/Capacity icons. +- **Log Viewer**: Real-time application logs with component-based filtering (e.g., `BATTERY-PRICE`, `OPTIMIZER`). +  ## Provided Data per **EOS connect** API @@ -401,7 +413,27 @@ Get current system control states and battery information. "soc": 23.8, "usable_capacity": 3867.11, "max_charge_power_dyn": 10000, - "max_grid_charge_rate": 10000 + "max_grid_charge_rate": 10000, + "stored_energy": { + "stored_energy_price": 0.000215, + "duration_of_analysis": 96, + "charged_energy": 12450.5, + "charged_from_pv": 8500.0, + "charged_from_grid": 3950.5, + "ratio": 68.3, + "charging_sessions": [ + { + "start_time": "2025-12-21T11:58:06+00:00", + "end_time": "2025-12-21T14:03:17+00:00", + "charged_energy": 772.9, + "charged_from_pv": 772.3, + "charged_from_grid": 0.6, + "ratio": 99.9, + "cost": 0.0002 + } + ], + "last_update": "2025-12-22T10:15:00Z" + } }, "inverter": { "inverter_special_data": { diff --git a/src/CONFIG_README.md b/src/CONFIG_README.md index c3d999d0..df5a9cb4 100644 --- a/src/CONFIG_README.md +++ b/src/CONFIG_README.md @@ -252,7 +252,7 @@ A default config file will be created with the first start, if there is no `conf #### Dynamic Battery Price Calculation -When `price_calculation_enabled` is set to `true`, the system performs a "forensic" analysis of your battery's charging history to determine the Weighted Average Cost (WAC) of the energy currently stored. +When `price_calculation_enabled` is set to `true`, the system performs a detailed analysis of your battery's charging history to determine the Weighted Average Cost (WAC) of the energy currently stored. **How it works:** 1. **Event Detection:** The system scans historical data (default 48h) to identify "charging events" where the battery power was above the `charging_threshold_w`. diff --git a/src/web/css/style.css b/src/web/css/style.css index 31b0eda4..ee529a89 100644 --- a/src/web/css/style.css +++ b/src/web/css/style.css @@ -19,6 +19,45 @@ body { display: flex; } +/* Battery Overview Styles */ +.battery-stat-card { + background-color: rgba(0, 0, 0, 0.3); + border-radius: 8px; + padding: 15px; + text-align: center; + border-left: 4px solid #888; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 80px; +} + +.battery-stat-card .label { + font-size: 0.8em; + color: #aaa; + margin-bottom: 5px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.battery-stat-card .value { + font-size: 1.4em; + font-weight: bold; + color: #fff; +} + +.battery-stat-card .sub-label { + font-size: 0.7em; + color: #666; + margin-top: 5px; +} + +@media (max-width: 768px) { + .battery-stat-card .value { + font-size: 1.2em; + } +} + .top-boxes { height: 20%; } diff --git a/src/web/index.html b/src/web/index.html index e0364f68..6dc902d1 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -139,7 +139,7 @@