diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e4b7e7d..e839035 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -466,7 +466,7 @@ These need more than a simple attribute swap: | Group | Fields | Interface | Change Required | | ------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------ | | Battery capacity | `battery.capacity_wh`, `battery.charge_efficiency`, `battery.discharge_efficiency`, `battery.max_charge_power_w` | BatteryInterface | Update battery_data dict + recalc charging curve | -| Battery price calc | `battery.price_update_interval`, `battery.price_history_lookback_hours`, `battery.price_euro_per_wh_accu` | BatteryPriceHandler | Restart timer or update interval | +| Battery price calc | `battery.price_update_interval`, `battery.price_history_lookback_hours`, `battery.price_ct_kwh_accu` | BatteryPriceHandler | Restart timer or update interval | | Price fixed array | `price.fixed_24h_array` | PriceInterface | Re-parse array + recalc prices | | EOS time slot | `eos.time_frame` (see Priority 1.5) | Multiple | Debounced reload with cache clear | diff --git a/docs/assets/data/config_schema.json b/docs/assets/data/config_schema.json index 0952c53..ccbe97a 100644 --- a/docs/assets/data/config_schema.json +++ b/docs/assets/data/config_schema.json @@ -1169,12 +1169,12 @@ "display_group": "Sensors" }, { - "key": "battery.price_euro_per_wh_accu", + "key": "battery.price_ct_kwh_accu", "type": "float", "default": 0.0, "section": "battery", "level": "standard", - "description": "Static battery price in €/Wh (0 = use dynamic or ignore)", + "description": "Static battery price in ct/kWh (0 = use dynamic or ignore)", "labels": [ "restart_required" ], diff --git a/docs/user-guide/configuration.html b/docs/user-guide/configuration.html index 821ea89..049c686 100644 --- a/docs/user-guide/configuration.html +++ b/docs/user-guide/configuration.html @@ -1676,11 +1676,11 @@

battery.sensor_battery_temperature

-

battery.price_euro_per_wh_accu

+

battery.price_ct_kwh_accu

- + @@ -1688,7 +1688,7 @@

battery.price_euro_per_wh_accu

- + @@ -1770,7 +1770,7 @@

battery.price_euro_per_wh_sensor

-
Parameterbattery.price_euro_per_wh_accubattery.price_ct_kwh_accu
Description
UnitEuro per Wh (€/Wh)Cents per kWh (ct/kWh)
Default
NotesIf configured, overrides static price_euro_per_wh_accu value. Leave empty + If configured, overrides static price_ct_kwh_accu value. Leave empty to use static price.
@@ -2015,7 +2015,7 @@

battery.battery_price_include_feedin

false - PV-sourced energy costs €0 (default, free solar energy)
true - PV-sourced energy is valued at price.feed_in_price - (€/kWh) as opportunity cost + (ct/kWh, converted internally to €/kWh) as opportunity cost @@ -3013,7 +3013,7 @@

Example Configuration

source: tibber token: "YOUR_TIBBER_TOKEN" feed_in_source: fixed - feed_in_price: 0.08 + feed_in_price: 8 # 8 ct/kWh feed_in_negative_price_switch: true # Smart price prediction with energyforecast.de diff --git a/docs/user-guide/index.html b/docs/user-guide/index.html index e4fa961..9d53fae 100644 --- a/docs/user-guide/index.html +++ b/docs/user-guide/index.html @@ -510,9 +510,9 @@

Battery Energy Pricing

Three Battery Pricing Modes

Mode 1: Fixed Static Price (Default)

@@ -671,9 +671,10 @@

Example Configuration

# Thresholds charging_threshold_w: 50 grid_charge_threshold_w: 100 - - # Feed-in price (for valuing PV surplus energy) - feed_in_price: 0.04 # €0.04/kWh when not charging from grid + + # To value PV surplus at the feed-in tariff instead of €0, also set: + # battery_price_include_feedin: true + # and configure price.feed_in_price (in the price: section, in ct/kWh) diff --git a/src/config_web/__init__.py b/src/config_web/__init__.py index a114e4f..e0dafa9 100644 --- a/src/config_web/__init__.py +++ b/src/config_web/__init__.py @@ -31,7 +31,11 @@ from .schema import ConfigSchema from .store import ConfigStore -from .migration import migrate_yaml_to_store, migrate_ha_options_to_store +from .migration import ( + migrate_yaml_to_store, + migrate_ha_options_to_store, + migrate_battery_price_unit_to_ct_kwh, +) from .merger import build_merged_config from .api import config_bp, init_api from .backup import backup_bp, init_backup @@ -124,6 +128,9 @@ def start_db(self): self._schema, ) + # One-time migration: battery.price_euro_per_wh_accu (€/Wh) -> battery.price_ct_kwh_accu (ct/kWh) + migrate_battery_price_unit_to_ct_kwh(self._store) + # Build the merged config dict self.rebuild_config() diff --git a/src/config_web/hot_reload.py b/src/config_web/hot_reload.py index 1c83360..005e8bd 100644 --- a/src/config_web/hot_reload.py +++ b/src/config_web/hot_reload.py @@ -383,7 +383,11 @@ def _sync_feed_in_negative_price_switch(self, negative_price_switch): e) def _apply_battery_feedin_price(self, feedin_price): - """Apply live feed-in price updates to the battery price handler.""" + """Apply live feed-in price updates to the battery price handler. + + feedin_price arrives as ct/kWh (price.feed_in_price); pv_cost_euro_per_kwh + expects €/kWh. + """ if self._battery is None: return @@ -392,12 +396,13 @@ def _apply_battery_feedin_price(self, feedin_price): return old_val = getattr(price_handler, "pv_cost_euro_per_kwh", "?") - price_handler.pv_cost_euro_per_kwh = feedin_price + new_val = feedin_price / 100.0 + price_handler.pv_cost_euro_per_kwh = new_val # Force a fresh historical calculation on next battery update cycle. price_handler.last_price_calculation = None logger.info( - "[HotReload] Updated battery price feed-in cost = %s (was %s)", - feedin_price, + "[HotReload] Updated battery price feed-in cost = %s €/kWh (was %s)", + new_val, old_val, ) diff --git a/src/config_web/migration.py b/src/config_web/migration.py index 0ec07bf..cee5e39 100644 --- a/src/config_web/migration.py +++ b/src/config_web/migration.py @@ -194,6 +194,45 @@ def migrate_ha_options_to_store( return True +_BATTERY_PRICE_UNIT_MIGRATION_KEY = "_migrated_battery_price_unit_v2" + + +def migrate_battery_price_unit_to_ct_kwh(store: ConfigStore) -> bool: + """ + One-time migration: ``battery.price_euro_per_wh_accu`` (€/Wh) becomes + ``battery.price_ct_kwh_accu`` (ct/kWh). + + The old key name no longer matched its unit once the field was switched + to ct/kWh, so the field itself is renamed. Existing installations with a + nonzero value configured have it rescaled (×100000, €/Wh → ct/kWh) and + moved to the new key, so the real-world price they configured is + preserved across the change. Runs exactly once, guarded by a marker key. + + Args: + store: An opened ConfigStore instance. + + Returns: + True if the migration ran (first time), False if it was already done. + """ + if store.get(_BATTERY_PRICE_UNIT_MIGRATION_KEY, False): + return False + + old_value = store.get("battery.price_euro_per_wh_accu") + if old_value: + new_value = old_value * 100000 + store.set("battery.price_ct_kwh_accu", new_value) + logger.info( + "[Migration] Moved battery.price_euro_per_wh_accu (%s €/Wh) to " + "battery.price_ct_kwh_accu (%s ct/kWh)", + old_value, + new_value, + ) + store.delete("battery.price_euro_per_wh_accu") + + store.set(_BATTERY_PRICE_UNIT_MIGRATION_KEY, True) + return True + + def _has_user_configured_values(config_dict: dict) -> bool: """ Detect whether a config dict contains real user-configured values or just diff --git a/src/config_web/schema.py b/src/config_web/schema.py index c5b47ad..aa2442b 100644 --- a/src/config_web/schema.py +++ b/src/config_web/schema.py @@ -973,12 +973,12 @@ def defaults_dict(self) -> dict: display_group="Sensors", ), FieldDef( - key="battery.price_euro_per_wh_accu", + key="battery.price_ct_kwh_accu", field_type="float", default=0.0, section="battery", level="standard", - description="Static battery price in €/Wh (0 = use dynamic or ignore)", + description="Static battery price in ct/kWh (0 = use dynamic or ignore)", labels=["restart_required"], help_url="configuration.html#battery", validation={"min": 0.0}, diff --git a/src/eos_connect.py b/src/eos_connect.py index 5941352..cfc02f9 100644 --- a/src/eos_connect.py +++ b/src/eos_connect.py @@ -200,8 +200,16 @@ def formatTime(self, record, datefmt=None): ) battery_config = dict(config_manager.config["battery"]) -battery_config["feed_in_price"] = config_manager.config.get("price", {}).get( - "feed_in_price", 0.0 +# price.feed_in_price is ct/kWh; BatteryPriceHandler.pv_cost_euro_per_kwh expects €/kWh +battery_config["feed_in_price"] = ( + config_manager.config.get("price", {}).get("feed_in_price", 0.0) / 100.0 +) +# battery.price_ct_kwh_accu is ct/kWh (user-facing); BatteryInterface/ +# BatteryPriceHandler expect price_euro_per_wh_accu in €/Wh internally +battery_config.pop("price_ct_kwh_accu", None) +battery_config["price_euro_per_wh_accu"] = ( + config_manager.config.get("battery", {}).get("price_ct_kwh_accu", 0.0) + / 100000.0 ) battery_interface = interface_factory.create_battery_interface( diff --git a/src/interfaces/feed_in_price_interface.py b/src/interfaces/feed_in_price_interface.py index 51d33fa..a23f351 100644 --- a/src/interfaces/feed_in_price_interface.py +++ b/src/interfaces/feed_in_price_interface.py @@ -97,10 +97,6 @@ def __init__(self, config, time_frame_base, timezone="UTC", evcc_interface=None) # Also try legacy key if fixed_price_ct_kwh == 0.0 and "fixed_price" in config: fixed_price_ct_kwh = config.get("fixed_price", 0.0) - # If value is suspiciously small (e.g., EUR instead of ct), convert it - if fixed_price_ct_kwh < 0.1 and fixed_price_ct_kwh > 0: - # Looks like EUR/kWh, convert to ct/kWh - fixed_price_ct_kwh = fixed_price_ct_kwh * 100 self.fixed_price_ct_kwh = fixed_price_ct_kwh # Negative price switching: if True, clamps negative market prices to 0 diff --git a/src/web/js/chart.js b/src/web/js/chart.js index 5ecb695..0de1a6e 100644 --- a/src/web/js/chart.js +++ b/src/web/js/chart.js @@ -228,7 +228,7 @@ class ChartManager { // Electricity Price - with segment styling for forecast data const priceRawData = data_response["result"]["Electricity_price"]; - const priceData = priceRawData.map(value => value * 1000); + const priceData = priceRawData.map(value => value * 100000); // Apply segment styling if forecast data is available if (priceInfo && priceInfo.forecast_start_index !== null && priceInfo.forecast_type !== "all_real") { @@ -268,7 +268,7 @@ class ChartManager { // Set dataset 10 (real prices) - solid orange this.chartInstance.data.datasets[10].data = dataset10Data; - this.chartInstance.data.datasets[10].label = `Electricity Price (${localization.currency_symbol}/kWh)`; + this.chartInstance.data.datasets[10].label = `Electricity Price (${localization.currency_minor_unit}/kWh)`; this.chartInstance.data.datasets[10].borderColor = 'rgba(255, 69, 0, 0.8)'; this.chartInstance.data.datasets[10].borderDash = []; @@ -277,7 +277,7 @@ class ChartManager { console.warn('[ChartManager] Dataset 11 does not exist for forecast visualization'); } else { this.chartInstance.data.datasets[11].data = dataset11Data; - this.chartInstance.data.datasets[11].label = `Electricity Price Forecast - ${priceInfo.forecast_type.replace(/_/g, ' ')} (${localization.currency_symbol}/kWh)`; + this.chartInstance.data.datasets[11].label = `Electricity Price Forecast - ${priceInfo.forecast_type.replace(/_/g, ' ')} (${localization.currency_minor_unit}/kWh)`; this.chartInstance.data.datasets[11].borderColor = 'rgba(167, 167, 167, 0.7)'; // this.chartInstance.data.datasets[11].borderDash = [5, 5]; // Dotted pattern this.chartInstance.data.datasets[11].borderWidth = 2; // Thicker to see dashing @@ -290,11 +290,11 @@ class ChartManager { this.chartInstance.data.datasets[11].hidden = false; } - this.chartInstance.options.scales.y1.title.text = `Price (${localization.currency_symbol}/kWh)`; + this.chartInstance.options.scales.y1.title.text = `Price (${localization.currency_minor_unit}/kWh)`; } else { // No forecasting - all real prices this.chartInstance.data.datasets[10].data = priceData; - this.chartInstance.data.datasets[10].label = `Electricity Price (${localization.currency_symbol}/kWh)`; + this.chartInstance.data.datasets[10].label = `Electricity Price (${localization.currency_minor_unit}/kWh)`; this.chartInstance.data.datasets[10].borderColor = 'rgba(255, 69, 0, 0.8)'; this.chartInstance.data.datasets[10].borderDash = []; @@ -304,7 +304,7 @@ class ChartManager { this.chartInstance.data.datasets[11].hidden = true; } - this.chartInstance.options.scales.y1.title.text = `Price (${localization.currency_symbol}/kWh)`; + this.chartInstance.options.scales.y1.title.text = `Price (${localization.currency_minor_unit}/kWh)`; } this.chartInstance.update('none'); // Update without animation @@ -330,7 +330,7 @@ class ChartManager { { label: 'Income', data: [], type: 'line', borderColor: 'lightyellow', backgroundColor: 'yellow', borderWidth: 1, yAxisID: 'y1', stepped: true, hidden: true, pointRadius: 1, pointHoverRadius: 4 }, { label: 'Discharge Allowed', data: [], type: 'line', borderColor: 'rgba(144, 238, 144, 0.3)', backgroundColor: 'rgba(144, 238, 144, 0.05)', borderWidth: 1, fill: true, yAxisID: 'y3', pointRadius: 1, pointHoverRadius: 4, stepped: true }, { label: 'Dynamic Discharge Allowed (PV > Load)', data: [], type: 'line', borderColor: 'rgba(50, 205, 50, 0.6)', backgroundColor: 'rgba(50, 205, 50, 0.1)', borderWidth: 1, fill: true, yAxisID: 'y3', pointRadius: 1, pointHoverRadius: 4, stepped: true, hidden: false }, - { label: `Electricity Price (${localization.currency_symbol}/kWh)`, data: [], type: 'line', borderColor: 'rgba(255, 69, 0, 0.8)', backgroundColor: 'rgba(255, 165, 0, 0.2)', borderWidth: 1, yAxisID: 'y1', stepped: true, pointRadius: 1, pointHoverRadius: 4 }, + { label: `Electricity Price (${localization.currency_minor_unit}/kWh)`, data: [], type: 'line', borderColor: 'rgba(255, 69, 0, 0.8)', backgroundColor: 'rgba(255, 165, 0, 0.2)', borderWidth: 1, yAxisID: 'y1', stepped: true, pointRadius: 1, pointHoverRadius: 4 }, { label: 'Electricity Price - Forecast', data: [], type: 'line', borderColor: 'rgba(167, 167, 167, 0.7)', backgroundColor: 'rgba(220, 20, 60, 0.05)', borderWidth: 2, yAxisID: 'y1', stepped: true, pointRadius: 1, pointHoverRadius: 4, fill: false, hidden: true }, { label: 'PV Charge Planned', data: [], type: 'line', borderColor: 'transparent', backgroundColor: 'transparent', borderWidth: 0, fill: false, yAxisID: 'y3', pointRadius: 0, pointHoverRadius: 0, stepped: true, hidden: true } ] @@ -340,7 +340,7 @@ class ChartManager { maintainAspectRatio: false, scales: { y: { beginAtZero: true, title: { display: true, text: 'Energy (kWh)', color: 'lightgray' }, grid: { color: 'rgb(54, 54, 54)' }, ticks: { color: 'lightgray' } }, - y1: { beginAtZero: true, position: 'right', title: { display: true, text: `Price (${localization.currency_symbol}/kWh)`, color: 'lightgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'lightgray', callback: value => value.toFixed(2) } }, + y1: { beginAtZero: true, position: 'right', title: { display: true, text: `Price (${localization.currency_minor_unit}/kWh)`, color: 'lightgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'lightgray', callback: value => value.toFixed(1) } }, y2: { beginAtZero: true, position: 'right', title: { display: true, text: 'Battery SOC (%)', color: 'darkgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'darkgray', callback: value => value.toFixed(0) } }, y3: { beginAtZero: true, position: 'right', display: false, title: { display: true, text: 'AC Charge', color: 'darkgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'darkgray', callback: value => value.toFixed(2) } }, x: { grid: { color: 'rgb(54, 54, 54)' }, ticks: { color: 'lightgray', font: { size: 10 } } } @@ -393,7 +393,7 @@ class ChartManager { else if (label === 'Income') return `${label}: ${value} ${localization.currency_symbol}`; else if (label.startsWith('Electricity Price')) - return `${label}: ${value.toFixed(3)} ${localization.currency_symbol}/kWh`; + return `${label}: ${value.toFixed(2)} ${localization.currency_minor_unit}/kWh`; else if (label === 'Discharge Allowed') return `${label}: ${value}`; else if (label === 'PV Charge Planned') diff --git a/tests/config_web/test_api.py b/tests/config_web/test_api.py index 23e6a1e..76d1405 100644 --- a/tests/config_web/test_api.py +++ b/tests/config_web/test_api.py @@ -39,7 +39,7 @@ def _sample_config(): "charge_efficiency": 0.88, "discharge_efficiency": 0.88, "max_charge_power_w": 5000, "min_soc_percentage": 5, "max_soc_percentage": 100, "charging_curve_enabled": True, - "sensor_battery_temperature": "", "price_euro_per_wh_accu": 0.0, + "sensor_battery_temperature": "", "price_ct_kwh_accu": 0.0, "price_euro_per_wh_sensor": "", "price_calculation_enabled": False, "price_update_interval": 900, "price_history_lookback_hours": 96, "battery_power_sensor": "", "pv_power_sensor": "", "grid_power_sensor": "", diff --git a/tests/config_web/test_hot_reload.py b/tests/config_web/test_hot_reload.py index fb1e527..3e30d18 100644 --- a/tests/config_web/test_hot_reload.py +++ b/tests/config_web/test_hot_reload.py @@ -389,10 +389,14 @@ def test_feed_in_price_updates_battery_price_handler( price_interface, battery_interface, ): - """Changing feed_in_price should propagate to BatteryPriceHandler live.""" - adapter.on_config_changed("price.feed_in_price", 0.0, 0.08) - assert price_interface.feed_in_tariff_price == 0.08 - assert battery_interface.price_handler.pv_cost_euro_per_kwh == 0.08 + """Changing feed_in_price should propagate to BatteryPriceHandler live. + + price.feed_in_price arrives as ct/kWh; pv_cost_euro_per_kwh must be + converted to €/kWh (divide by 100) before being applied. + """ + adapter.on_config_changed("price.feed_in_price", 0.0, 8.0) + assert price_interface.feed_in_tariff_price == 8.0 + assert battery_interface.price_handler.pv_cost_euro_per_kwh == pytest.approx(0.08) assert battery_interface.price_handler.last_price_calculation is None diff --git a/tests/config_web/test_merger.py b/tests/config_web/test_merger.py index 98d74ad..49f6627 100644 --- a/tests/config_web/test_merger.py +++ b/tests/config_web/test_merger.py @@ -74,7 +74,7 @@ def _sample_config(): "max_soc_percentage": 100, "charging_curve_enabled": True, "sensor_battery_temperature": "", - "price_euro_per_wh_accu": 0.0, + "price_ct_kwh_accu": 0.0, "price_euro_per_wh_sensor": "", "price_calculation_enabled": False, "price_update_interval": 900, diff --git a/tests/config_web/test_migration.py b/tests/config_web/test_migration.py index 873d65c..39b089a 100644 --- a/tests/config_web/test_migration.py +++ b/tests/config_web/test_migration.py @@ -8,6 +8,7 @@ from src.config_web.schema import ConfigSchema from src.config_web.migration import ( migrate_yaml_to_store, + migrate_battery_price_unit_to_ct_kwh, _flatten_config, _has_user_configured_values, _coerce_migrated_value, @@ -77,7 +78,7 @@ def _sample_config(): "max_soc_percentage": 100, "charging_curve_enabled": True, "sensor_battery_temperature": "", - "price_euro_per_wh_accu": 0.0, + "price_ct_kwh_accu": 0.0, "price_euro_per_wh_sensor": "", "price_calculation_enabled": False, "price_update_interval": 900, @@ -720,3 +721,53 @@ def test_migration_retryable_after_failure(self, store, schema): assert result is True assert not store.is_empty() assert store.get("_wizard_completed") is True + + +class TestBatteryPriceUnitMigration: + """Tests for the one-time battery.price_euro_per_wh_accu -> price_ct_kwh_accu migration.""" + + @pytest.fixture + def store(self, tmp_path): + s = ConfigStore(str(tmp_path / "test.db")) + s.open() + yield s + s.close() + + def test_fresh_install_no_value_sets_marker_only(self, store): + """No existing value: nothing to move, marker still gets set.""" + ran = migrate_battery_price_unit_to_ct_kwh(store) + + assert ran is True + assert store.get("_migrated_battery_price_unit_v2") is True + assert store.get("battery.price_ct_kwh_accu") is None + assert store.get("battery.price_euro_per_wh_accu") is None + + def test_zero_value_left_unchanged(self, store): + """A stored 0.0 (the default/'unused') leaves the new key unset (schema default applies).""" + store.set("battery.price_euro_per_wh_accu", 0.0) + migrate_battery_price_unit_to_ct_kwh(store) + + assert store.get("battery.price_ct_kwh_accu") is None + assert store.get("battery.price_euro_per_wh_accu") is None + + def test_nonzero_value_rescaled_and_moved_to_new_key(self, store): + """An existing €/Wh value is rescaled ×100000 and moved to the ct/kWh key.""" + store.set("battery.price_euro_per_wh_accu", 0.00004) # 4 ct/kWh + ran = migrate_battery_price_unit_to_ct_kwh(store) + + assert ran is True + assert store.get("battery.price_ct_kwh_accu") == pytest.approx(4.0) + assert store.get("battery.price_euro_per_wh_accu") is None + + def test_runs_only_once(self, store): + """A second call is a no-op even if the new key already has a value.""" + store.set("battery.price_euro_per_wh_accu", 0.00004) + migrate_battery_price_unit_to_ct_kwh(store) + + # Simulate a value someone set AFTER migration already ran once — + # a second invocation must not touch it again. + store.set("battery.price_ct_kwh_accu", 8.0) + ran_again = migrate_battery_price_unit_to_ct_kwh(store) + + assert ran_again is False + assert store.get("battery.price_ct_kwh_accu") == 8.0