From ed7e0564a0948f6aacc03df24385fd5c26e52e45 Mon Sep 17 00:00:00 2001
From: ohand <15704728+ohAnd@users.noreply.github.com>
Date: Tue, 25 Aug 2026 08:22:13 +0200
Subject: [PATCH 1/3] =?UTF-8?q?fix:=20convert=20feed-in=20price=20ct/kWh?=
=?UTF-8?q?=20to=20=E2=82=AC/kWh=20for=20battery=20cost?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
BatteryPriceHandler.pv_cost_euro_per_kwh expects €/kWh, but both the
startup wiring in eos_connect.py and the hot-reload path in
hot_reload.py passed the raw ct/kWh config value through unconverted.
This inflated the PV opportunity-cost term, and therefore the
computed battery price, by 100x whenever battery_price_include_feedin
was enabled.
Also removes the fixed-price EUR/ct auto-detection heuristic in
FeedInPriceInterface: it silently guessed the unit based on a
magnitude threshold (<0.1) and missed exactly this class of mistake
for realistic ct/kWh values above that threshold.
Fixes: #283
---
src/config_web/hot_reload.py | 13 +++++++++----
src/eos_connect.py | 5 +++--
src/interfaces/feed_in_price_interface.py | 4 ----
tests/config_web/test_hot_reload.py | 12 ++++++++----
4 files changed, 20 insertions(+), 14 deletions(-)
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/eos_connect.py b/src/eos_connect.py
index 5941352..a6ef489 100644
--- a/src/eos_connect.py
+++ b/src/eos_connect.py
@@ -200,8 +200,9 @@ 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_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/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
From 1f8f0169da49a2929629164f7b099f2671548e58 Mon Sep 17 00:00:00 2001
From: ohand <15704728+ohAnd@users.noreply.github.com>
Date: Tue, 25 Aug 2026 08:22:26 +0200
Subject: [PATCH 2/3] docs: correct feed-in price unit in examples and toggle
description
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
price.feed_in_price is ct/kWh everywhere in the schema and code, but
two examples and one field description in the user guide showed or
implied €/kWh — the same mix-up reported in #283. Also clarifies that
feed_in_price is a price.* key, not a battery.* key, and that
battery_price_include_feedin must be enabled for it to take effect.
---
docs/user-guide/configuration.html | 4 ++--
docs/user-guide/index.html | 7 ++++---
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/docs/user-guide/configuration.html b/docs/user-guide/configuration.html
index 821ea89..1b4f8fa 100644
--- a/docs/user-guide/configuration.html
+++ b/docs/user-guide/configuration.html
@@ -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..78b538c 100644
--- a/docs/user-guide/index.html
+++ b/docs/user-guide/index.html
@@ -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)
From 1b87544e836ed950a4d7671bfcac78e6090805ba Mon Sep 17 00:00:00 2001
From: ohand <15704728+ohAnd@users.noreply.github.com>
Date: Tue, 25 Aug 2026 10:13:35 +0200
Subject: [PATCH 3/3] feat: unify battery static price config to ct/kWh
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
battery.price_euro_per_wh_accu was the only price the user types
directly into config that used €/Wh instead of ct/kWh, unlike every
other price field in the app — and its name no longer matched its
unit once switched. Renamed to battery.price_ct_kwh_accu and convert
at the same boundary already used for price.feed_in_price, keeping
the €/Wh-based internal engine and the EOS optimizer request payload
untouched.
Includes a one-time migration that moves and rescales any existing
installation's stored value under the new key so already-configured
prices survive the change.
---
.github/copilot-instructions.md | 2 +-
docs/assets/data/config_schema.json | 4 +--
docs/user-guide/configuration.html | 8 ++---
docs/user-guide/index.html | 4 +--
src/config_web/__init__.py | 9 ++++-
src/config_web/migration.py | 39 +++++++++++++++++++++
src/config_web/schema.py | 4 +--
src/eos_connect.py | 7 ++++
src/web/js/chart.js | 18 +++++-----
tests/config_web/test_api.py | 2 +-
tests/config_web/test_merger.py | 2 +-
tests/config_web/test_migration.py | 53 ++++++++++++++++++++++++++++-
12 files changed, 128 insertions(+), 24 deletions(-)
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 1b4f8fa..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
| Parameter |
- battery.price_euro_per_wh_accu |
+ battery.price_ct_kwh_accu |
| Description |
@@ -1688,7 +1688,7 @@ battery.price_euro_per_wh_accu
| Unit |
- Euro per Wh (€/Wh) |
+ Cents per kWh (ct/kWh) |
| Default |
@@ -1770,7 +1770,7 @@ battery.price_euro_per_wh_sensor
| Notes |
- If 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. |
diff --git a/docs/user-guide/index.html b/docs/user-guide/index.html
index 78b538c..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)
- - Battery energy cost set to a single fixed value (€/Wh)
+ - Battery energy cost set to a single fixed value (ct/kWh)
- Simple but inaccurate - doesn't reflect actual charging sources
- - Configuration:
battery.price_euro_per_wh_accu: 0.00004 (default)
+ - Configuration:
battery.price_ct_kwh_accu: 4 (4 ct/kWh; 0 is the default, meaning "unused")
- Use case: Quick setup when you don't have sensor data available
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/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 a6ef489..cfc02f9 100644
--- a/src/eos_connect.py
+++ b/src/eos_connect.py
@@ -204,6 +204,13 @@ def formatTime(self, record, datefmt=None):
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(
battery_config,
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_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