Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
4 changes: 2 additions & 2 deletions docs/assets/data/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
12 changes: 6 additions & 6 deletions docs/user-guide/configuration.html
Original file line number Diff line number Diff line change
Expand Up @@ -1676,19 +1676,19 @@ <h3>battery.sensor_battery_temperature</h3>
</tr>
</table>

<h3>battery.price_euro_per_wh_accu</h3>
<h3>battery.price_ct_kwh_accu</h3>
<table>
<tr>
<th>Parameter</th>
<td><code>battery.price_euro_per_wh_accu</code></td>
<td><code>battery.price_ct_kwh_accu</code></td>
</tr>
<tr>
<th>Description</th>
<td>Static price for battery energy storage</td>
</tr>
<tr>
<th>Unit</th>
<td>Euro per Wh (€/Wh)</td>
<td>Cents per kWh (ct/kWh)</td>
</tr>
<tr>
<th>Default</th>
Expand Down Expand Up @@ -1770,7 +1770,7 @@ <h3>battery.price_euro_per_wh_sensor</h3>
</tr>
<tr>
<th>Notes</th>
<td>If configured, overrides static <code>price_euro_per_wh_accu</code> value. Leave empty
<td>If configured, overrides static <code>price_ct_kwh_accu</code> value. Leave empty
to use static price.</td>
</tr>
</table>
Expand Down Expand Up @@ -2015,7 +2015,7 @@ <h3>battery.battery_price_include_feedin</h3>
<td>
<code>false</code> - PV-sourced energy costs €0 (default, free solar energy)<br>
<code>true</code> - PV-sourced energy is valued at <code>price.feed_in_price</code>
(€/kWh) as opportunity cost
(ct/kWh, converted internally to €/kWh) as opportunity cost
</td>
</tr>
<tr>
Expand Down Expand Up @@ -3013,7 +3013,7 @@ <h4>Example Configuration</h4>
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
Expand Down
11 changes: 6 additions & 5 deletions docs/user-guide/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -510,9 +510,9 @@ <h2><i class="fas fa-coins"></i> Battery Energy Pricing</h2>
<h4>Three Battery Pricing Modes</h4>
<p><strong>Mode 1: Fixed Static Price</strong> (Default)</p>
<ul>
<li>Battery energy cost set to a single fixed value (€/Wh)</li>
<li>Battery energy cost set to a single fixed value (ct/kWh)</li>
<li>Simple but inaccurate - doesn't reflect actual charging sources</li>
<li>Configuration: <code>battery.price_euro_per_wh_accu: 0.00004</code> (default)</li>
<li>Configuration: <code>battery.price_ct_kwh_accu: 4</code> (4 ct/kWh; <code>0</code> is the default, meaning "unused")</li>
<li>Use case: Quick setup when you don't have sensor data available</li>
</ul>

Expand Down Expand Up @@ -671,9 +671,10 @@ <h4>Example Configuration</h4>
# 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</code></pre>

# 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)</code></pre>
</div>

<!-- Battery Overview -->
Expand Down
9 changes: 8 additions & 1 deletion src/config_web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
13 changes: 9 additions & 4 deletions src/config_web/hot_reload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
)

Expand Down
39 changes: 39 additions & 0 deletions src/config_web/migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/config_web/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
12 changes: 10 additions & 2 deletions src/eos_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 0 additions & 4 deletions src/interfaces/feed_in_price_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions src/web/js/chart.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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 = [];

Expand All @@ -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
Expand All @@ -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 = [];

Expand All @@ -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
Expand All @@ -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 }
]
Expand All @@ -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 } } }
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion tests/config_web/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down
Loading
Loading