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`). + ![webpage screenshot](doc/screenshot_0_1_20.png) ## 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 @@
Battery State ... - ... + ...
diff --git a/src/web/js/battery.js b/src/web/js/battery.js index bbd4221f..9081dfc5 100644 --- a/src/web/js/battery.js +++ b/src/web/js/battery.js @@ -16,6 +16,201 @@ class BatteryManager { console.log('[BatteryManager] Manager initialized'); } + /** + * Show battery overview in a full-screen overlay + */ + showBatteryOverview() { + if (typeof data_controls === 'undefined' || !data_controls) { + console.error('[BatteryManager] No data available for battery overview'); + return; + } + + const battery = data_controls.battery || {}; + const stored = battery.stored_energy || {}; + const sessions = stored.charging_sessions || []; + const lookbackHours = stored.duration_of_analysis || 96; + + const header = ` +
+ + Battery Overview +
+ `; + + // Format values + const soc = battery.soc || 0; + const usableKWh = (battery.usable_capacity / 1000).toFixed(1); + const maxChargeKW = (battery.max_charge_power_dyn / 1000).toFixed(2); + const wac = stored.stored_energy_price !== undefined ? (stored.stored_energy_price * 1000).toFixed(2) : "--"; + const pvRatio = stored.ratio !== undefined ? stored.ratio.toFixed(1) : "--"; + const lastUpdate = stored.last_update ? new Date(stored.last_update).toLocaleString() : "Never"; + + const content = ` +
+ + +
+
+
State of Charge
+
${soc}%
+
${getBatteryIcon(soc)}
+
+
+
Usable Energy
+
${usableKWh} kWh
+
Current capacity
+
+
+
Max Charge
+
${maxChargeKW} kW
+
Dynamic limit
+
+
+
Energy Cost (WAC)
+
${wac} ct/kWh
+
Weighted average
+
+
+
PV Share
+
${pvRatio}%
+
Solar vs Grid
+
+
+ + +
+
+
Recent Charging Sessions
+
Last analysis: ${lastUpdate}
+
+
+ +
+
+ + +
+
Session Details (${sessions.length} sessions in last ${lookbackHours}h)
+
+
+ + + + + + + + + + + + ${sessions.length === 0 ? '' : + sessions.slice().reverse().map(s => { + const start = new Date(s.start_time); + const end = new Date(s.end_time); + const diffMs = end - start; + const diffSec = Math.round(diffMs / 1000); + let durationStr; + if (diffSec < 60) { + durationStr = diffSec + ' sec'; + } else if (diffSec < 3600) { + durationStr = Math.round(diffSec / 60) + ' min'; + } else { + durationStr = (diffSec / 3600).toFixed(1) + ' h'; + } + const isGridHeavy = s.ratio < 50; + const rowStyle = isGridHeavy ? `color: ${COLOR_MODE_CHARGE_FROM_GRID};` : ''; + + return ` + + + + + + + + + `; + }).join('')} + +
TimeDurationTotal (kWh)PV (kWh)Grid (kWh)Cost
No recent sessions found
${start.toLocaleDateString()} ${start.getHours().toString().padStart(2, '0')}:${start.getMinutes().toString().padStart(2, '0')}${durationStr}${(s.charged_energy / 1000).toFixed(3)}${(s.charged_from_pv / 1000).toFixed(3)}${(s.charged_from_grid / 1000).toFixed(3)}${s.cost.toFixed(2)} ${localization.currency_symbol}
+
+ + + `; + + showFullScreenOverlay(header, content); + + // Render Chart + if (sessions.length > 0) { + this.renderSessionsChart(sessions); + } + } + + /** + * Render the charging sessions chart + */ + renderSessionsChart(sessions) { + const ctx = document.getElementById('batterySessionsChart').getContext('2d'); + + // Prepare data + const labels = sessions.map(s => { + const d = new Date(s.start_time); + return `${d.getDate()}.${d.getMonth()+1} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; + }); + + const pvData = sessions.map(s => s.charged_from_pv / 1000); + const gridData = sessions.map(s => s.charged_from_grid / 1000); + + new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: [ + { + label: 'PV Energy (kWh)', + data: pvData, + backgroundColor: '#4caf50', + stack: 'Stack 0', + }, + { + label: 'Grid Energy (kWh)', + data: gridData, + backgroundColor: '#2196f3', + stack: 'Stack 0', + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + labels: { color: '#ccc' } + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + x: { + stacked: true, + ticks: { color: '#888', maxRotation: 45, minRotation: 45 }, + grid: { color: 'rgba(255,255,255,0.05)' } + }, + y: { + stacked: true, + title: { display: true, text: 'Energy (kWh)', color: '#888' }, + ticks: { color: '#888' }, + grid: { color: 'rgba(255,255,255,0.05)' } + } + } + } + }); + } + /** * Converts 15-min interval data to hourly averages based on the base timestamp. * @param {Array} dataArray - Array of 15-min interval values. diff --git a/src/web/js/main.js b/src/web/js/main.js index 037ace0a..11c8f63e 100644 --- a/src/web/js/main.js +++ b/src/web/js/main.js @@ -69,6 +69,29 @@ async function showCurrentData() { document.getElementById('battery_usable_capacity').innerHTML = ' ' + (data_controls["battery"]["usable_capacity"] / 1000).toFixed(1) + ' kWh'; document.getElementById('battery_usable_capacity').title = "usable capacity: " + (data_controls["battery"]["usable_capacity"] / 1000).toFixed(1) + " kWh"; + // Add click events for battery overview if not already present + const batterySoc = document.getElementById('battery_soc'); + const batteryUsable = document.getElementById('battery_usable_capacity'); + const batteryIcon = document.getElementById('battery_icon_main'); + + if (batterySoc && !batterySoc.onclick) { + batterySoc.onclick = () => batteryManager.showBatteryOverview(); + batterySoc.title = "Click to open Battery Overview"; + } + if (batteryUsable && !batteryUsable.onclick) { + batteryUsable.onclick = () => batteryManager.showBatteryOverview(); + // Keep existing title but append info + const currentTitle = batteryUsable.title; + if (!currentTitle.includes("Click")) { + batteryUsable.title = currentTitle + " - Click to open Battery Overview"; + } + } + if (batteryIcon && !batteryIcon.onclick) { + batteryIcon.onclick = () => batteryManager.showBatteryOverview(); + batteryIcon.style.cursor = 'pointer'; + batteryIcon.title = "Click to open Battery Overview"; + } + // timestamp and version const timestamp_last_run = new Date(data_controls.state.last_response_timestamp); const timestamp_next_run = new Date(data_controls.state.next_run); diff --git a/src/web/js/ui.js b/src/web/js/ui.js index 830557df..97575d5a 100644 --- a/src/web/js/ui.js +++ b/src/web/js/ui.js @@ -182,6 +182,13 @@ function showMainMenu(version, backend, granularity) { Override Controls + +
+ + Battery Overview +

@@ -476,6 +483,18 @@ function showOverrideControlsMenu() { } } +/** + * Show battery overview menu using BatteryManager + */ +function showBatteryOverviewMenu() { + if (batteryManager) { + batteryManager.showBatteryOverview(); + } else { + showFullScreenOverlay("Battery Overview", "
Battery system not initialized
"); + setTimeout(() => closeFullScreenOverlay(), 2000); + } +} + /** * Show logs menu using LoggingManager */ From 38bd8378e59e777d48d97bbc54dcdcf09cf950fc Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Mon, 22 Dec 2025 17:50:31 +0100 Subject: [PATCH 3/8] feat: enhance battery overview UI for better mobile responsiveness and readability --- src/web/css/style.css | 40 +++++++++++++++++++++++++++++++++++++++- src/web/js/battery.js | 38 ++++++++++++++++++++++++++++++-------- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/web/css/style.css b/src/web/css/style.css index ee529a89..dfc8d45c 100644 --- a/src/web/css/style.css +++ b/src/web/css/style.css @@ -53,8 +53,46 @@ body { } @media (max-width: 768px) { + .battery-stats-container { + gap: 5px !important; + } + .battery-stat-card { + padding: 8px; + min-height: 60px; + } .battery-stat-card .value { - font-size: 1.2em; + font-size: 1.1em; + } + .battery-stat-card .label { + font-size: 0.7em; + margin-bottom: 2px; + } + .battery-stat-card .sub-label { + display: none; + } + /* Compact sections on mobile */ + .battery-overview-section { + padding: 10px !important; + gap: 10px !important; + } + .battery-overview-card { + padding: 10px !important; + } + .battery-overview-card table { + font-size: 0.75em !important; + } + .battery-overview-card td, .battery-overview-card th { + padding: 4px 2px !important; + } +} + +/* Very small screens (e.g. iPhone SE) */ +@media (max-width: 400px) { + .battery-sessions-list { + display: none !important; + } + .battery-overview-card { + flex: 1 1 0 !important; /* Let the chart take all space */ } } diff --git a/src/web/js/battery.js b/src/web/js/battery.js index 9081dfc5..f5d30cc1 100644 --- a/src/web/js/battery.js +++ b/src/web/js/battery.js @@ -46,10 +46,10 @@ class BatteryManager { const lastUpdate = stored.last_update ? new Date(stored.last_update).toLocaleString() : "Never"; const content = ` -
+
-
+
State of Charge
${soc}%
@@ -78,7 +78,7 @@ class BatteryManager {
-
+
Recent Charging Sessions
Last analysis: ${lastUpdate}
@@ -89,7 +89,7 @@ class BatteryManager {
-
+
Session Details (${sessions.length} sessions in last ${lookbackHours}h)
@@ -162,6 +162,10 @@ class BatteryManager { const pvData = sessions.map(s => s.charged_from_pv / 1000); const gridData = sessions.map(s => s.charged_from_grid / 1000); + const mobile = isMobile(); + const fontSize = mobile ? 9 : 12; + const tickSize = mobile ? 9 : 11; + new Chart(ctx, { type: 'bar', data: { @@ -186,8 +190,13 @@ class BatteryManager { maintainAspectRatio: false, plugins: { legend: { + display: window.innerWidth > 400, position: 'top', - labels: { color: '#ccc' } + labels: { + color: '#ccc', + font: { size: fontSize }, + boxWidth: mobile ? 10 : 40 + } }, tooltip: { mode: 'index', @@ -197,13 +206,26 @@ class BatteryManager { scales: { x: { stacked: true, - ticks: { color: '#888', maxRotation: 45, minRotation: 45 }, + ticks: { + color: '#888', + maxRotation: 45, + minRotation: 45, + font: { size: tickSize } + }, grid: { color: 'rgba(255,255,255,0.05)' } }, y: { stacked: true, - title: { display: true, text: 'Energy (kWh)', color: '#888' }, - ticks: { color: '#888' }, + title: { + display: !mobile, + text: 'Energy (kWh)', + color: '#888', + font: { size: fontSize } + }, + ticks: { + color: '#888', + font: { size: tickSize } + }, grid: { color: 'rgba(255,255,255,0.05)' } } } From 52e8b6facfda49764426ee5b8503b020a24117e8 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Mon, 22 Dec 2025 18:53:49 +0100 Subject: [PATCH 4/8] feat: enhance battery price calculation by adjusting gap tolerances and improving event handling --- src/interfaces/battery_price_handler.py | 63 +++++++++++++++---------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/src/interfaces/battery_price_handler.py b/src/interfaces/battery_price_handler.py index 73a198de..5637eee2 100644 --- a/src/interfaces/battery_price_handler.py +++ b/src/interfaces/battery_price_handler.py @@ -32,7 +32,7 @@ class BatteryPriceHandler: # pylint: disable=too-many-instance-attributes # Constants for algorithm tuning - MAX_GAP_SECONDS_IDENTIFY = 120 # Gap tolerance when identifying charging events + MAX_GAP_SECONDS_IDENTIFY = 600 # 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 @@ -259,6 +259,10 @@ def _calculate_total_costs( energy_from_grid = event_totals["grid_to_battery_wh"] battery_in_wh = event_totals["total_battery_wh"] + # Skip sessions with no energy (e.g. single point sessions) + if battery_in_wh <= 0.001: + continue + # Apply efficiency to the cost event_cost = event_totals["grid_cost_euro"] / self.charge_efficiency @@ -464,8 +468,20 @@ def _identify_charging_periods(self, historical_data: Dict) -> List[Dict]: "power_points": [point], } else: - current_event["end_time"] = timestamp - current_event["power_points"].append(point) + # Check for gap even if power is still above threshold + 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 = { + "start_time": timestamp, + "end_time": timestamp, + "power_points": [point], + } last_charging_time = timestamp elif current_event is not None: gap = (timestamp - last_charging_time).total_seconds() @@ -514,17 +530,26 @@ def _split_energy_sources( } power_points = event["power_points"] - if not power_points: + if len(power_points) < 2: 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 + for i in range(len(power_points) - 1): + p_start = power_points[i] + p_end = power_points[i + 1] + + timestamp = p_start["timestamp"] + delta_seconds = (p_end["timestamp"] - p_start["timestamp"]).total_seconds() + + # Cap delta to avoid huge jumps if data is missing within an event + if delta_seconds > self.MAX_GAP_SECONDS_IDENTIFY: + delta_seconds = self.DEFAULT_DELTA_SECONDS - # Align sensor streams + time_hours = delta_seconds / 3600.0 + + # Align sensor streams to the start of the interval pv_power = self._get_aligned_value( historical_data.get("pv_power", []), timestamp, indices, "pv" ) @@ -542,14 +567,17 @@ def _split_energy_sources( fallback_func=self._get_fallback_price, ) + # Use average battery power for the interval + avg_battery_power = (p_start["value"] + p_end["value"]) / 2.0 + pv_to_bat, grid_to_bat = self._calculate_power_split( - point["value"], pv_power, grid_power, load_power + avg_battery_power, 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_battery_wh"] += avg_battery_power * 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 @@ -557,21 +585,6 @@ def _split_energy_sources( 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, From 85e699692a8a9d90d73cc77bec899c4a4f59fbf4 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Mon, 22 Dec 2025 20:57:14 +0100 Subject: [PATCH 5/8] feat: implement inventory valuation for dynamic battery price calculation and enhance UI for better clarity --- README.md | 13 +-- src/CONFIG_README.md | 7 +- src/interfaces/battery_interface.py | 4 +- src/interfaces/battery_price_handler.py | 129 ++++++++++++++++-------- src/web/js/battery.js | 107 +++++++++++++++++--- 5 files changed, 192 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index a4e40e29..6b0c11f7 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,9 @@ EOS Connect helps you get the most out of your solar and storage systems—wheth - **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. + - Analyzes charging history to determine the real cost of energy currently stored in the battery. + - Uses an **Inventory Valuation (LIFO)** model to ensure the price reflects the most recent charging sessions. + - Distinguishes between PV surplus and grid charging to provide an accurate cost basis. - 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. @@ -311,12 +312,12 @@ 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)**: +#### Battery Price Analysis (Inventory Valuation) +EOS Connect calculates the actual cost of the energy currently stored in your battery by analyzing your recent charging history. Instead of a simple average, it uses an **Inventory Valuation (LIFO)** model: - **Smart Tracking**: It automatically identifies if energy came from your solar panels (0€) or the grid. +- **Inventory Focus**: It calculates the price based on the most recent charging sessions that match your current battery level. This means the price reflects the "value" of the energy actually inside the battery. - **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. +- **Visual Feedback**: The dashboard highlights which charging sessions are currently "in inventory" and which are historical. > **Note:** > All data collection, forecasting, and optimization cycles are now driven by the `time_frame` setting in your configuration. diff --git a/src/CONFIG_README.md b/src/CONFIG_README.md index df5a9cb4..2ef7ca58 100644 --- a/src/CONFIG_README.md +++ b/src/CONFIG_README.md @@ -252,13 +252,14 @@ 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 detailed 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 real cost 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. +3. **Inventory Valuation (LIFO):** Instead of a simple average, the system uses a **Last-In, First-Out** model. It looks at the most recent charging sessions that match your current battery level. This ensures the price reflects the actual "value" of the energy currently inside the battery. +4. **Optimizer Integration:** This resulting price is used by the optimizer to decide when it is profitable to discharge the battery. +5. **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. --- diff --git a/src/interfaces/battery_interface.py b/src/interfaces/battery_interface.py index e4356e28..62ed6f97 100644 --- a/src/interfaces/battery_interface.py +++ b/src/interfaces/battery_interface.py @@ -199,7 +199,9 @@ def __update_price_euro_per_wh(self): """ # 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(): + if self.price_handler.update_price_if_needed( + inventory_wh=self.current_usable_capacity + ): self.price_euro_per_wh = self.price_handler.get_current_price() logger.info( "[BATTERY-IF] Dynamic battery price updated: %.4f €/kWh", diff --git a/src/interfaces/battery_price_handler.py b/src/interfaces/battery_price_handler.py index 5637eee2..58fe39dd 100644 --- a/src/interfaces/battery_price_handler.py +++ b/src/interfaces/battery_price_handler.py @@ -126,7 +126,7 @@ def _validate_configuration(self): raise ValueError("Access token is required for Home Assistant") def calculate_battery_price_from_history( - self, lookback_hours: Optional[int] = None + self, lookback_hours: Optional[int] = None, inventory_wh: Optional[float] = None ) -> Optional[float]: """ Calculate battery price by analyzing historical charging data. @@ -135,15 +135,15 @@ def calculate_battery_price_from_history( 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. + 4. Splits the charging energy into PV-sourced and Grid-sourced. + 5. Calculates costs per event. + 6. If inventory_wh is provided, it uses an "inventory" approach: + - It goes backwards through sessions until the inventory_wh is reached. + - This ensures the price reflects the energy actually stored. Args: lookback_hours: Hours of history to analyze (overrides config) + inventory_wh: Current energy stored in battery (Wh) to use for inventory calculation Returns: Weighted average price in €/Wh, or None if calculation failed @@ -153,8 +153,9 @@ def calculate_battery_price_from_history( try: logger.info( - "[BATTERY-PRICE] Starting historical analysis (%sh lookback)", + "[BATTERY-PRICE] Starting historical analysis (%sh lookback, Inventory: %s Wh)", lookback_hours, + round(inventory_wh, 1) if inventory_wh is not None else "N/A", ) # Fetch historical data (Step 1: Battery Power only) @@ -193,7 +194,7 @@ def calculate_battery_price_from_history( # Calculate costs per event results = self._calculate_total_costs( - charging_events, historical_data, lookback_hours + charging_events, historical_data, lookback_hours, inventory_wh ) total_cost = results["total_cost"] @@ -235,7 +236,11 @@ def calculate_battery_price_from_history( # pylint: disable=too-many-locals def _calculate_total_costs( - self, charging_events: List[Dict], historical_data: Dict, lookback_hours: int + self, + charging_events: List[Dict], + historical_data: Dict, + lookback_hours: int, + inventory_wh: Optional[float] = None, ) -> Dict[str, Any]: """Calculate total cost and energy from all charging events.""" total_cost = 0.0 @@ -249,6 +254,8 @@ def _calculate_total_costs( ) active_window_start = now_tz - timedelta(hours=lookback_hours) + # First, calculate all sessions in the window + all_sessions_data = [] for i, event in enumerate(charging_events): # Skip events that end before our active window if event["end_time"] < active_window_start: @@ -266,40 +273,82 @@ def _calculate_total_costs( # 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 + all_sessions_data.append( + { + "start_time": event["start_time"], + "end_time": event["end_time"], + "charged_energy": battery_in_wh, + "charged_from_pv": energy_from_pv, + "charged_from_grid": energy_from_grid, + "cost": event_cost, + "is_inventory": False, + "inventory_energy": 0.0, + } + ) + + # Inventory approach: walk backwards from most recent session + accumulated_inventory = 0.0 + + # Sort sessions by end_time descending (most recent first) + all_sessions_data.sort(key=lambda x: x["end_time"], reverse=True) + + for session in all_sessions_data: + if inventory_wh is not None and accumulated_inventory < inventory_wh: + remaining_needed = inventory_wh - accumulated_inventory + session_energy = session["charged_energy"] + + session["is_inventory"] = True + if session_energy <= remaining_needed: + # Full session is part of inventory + session["inventory_energy"] = session_energy + accumulated_inventory += session_energy + else: + # Partial session is part of inventory + session["inventory_energy"] = remaining_needed + accumulated_inventory = inventory_wh + + # Final aggregation for the price (only use inventory if inventory_wh provided) + if inventory_wh is None or session["is_inventory"]: + # If inventory mode, we only use the inventory_energy part for the price + energy_to_use = ( + session["inventory_energy"] + if inventory_wh is not None + else session["charged_energy"] + ) + ratio = energy_to_use / session["charged_energy"] + + total_cost += session["cost"] * ratio + total_energy_charged += energy_to_use + total_pv_energy += session["charged_from_pv"] * ratio + total_grid_energy += session["charged_from_grid"] * ratio + + # Prepare sessions for output (sort back to chronological) + all_sessions_data.sort(key=lambda x: x["start_time"]) + for session in all_sessions_data: 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), + "start_time": session["start_time"].isoformat(), + "end_time": session["end_time"].isoformat(), + "charged_energy": round(session["charged_energy"], 1), + "charged_from_pv": round(session["charged_from_pv"], 1), + "charged_from_grid": round(session["charged_from_grid"], 1), "ratio": ( - round(energy_from_pv / battery_in_wh * 100, 1) - if battery_in_wh > 0 + round( + session["charged_from_pv"] + / session["charged_energy"] + * 100, + 1, + ) + if session["charged_energy"] > 0 else 0 ), - "cost": round(event_cost, 4), + "cost": round(session["cost"], 4), + "is_inventory": session["is_inventory"], + "inventory_energy": round(session["inventory_energy"], 1), } ) - 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 @@ -334,12 +383,12 @@ def should_update_price(self) -> bool: 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: + def update_price_if_needed(self, inventory_wh: Optional[float] = None) -> 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() + new_price = self.calculate_battery_price_from_history(inventory_wh=inventory_wh) if new_price is not None: self.price_euro_per_wh = new_price self.last_price_calculation = ( @@ -631,12 +680,6 @@ def _calculate_power_split( 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 diff --git a/src/web/js/battery.js b/src/web/js/battery.js index f5d30cc1..c0db53a2 100644 --- a/src/web/js/battery.js +++ b/src/web/js/battery.js @@ -66,9 +66,9 @@ class BatteryManager {
Dynamic limit
-
Energy Cost (WAC)
+
Stored Energy Price
${wac} ct/kWh
-
Weighted average
+
Inventory Valuation
PV Share
@@ -119,13 +119,31 @@ class BatteryManager { durationStr = (diffSec / 3600).toFixed(1) + ' h'; } const isGridHeavy = s.ratio < 50; - const rowStyle = isGridHeavy ? `color: ${COLOR_MODE_CHARGE_FROM_GRID};` : ''; + const isInventory = s.is_inventory; + const inventoryEnergy = s.inventory_energy || 0; + let rowStyle = isGridHeavy ? `color: ${COLOR_MODE_CHARGE_FROM_GRID};` : ''; + if (isInventory) { + rowStyle += 'background-color: rgba(76, 175, 80, 0.05); border-left: 3px solid #4caf50;'; + } else { + rowStyle += 'opacity: 0.5;'; + } + + const inventoryInfo = isInventory && inventoryEnergy < s.charged_energy + ? `
Stored: ${(inventoryEnergy/1000).toFixed(3)}
` + : ''; + return `
- + - + @@ -151,16 +169,51 @@ class BatteryManager { * Render the charging sessions chart */ renderSessionsChart(sessions) { - const ctx = document.getElementById('batterySessionsChart').getContext('2d'); + const canvas = document.getElementById('batterySessionsChart'); + const ctx = canvas.getContext('2d'); + // Create hatched patterns for historical data + const createPattern = (color) => { + const pCanvas = document.createElement('canvas'); + const pCtx = pCanvas.getContext('2d'); + pCanvas.width = 10; + pCanvas.height = 10; + pCtx.strokeStyle = color; + pCtx.lineWidth = 1; + pCtx.beginPath(); + pCtx.moveTo(0, 10); + pCtx.lineTo(10, 0); + pCtx.stroke(); + return ctx.createPattern(pCanvas, 'repeat'); + }; + + const pvPattern = createPattern('rgba(76, 175, 80, 0.4)'); + const gridPattern = createPattern('rgba(33, 150, 243, 0.4)'); + // Prepare data const labels = sessions.map(s => { const d = new Date(s.start_time); return `${d.getDate()}.${d.getMonth()+1} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; }); - const pvData = sessions.map(s => s.charged_from_pv / 1000); - const gridData = sessions.map(s => s.charged_from_grid / 1000); + // Split data into Inventory and Historical parts + const pvInventory = []; + const pvHistorical = []; + const gridInventory = []; + const gridHistorical = []; + + sessions.forEach(s => { + const total = s.charged_energy || 1; + const inv = s.inventory_energy || 0; + const hist = s.charged_energy - inv; + const pvRatio = s.charged_from_pv / total; + const gridRatio = s.charged_from_grid / total; + + pvInventory.push((inv * pvRatio) / 1000); + pvHistorical.push((hist * pvRatio) / 1000); + gridInventory.push((inv * gridRatio) / 1000); + gridHistorical.push((hist * gridRatio) / 1000); + }); const mobile = isMobile(); const fontSize = mobile ? 9 : 12; @@ -172,16 +225,28 @@ class BatteryManager { labels: labels, datasets: [ { - label: 'PV Energy (kWh)', - data: pvData, + label: 'PV (Inventory)', + data: pvInventory, backgroundColor: '#4caf50', stack: 'Stack 0', }, { - label: 'Grid Energy (kWh)', - data: gridData, + label: 'PV (Historical)', + data: pvHistorical, + backgroundColor: pvPattern, + stack: 'Stack 0', + }, + { + label: 'Grid (Inventory)', + data: gridInventory, backgroundColor: '#2196f3', stack: 'Stack 0', + }, + { + label: 'Grid (Historical)', + data: gridHistorical, + backgroundColor: gridPattern, + stack: 'Stack 0', } ] }, @@ -190,17 +255,29 @@ class BatteryManager { maintainAspectRatio: false, plugins: { legend: { - display: window.innerWidth > 400, + display: window.innerWidth > 600, position: 'top', labels: { color: '#ccc', font: { size: fontSize }, - boxWidth: mobile ? 10 : 40 + boxWidth: mobile ? 10 : 20, + // Filter legend to show only main categories + filter: (item) => !item.text.includes('Historical') } }, tooltip: { mode: 'index', - intersect: false + intersect: false, + callbacks: { + label: function(context) { + let label = context.dataset.label || ''; + if (label) label += ': '; + if (context.parsed.y !== undefined) { + label += context.parsed.y.toFixed(3) + ' kWh'; + } + return label; + } + } } }, scales: { From e22fad7f1e87665f14a276c8f56a9f7b4d11e27a Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:02:23 +0100 Subject: [PATCH 6/8] fix: correct import statement for BatteryPriceHandler in battery_interface.py --- src/interfaces/battery_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interfaces/battery_interface.py b/src/interfaces/battery_interface.py index 62ed6f97..0b343fb7 100644 --- a/src/interfaces/battery_interface.py +++ b/src/interfaces/battery_interface.py @@ -40,7 +40,7 @@ import threading import time import requests -from interfaces.battery_price_handler import BatteryPriceHandler +from .battery_price_handler import BatteryPriceHandler logger = logging.getLogger("__main__") logger.info("[BATTERY-IF] loading module ") From 6066ab0633b3dc5f14a9b3a2a1f112098ad355ff Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:07:33 +0100 Subject: [PATCH 7/8] feat: add sanitization step for Docker tag reference name in workflow --- .github/workflows/docker_feature.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker_feature.yml b/.github/workflows/docker_feature.yml index 7697eaf0..ce02a151 100644 --- a/.github/workflows/docker_feature.yml +++ b/.github/workflows/docker_feature.yml @@ -44,6 +44,9 @@ jobs: - name: Convert repository owner to lowercase run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + - name: Sanitize ref name for Docker tag + run: echo "clean_ref=$(echo '${{ github.ref_name }}' | sed 's/\//_/g')" >> $GITHUB_ENV + # 1. Setup QEMU for ARM64 emulation - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -69,5 +72,5 @@ jobs: context: . platforms: linux/amd64,linux/arm64 tags: | - ghcr.io/${{ env.owner }}/eos_connect:feature_dev_${{ github.ref_name }} + ghcr.io/${{ env.owner }}/eos_connect:feature_dev_${{ env.clean_ref }} push: true From 9f3d609aec15befe6c2693fc687f2639a1b719de Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:15:43 +0100 Subject: [PATCH 8/8] feat: enhance battery overview to reflect dynamic price calculation status and improve UI responsiveness --- src/interfaces/battery_interface.py | 5 ++++- src/web/js/battery.js | 26 ++++++++++++++++++-------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/interfaces/battery_interface.py b/src/interfaces/battery_interface.py index 0b343fb7..10433e22 100644 --- a/src/interfaces/battery_interface.py +++ b/src/interfaces/battery_interface.py @@ -309,7 +309,10 @@ def get_stored_energy_info(self): """ Returns detailed information about the stored energy cost analysis. """ - return self.price_handler.get_analysis_results() + results = self.price_handler.get_analysis_results().copy() + results["enabled"] = self.price_handler.price_calculation_enabled + results["price_source"] = "sensor" if self.price_sensor else "fixed" + return results def set_min_soc(self, min_soc): """ diff --git a/src/web/js/battery.js b/src/web/js/battery.js index c0db53a2..26c9ce56 100644 --- a/src/web/js/battery.js +++ b/src/web/js/battery.js @@ -29,6 +29,7 @@ class BatteryManager { const stored = battery.stored_energy || {}; const sessions = stored.charging_sessions || []; const lookbackHours = stored.duration_of_analysis || 96; + const isEnabled = stored.enabled !== false; const header = `
@@ -42,9 +43,14 @@ class BatteryManager { const usableKWh = (battery.usable_capacity / 1000).toFixed(1); const maxChargeKW = (battery.max_charge_power_dyn / 1000).toFixed(2); const wac = stored.stored_energy_price !== undefined ? (stored.stored_energy_price * 1000).toFixed(2) : "--"; - const pvRatio = stored.ratio !== undefined ? stored.ratio.toFixed(1) : "--"; + const pvRatio = isEnabled && stored.ratio !== undefined ? stored.ratio.toFixed(1) : "--"; const lastUpdate = stored.last_update ? new Date(stored.last_update).toLocaleString() : "Never"; + let priceSubLabel = "Inventory Valuation"; + if (!isEnabled) { + priceSubLabel = stored.price_source === "sensor" ? "External Sensor" : "Fixed Value"; + } + const content = `
@@ -68,11 +74,11 @@ class BatteryManager {
Stored Energy Price
${wac} ct/kWh
-
Inventory Valuation
+
${priceSubLabel}
PV Share
-
${pvRatio}%
+
${pvRatio}${isEnabled ? '%' : ''}
Solar vs Grid
@@ -81,17 +87,20 @@ class BatteryManager {
Recent Charging Sessions
-
Last analysis: ${lastUpdate}
+
${isEnabled ? `Last analysis: ${lastUpdate}` : 'Analysis disabled'}
-
- +
+ ${isEnabled ? '' : '
Dynamic price calculation is not enabled in configuration.
'}
-
Session Details (${sessions.length} sessions in last ${lookbackHours}h)
+
+ ${isEnabled ? `Session Details (${sessions.length} sessions in last ${lookbackHours}h)` : 'Session Details'} +
+ ${isEnabled ? `
${start.toLocaleDateString()} ${start.getHours().toString().padStart(2, '0')}:${start.getMinutes().toString().padStart(2, '0')} + ${start.toLocaleDateString()} ${start.getHours().toString().padStart(2, '0')}:${start.getMinutes().toString().padStart(2, '0')} + ${isInventory ? ' ' : ''} + ${durationStr}${(s.charged_energy / 1000).toFixed(3)} + ${(s.charged_energy / 1000).toFixed(3)} + ${inventoryInfo} + ${(s.charged_from_pv / 1000).toFixed(3)} ${(s.charged_from_grid / 1000).toFixed(3)} ${s.cost.toFixed(2)} ${localization.currency_symbol}
@@ -152,6 +161,7 @@ class BatteryManager { }).join('')}
+ ` : '
Session history is only available when dynamic price calculation is enabled.
'}
@@ -160,7 +170,7 @@ class BatteryManager { showFullScreenOverlay(header, content); // Render Chart - if (sessions.length > 0) { + if (isEnabled && sessions.length > 0) { this.renderSessionsChart(sessions); } }