diff --git a/.github/workflows/docker_feature.yml b/.github/workflows/docker_feature.yml index 7697eaf..ce02a15 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 diff --git a/README.md b/README.md index 5fb5067..6b0c11f 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) @@ -83,6 +84,11 @@ 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 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. - Incorporates PV forecasts to prioritize charging during periods of high solar output. @@ -306,6 +312,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 (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. +- **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. > For more precise and responsive optimization, set `time_frame: 900` for a 15-minute cycle. @@ -316,6 +329,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 @@ -397,7 +414,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 48ea2c6..2ef7ca5 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,69 @@ 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 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. **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. + --- ### PV Forecast Configuration diff --git a/src/config.py b/src/config.py index c9ae0d4..55e4ba1 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 0e19f43..a97ad7c 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 36450e3..10433e2 100644 --- a/src/interfaces/battery_interface.py +++ b/src/interfaces/battery_interface.py @@ -40,6 +40,7 @@ import threading import time import requests +from .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,18 @@ 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( + 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", + 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 +305,15 @@ 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. + """ + 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): """ 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 0000000..58fe39d --- /dev/null +++ b/src/interfaces/battery_price_handler.py @@ -0,0 +1,793 @@ +""" +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 = 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 + + 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, inventory_wh: Optional[float] = 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. + 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 + """ + if lookback_hours is None: + lookback_hours = self.price_history_lookback_hours + + try: + logger.info( + "[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) + 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, inventory_wh + ) + + 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, + inventory_wh: Optional[float] = None, + ) -> 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) + + # 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: + 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"] + + # 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 + + 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": 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( + session["charged_from_pv"] + / session["charged_energy"] + * 100, + 1, + ) + if session["charged_energy"] > 0 + else 0 + ), + "cost": round(session["cost"], 4), + "is_inventory": session["is_inventory"], + "inventory_energy": round(session["inventory_energy"], 1), + } + ) + + 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, 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(inventory_wh=inventory_wh) + 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: + # 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() + 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 len(power_points) < 2: + return totals + + # Indices for stream alignment + indices = {"pv": 0, "grid": 0, "load": 0, "price": 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 + + 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" + ) + 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, + ) + + # 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( + 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"] += 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 + totals["grid_cost_euro"] += (grid_energy_wh / 1000.0) * current_price + + return totals + + # 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 _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 8bb124b..7374583 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/css/style.css b/src/web/css/style.css index 31b0eda..dfc8d45 100644 --- a/src/web/css/style.css +++ b/src/web/css/style.css @@ -19,6 +19,83 @@ 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-stats-container { + gap: 5px !important; + } + .battery-stat-card { + padding: 8px; + min-height: 60px; + } + .battery-stat-card .value { + 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 */ + } +} + .top-boxes { height: 20%; } diff --git a/src/web/index.html b/src/web/index.html index e0364f6..6dc902d 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 bbd4221..26c9ce5 100644 --- a/src/web/js/battery.js +++ b/src/web/js/battery.js @@ -16,6 +16,310 @@ 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 isEnabled = stored.enabled !== false; + + 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 = 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 = ` +
+ + +
+
+
State of Charge
+
${soc}%
+
${getBatteryIcon(soc)}
+
+
+
Usable Energy
+
${usableKWh} kWh
+
Current capacity
+
+
+
Max Charge
+
${maxChargeKW} kW
+
Dynamic limit
+
+
+
Stored Energy Price
+
${wac} ct/kWh
+
${priceSubLabel}
+
+
+
PV Share
+
${pvRatio}${isEnabled ? '%' : ''}
+
Solar vs Grid
+
+
+ + +
+
+
Recent Charging Sessions
+
${isEnabled ? `Last analysis: ${lastUpdate}` : 'Analysis disabled'}
+
+
+ ${isEnabled ? '' : '
Dynamic price calculation is not enabled in configuration.
'} +
+
+ + +
+
+ ${isEnabled ? `Session Details (${sessions.length} sessions in last ${lookbackHours}h)` : 'Session Details'} +
+
+ ${isEnabled ? ` +
+ + + + + + + + + + + + ${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 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 ` + + + + + + + + + `; + }).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')} + ${isInventory ? ' ' : ''} + ${durationStr} + ${(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}
+ ` : '
Session history is only available when dynamic price calculation is enabled.
'} +
+ + + `; + + showFullScreenOverlay(header, content); + + // Render Chart + if (isEnabled && sessions.length > 0) { + this.renderSessionsChart(sessions); + } + } + + /** + * Render the charging sessions chart + */ + renderSessionsChart(sessions) { + 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')}`; + }); + + // 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; + const tickSize = mobile ? 9 : 11; + + new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: [ + { + label: 'PV (Inventory)', + data: pvInventory, + backgroundColor: '#4caf50', + stack: 'Stack 0', + }, + { + 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', + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: window.innerWidth > 600, + position: 'top', + labels: { + color: '#ccc', + font: { size: fontSize }, + boxWidth: mobile ? 10 : 20, + // Filter legend to show only main categories + filter: (item) => !item.text.includes('Historical') + } + }, + tooltip: { + mode: 'index', + 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: { + x: { + stacked: true, + ticks: { + color: '#888', + maxRotation: 45, + minRotation: 45, + font: { size: tickSize } + }, + grid: { color: 'rgba(255,255,255,0.05)' } + }, + y: { + stacked: true, + 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)' } + } + } + } + }); + } + /** * 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/logging.js b/src/web/js/logging.js index 6d31419..07c4c27 100644 Binary files a/src/web/js/logging.js and b/src/web/js/logging.js differ diff --git a/src/web/js/main.js b/src/web/js/main.js index 037ace0..11c8f63 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 830557d..97575d5 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 */