feat(services): add plan_charging service for battery/EV scheduling

Accepts battery parameters (capacity, current/target SoC, max power) and
returns a cost-minimized charging schedule with per-interval power, SoC
progression, and total cost — no manual duration calculation needed.

Supports fixed, continuous (min_charge_power_w), and stepped
(charge_power_steps_w) charging modes, deadline-aware two-pass planning
(must_reach_soc + must_reach_by / must_reach_by_event), and round-trip
economics (expected_discharge_price, reserve_for_discharge,
max_cost_per_kwh) for arbitrage use cases. Includes min_charge_duration
and max_cycles_per_day constraints.

Groups deadline fields (must_reach_soc_*, must_reach_by,
must_reach_by_event) into a dedicated section so a deadline use case can
be configured in one place. Battery section lists capacity before the
percent SoC fields that depend on it. Response exposes stable reason
codes (already_at_target, energy_unreachable, energy_unreachable_by_
deadline, no_intervals_after_economic_filter, …) documented in the
service description and user docs.
This commit is contained in:
Julian Pawlowski 2026-04-20 21:43:41 +00:00
parent 093e904329
commit 96f36a3339
20 changed files with 4052 additions and 6 deletions

View file

@ -74,6 +74,20 @@
"search_tuning": "mdi:cog-outline",
"output": "mdi:tune-variant"
}
},
"plan_charging": {
"service": "mdi:battery-charging",
"sections": {
"battery": "mdi:battery",
"charging": "mdi:ev-station",
"search_range": "mdi:calendar-search",
"deadline": "mdi:calendar-clock",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"search_tuning": "mdi:cog-outline",
"economics": "mdi:cash-multiple",
"output": "mdi:tune-variant"
}
}
}
}

View file

@ -1113,6 +1113,354 @@ find_cheapest_schedule:
selector:
boolean:
plan_charging:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
battery:
collapsed: false
fields:
max_charge_power_w:
required: true
example: 11000
selector:
number:
min: 1
max: 100000
step: 100
unit_of_measurement: "W"
mode: box
battery_capacity_kwh:
required: false
example: 50.0
selector:
number:
min: 0.1
max: 1000
step: 0.1
unit_of_measurement: "kWh"
mode: box
current_soc_percent:
required: false
example: 20
selector:
number:
min: 0
max: 100
step: 0.1
unit_of_measurement: "%"
mode: box
current_soc_kwh:
required: false
example: 10.0
selector:
number:
min: 0
max: 1000
step: 0.1
unit_of_measurement: "kWh"
mode: box
target_soc_percent:
required: false
example: 80
selector:
number:
min: 0
max: 100
step: 0.1
unit_of_measurement: "%"
mode: box
target_soc_kwh:
required: false
example: 40.0
selector:
number:
min: 0
max: 1000
step: 0.1
unit_of_measurement: "kWh"
mode: box
charging_efficiency:
required: false
default: 1.0
selector:
number:
min: 0.5
max: 1.0
step: 0.01
mode: box
charging:
collapsed: true
fields:
min_charge_power_w:
required: false
example: 1400
selector:
number:
min: 1
max: 100000
step: 100
unit_of_measurement: "W"
mode: box
charge_power_steps_w:
required: false
example: [1380, 4140, 11000]
selector:
object:
grid_import_limit_w:
required: false
example: 16000
selector:
number:
min: 1
max: 100000
step: 100
unit_of_measurement: "W"
mode: box
min_charge_duration_minutes:
required: false
example: 30
selector:
number:
min: 15
max: 240
step: 15
unit_of_measurement: "min"
mode: box
max_cycles_per_day:
required: false
example: 2
selector:
number:
min: 1
max: 20
step: 1
mode: box
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
include_current_interval:
required: false
default: true
selector:
boolean:
search_range:
collapsed: true
fields:
search_start:
required: false
example: "2026-04-11T06:00:00+02:00"
selector:
datetime:
search_end:
required: false
example: "2026-04-12T00:00:00+02:00"
selector:
datetime:
must_finish_by:
required: false
example: "2026-04-12T07:00:00+02:00"
selector:
datetime:
deadline:
collapsed: true
fields:
must_reach_soc_percent:
required: false
example: 50
selector:
number:
min: 0
max: 100
step: 0.1
unit_of_measurement: "%"
mode: box
must_reach_soc_kwh:
required: false
example: 25.0
selector:
number:
min: 0
max: 1000
step: 0.1
unit_of_measurement: "kWh"
mode: box
must_reach_by:
required: false
example: "2026-04-11T17:00:00+02:00"
selector:
datetime:
must_reach_by_event:
required: false
selector:
select:
options:
- midnight
- next_peak_period
- next_best_period_end
translation_key: charging_deadline_event
time_alternatives:
collapsed: true
fields:
search_start_time:
required: false
example: "06:00:00"
selector:
time:
search_start_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_end_time:
required: false
example: "23:00:00"
selector:
time:
search_end_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_start_offset_minutes:
required: false
example: 60
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_end_offset_minutes:
required: false
example: 480
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
price_filter:
collapsed: true
fields:
max_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
min_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
search_tuning:
collapsed: true
fields:
smooth_outliers:
required: false
default: true
selector:
boolean:
min_distance_from_avg:
required: false
selector:
number:
min: 0.1
max: 50.0
step: 0.1
unit_of_measurement: "%"
mode: box
allow_relaxation:
required: false
default: true
selector:
boolean:
duration_flexibility_minutes:
required: false
selector:
number:
min: 0
max: 120
step: 15
unit_of_measurement: "min"
mode: box
economics:
collapsed: true
fields:
discharging_efficiency:
required: false
default: 1.0
selector:
number:
min: 0.5
max: 1.0
step: 0.01
mode: box
expected_discharge_price:
required: false
example: 0.28
selector:
number:
min: 0
max: 100000
step: 0.0001
mode: box
reserve_for_discharge:
required: false
default: false
selector:
boolean:
max_cost_per_kwh:
required: false
example: 0.18
selector:
number:
min: 0
max: 100000
step: 0.0001
mode: box
output:
collapsed: true
fields:
include_comparison_details:
required: false
selector:
boolean:
use_base_unit:
required: false
selector:
boolean:
debug_clear_tomorrow:
fields:
entry_id:

View file

@ -53,6 +53,7 @@ from .find_most_expensive_hours import (
from .get_apexcharts_yaml import APEXCHARTS_SERVICE_SCHEMA, APEXCHARTS_YAML_SERVICE_NAME, handle_apexcharts_yaml
from .get_chartdata import CHARTDATA_SERVICE_NAME, CHARTDATA_SERVICE_SCHEMA, handle_chartdata
from .get_price import GET_PRICE_SERVICE_NAME, GET_PRICE_SERVICE_SCHEMA, handle_get_price
from .plan_charging import PLAN_CHARGING_SERVICE_NAME, PLAN_CHARGING_SERVICE_SCHEMA, handle_plan_charging
from .refresh_user_data import (
REFRESH_USER_DATA_SERVICE_NAME,
REFRESH_USER_DATA_SERVICE_SCHEMA,
@ -129,6 +130,13 @@ def async_setup_services(hass: HomeAssistant) -> None:
schema=FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
PLAN_CHARGING_SERVICE_NAME,
handle_plan_charging,
schema=PLAN_CHARGING_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
REFRESH_USER_DATA_SERVICE_NAME,

View file

@ -0,0 +1,46 @@
"""
Charging-specific calculation modules for the plan_charging service.
Packages:
energy_calculator: Pure SoC/energy math (Phase 1)
power_scheduler: Variable power distribution (Phase 2)
deadline_solver: Deadline constraint solving (Phase 3)
economics: Round-trip economic analysis (Phase 4)
"""
from __future__ import annotations
from .deadline_solver import build_deadline_schedule, get_deadline_events, resolve_deadline
from .economics import (
calculate_break_even_price,
calculate_plan_economics,
calculate_round_trip_efficiency,
filter_intervals_by_profitability,
)
from .energy_calculator import (
build_soc_progression,
build_soc_progression_from_schedule,
calculate_duration_intervals,
calculate_energy_needed,
soc_percent_to_kwh,
)
from .power_scheduler import apply_segment_constraints, build_power_schedule, determine_power_mode, energy_for_power
__all__ = [
"apply_segment_constraints",
"build_deadline_schedule",
"build_power_schedule",
"build_soc_progression",
"build_soc_progression_from_schedule",
"calculate_break_even_price",
"calculate_duration_intervals",
"calculate_energy_needed",
"calculate_plan_economics",
"calculate_round_trip_efficiency",
"determine_power_mode",
"energy_for_power",
"filter_intervals_by_profitability",
"get_deadline_events",
"resolve_deadline",
"soc_percent_to_kwh",
]

View file

@ -0,0 +1,135 @@
"""Deadline helpers for the plan_charging service."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.services.helpers import localize_to_home_tz
from custom_components.tibber_prices.utils.price_window import group_intervals_into_segments
from .power_scheduler import build_power_schedule
if TYPE_CHECKING:
from zoneinfo import ZoneInfo
_DEADLINE_EVENTS = frozenset({"next_peak_period", "next_best_period_end", "midnight"})
def get_deadline_events() -> frozenset[str]:
"""Return the supported deadline event selector values."""
return _DEADLINE_EVENTS
def resolve_deadline(
*,
coordinator_data: dict[str, Any],
now: datetime,
home_tz: ZoneInfo,
must_reach_by: datetime | None = None,
must_reach_by_event: str | None = None,
) -> tuple[datetime | None, str | None]:
"""Resolve an absolute deadline from an explicit datetime or a known event."""
if must_reach_by is not None and must_reach_by_event is not None:
raise ValueError("deadline_conflict")
if must_reach_by is not None:
return localize_to_home_tz(must_reach_by, home_tz), "explicit"
if must_reach_by_event is None:
return None, None
if must_reach_by_event not in _DEADLINE_EVENTS:
raise ValueError("deadline_event_not_available")
if must_reach_by_event == "midnight":
next_day = (now + timedelta(days=1)).date()
return datetime.combine(next_day, datetime.min.time(), tzinfo=home_tz), "midnight"
periods_data = coordinator_data.get("pricePeriods", {})
if must_reach_by_event == "next_peak_period":
periods = periods_data.get("peak_price", {}).get("periods", [])
for period in periods:
start = period.get("start")
if start and start > now:
return start, "next_peak_period"
raise ValueError("deadline_event_not_available")
periods = periods_data.get("best_price", {}).get("periods", [])
for period in periods:
end = period.get("end")
if end and end > now:
return end, "next_best_period_end"
raise ValueError("deadline_event_not_available")
def build_deadline_schedule(
candidate_intervals: list[dict[str, Any]],
*,
total_energy_needed_grid_kwh: float,
energy_needed_by_deadline_grid_kwh: float,
deadline: datetime,
max_charge_power_w: int,
charging_efficiency: float,
min_charge_power_w: int | None = None,
charge_power_steps_w: list[int] | None = None,
grid_import_limit_w: int | None = None,
interval_minutes: int = 15,
) -> dict[str, Any]:
"""Build a two-pass schedule that satisfies a minimum SoC by a deadline."""
deadline_intervals = [interval for interval in candidate_intervals if _interval_start(interval) < deadline]
pre_deadline = build_power_schedule(
deadline_intervals,
energy_needed_by_deadline_grid_kwh,
max_charge_power_w=max_charge_power_w,
charging_efficiency=charging_efficiency,
min_charge_power_w=min_charge_power_w,
charge_power_steps_w=charge_power_steps_w,
grid_import_limit_w=grid_import_limit_w,
interval_minutes=interval_minutes,
)
used_timestamps = {interval["startsAt"] for interval in pre_deadline["intervals"]}
remaining_candidates = [interval for interval in candidate_intervals if interval["startsAt"] not in used_timestamps]
remaining_energy = max(0.0, total_energy_needed_grid_kwh - pre_deadline["total_grid_energy_kwh"])
post_deadline = build_power_schedule(
remaining_candidates,
remaining_energy,
max_charge_power_w=max_charge_power_w,
charging_efficiency=charging_efficiency,
min_charge_power_w=min_charge_power_w,
charge_power_steps_w=charge_power_steps_w,
grid_import_limit_w=grid_import_limit_w,
interval_minutes=interval_minutes,
)
combined_intervals = sorted(
[*pre_deadline["intervals"], *post_deadline["intervals"]],
key=_interval_start,
)
return {
"intervals": combined_intervals,
"segments": group_intervals_into_segments(combined_intervals),
"deadline": deadline,
"pre_deadline": pre_deadline,
"post_deadline": post_deadline,
"total_grid_energy_kwh": round(
pre_deadline["total_grid_energy_kwh"] + post_deadline["total_grid_energy_kwh"], 6
),
"total_stored_energy_kwh": round(
pre_deadline["total_stored_energy_kwh"] + post_deadline["total_stored_energy_kwh"], 6
),
"unallocated_grid_energy_kwh": round(post_deadline["unallocated_grid_energy_kwh"], 6),
"deadline_unallocated_grid_energy_kwh": round(pre_deadline["unallocated_grid_energy_kwh"], 6),
"mode": pre_deadline["mode"],
"effective_max_power_w": pre_deadline["effective_max_power_w"],
"allowed_steps": pre_deadline["allowed_steps"],
"minimum_power_w": pre_deadline["minimum_power_w"],
}
def _interval_start(interval: dict[str, Any]) -> datetime:
starts_at = interval["startsAt"]
return datetime.fromisoformat(starts_at) if isinstance(starts_at, str) else starts_at

View file

@ -0,0 +1,104 @@
"""Economic helpers for the plan_charging service."""
from __future__ import annotations
from typing import Any
def calculate_round_trip_efficiency(charging_efficiency: float, discharging_efficiency: float) -> float:
"""Return the round-trip efficiency as a fraction."""
return round(charging_efficiency * discharging_efficiency, 6)
def calculate_break_even_price(expected_discharge_price: float, round_trip_efficiency: float) -> float:
"""Return the maximum profitable charge price in base currency per kWh."""
return round(expected_discharge_price * round_trip_efficiency, 6)
def filter_intervals_by_profitability(
intervals: list[dict[str, Any]],
*,
charging_efficiency: float,
discharging_efficiency: float,
expected_discharge_price: float | None = None,
reserve_for_discharge: bool = False,
max_cost_per_kwh: float | None = None,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""Filter candidate intervals by hard ceiling and optional profitability."""
filtered = list(intervals)
metadata: dict[str, Any] = {
"reserve_for_discharge": reserve_for_discharge,
"expected_discharge_price": expected_discharge_price,
"max_cost_per_kwh": max_cost_per_kwh,
"break_even_price": None,
"filtered_out_by_cost": 0,
"filtered_out_by_profitability": 0,
}
if max_cost_per_kwh is not None:
before = len(filtered)
filtered = [interval for interval in filtered if float(interval["total"]) <= max_cost_per_kwh]
metadata["filtered_out_by_cost"] = before - len(filtered)
if expected_discharge_price is not None:
round_trip_efficiency = calculate_round_trip_efficiency(charging_efficiency, discharging_efficiency)
break_even_price = calculate_break_even_price(expected_discharge_price, round_trip_efficiency)
metadata["break_even_price"] = break_even_price
if reserve_for_discharge:
before = len(filtered)
filtered = [interval for interval in filtered if float(interval["total"]) <= break_even_price]
metadata["filtered_out_by_profitability"] = before - len(filtered)
return filtered, metadata
def calculate_plan_economics(
scheduled_intervals: list[dict[str, Any]],
*,
charging_efficiency: float,
discharging_efficiency: float,
expected_discharge_price: float | None,
unit_factor: int,
max_cost_per_kwh: float | None = None,
reserve_for_discharge: bool = False,
) -> dict[str, Any] | None:
"""Calculate round-trip economics for the selected charging plan."""
if expected_discharge_price is None and max_cost_per_kwh is None and not reserve_for_discharge:
return None
round_trip_efficiency = calculate_round_trip_efficiency(charging_efficiency, discharging_efficiency)
break_even_price = (
calculate_break_even_price(expected_discharge_price, round_trip_efficiency)
if expected_discharge_price is not None
else None
)
total_grid_energy_kwh = sum(float(interval.get("grid_energy_kwh", 0.0)) for interval in scheduled_intervals)
total_stored_energy_kwh = sum(float(interval.get("stored_energy_kwh", 0.0)) for interval in scheduled_intervals)
total_cost_base = sum(
float(interval["total"]) * float(interval.get("grid_energy_kwh", 0.0)) for interval in scheduled_intervals
)
expected_revenue_base = None
expected_net_savings_base = None
if expected_discharge_price is not None:
expected_revenue_base = total_stored_energy_kwh * discharging_efficiency * expected_discharge_price
expected_net_savings_base = expected_revenue_base - total_cost_base
return {
"reserve_for_discharge": reserve_for_discharge,
"round_trip_efficiency": round(round_trip_efficiency, 6),
"expected_discharge_price": round(expected_discharge_price * unit_factor, 4)
if expected_discharge_price is not None
else None,
"break_even_price": round(break_even_price * unit_factor, 4) if break_even_price is not None else None,
"max_cost_per_kwh": round(max_cost_per_kwh * unit_factor, 4) if max_cost_per_kwh is not None else None,
"expected_revenue": round(expected_revenue_base * unit_factor, 4)
if expected_revenue_base is not None
else None,
"expected_net_savings": round(expected_net_savings_base * unit_factor, 4)
if expected_net_savings_base is not None
else None,
"total_grid_energy_kwh": round(total_grid_energy_kwh, 6),
"total_stored_energy_kwh": round(total_stored_energy_kwh, 6),
}

View file

@ -0,0 +1,164 @@
"""
Pure energy and state-of-charge calculations for the plan_charging service.
All functions are stateless and have no Home Assistant dependencies.
They operate on plain Python types and are designed for unit testing.
"""
from __future__ import annotations
import math
from typing import Any
# Quarter-hour interval duration in hours
_INTERVAL_HOURS = 0.25
def soc_percent_to_kwh(soc_percent: float, capacity_kwh: float) -> float:
"""Convert state of charge from percent to kilowatt-hours.
Args:
soc_percent: State of charge as a percentage (0100).
capacity_kwh: Total usable battery capacity in kWh.
Returns:
Absolute energy stored in kWh.
"""
return soc_percent / 100.0 * capacity_kwh
def calculate_energy_needed(
current_soc_kwh: float,
target_soc_kwh: float,
charging_efficiency: float = 1.0,
) -> float:
"""Calculate the grid energy required to reach the target SoC.
Accounts for charging losses: to store X kWh in the battery, the grid must
supply X / efficiency kWh.
Args:
current_soc_kwh: Current battery energy in kWh.
target_soc_kwh: Desired battery energy in kWh.
charging_efficiency: Round-trip charging efficiency (0.51.0).
A value of 0.92 means 8% loss during charging.
Returns:
Grid energy required in kWh. Returns 0.0 if already at or above target.
"""
net_energy = target_soc_kwh - current_soc_kwh
if net_energy <= 0.0:
return 0.0
return net_energy / charging_efficiency
def calculate_duration_intervals(
energy_kwh: float,
power_w: int,
interval_minutes: int = 15,
) -> int:
"""Calculate the number of intervals needed to charge a given energy.
Always rounds up to the nearest full interval so the charging target
is always met or exceeded.
Args:
energy_kwh: Energy to be charged from the grid in kWh.
power_w: Charging power in watts.
interval_minutes: Duration of each interval in minutes (default 15).
Returns:
Number of intervals required ( 1 if energy > 0, else 0).
"""
if energy_kwh <= 0.0:
return 0
interval_hours = interval_minutes / 60.0
energy_per_interval_kwh = (power_w / 1000.0) * interval_hours
return math.ceil(energy_kwh / energy_per_interval_kwh)
def build_soc_progression(
intervals: list[dict[str, Any]],
power_w: int,
start_soc_kwh: float,
capacity_kwh: float | None,
charging_efficiency: float = 1.0,
interval_minutes: int = 15,
) -> list[dict[str, Any]]:
"""Build a per-interval SoC progression for a fixed-power charging session.
For each interval, calculates:
- ``power_w``: Charging power applied (same as input, Phase 1 fixed).
- ``energy_kwh``: Energy stored in the battery during this interval.
- ``soc_after_kwh``: Absolute SoC after this interval.
- ``soc_after_percent``: SoC as percentage (only if capacity_kwh provided).
The function mutates nothing it returns a new list of dicts augmented
with the SoC fields.
Args:
intervals: Chronologically ordered interval dicts (with at least
``startsAt`` and ``total`` keys). Not mutated.
power_w: Fixed charging power in watts.
start_soc_kwh: Battery SoC at the beginning of the first interval.
capacity_kwh: Total battery capacity for % calculation. Pass ``None``
to omit ``soc_after_percent`` from output.
charging_efficiency: Fraction of grid energy stored in the battery
(0.51.0). Default is 1.0 (no losses).
interval_minutes: Duration of each interval in minutes (default 15).
Returns:
New list of interval dicts, each augmented with ``power_w``,
``energy_kwh``, ``soc_after_kwh``, and optionally ``soc_after_percent``.
"""
interval_hours = interval_minutes / 60.0
energy_per_interval_kwh = (power_w / 1000.0) * interval_hours * charging_efficiency
result: list[dict[str, Any]] = []
current_soc_kwh = start_soc_kwh
for iv in intervals:
current_soc_kwh += energy_per_interval_kwh
augmented: dict[str, Any] = dict(iv)
augmented["power_w"] = power_w
augmented["energy_kwh"] = round(energy_per_interval_kwh, 6)
augmented["soc_after_kwh"] = round(current_soc_kwh, 6)
if capacity_kwh is not None and capacity_kwh > 0:
augmented["soc_after_percent"] = round(current_soc_kwh / capacity_kwh * 100.0, 2)
result.append(augmented)
return result
def build_soc_progression_from_schedule(
scheduled_intervals: list[dict[str, Any]],
start_soc_kwh: float,
capacity_kwh: float | None,
) -> list[dict[str, Any]]:
"""Build SoC progression from a precomputed charging schedule.
Each scheduled interval is expected to provide ``stored_energy_kwh`` and
usually ``grid_energy_kwh`` plus ``power_w``. The returned intervals keep
those fields and add the cumulative SoC state.
"""
result: list[dict[str, Any]] = []
current_soc_kwh = start_soc_kwh
for interval in scheduled_intervals:
stored_energy_kwh = float(interval.get("stored_energy_kwh", interval.get("energy_kwh", 0.0)))
current_soc_kwh += stored_energy_kwh
augmented: dict[str, Any] = dict(interval)
# Preserve the historical Phase 1 response field name while also exposing
# explicit grid/stored energy values for later phases.
augmented["energy_kwh"] = round(stored_energy_kwh, 6)
augmented["soc_after_kwh"] = round(current_soc_kwh, 6)
if capacity_kwh is not None and capacity_kwh > 0:
augmented["soc_after_percent"] = round(current_soc_kwh / capacity_kwh * 100.0, 2)
result.append(augmented)
return result

View file

@ -0,0 +1,346 @@
"""Power allocation helpers for the plan_charging service."""
from __future__ import annotations
from datetime import datetime, timedelta
from itertools import pairwise
import math
from typing import Any
from custom_components.tibber_prices.utils.price_window import group_intervals_into_segments
_INTERVAL_TOLERANCE = 1e-9
def determine_power_mode(
*,
max_charge_power_w: int,
min_charge_power_w: int | None = None,
charge_power_steps_w: list[int] | None = None,
grid_import_limit_w: int | None = None,
) -> tuple[str, int, list[int] | None]:
"""Resolve the active power mode and effective power limits.
Returns:
Tuple of ``(mode, effective_max_power_w, allowed_steps)``.
Raises:
ValueError: If power settings are mutually exclusive or impossible.
"""
if min_charge_power_w is not None and charge_power_steps_w:
raise ValueError("power_strategy_conflict")
effective_max_power_w = min(max_charge_power_w, grid_import_limit_w) if grid_import_limit_w else max_charge_power_w
if effective_max_power_w <= 0:
raise ValueError("grid_limit_too_low")
if charge_power_steps_w:
allowed_steps = sorted({int(step) for step in charge_power_steps_w if 0 < int(step) <= effective_max_power_w})
if not allowed_steps:
raise ValueError("grid_limit_too_low")
return "stepped", effective_max_power_w, allowed_steps
if min_charge_power_w is not None:
if min_charge_power_w > effective_max_power_w:
raise ValueError("grid_limit_too_low")
return "continuous", effective_max_power_w, None
return "fixed", effective_max_power_w, None
def energy_for_power(power_w: float, interval_minutes: int = 15) -> float:
"""Return grid energy in kWh for an interval at the given power."""
return float(power_w) / 1000.0 * (interval_minutes / 60.0)
def minimum_operating_power_w(
*,
mode: str,
effective_max_power_w: int,
min_charge_power_w: int | None = None,
allowed_steps: list[int] | None = None,
) -> int:
"""Return the minimum usable power for the selected power mode."""
if mode == "continuous":
return min_charge_power_w or effective_max_power_w
if mode == "stepped":
return min(allowed_steps or [effective_max_power_w])
return effective_max_power_w
def _interval_start(interval: dict[str, Any]) -> datetime:
starts_at = interval["startsAt"]
return datetime.fromisoformat(starts_at) if isinstance(starts_at, str) else starts_at
def _sort_price(interval: dict[str, Any]) -> float:
return float(interval.get("_sort_total", interval["total"]))
def _choose_power_for_remaining_energy(
remaining_grid_energy_kwh: float,
*,
mode: str,
effective_max_power_w: int,
min_charge_power_w: int | None,
allowed_steps: list[int] | None,
interval_minutes: int,
) -> int:
"""Choose the power assignment for the next interval."""
max_interval_energy = energy_for_power(effective_max_power_w, interval_minutes)
if remaining_grid_energy_kwh > max_interval_energy + _INTERVAL_TOLERANCE:
return effective_max_power_w
if mode == "continuous":
interval_hours = interval_minutes / 60.0
exact_power = math.ceil(remaining_grid_energy_kwh / interval_hours * 1000.0)
if min_charge_power_w is not None:
return max(min_charge_power_w, min(exact_power, effective_max_power_w))
return min(exact_power, effective_max_power_w)
if mode == "stepped":
needed_power = remaining_grid_energy_kwh / (interval_minutes / 60.0) * 1000.0
for step in allowed_steps or []:
if step >= needed_power - _INTERVAL_TOLERANCE:
return step
return (allowed_steps or [effective_max_power_w])[-1]
return effective_max_power_w
def _build_assignment(
interval: dict[str, Any],
*,
power_w: int,
charging_efficiency: float,
interval_minutes: int,
) -> dict[str, Any]:
"""Attach charging assignment fields to an interval."""
grid_energy_kwh = round(energy_for_power(power_w, interval_minutes), 6)
stored_energy_kwh = round(grid_energy_kwh * charging_efficiency, 6)
assigned = dict(interval)
assigned["power_w"] = power_w
assigned["grid_energy_kwh"] = grid_energy_kwh
assigned["stored_energy_kwh"] = stored_energy_kwh
return assigned
def build_power_schedule(
candidate_intervals: list[dict[str, Any]],
energy_needed_grid_kwh: float,
*,
max_charge_power_w: int,
charging_efficiency: float,
min_charge_power_w: int | None = None,
charge_power_steps_w: list[int] | None = None,
grid_import_limit_w: int | None = None,
interval_minutes: int = 15,
) -> dict[str, Any]:
"""Allocate required grid energy across the cheapest candidate intervals."""
mode, effective_max_power_w, allowed_steps = determine_power_mode(
max_charge_power_w=max_charge_power_w,
min_charge_power_w=min_charge_power_w,
charge_power_steps_w=charge_power_steps_w,
grid_import_limit_w=grid_import_limit_w,
)
sorted_candidates = sorted(
candidate_intervals, key=lambda interval: (_sort_price(interval), _interval_start(interval))
)
assignments: list[dict[str, Any]] = []
remaining_grid_energy_kwh = max(0.0, energy_needed_grid_kwh)
for interval in sorted_candidates:
if remaining_grid_energy_kwh <= _INTERVAL_TOLERANCE:
break
power_w = _choose_power_for_remaining_energy(
remaining_grid_energy_kwh,
mode=mode,
effective_max_power_w=effective_max_power_w,
min_charge_power_w=min_charge_power_w,
allowed_steps=allowed_steps,
interval_minutes=interval_minutes,
)
assignment = _build_assignment(
interval,
power_w=power_w,
charging_efficiency=charging_efficiency,
interval_minutes=interval_minutes,
)
assignments.append(assignment)
remaining_grid_energy_kwh = max(0.0, remaining_grid_energy_kwh - assignment["grid_energy_kwh"])
assignments.sort(key=_interval_start)
segments = group_intervals_into_segments(assignments)
total_grid_energy_kwh = round(sum(interval["grid_energy_kwh"] for interval in assignments), 6)
total_stored_energy_kwh = round(sum(interval["stored_energy_kwh"] for interval in assignments), 6)
return {
"mode": mode,
"effective_max_power_w": effective_max_power_w,
"allowed_steps": allowed_steps,
"intervals": assignments,
"segments": segments,
"total_grid_energy_kwh": total_grid_energy_kwh,
"total_stored_energy_kwh": total_stored_energy_kwh,
"unallocated_grid_energy_kwh": round(remaining_grid_energy_kwh, 6),
"minimum_power_w": minimum_operating_power_w(
mode=mode,
effective_max_power_w=effective_max_power_w,
min_charge_power_w=min_charge_power_w,
allowed_steps=allowed_steps,
),
}
def _add_interval_if_available(
selected_map: dict[str, dict[str, Any]],
candidate_map: dict[str, dict[str, Any]],
starts_at: str,
*,
power_w: int,
charging_efficiency: float,
interval_minutes: int,
) -> bool:
"""Add a candidate interval to the selection map if it is available."""
if starts_at in selected_map or starts_at not in candidate_map:
return False
selected_map[starts_at] = _build_assignment(
candidate_map[starts_at],
power_w=power_w,
charging_efficiency=charging_efficiency,
interval_minutes=interval_minutes,
)
return True
def apply_segment_constraints(
schedule: dict[str, Any],
candidate_intervals: list[dict[str, Any]],
*,
charging_efficiency: float,
min_charge_duration_minutes: int | None = None,
max_cycles_per_day: int | None = None,
interval_minutes: int = 15,
) -> tuple[dict[str, Any], list[str]]:
"""Extend/bridge selected intervals to satisfy segment duration and cycle constraints."""
warnings: list[str] = []
selected_map = {interval["startsAt"]: dict(interval) for interval in schedule["intervals"]}
candidate_map = {interval["startsAt"]: interval for interval in candidate_intervals}
candidates_sorted = sorted(candidate_intervals, key=_interval_start)
candidate_index = {interval["startsAt"]: index for index, interval in enumerate(candidates_sorted)}
minimum_power_w = int(schedule["minimum_power_w"])
if min_charge_duration_minutes:
required_intervals = max(1, math.ceil(min_charge_duration_minutes / interval_minutes))
progress = True
while progress:
progress = False
selected_intervals = sorted(selected_map.values(), key=_interval_start)
segments = group_intervals_into_segments(selected_intervals)
for segment in segments:
if segment["interval_count"] >= required_intervals:
continue
while segment["interval_count"] < required_intervals:
first = segment["intervals"][0]["startsAt"]
last = segment["intervals"][-1]["startsAt"]
first_index = candidate_index[first]
last_index = candidate_index[last]
prev_interval = candidates_sorted[first_index - 1] if first_index > 0 else None
next_interval = (
candidates_sorted[last_index + 1] if last_index + 1 < len(candidates_sorted) else None
)
prev_contiguous = False
next_contiguous = False
if prev_interval is not None:
prev_contiguous = _interval_start(candidate_map[first]) - _interval_start(
prev_interval
) == timedelta(minutes=interval_minutes)
if next_interval is not None:
next_contiguous = _interval_start(next_interval) - _interval_start(
candidate_map[last]
) == timedelta(minutes=interval_minutes)
options: list[dict[str, Any]] = []
if prev_interval is not None and prev_contiguous and prev_interval["startsAt"] not in selected_map:
options.append(prev_interval)
if next_interval is not None and next_contiguous and next_interval["startsAt"] not in selected_map:
options.append(next_interval)
if not options:
warnings.append("min_charge_duration_unreachable")
break
cheapest = min(options, key=lambda interval: (_sort_price(interval), _interval_start(interval)))
added = _add_interval_if_available(
selected_map,
candidate_map,
cheapest["startsAt"],
power_w=minimum_power_w,
charging_efficiency=charging_efficiency,
interval_minutes=interval_minutes,
)
if not added:
break
progress = True
selected_intervals = sorted(selected_map.values(), key=_interval_start)
segment = next(
seg
for seg in group_intervals_into_segments(selected_intervals)
if first in {iv["startsAt"] for iv in seg["intervals"]}
)
if max_cycles_per_day:
while True:
selected_intervals = sorted(selected_map.values(), key=_interval_start)
segments = group_intervals_into_segments(selected_intervals)
if len(segments) <= max_cycles_per_day:
break
best_gap: tuple[float, list[dict[str, Any]]] | None = None
for left, right in pairwise(segments):
left_end_index = candidate_index[left["intervals"][-1]["startsAt"]]
right_start_index = candidate_index[right["intervals"][0]["startsAt"]]
gap = candidates_sorted[left_end_index + 1 : right_start_index]
if not gap:
continue
if any(interval["startsAt"] in selected_map for interval in gap):
continue
if any(
_interval_start(gap[index + 1]) - _interval_start(gap[index]) != timedelta(minutes=interval_minutes)
for index in range(len(gap) - 1)
):
continue
penalty = sum(_sort_price(interval) for interval in gap)
if best_gap is None or penalty < best_gap[0]:
best_gap = (penalty, gap)
if best_gap is None:
warnings.append("max_cycles_unreachable")
break
for interval in best_gap[1]:
_add_interval_if_available(
selected_map,
candidate_map,
interval["startsAt"],
power_w=minimum_power_w,
charging_efficiency=charging_efficiency,
interval_minutes=interval_minutes,
)
selected_intervals = sorted(selected_map.values(), key=_interval_start)
segments = group_intervals_into_segments(selected_intervals)
schedule["intervals"] = selected_intervals
schedule["segments"] = segments
schedule["total_grid_energy_kwh"] = round(sum(interval["grid_energy_kwh"] for interval in selected_intervals), 6)
schedule["total_stored_energy_kwh"] = round(
sum(interval["stored_energy_kwh"] for interval in selected_intervals), 6
)
schedule["constraint_warnings"] = warnings
return schedule, warnings

View file

@ -0,0 +1,973 @@
"""Service handler for the plan_charging service."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, time as dt_time, timedelta
import logging
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from custom_components.tibber_prices.const import DOMAIN, get_display_unit_factor, get_display_unit_string
from custom_components.tibber_prices.utils.price_window import (
calculate_window_statistics,
find_cheapest_n_intervals,
group_intervals_into_segments,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .charging import (
apply_segment_constraints,
build_deadline_schedule,
build_power_schedule,
build_soc_progression_from_schedule,
calculate_energy_needed,
calculate_plan_economics,
determine_power_mode,
energy_for_power,
filter_intervals_by_profitability,
get_deadline_events,
resolve_deadline,
soc_percent_to_kwh,
)
from .entity_resolver import or_entity_ref, resolve_entity_references
from .helpers import (
INTERVAL_MINUTES,
PRICE_LEVEL_ORDER,
VALID_SEARCH_SCOPES,
apply_must_finish_by,
build_rating_lookup,
build_response_interval,
calculate_search_range_avg,
check_min_distance_from_avg,
filter_intervals_by_price_level,
get_entry_and_data,
resolve_home_timezone,
resolve_search_range,
validate_price_level_range,
validate_search_params,
)
from .relaxation import (
MIN_RELAXED_DURATION_INTERVALS,
calculate_max_duration_reduction_intervals,
generate_relaxation_steps,
)
if TYPE_CHECKING:
from zoneinfo import ZoneInfo
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
_LOGGER = logging.getLogger(__name__)
PLAN_CHARGING_SERVICE_NAME = "plan_charging"
_CHARGING_ENTITY_PARAMS: dict[str, type] = {
"battery_capacity_kwh": float,
"current_soc_percent": float,
"current_soc_kwh": float,
"target_soc_percent": float,
"target_soc_kwh": float,
"must_reach_soc_percent": float,
"must_reach_soc_kwh": float,
"max_charge_power_w": int,
"min_charge_power_w": int,
"grid_import_limit_w": int,
"charging_efficiency": float,
"discharging_efficiency": float,
"expected_discharge_price": float,
"max_cost_per_kwh": float,
"min_charge_duration_minutes": int,
"max_cycles_per_day": int,
"search_start": datetime,
"search_end": datetime,
"search_start_time": dt_time,
"search_end_time": dt_time,
"search_start_day_offset": int,
"search_end_day_offset": int,
"search_start_offset_minutes": int,
"search_end_offset_minutes": int,
"min_distance_from_avg": float,
"duration_flexibility_minutes": int,
"must_finish_by": datetime,
"must_reach_by": datetime,
}
PLAN_CHARGING_SERVICE_SCHEMA = vol.Schema(
{
vol.Optional("entry_id", default=""): cv.string,
vol.Optional("battery_capacity_kwh"): or_entity_ref(
vol.All(vol.Coerce(float), vol.Range(min=0.1, max=1000.0)),
),
vol.Optional("current_soc_percent"): or_entity_ref(
vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)),
),
vol.Optional("current_soc_kwh"): or_entity_ref(vol.All(vol.Coerce(float), vol.Range(min=0.0))),
vol.Optional("target_soc_percent"): or_entity_ref(
vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)),
),
vol.Optional("target_soc_kwh"): or_entity_ref(vol.All(vol.Coerce(float), vol.Range(min=0.0))),
vol.Optional("must_reach_soc_percent"): or_entity_ref(
vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)),
),
vol.Optional("must_reach_soc_kwh"): or_entity_ref(vol.All(vol.Coerce(float), vol.Range(min=0.0))),
vol.Required("max_charge_power_w"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=1, max=100000)),
),
vol.Optional("min_charge_power_w"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=1, max=100000)),
),
vol.Optional("charge_power_steps_w"): vol.All(
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
vol.Length(min=1, max=20),
),
vol.Optional("grid_import_limit_w"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=1, max=100000)),
),
vol.Optional("charging_efficiency", default=1.0): or_entity_ref(
vol.All(vol.Coerce(float), vol.Range(min=0.5, max=1.0)),
),
vol.Optional("search_start"): or_entity_ref(cv.datetime),
vol.Optional("search_end"): or_entity_ref(cv.datetime),
vol.Optional("search_start_time"): or_entity_ref(cv.time),
vol.Optional("search_start_day_offset", default=0): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
),
vol.Optional("search_end_time"): or_entity_ref(cv.time),
vol.Optional("search_end_day_offset", default=0): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
),
vol.Optional("search_start_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
),
vol.Optional("search_end_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
),
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
vol.Optional("include_current_interval", default=True): cv.boolean,
vol.Optional("must_finish_by"): or_entity_ref(cv.datetime),
vol.Optional("must_reach_by"): or_entity_ref(cv.datetime),
vol.Optional("must_reach_by_event"): vol.In(sorted(get_deadline_events())),
vol.Optional("max_price_level"): vol.In([level.lower() for level in PRICE_LEVEL_ORDER]),
vol.Optional("min_price_level"): vol.In([level.lower() for level in PRICE_LEVEL_ORDER]),
vol.Optional("include_comparison_details", default=False): cv.boolean,
vol.Optional("use_base_unit", default=False): cv.boolean,
vol.Optional("smooth_outliers", default=True): cv.boolean,
vol.Optional("min_distance_from_avg"): or_entity_ref(
vol.All(vol.Coerce(float), vol.Range(min=0.1, max=50.0)),
),
vol.Optional("allow_relaxation", default=True): cv.boolean,
vol.Optional("duration_flexibility_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
),
vol.Optional("discharging_efficiency", default=1.0): or_entity_ref(
vol.All(vol.Coerce(float), vol.Range(min=0.5, max=1.0)),
),
vol.Optional("expected_discharge_price"): or_entity_ref(
vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100000.0)),
),
vol.Optional("reserve_for_discharge", default=False): cv.boolean,
vol.Optional("max_cost_per_kwh"): or_entity_ref(
vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100000.0)),
),
vol.Optional("min_charge_duration_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=15, max=240)),
),
vol.Optional("max_cycles_per_day"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=1, max=20)),
),
}
)
def _interval_start(interval: dict[str, Any]) -> datetime:
starts_at = interval["startsAt"]
return datetime.fromisoformat(starts_at) if isinstance(starts_at, str) else starts_at
def _translate_error_key(error_key: str) -> ServiceValidationError:
return ServiceValidationError(
translation_domain=DOMAIN,
translation_key=error_key,
)
def _resolve_soc_value(
data: dict[str, Any],
*,
percent_key: str,
kwh_key: str,
capacity_kwh: float | None,
field_name: str,
required: bool,
) -> float | None:
has_percent = percent_key in data
has_kwh = kwh_key in data
if has_percent and has_kwh:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="ambiguous_soc_input",
translation_placeholders={"field": field_name},
)
if not has_percent and not has_kwh:
if required:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"missing_{field_name}",
)
return None
if has_percent:
if capacity_kwh is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="capacity_required_for_percent",
)
return soc_percent_to_kwh(float(data[percent_key]), capacity_kwh)
return float(data[kwh_key])
def _validate_soc_inputs(data: dict[str, Any]) -> dict[str, float | None]:
capacity_kwh = float(data["battery_capacity_kwh"]) if "battery_capacity_kwh" in data else None
current_soc_kwh = _resolve_soc_value(
data,
percent_key="current_soc_percent",
kwh_key="current_soc_kwh",
capacity_kwh=capacity_kwh,
field_name="current_soc",
required=True,
)
target_soc_kwh = _resolve_soc_value(
data,
percent_key="target_soc_percent",
kwh_key="target_soc_kwh",
capacity_kwh=capacity_kwh,
field_name="target_soc",
required=True,
)
must_reach_soc_kwh = _resolve_soc_value(
data,
percent_key="must_reach_soc_percent",
kwh_key="must_reach_soc_kwh",
capacity_kwh=capacity_kwh,
field_name="must_reach_soc",
required=False,
)
if capacity_kwh is not None and target_soc_kwh is not None and target_soc_kwh > capacity_kwh + 1e-6:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="target_exceeds_capacity",
translation_placeholders={
"target_soc_kwh": f"{target_soc_kwh:.2f}",
"capacity_kwh": f"{capacity_kwh:.2f}",
},
)
if must_reach_soc_kwh is not None and "must_reach_by" not in data and "must_reach_by_event" not in data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_deadline_for_must_reach",
)
if must_reach_soc_kwh is None and ("must_reach_by" in data or "must_reach_by_event" in data):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_must_reach_soc",
)
if current_soc_kwh is not None and target_soc_kwh is not None and must_reach_soc_kwh is not None:
if must_reach_soc_kwh < current_soc_kwh - 1e-6 or must_reach_soc_kwh > target_soc_kwh + 1e-6:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_must_reach_soc",
)
return {
"capacity_kwh": capacity_kwh,
"current_soc_kwh": current_soc_kwh,
"target_soc_kwh": target_soc_kwh,
"must_reach_soc_kwh": must_reach_soc_kwh,
}
def _build_candidate_intervals(
price_info: list[dict[str, Any]],
*,
max_price_level: str | None,
min_price_level: str | None,
smooth_outliers: bool,
charging_efficiency: float,
discharging_efficiency: float,
expected_discharge_price_base: float | None,
reserve_for_discharge: bool,
max_cost_per_kwh_base: float | None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]:
filtered_by_level = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
candidates = [dict(interval) for interval in filtered_by_level]
if smooth_outliers and candidates:
from .helpers import smooth_service_intervals # noqa: PLC0415
smoothed = smooth_service_intervals([dict(interval) for interval in candidates])
smoothed_map = {interval["startsAt"]: float(interval["total"]) for interval in smoothed}
for candidate in candidates:
candidate["_sort_total"] = smoothed_map.get(candidate["startsAt"], float(candidate["total"]))
candidates, economics_filter = filter_intervals_by_profitability(
candidates,
charging_efficiency=charging_efficiency,
discharging_efficiency=discharging_efficiency,
expected_discharge_price=expected_discharge_price_base,
reserve_for_discharge=reserve_for_discharge,
max_cost_per_kwh=max_cost_per_kwh_base,
)
return candidates, filtered_by_level, economics_filter
def _build_charging_interval(
interval: dict[str, Any],
*,
unit_factor: int,
rating_lookup: dict[str, str | None],
) -> dict[str, Any]:
response = build_response_interval(interval, unit_factor, rating_lookup)
response["power_w"] = interval.get("power_w")
response["grid_energy_kwh"] = interval.get("grid_energy_kwh")
response["energy_kwh"] = interval.get("energy_kwh")
response["stored_energy_kwh"] = interval.get("stored_energy_kwh")
response["soc_after_kwh"] = interval.get("soc_after_kwh")
if "soc_after_percent" in interval:
response["soc_after_percent"] = interval["soc_after_percent"]
return response
def _build_response_segments(
scheduled_intervals: list[dict[str, Any]],
*,
unit_factor: int,
rating_lookup: dict[str, str | None],
) -> list[dict[str, Any]]:
response_segments: list[dict[str, Any]] = []
for segment in group_intervals_into_segments(scheduled_intervals):
seg_stats = calculate_window_statistics(
segment["intervals"],
unit_factor=unit_factor,
round_decimals=4,
power_profile=[int(interval["power_w"]) for interval in segment["intervals"]],
)
segment_end = _interval_start(segment["intervals"][-1]) + timedelta(minutes=INTERVAL_MINUTES)
response_segments.append(
{
"start": segment["start"],
"end": segment_end.isoformat(),
"duration_minutes": segment["duration_minutes"],
"interval_count": segment["interval_count"],
"price_mean": seg_stats.get("price_mean"),
"intervals": [
_build_charging_interval(interval, unit_factor=unit_factor, rating_lookup=rating_lookup)
for interval in segment["intervals"]
],
}
)
return response_segments
def _build_battery_info(
*,
current_soc_kwh: float,
target_soc_kwh: float,
capacity_kwh: float | None,
requested_energy_needed_kwh: float,
charging_efficiency: float,
achieved_soc_kwh: float,
must_reach_soc_kwh: float | None,
) -> dict[str, Any]:
info: dict[str, Any] = {
"current_soc_kwh": round(current_soc_kwh, 4),
"target_soc_kwh": round(target_soc_kwh, 4),
"energy_needed_kwh": round(requested_energy_needed_kwh, 4),
"charging_efficiency": charging_efficiency,
"achieved_soc_kwh": round(achieved_soc_kwh, 4),
"target_met": achieved_soc_kwh >= target_soc_kwh - 1e-6,
}
if must_reach_soc_kwh is not None:
info["must_reach_soc_kwh"] = round(must_reach_soc_kwh, 4)
if capacity_kwh is not None and capacity_kwh > 0:
info["capacity_kwh"] = round(capacity_kwh, 4)
info["current_soc_percent"] = round(current_soc_kwh / capacity_kwh * 100.0, 2)
info["target_soc_percent"] = round(target_soc_kwh / capacity_kwh * 100.0, 2)
info["achieved_soc_percent"] = round(achieved_soc_kwh / capacity_kwh * 100.0, 2)
if must_reach_soc_kwh is not None:
info["must_reach_soc_percent"] = round(must_reach_soc_kwh / capacity_kwh * 100.0, 2)
return info
@dataclass(frozen=True)
class _PlanContext:
"""Inputs that stay constant across relaxation attempts."""
price_info: list[dict[str, Any]]
current_soc_kwh: float
target_soc_kwh: float
capacity_kwh: float | None
must_reach_soc_kwh: float | None
deadline: datetime | None
charging_efficiency: float
discharging_efficiency: float
max_charge_power_w: int
min_charge_power_w: int | None
charge_power_steps_w: list[int] | None
grid_import_limit_w: int | None
min_charge_duration_minutes: int | None
max_cycles_per_day: int | None
smooth_outliers: bool
expected_discharge_price_base: float | None
reserve_for_discharge: bool
max_cost_per_kwh_base: float | None
unit_factor: int
@property
def economic_filter_active(self) -> bool:
"""Whether any economic filter param was supplied by the user."""
return (
self.expected_discharge_price_base is not None
or self.max_cost_per_kwh_base is not None
or self.reserve_for_discharge
)
def _classify_empty_candidates(
ctx: _PlanContext,
filtered_by_level: list[dict[str, Any]],
economics_filter: dict[str, Any],
*,
max_price_level: str | None,
min_price_level: str | None,
) -> str:
"""Return a stable reason code when no candidate intervals remain."""
level_filter_active = min_price_level is not None or max_price_level is not None
if not ctx.price_info:
return "no_data_in_range"
if level_filter_active and not filtered_by_level:
return "no_intervals_matching_level_filter"
if economics_filter.get("filtered_out_by_cost") or economics_filter.get("filtered_out_by_profitability"):
return "no_intervals_after_economic_filter"
return "energy_unreachable"
def _build_raw_schedule(
ctx: _PlanContext,
candidates: list[dict[str, Any]],
effective_energy_needed_grid_kwh: float,
) -> tuple[dict[str, Any] | None, str]:
"""Run the deadline-aware or single-pass scheduler and return a schedule dict."""
if ctx.deadline is not None and ctx.must_reach_soc_kwh is not None:
energy_needed_by_deadline = min(
effective_energy_needed_grid_kwh,
calculate_energy_needed(ctx.current_soc_kwh, ctx.must_reach_soc_kwh, ctx.charging_efficiency),
)
schedule = build_deadline_schedule(
candidates,
total_energy_needed_grid_kwh=effective_energy_needed_grid_kwh,
energy_needed_by_deadline_grid_kwh=energy_needed_by_deadline,
deadline=ctx.deadline,
max_charge_power_w=ctx.max_charge_power_w,
charging_efficiency=ctx.charging_efficiency,
min_charge_power_w=ctx.min_charge_power_w,
charge_power_steps_w=ctx.charge_power_steps_w,
grid_import_limit_w=ctx.grid_import_limit_w,
interval_minutes=INTERVAL_MINUTES,
)
if schedule["deadline_unallocated_grid_energy_kwh"] > 1e-6:
return None, "energy_unreachable_by_deadline"
return schedule, ""
schedule = build_power_schedule(
candidates,
effective_energy_needed_grid_kwh,
max_charge_power_w=ctx.max_charge_power_w,
charging_efficiency=ctx.charging_efficiency,
min_charge_power_w=ctx.min_charge_power_w,
charge_power_steps_w=ctx.charge_power_steps_w,
grid_import_limit_w=ctx.grid_import_limit_w,
interval_minutes=INTERVAL_MINUTES,
)
return schedule, ""
def _selection_passes_distance_check(
ctx: _PlanContext,
scheduled_intervals: list[dict[str, Any]],
min_distance_from_avg: float | None,
) -> bool:
"""Return True if the selection meets min_distance_from_avg (or the check is disabled)."""
if min_distance_from_avg is None or not scheduled_intervals:
return True
range_avg = calculate_search_range_avg(ctx.price_info)
if range_avg is None:
return True
selection_mean = sum(float(interval["total"]) for interval in scheduled_intervals) / len(scheduled_intervals)
return check_min_distance_from_avg(selection_mean, range_avg, min_distance_from_avg, reverse=False)
def _build_deadline_info(
ctx: _PlanContext,
scheduled_intervals: list[dict[str, Any]],
) -> dict[str, Any] | None:
"""Compute deadline adherence by splitting the constrained intervals at the deadline."""
if ctx.deadline is None or ctx.must_reach_soc_kwh is None:
return None
pre_stored_kwh = sum(
float(interval.get("stored_energy_kwh", 0.0))
for interval in scheduled_intervals
if _interval_start(interval) < ctx.deadline
)
achieved_by_deadline = ctx.current_soc_kwh + pre_stored_kwh
return {
"must_reach_by": ctx.deadline.isoformat(),
"must_reach_soc_kwh": round(ctx.must_reach_soc_kwh, 4),
"achieved_soc_kwh": round(achieved_by_deadline, 4),
"deadline_met": achieved_by_deadline >= ctx.must_reach_soc_kwh - 1e-6,
}
def _attempt_plan(
ctx: _PlanContext,
*,
effective_energy_needed_grid_kwh: float,
max_price_level: str | None,
min_price_level: str | None,
min_distance_from_avg: float | None,
) -> tuple[dict[str, Any] | None, str]:
"""Run one scheduling attempt with the provided (possibly relaxed) filters."""
candidates, filtered_by_level, economics_filter = _build_candidate_intervals(
ctx.price_info,
max_price_level=max_price_level,
min_price_level=min_price_level,
smooth_outliers=ctx.smooth_outliers,
charging_efficiency=ctx.charging_efficiency,
discharging_efficiency=ctx.discharging_efficiency,
expected_discharge_price_base=ctx.expected_discharge_price_base,
reserve_for_discharge=ctx.reserve_for_discharge,
max_cost_per_kwh_base=ctx.max_cost_per_kwh_base,
)
if not candidates:
reason = _classify_empty_candidates(
ctx,
filtered_by_level,
economics_filter,
max_price_level=max_price_level,
min_price_level=min_price_level,
)
return None, reason
schedule, reason = _build_raw_schedule(ctx, candidates, effective_energy_needed_grid_kwh)
if schedule is None:
return None, reason
if schedule["unallocated_grid_energy_kwh"] > 1e-6:
return None, "energy_unreachable"
schedule, warnings = apply_segment_constraints(
schedule,
candidates,
charging_efficiency=ctx.charging_efficiency,
min_charge_duration_minutes=ctx.min_charge_duration_minutes,
max_cycles_per_day=ctx.max_cycles_per_day,
interval_minutes=INTERVAL_MINUTES,
)
scheduled_intervals = build_soc_progression_from_schedule(
schedule["intervals"], ctx.current_soc_kwh, ctx.capacity_kwh
)
if not _selection_passes_distance_check(ctx, scheduled_intervals, min_distance_from_avg):
return None, "selection_above_distance_threshold"
economics = calculate_plan_economics(
scheduled_intervals,
charging_efficiency=ctx.charging_efficiency,
discharging_efficiency=ctx.discharging_efficiency,
expected_discharge_price=ctx.expected_discharge_price_base,
unit_factor=ctx.unit_factor,
max_cost_per_kwh=ctx.max_cost_per_kwh_base,
reserve_for_discharge=ctx.reserve_for_discharge,
)
return (
{
"schedule": schedule,
"scheduled_intervals": scheduled_intervals,
"achieved_soc_kwh": ctx.current_soc_kwh + schedule["total_stored_energy_kwh"],
"deadline": _build_deadline_info(ctx, scheduled_intervals),
"economics": economics,
"warnings": warnings,
"economics_filter": economics_filter if ctx.economic_filter_active else None,
},
"",
)
async def handle_plan_charging(call: ServiceCall) -> ServiceResponse:
"""Handle the plan_charging service call."""
hass: HomeAssistant = call.hass
data, resolved_refs = resolve_entity_references(hass, call.data, _CHARGING_ENTITY_PARAMS)
validated = _validate_soc_inputs(data)
capacity_kwh = validated["capacity_kwh"]
current_soc_value = validated["current_soc_kwh"]
target_soc_value = validated["target_soc_kwh"]
if current_soc_value is None or target_soc_value is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_current_soc" if current_soc_value is None else "missing_target_soc",
)
current_soc_kwh = float(current_soc_value)
target_soc_kwh = float(target_soc_value)
must_reach_soc_value = validated["must_reach_soc_kwh"]
must_reach_soc_kwh = float(must_reach_soc_value) if must_reach_soc_value is not None else None
entry_id = data.get("entry_id", "")
max_charge_power_w = int(data["max_charge_power_w"])
min_charge_power_w = int(data["min_charge_power_w"]) if "min_charge_power_w" in data else None
charge_power_steps_w = [int(step) for step in data.get("charge_power_steps_w", [])] or None
grid_import_limit_w = int(data["grid_import_limit_w"]) if "grid_import_limit_w" in data else None
charging_efficiency = float(data.get("charging_efficiency", 1.0))
discharging_efficiency = float(data.get("discharging_efficiency", 1.0))
use_base_unit = bool(data.get("use_base_unit", False))
max_price_level = data.get("max_price_level")
min_price_level = data.get("min_price_level")
include_comparison_details = bool(data.get("include_comparison_details", False))
smooth_outliers = bool(data.get("smooth_outliers", True))
min_distance_from_avg = data.get("min_distance_from_avg")
allow_relaxation = bool(data.get("allow_relaxation", True))
duration_flexibility_minutes = data.get("duration_flexibility_minutes")
reserve_for_discharge = bool(data.get("reserve_for_discharge", False))
min_charge_duration_minutes = (
int(data["min_charge_duration_minutes"]) if "min_charge_duration_minutes" in data else None
)
max_cycles_per_day = int(data["max_cycles_per_day"]) if "max_cycles_per_day" in data else None
if current_soc_kwh >= target_soc_kwh - 1e-6:
entry, _coordinator, _coordinator_data = get_entry_and_data(hass, entry_id)
currency = entry.data.get("currency", "EUR")
price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
response: dict[str, Any] = {
"home_id": entry.data.get("home_id", ""),
"intervals_found": False,
"reason": "already_at_target",
"battery": _build_battery_info(
current_soc_kwh=current_soc_kwh,
target_soc_kwh=target_soc_kwh,
capacity_kwh=capacity_kwh,
requested_energy_needed_kwh=0.0,
charging_efficiency=charging_efficiency,
achieved_soc_kwh=current_soc_kwh,
must_reach_soc_kwh=must_reach_soc_kwh,
),
"charging": None,
"currency": currency,
"price_unit": price_unit,
}
if resolved_refs:
response["_resolved"] = resolved_refs
return response
requested_energy_needed_grid_kwh = calculate_energy_needed(current_soc_kwh, target_soc_kwh, charging_efficiency)
entry, coordinator, coordinator_data = get_entry_and_data(hass, entry_id)
rating_lookup = build_rating_lookup(coordinator_data)
home_id = entry.data.get("home_id")
if not home_id:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_home_id",
)
validate_price_level_range(min_price_level, max_price_level)
validate_search_params(data)
home_timezone = resolve_home_timezone(coordinator, home_id)
from zoneinfo import ZoneInfo # noqa: PLC0415
home_tz: ZoneInfo = ZoneInfo(home_timezone)
effective_data, must_finish_by_dt = apply_must_finish_by(data, home_tz)
now = dt_util.now().astimezone(home_tz)
search_start, search_end = resolve_search_range(effective_data, now, home_tz)
currency = entry.data.get("currency", "EUR")
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry)
price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
expected_discharge_price_base = (
float(data["expected_discharge_price"]) / unit_factor if "expected_discharge_price" in data else None
)
max_cost_per_kwh_base = float(data["max_cost_per_kwh"]) / unit_factor if "max_cost_per_kwh" in data else None
try:
_mode, effective_max_power_w, _allowed_steps = determine_power_mode(
max_charge_power_w=max_charge_power_w,
min_charge_power_w=min_charge_power_w,
charge_power_steps_w=charge_power_steps_w,
grid_import_limit_w=grid_import_limit_w,
)
except ValueError as error:
raise _translate_error_key(str(error)) from error
max_interval_energy = energy_for_power(effective_max_power_w, INTERVAL_MINUTES)
requested_intervals = max(1, int((requested_energy_needed_grid_kwh / max_interval_energy) + 0.999999))
try:
deadline, deadline_source = resolve_deadline(
coordinator_data=coordinator_data,
now=now,
home_tz=home_tz,
must_reach_by=data.get("must_reach_by"),
must_reach_by_event=data.get("must_reach_by_event"),
)
except ValueError as error:
raise _translate_error_key(str(error)) from error
if deadline is not None and (deadline <= search_start or deadline > search_end):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="deadline_outside_search_range",
)
api_client = coordinator.api
user_data = coordinator._cached_user_data # noqa: SLF001
pool = entry.runtime_data.interval_pool
try:
price_info, _api_called = await pool.get_intervals(
api_client=api_client,
user_data=user_data,
start_time=search_start,
end_time=search_end,
)
except Exception as error:
_LOGGER.exception("Error fetching price data for %s", PLAN_CHARGING_SERVICE_NAME)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="price_fetch_failed",
) from error
plan_ctx = _PlanContext(
price_info=price_info,
current_soc_kwh=current_soc_kwh,
target_soc_kwh=target_soc_kwh,
capacity_kwh=capacity_kwh,
must_reach_soc_kwh=must_reach_soc_kwh,
deadline=deadline,
charging_efficiency=charging_efficiency,
discharging_efficiency=discharging_efficiency,
max_charge_power_w=max_charge_power_w,
min_charge_power_w=min_charge_power_w,
charge_power_steps_w=charge_power_steps_w,
grid_import_limit_w=grid_import_limit_w,
min_charge_duration_minutes=min_charge_duration_minutes,
max_cycles_per_day=max_cycles_per_day,
smooth_outliers=smooth_outliers,
expected_discharge_price_base=expected_discharge_price_base,
reserve_for_discharge=reserve_for_discharge,
max_cost_per_kwh_base=max_cost_per_kwh_base,
unit_factor=unit_factor,
)
planning_result, reason = _attempt_plan(
plan_ctx,
effective_energy_needed_grid_kwh=requested_energy_needed_grid_kwh,
max_price_level=max_price_level,
min_price_level=min_price_level,
min_distance_from_avg=min_distance_from_avg,
)
relaxation_applied = False
relaxation_steps = 0
warnings: list[str] = []
if planning_result is None and allow_relaxation:
max_reduction = calculate_max_duration_reduction_intervals(requested_intervals, duration_flexibility_minutes)
steps = generate_relaxation_steps(
min_distance_from_avg=min_distance_from_avg,
max_price_level=max_price_level,
min_price_level=min_price_level,
total_intervals=requested_intervals,
min_duration_intervals=max(MIN_RELAXED_DURATION_INTERVALS, 1),
max_duration_reduction_intervals=max_reduction,
reverse=False,
)
for step in steps:
reduced_energy = max(
calculate_energy_needed(current_soc_kwh, must_reach_soc_kwh, charging_efficiency)
if must_reach_soc_kwh is not None
else 0.0,
requested_energy_needed_grid_kwh - (step.duration_reduction * max_interval_energy),
)
attempt, reason = _attempt_plan(
plan_ctx,
effective_energy_needed_grid_kwh=reduced_energy,
max_price_level=step.max_price_level,
min_price_level=step.min_price_level,
min_distance_from_avg=step.min_distance_from_avg,
)
if attempt is not None:
planning_result = attempt
relaxation_applied = True
relaxation_steps = step.step_number
warnings.append("target_reduced_by_relaxation")
break
if planning_result is None:
response: dict[str, Any] = {
"home_id": home_id,
"search_start": search_start.isoformat(),
"search_end": search_end.isoformat(),
"must_finish_by": must_finish_by_dt.isoformat() if must_finish_by_dt else None,
"intervals_found": False,
"reason": reason,
"battery": _build_battery_info(
current_soc_kwh=current_soc_kwh,
target_soc_kwh=target_soc_kwh,
capacity_kwh=capacity_kwh,
requested_energy_needed_kwh=requested_energy_needed_grid_kwh,
charging_efficiency=charging_efficiency,
achieved_soc_kwh=current_soc_kwh,
must_reach_soc_kwh=must_reach_soc_kwh,
),
"charging": None,
"deadline": {"must_reach_by": deadline.isoformat(), "source": deadline_source} if deadline else None,
"economics": None,
"currency": currency,
"price_unit": price_unit,
"relaxation_applied": relaxation_applied,
}
if relaxation_applied:
response["relaxation_steps"] = relaxation_steps
if resolved_refs:
response["_resolved"] = resolved_refs
return response
scheduled_intervals = planning_result["scheduled_intervals"]
schedule_data = planning_result["schedule"]
warnings.extend(planning_result.get("warnings", []))
achieved_soc_kwh = float(planning_result["achieved_soc_kwh"])
response_intervals = [
_build_charging_interval(interval, unit_factor=unit_factor, rating_lookup=rating_lookup)
for interval in scheduled_intervals
]
response_segments = _build_response_segments(
scheduled_intervals,
unit_factor=unit_factor,
rating_lookup=rating_lookup,
)
power_profile = [int(interval["power_w"]) for interval in scheduled_intervals]
stats = calculate_window_statistics(
scheduled_intervals,
unit_factor=unit_factor,
round_decimals=4,
power_profile=power_profile,
)
total_cost_base = sum(
float(interval["total"]) * float(interval["grid_energy_kwh"]) for interval in scheduled_intervals
)
avg_price_per_kwh_base = (
total_cost_base / schedule_data["total_grid_energy_kwh"] if schedule_data["total_grid_energy_kwh"] > 0 else 0.0
)
comparison_result = find_cheapest_n_intervals(price_info, len(scheduled_intervals), 1, reverse=True)
price_comparison: dict[str, Any] = {}
if comparison_result is not None:
comparison_stats = calculate_window_statistics(
comparison_result["intervals"],
unit_factor=unit_factor,
round_decimals=4,
)
own_mean = stats.get("price_mean")
comparison_mean = comparison_stats.get("price_mean")
if own_mean is not None and comparison_mean is not None:
price_comparison = {
"comparison_price_mean": comparison_mean,
"price_difference": abs(round(float(comparison_mean) - float(own_mean), 4)),
}
if include_comparison_details:
price_comparison["comparison_price_min"] = comparison_stats.get("price_min")
price_comparison["comparison_price_max"] = comparison_stats.get("price_max")
seconds_until_start = None
seconds_until_end = None
if response_segments:
first_dt = datetime.fromisoformat(response_segments[0]["start"])
last_dt = datetime.fromisoformat(response_segments[-1]["end"])
seconds_until_start = max(0, int((first_dt - now).total_seconds()))
seconds_until_end = max(0, int((last_dt - now).total_seconds()))
battery_info = _build_battery_info(
current_soc_kwh=current_soc_kwh,
target_soc_kwh=target_soc_kwh,
capacity_kwh=capacity_kwh,
requested_energy_needed_kwh=requested_energy_needed_grid_kwh,
charging_efficiency=charging_efficiency,
achieved_soc_kwh=achieved_soc_kwh,
must_reach_soc_kwh=must_reach_soc_kwh,
)
if relaxation_applied and achieved_soc_kwh < target_soc_kwh - 1e-6:
battery_info["target_met"] = False
deadline_info = planning_result.get("deadline")
if deadline_info is not None:
deadline_info = dict(deadline_info)
deadline_info["source"] = deadline_source
if capacity_kwh is not None and capacity_kwh > 0:
deadline_info["achieved_soc_percent"] = round(deadline_info["achieved_soc_kwh"] / capacity_kwh * 100.0, 2)
deadline_info["must_reach_soc_percent"] = round(
deadline_info["must_reach_soc_kwh"] / capacity_kwh * 100.0, 2
)
response: dict[str, Any] = {
"home_id": home_id,
"search_start": search_start.isoformat(),
"search_end": search_end.isoformat(),
"must_finish_by": must_finish_by_dt.isoformat() if must_finish_by_dt else None,
"intervals_found": True,
"currency": currency,
"price_unit": price_unit,
"battery": battery_info,
"charging": {
"mode": schedule_data["mode"],
"charge_power_w": max_charge_power_w,
"min_charge_power_w": min_charge_power_w,
"charge_power_steps_w": charge_power_steps_w,
"grid_import_limit_w": grid_import_limit_w,
"effective_max_charge_power_w": schedule_data["effective_max_power_w"],
"total_duration_minutes": len(scheduled_intervals) * INTERVAL_MINUTES,
"total_energy_kwh": round(schedule_data["total_grid_energy_kwh"], 6),
"stored_energy_kwh": round(schedule_data["total_stored_energy_kwh"], 6),
"total_cost": round(total_cost_base * unit_factor, 4),
"avg_price_per_kwh": round(avg_price_per_kwh_base * unit_factor, 4),
"schedule": {
"segment_count": len(response_segments),
"segments": response_segments,
"intervals": response_intervals,
"seconds_until_start": seconds_until_start,
"seconds_until_end": seconds_until_end,
**stats,
},
},
"deadline": deadline_info,
"economics": planning_result.get("economics"),
"economics_filter": planning_result.get("economics_filter"),
"price_comparison": price_comparison or None,
"relaxation_applied": relaxation_applied,
"warnings": warnings or None,
}
if relaxation_applied:
response["relaxation_steps"] = relaxation_steps
if resolved_refs:
response["_resolved"] = resolved_refs
return response

View file

@ -1237,7 +1237,7 @@
"message": "Der Endzeitpunkt ({search_end}) muss nach dem Startzeitpunkt ({search_start}) liegen. Überprüfe die Zeit-Parameter und eventuelle Day-Offsets."
},
"price_fetch_failed": {
"message": "Preisdaten konnten nicht von der Tibber-API abgerufen werden. Bitte versuche es später erneut."
"message": "Unable to fetch price data for the requested search range."
},
"invalid_search_scope": {
"message": "Ungültiger Suchbereich. Gültige Werte sind: today, tomorrow, remaining_today, next_24h, next_48h."
@ -1307,6 +1307,51 @@
},
"entity_value_conversion_failed": {
"message": "Wert '{raw_value}' von '{entity_id}' ({attribute}) kann nicht in {expected_type} konvertiert werden. Überprüfe, ob die Entity einen kompatiblen Wert liefert."
},
"capacity_required_for_percent": {
"message": "battery_capacity_kwh is required when current or target SoC is provided as a percentage."
},
"ambiguous_soc_input": {
"message": "The field {field} was provided both as percent and as kWh. Use only one representation."
},
"already_at_target": {
"message": "Current state of charge is already at or above the target. No charging schedule is needed."
},
"target_exceeds_capacity": {
"message": "Target SoC ({target_soc_kwh} kWh) exceeds battery capacity ({capacity_kwh} kWh)."
},
"energy_unreachable": {
"message": "The requested charging target cannot be reached within the available search window and charging constraints."
},
"missing_current_soc": {
"message": "Provide either current_soc_percent or current_soc_kwh."
},
"missing_target_soc": {
"message": "Provide either target_soc_percent or target_soc_kwh."
},
"missing_deadline_for_must_reach": {
"message": "Provide must_reach_by or must_reach_by_event when using must_reach_soc_percent or must_reach_soc_kwh."
},
"missing_must_reach_soc": {
"message": "Provide must_reach_soc_percent or must_reach_soc_kwh when using a must_reach deadline."
},
"invalid_must_reach_soc": {
"message": "The minimum state of charge by deadline must be between current and target state of charge."
},
"power_strategy_conflict": {
"message": "Use either min_charge_power_w or charge_power_steps_w, not both at the same time."
},
"grid_limit_too_low": {
"message": "grid_import_limit_w is lower than the minimum required charging power."
},
"deadline_conflict": {
"message": "Use either must_reach_by or must_reach_by_event, not both at the same time."
},
"deadline_event_not_available": {
"message": "The selected deadline event is not available in the current coordinator data."
},
"deadline_outside_search_range": {
"message": "The resolved deadline must be inside the selected search range."
}
},
"services": {
@ -2115,6 +2160,210 @@
"description": "Maximale Minuten, um die die Dauer bei der Lockerung verkürzt werden darf (0120, Schritt 15). Leer lassen für automatische Berechnung (~20% der Dauer, max. 60 Min.)."
}
}
},
"plan_charging": {
"name": "Plan Charging",
"description": "Creates a lowest-cost charging schedule from battery parameters instead of a fixed duration. Supports fixed, continuous, or stepped charging power, optional deadline-aware minimum SoC planning, and economic filtering for later discharge use cases. Returns a per-interval charging plan with power, energy, SoC progression, segment grouping, total cost, and profitability details. If no schedule is found, the response includes a stable reason code in the reason field (for example: already_at_target, no_data_in_range, no_intervals_matching_level_filter, no_intervals_after_economic_filter, energy_unreachable, energy_unreachable_by_deadline, selection_above_distance_threshold).",
"sections": {
"battery": {
"name": "Battery Parameters",
"description": "Describe the battery: usable capacity, current and target SoC, and charging efficiency. You can provide SoC in percent (requires capacity) or directly in kWh."
},
"charging": {
"name": "Charging Strategy",
"description": "Configure variable-power charging behavior, grid limits, and minimum run-time constraints."
},
"deadline": {
"name": "Deadline Planning",
"description": "Require a minimum state of charge by a specific moment (for example before a peak period). Set both the Minimum SoC by Deadline and when it must be reached — either as an absolute time (Must Reach By) or from a known event (Deadline Event)."
},
"search_range": {
"name": "Custom Search Range",
"description": "Define precise start and end times for the search. Overrides Search Scope when set."
},
"time_alternatives": {
"name": "Advanced Time Options",
"description": "Alternative ways to define the search range using time-of-day and minute offsets."
},
"price_filter": {
"name": "Price Level Filter",
"description": "Restrict search to intervals within the specified Tibber price level range."
},
"search_tuning": {
"name": "Search Algorithm Tuning",
"description": "Fine-tune how the search handles outliers, minimum quality thresholds, and fallback behavior."
},
"economics": {
"name": "Economic Filters",
"description": "Filter charging intervals by maximum cost or expected later discharge value."
},
"output": {
"name": "Output Options",
"description": "Control output format: comparison details and currency unit."
}
},
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
},
"max_charge_power_w": {
"name": "Maximum Charge Power",
"description": "Maximum charging power in watts. This defines the upper bound for fixed, continuous, or stepped charging schedules."
},
"battery_capacity_kwh": {
"name": "Battery Capacity",
"description": "Usable battery capacity in kWh. Required when current or target SoC is provided as a percentage."
},
"current_soc_percent": {
"name": "Current SoC (%)",
"description": "Current battery state of charge in percent. Cannot be combined with Current SoC (kWh)."
},
"current_soc_kwh": {
"name": "Current SoC (kWh)",
"description": "Current battery state of charge in kWh. Cannot be combined with Current SoC (%)."
},
"target_soc_percent": {
"name": "Target SoC (%)",
"description": "Desired battery state of charge in percent. Cannot be combined with Target SoC (kWh)."
},
"target_soc_kwh": {
"name": "Target SoC (kWh)",
"description": "Desired battery state of charge in kWh. Cannot be combined with Target SoC (%)."
},
"charging_efficiency": {
"name": "Charging Efficiency",
"description": "Fraction of grid energy that is stored in the battery. Example: 0.92 means 8% charging losses."
},
"search_scope": {
"name": "Search Scope",
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
},
"include_current_interval": {
"name": "Include Current Interval",
"description": "Include the currently running 15-minute interval in the search. When enabled, charging may begin in the current interval if it is part of the cheapest result."
},
"search_start": {
"name": "Search Start",
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
},
"search_end": {
"name": "Search End",
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
},
"must_finish_by": {
"name": "Must Finish By",
"description": "Deadline: charging must be finished by this time. The search range ends at this deadline — the service finds the cheapest intervals that complete before it. Cannot be combined with Search End, Search End Time, or Search End Offset."
},
"search_start_time": {
"name": "Search Start Time",
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
},
"search_start_day_offset": {
"name": "Search Start Day Offset",
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search Start Time."
},
"search_end_time": {
"name": "Search End Time",
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
},
"search_end_day_offset": {
"name": "Search End Day Offset",
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search End Time."
},
"search_start_offset_minutes": {
"name": "Search Start Offset (minutes)",
"description": "Alternative: start searching this many minutes from now. Positive = future, negative = past. Ignored if Search Start or Search Start Time is set."
},
"search_end_offset_minutes": {
"name": "Search End Offset (minutes)",
"description": "Alternative: stop searching this many minutes from now. Positive = future, negative = past. Ignored if Search End or Search End Time is set."
},
"max_price_level": {
"name": "Maximum Price Level",
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
},
"min_price_level": {
"name": "Minimum Price Level",
"description": "Only consider intervals at or above this Tibber price level. Useful to exclude unrealistically cheap negative-price-only windows from mixed searches."
},
"include_comparison_details": {
"name": "Include Comparison Details",
"description": "Enrich the price_comparison result with additional fields: comparison_price_min and comparison_price_max."
},
"use_base_unit": {
"name": "Use Base Currency Unit",
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
},
"smooth_outliers": {
"name": "Smooth Outliers",
"description": "Smooth price outliers before searching. Outlier intervals are temporarily replaced by the average of their neighbors, so a single spike or dip does not dominate the result. The response always shows the original (unsmoothed) prices. Default: enabled."
},
"min_distance_from_avg": {
"name": "Min. Distance from Average",
"description": "Require the selected charging intervals to be at least this percentage below the average price of the full search range. Leave empty to disable."
},
"allow_relaxation": {
"name": "Allow relaxation",
"description": "Progressively relax filters to guarantee a result when possible. Phases: 1) Reduce/remove distance threshold 2) Expand level filters 3) Reduce duration. Default: enabled."
},
"duration_flexibility_minutes": {
"name": "Duration flexibility",
"description": "Maximum minutes the automatically calculated charging duration may be shortened during relaxation (0120, step 15). Leave empty for automatic calculation."
},
"must_reach_soc_percent": {
"name": "Minimum SoC by Deadline (%)",
"description": "Minimum battery state of charge that must be reached by the deadline. Cannot be combined with Minimum SoC by Deadline (kWh)."
},
"must_reach_soc_kwh": {
"name": "Minimum SoC by Deadline (kWh)",
"description": "Minimum battery state of charge that must be reached by the deadline. Cannot be combined with Minimum SoC by Deadline (%)."
},
"min_charge_power_w": {
"name": "Minimum Charge Power",
"description": "Enable continuous charging mode. The planner may reduce the last interval down to this minimum power instead of always using the maximum."
},
"charge_power_steps_w": {
"name": "Charge Power Steps",
"description": "Enable stepped charging mode. Provide the allowed power steps in watts as a list, for example [1380, 4140, 11000]. The planner picks the smallest step that still covers the remaining energy in the final interval. Mutually exclusive with Minimum Charge Power."
},
"grid_import_limit_w": {
"name": "Grid Import Limit",
"description": "Upper limit for charging power drawn from the grid in watts. Useful when the charger must share available power with other loads."
},
"must_reach_by": {
"name": "Must Reach By",
"description": "Absolute deadline for must_reach_soc_*. The planner first guarantees the minimum SoC before this moment, then continues with the remaining target if possible."
},
"must_reach_by_event": {
"name": "Deadline Event",
"description": "Alternative deadline derived from coordinator data. Use this instead of Must Reach By to plan around midnight, the next peak period, or the next best-price period end."
},
"discharging_efficiency": {
"name": "Discharging Efficiency",
"description": "Fraction of stored battery energy that is still usable when later discharged. Used for profitability calculations."
},
"expected_discharge_price": {
"name": "Expected Discharge Price",
"description": "Expected value of each discharged kWh. Intervals above the break-even price can be filtered when Reserve For Discharge is enabled."
},
"reserve_for_discharge": {
"name": "Reserve For Discharge",
"description": "Keep only intervals that are economically sensible for a later discharge based on expected discharge price and round-trip efficiency."
},
"max_cost_per_kwh": {
"name": "Maximum Charge Price",
"description": "Discard candidate intervals above this price per kWh before scheduling. Uses the selected price unit."
},
"min_charge_duration_minutes": {
"name": "Minimum Charge Duration",
"description": "Merge short isolated intervals into longer charging blocks where possible. Useful for chargers that should avoid very short runs."
},
"max_cycles_per_day": {
"name": "Maximum Charge Cycles Per Day",
"description": "Limit how many separate charging segments may be used per day. The planner keeps the cheapest segments within this limit."
}
}
}
},
"selector": {
@ -2224,6 +2473,13 @@
"next_24h": "Nächste 24 Stunden",
"next_48h": "Nächste 48 Stunden"
}
},
"charging_deadline_event": {
"options": {
"midnight": "Midnight",
"next_peak_period": "Next Peak Period",
"next_best_period_end": "End of Next Best-Price Period"
}
}
}
}

View file

@ -1237,7 +1237,7 @@
"message": "End time ({search_end}) must be after start time ({search_start}). Check your time parameters and any day offsets."
},
"price_fetch_failed": {
"message": "Failed to fetch price data from the Tibber API. Please try again later."
"message": "Unable to fetch price data for the requested search range."
},
"invalid_search_scope": {
"message": "Invalid search scope value. Valid scopes are: today, tomorrow, remaining_today, next_24h, next_48h."
@ -1307,6 +1307,51 @@
},
"entity_value_conversion_failed": {
"message": "Cannot convert value '{raw_value}' from '{entity_id}' ({attribute}) to {expected_type}. Verify the entity provides a compatible value."
},
"capacity_required_for_percent": {
"message": "battery_capacity_kwh is required when current or target SoC is provided as a percentage."
},
"ambiguous_soc_input": {
"message": "The field {field} was provided both as percent and as kWh. Use only one representation."
},
"already_at_target": {
"message": "Current state of charge is already at or above the target. No charging schedule is needed."
},
"target_exceeds_capacity": {
"message": "Target SoC ({target_soc_kwh} kWh) exceeds battery capacity ({capacity_kwh} kWh)."
},
"energy_unreachable": {
"message": "The requested charging target cannot be reached within the available search window and charging constraints."
},
"missing_current_soc": {
"message": "Provide either current_soc_percent or current_soc_kwh."
},
"missing_target_soc": {
"message": "Provide either target_soc_percent or target_soc_kwh."
},
"missing_deadline_for_must_reach": {
"message": "Provide must_reach_by or must_reach_by_event when using must_reach_soc_percent or must_reach_soc_kwh."
},
"missing_must_reach_soc": {
"message": "Provide must_reach_soc_percent or must_reach_soc_kwh when using a must_reach deadline."
},
"invalid_must_reach_soc": {
"message": "The minimum state of charge by deadline must be between current and target state of charge."
},
"power_strategy_conflict": {
"message": "Use either min_charge_power_w or charge_power_steps_w, not both at the same time."
},
"grid_limit_too_low": {
"message": "grid_import_limit_w is lower than the minimum required charging power."
},
"deadline_conflict": {
"message": "Use either must_reach_by or must_reach_by_event, not both at the same time."
},
"deadline_event_not_available": {
"message": "The selected deadline event is not available in the current coordinator data."
},
"deadline_outside_search_range": {
"message": "The resolved deadline must be inside the selected search range."
}
},
"services": {
@ -2116,6 +2161,210 @@
}
}
},
"plan_charging": {
"name": "Plan Charging",
"description": "Creates a lowest-cost charging schedule from battery parameters instead of a fixed duration. Supports fixed, continuous, or stepped charging power, optional deadline-aware minimum SoC planning, and economic filtering for later discharge use cases. Returns a per-interval charging plan with power, energy, SoC progression, segment grouping, total cost, and profitability details. If no schedule is found, the response includes a stable reason code in the reason field (for example: already_at_target, no_data_in_range, no_intervals_matching_level_filter, no_intervals_after_economic_filter, energy_unreachable, energy_unreachable_by_deadline, selection_above_distance_threshold).",
"sections": {
"battery": {
"name": "Battery Parameters",
"description": "Describe the battery: usable capacity, current and target SoC, and charging efficiency. You can provide SoC in percent (requires capacity) or directly in kWh."
},
"charging": {
"name": "Charging Strategy",
"description": "Configure variable-power charging behavior, grid limits, and minimum run-time constraints."
},
"deadline": {
"name": "Deadline Planning",
"description": "Require a minimum state of charge by a specific moment (for example before a peak period). Set both the Minimum SoC by Deadline and when it must be reached — either as an absolute time (Must Reach By) or from a known event (Deadline Event)."
},
"search_range": {
"name": "Custom Search Range",
"description": "Define precise start and end times for the search. Overrides Search Scope when set."
},
"time_alternatives": {
"name": "Advanced Time Options",
"description": "Alternative ways to define the search range using time-of-day and minute offsets."
},
"price_filter": {
"name": "Price Level Filter",
"description": "Restrict search to intervals within the specified Tibber price level range."
},
"search_tuning": {
"name": "Search Algorithm Tuning",
"description": "Fine-tune how the search handles outliers, minimum quality thresholds, and fallback behavior."
},
"economics": {
"name": "Economic Filters",
"description": "Filter charging intervals by maximum cost or expected later discharge value."
},
"output": {
"name": "Output Options",
"description": "Control output format: comparison details and currency unit."
}
},
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
},
"max_charge_power_w": {
"name": "Maximum Charge Power",
"description": "Maximum charging power in watts. This defines the upper bound for fixed, continuous, or stepped charging schedules."
},
"battery_capacity_kwh": {
"name": "Battery Capacity",
"description": "Usable battery capacity in kWh. Required when current or target SoC is provided as a percentage."
},
"current_soc_percent": {
"name": "Current SoC (%)",
"description": "Current battery state of charge in percent. Cannot be combined with Current SoC (kWh)."
},
"current_soc_kwh": {
"name": "Current SoC (kWh)",
"description": "Current battery state of charge in kWh. Cannot be combined with Current SoC (%)."
},
"target_soc_percent": {
"name": "Target SoC (%)",
"description": "Desired battery state of charge in percent. Cannot be combined with Target SoC (kWh)."
},
"target_soc_kwh": {
"name": "Target SoC (kWh)",
"description": "Desired battery state of charge in kWh. Cannot be combined with Target SoC (%)."
},
"charging_efficiency": {
"name": "Charging Efficiency",
"description": "Fraction of grid energy that is stored in the battery. Example: 0.92 means 8% charging losses."
},
"search_scope": {
"name": "Search Scope",
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
},
"include_current_interval": {
"name": "Include Current Interval",
"description": "Include the currently running 15-minute interval in the search. When enabled, charging may begin in the current interval if it is part of the cheapest result."
},
"search_start": {
"name": "Search Start",
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
},
"search_end": {
"name": "Search End",
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
},
"must_finish_by": {
"name": "Must Finish By",
"description": "Deadline: charging must be finished by this time. The search range ends at this deadline — the service finds the cheapest intervals that complete before it. Cannot be combined with Search End, Search End Time, or Search End Offset."
},
"search_start_time": {
"name": "Search Start Time",
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
},
"search_start_day_offset": {
"name": "Search Start Day Offset",
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search Start Time."
},
"search_end_time": {
"name": "Search End Time",
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
},
"search_end_day_offset": {
"name": "Search End Day Offset",
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search End Time."
},
"search_start_offset_minutes": {
"name": "Search Start Offset (minutes)",
"description": "Alternative: start searching this many minutes from now. Positive = future, negative = past. Ignored if Search Start or Search Start Time is set."
},
"search_end_offset_minutes": {
"name": "Search End Offset (minutes)",
"description": "Alternative: stop searching this many minutes from now. Positive = future, negative = past. Ignored if Search End or Search End Time is set."
},
"max_price_level": {
"name": "Maximum Price Level",
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
},
"min_price_level": {
"name": "Minimum Price Level",
"description": "Only consider intervals at or above this Tibber price level. Useful to exclude unrealistically cheap negative-price-only windows from mixed searches."
},
"include_comparison_details": {
"name": "Include Comparison Details",
"description": "Enrich the price_comparison result with additional fields: comparison_price_min and comparison_price_max."
},
"use_base_unit": {
"name": "Use Base Currency Unit",
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
},
"smooth_outliers": {
"name": "Smooth Outliers",
"description": "Smooth price outliers before searching. Outlier intervals are temporarily replaced by the average of their neighbors, so a single spike or dip does not dominate the result. The response always shows the original (unsmoothed) prices. Default: enabled."
},
"min_distance_from_avg": {
"name": "Min. Distance from Average",
"description": "Require the selected charging intervals to be at least this percentage below the average price of the full search range. Leave empty to disable."
},
"allow_relaxation": {
"name": "Allow relaxation",
"description": "Progressively relax filters to guarantee a result when possible. Phases: 1) Reduce/remove distance threshold 2) Expand level filters 3) Reduce duration. Default: enabled."
},
"duration_flexibility_minutes": {
"name": "Duration flexibility",
"description": "Maximum minutes the automatically calculated charging duration may be shortened during relaxation (0120, step 15). Leave empty for automatic calculation."
},
"must_reach_soc_percent": {
"name": "Minimum SoC by Deadline (%)",
"description": "Minimum battery state of charge that must be reached by the deadline. Cannot be combined with Minimum SoC by Deadline (kWh)."
},
"must_reach_soc_kwh": {
"name": "Minimum SoC by Deadline (kWh)",
"description": "Minimum battery state of charge that must be reached by the deadline. Cannot be combined with Minimum SoC by Deadline (%)."
},
"min_charge_power_w": {
"name": "Minimum Charge Power",
"description": "Enable continuous charging mode. The planner may reduce the last interval down to this minimum power instead of always using the maximum."
},
"charge_power_steps_w": {
"name": "Charge Power Steps",
"description": "Enable stepped charging mode. Provide the allowed power steps in watts as a list, for example [1380, 4140, 11000]. The planner picks the smallest step that still covers the remaining energy in the final interval. Mutually exclusive with Minimum Charge Power."
},
"grid_import_limit_w": {
"name": "Grid Import Limit",
"description": "Upper limit for charging power drawn from the grid in watts. Useful when the charger must share available power with other loads."
},
"must_reach_by": {
"name": "Must Reach By",
"description": "Absolute deadline for must_reach_soc_*. The planner first guarantees the minimum SoC before this moment, then continues with the remaining target if possible."
},
"must_reach_by_event": {
"name": "Deadline Event",
"description": "Alternative deadline derived from coordinator data. Use this instead of Must Reach By to plan around midnight, the next peak period, or the next best-price period end."
},
"discharging_efficiency": {
"name": "Discharging Efficiency",
"description": "Fraction of stored battery energy that is still usable when later discharged. Used for profitability calculations."
},
"expected_discharge_price": {
"name": "Expected Discharge Price",
"description": "Expected value of each discharged kWh. Intervals above the break-even price can be filtered when Reserve For Discharge is enabled."
},
"reserve_for_discharge": {
"name": "Reserve For Discharge",
"description": "Keep only intervals that are economically sensible for a later discharge based on expected discharge price and round-trip efficiency."
},
"max_cost_per_kwh": {
"name": "Maximum Charge Price",
"description": "Discard candidate intervals above this price per kWh before scheduling. Uses the selected price unit."
},
"min_charge_duration_minutes": {
"name": "Minimum Charge Duration",
"description": "Merge short isolated intervals into longer charging blocks where possible. Useful for chargers that should avoid very short runs."
},
"max_cycles_per_day": {
"name": "Maximum Charge Cycles Per Day",
"description": "Limit how many separate charging segments may be used per day. The planner keeps the cheapest segments within this limit."
}
}
},
"debug_clear_tomorrow": {
"name": "Debug: Clear Tomorrow Data",
"description": "DEBUG/TESTING: Removes tomorrow's price data from the interval pool cache. Use this to test the tomorrow data refresh cycle without waiting for the next day. After calling this service, the lifecycle sensor will show 'searching_tomorrow' (after 13:00) and the next Timer #1 cycle will fetch new data from the API.",
@ -2234,6 +2483,13 @@
"next_24h": "Next 24 Hours",
"next_48h": "Next 48 Hours"
}
},
"charging_deadline_event": {
"options": {
"midnight": "Midnight",
"next_peak_period": "Next Peak Period",
"next_best_period_end": "End of Next Best-Price Period"
}
}
}
}

View file

@ -1237,7 +1237,7 @@
"message": "Sluttidspunktet ({search_end}) må være etter starttidspunktet ({search_start}). Sjekk tid-parameterne og eventuelle day-offsets."
},
"price_fetch_failed": {
"message": "Kunne ikke hente prisdata fra Tibber API. Vennligst prøv igjen senere."
"message": "Unable to fetch price data for the requested search range."
},
"invalid_search_scope": {
"message": "Ugyldig søkeområde. Gyldige verdier er: today, tomorrow, remaining_today, next_24h, next_48h."
@ -1307,6 +1307,51 @@
},
"entity_value_conversion_failed": {
"message": "Kan ikke konvertere verdi '{raw_value}' fra '{entity_id}' ({attribute}) til {expected_type}. Kontroller at entiteten gir en kompatibel verdi."
},
"capacity_required_for_percent": {
"message": "battery_capacity_kwh is required when current or target SoC is provided as a percentage."
},
"ambiguous_soc_input": {
"message": "The field {field} was provided both as percent and as kWh. Use only one representation."
},
"already_at_target": {
"message": "Current state of charge is already at or above the target. No charging schedule is needed."
},
"target_exceeds_capacity": {
"message": "Target SoC ({target_soc_kwh} kWh) exceeds battery capacity ({capacity_kwh} kWh)."
},
"energy_unreachable": {
"message": "The requested charging target cannot be reached within the available search window and charging constraints."
},
"missing_current_soc": {
"message": "Provide either current_soc_percent or current_soc_kwh."
},
"missing_target_soc": {
"message": "Provide either target_soc_percent or target_soc_kwh."
},
"missing_deadline_for_must_reach": {
"message": "Provide must_reach_by or must_reach_by_event when using must_reach_soc_percent or must_reach_soc_kwh."
},
"missing_must_reach_soc": {
"message": "Provide must_reach_soc_percent or must_reach_soc_kwh when using a must_reach deadline."
},
"invalid_must_reach_soc": {
"message": "The minimum state of charge by deadline must be between current and target state of charge."
},
"power_strategy_conflict": {
"message": "Use either min_charge_power_w or charge_power_steps_w, not both at the same time."
},
"grid_limit_too_low": {
"message": "grid_import_limit_w is lower than the minimum required charging power."
},
"deadline_conflict": {
"message": "Use either must_reach_by or must_reach_by_event, not both at the same time."
},
"deadline_event_not_available": {
"message": "The selected deadline event is not available in the current coordinator data."
},
"deadline_outside_search_range": {
"message": "The resolved deadline must be inside the selected search range."
}
},
"services": {
@ -2115,6 +2160,210 @@
"description": "Maks minutter varigheten kan forkortes under slakking (0120, steg 15). La stå tom for automatisk beregning (~20% av varigheten, maks 60 min)."
}
}
},
"plan_charging": {
"name": "Plan Charging",
"description": "Creates a lowest-cost charging schedule from battery parameters instead of a fixed duration. Supports fixed, continuous, or stepped charging power, optional deadline-aware minimum SoC planning, and economic filtering for later discharge use cases. Returns a per-interval charging plan with power, energy, SoC progression, segment grouping, total cost, and profitability details. If no schedule is found, the response includes a stable reason code in the reason field (for example: already_at_target, no_data_in_range, no_intervals_matching_level_filter, no_intervals_after_economic_filter, energy_unreachable, energy_unreachable_by_deadline, selection_above_distance_threshold).",
"sections": {
"battery": {
"name": "Battery Parameters",
"description": "Describe the battery: usable capacity, current and target SoC, and charging efficiency. You can provide SoC in percent (requires capacity) or directly in kWh."
},
"charging": {
"name": "Charging Strategy",
"description": "Configure variable-power charging behavior, grid limits, and minimum run-time constraints."
},
"deadline": {
"name": "Deadline Planning",
"description": "Require a minimum state of charge by a specific moment (for example before a peak period). Set both the Minimum SoC by Deadline and when it must be reached — either as an absolute time (Must Reach By) or from a known event (Deadline Event)."
},
"search_range": {
"name": "Custom Search Range",
"description": "Define precise start and end times for the search. Overrides Search Scope when set."
},
"time_alternatives": {
"name": "Advanced Time Options",
"description": "Alternative ways to define the search range using time-of-day and minute offsets."
},
"price_filter": {
"name": "Price Level Filter",
"description": "Restrict search to intervals within the specified Tibber price level range."
},
"search_tuning": {
"name": "Search Algorithm Tuning",
"description": "Fine-tune how the search handles outliers, minimum quality thresholds, and fallback behavior."
},
"economics": {
"name": "Economic Filters",
"description": "Filter charging intervals by maximum cost or expected later discharge value."
},
"output": {
"name": "Output Options",
"description": "Control output format: comparison details and currency unit."
}
},
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
},
"max_charge_power_w": {
"name": "Maximum Charge Power",
"description": "Maximum charging power in watts. This defines the upper bound for fixed, continuous, or stepped charging schedules."
},
"battery_capacity_kwh": {
"name": "Battery Capacity",
"description": "Usable battery capacity in kWh. Required when current or target SoC is provided as a percentage."
},
"current_soc_percent": {
"name": "Current SoC (%)",
"description": "Current battery state of charge in percent. Cannot be combined with Current SoC (kWh)."
},
"current_soc_kwh": {
"name": "Current SoC (kWh)",
"description": "Current battery state of charge in kWh. Cannot be combined with Current SoC (%)."
},
"target_soc_percent": {
"name": "Target SoC (%)",
"description": "Desired battery state of charge in percent. Cannot be combined with Target SoC (kWh)."
},
"target_soc_kwh": {
"name": "Target SoC (kWh)",
"description": "Desired battery state of charge in kWh. Cannot be combined with Target SoC (%)."
},
"charging_efficiency": {
"name": "Charging Efficiency",
"description": "Fraction of grid energy that is stored in the battery. Example: 0.92 means 8% charging losses."
},
"search_scope": {
"name": "Search Scope",
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
},
"include_current_interval": {
"name": "Include Current Interval",
"description": "Include the currently running 15-minute interval in the search. When enabled, charging may begin in the current interval if it is part of the cheapest result."
},
"search_start": {
"name": "Search Start",
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
},
"search_end": {
"name": "Search End",
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
},
"must_finish_by": {
"name": "Must Finish By",
"description": "Deadline: charging must be finished by this time. The search range ends at this deadline — the service finds the cheapest intervals that complete before it. Cannot be combined with Search End, Search End Time, or Search End Offset."
},
"search_start_time": {
"name": "Search Start Time",
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
},
"search_start_day_offset": {
"name": "Search Start Day Offset",
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search Start Time."
},
"search_end_time": {
"name": "Search End Time",
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
},
"search_end_day_offset": {
"name": "Search End Day Offset",
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search End Time."
},
"search_start_offset_minutes": {
"name": "Search Start Offset (minutes)",
"description": "Alternative: start searching this many minutes from now. Positive = future, negative = past. Ignored if Search Start or Search Start Time is set."
},
"search_end_offset_minutes": {
"name": "Search End Offset (minutes)",
"description": "Alternative: stop searching this many minutes from now. Positive = future, negative = past. Ignored if Search End or Search End Time is set."
},
"max_price_level": {
"name": "Maximum Price Level",
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
},
"min_price_level": {
"name": "Minimum Price Level",
"description": "Only consider intervals at or above this Tibber price level. Useful to exclude unrealistically cheap negative-price-only windows from mixed searches."
},
"include_comparison_details": {
"name": "Include Comparison Details",
"description": "Enrich the price_comparison result with additional fields: comparison_price_min and comparison_price_max."
},
"use_base_unit": {
"name": "Use Base Currency Unit",
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
},
"smooth_outliers": {
"name": "Smooth Outliers",
"description": "Smooth price outliers before searching. Outlier intervals are temporarily replaced by the average of their neighbors, so a single spike or dip does not dominate the result. The response always shows the original (unsmoothed) prices. Default: enabled."
},
"min_distance_from_avg": {
"name": "Min. Distance from Average",
"description": "Require the selected charging intervals to be at least this percentage below the average price of the full search range. Leave empty to disable."
},
"allow_relaxation": {
"name": "Allow relaxation",
"description": "Progressively relax filters to guarantee a result when possible. Phases: 1) Reduce/remove distance threshold 2) Expand level filters 3) Reduce duration. Default: enabled."
},
"duration_flexibility_minutes": {
"name": "Duration flexibility",
"description": "Maximum minutes the automatically calculated charging duration may be shortened during relaxation (0120, step 15). Leave empty for automatic calculation."
},
"must_reach_soc_percent": {
"name": "Minimum SoC by Deadline (%)",
"description": "Minimum battery state of charge that must be reached by the deadline. Cannot be combined with Minimum SoC by Deadline (kWh)."
},
"must_reach_soc_kwh": {
"name": "Minimum SoC by Deadline (kWh)",
"description": "Minimum battery state of charge that must be reached by the deadline. Cannot be combined with Minimum SoC by Deadline (%)."
},
"min_charge_power_w": {
"name": "Minimum Charge Power",
"description": "Enable continuous charging mode. The planner may reduce the last interval down to this minimum power instead of always using the maximum."
},
"charge_power_steps_w": {
"name": "Charge Power Steps",
"description": "Enable stepped charging mode. Provide the allowed power steps in watts as a list, for example [1380, 4140, 11000]. The planner picks the smallest step that still covers the remaining energy in the final interval. Mutually exclusive with Minimum Charge Power."
},
"grid_import_limit_w": {
"name": "Grid Import Limit",
"description": "Upper limit for charging power drawn from the grid in watts. Useful when the charger must share available power with other loads."
},
"must_reach_by": {
"name": "Must Reach By",
"description": "Absolute deadline for must_reach_soc_*. The planner first guarantees the minimum SoC before this moment, then continues with the remaining target if possible."
},
"must_reach_by_event": {
"name": "Deadline Event",
"description": "Alternative deadline derived from coordinator data. Use this instead of Must Reach By to plan around midnight, the next peak period, or the next best-price period end."
},
"discharging_efficiency": {
"name": "Discharging Efficiency",
"description": "Fraction of stored battery energy that is still usable when later discharged. Used for profitability calculations."
},
"expected_discharge_price": {
"name": "Expected Discharge Price",
"description": "Expected value of each discharged kWh. Intervals above the break-even price can be filtered when Reserve For Discharge is enabled."
},
"reserve_for_discharge": {
"name": "Reserve For Discharge",
"description": "Keep only intervals that are economically sensible for a later discharge based on expected discharge price and round-trip efficiency."
},
"max_cost_per_kwh": {
"name": "Maximum Charge Price",
"description": "Discard candidate intervals above this price per kWh before scheduling. Uses the selected price unit."
},
"min_charge_duration_minutes": {
"name": "Minimum Charge Duration",
"description": "Merge short isolated intervals into longer charging blocks where possible. Useful for chargers that should avoid very short runs."
},
"max_cycles_per_day": {
"name": "Maximum Charge Cycles Per Day",
"description": "Limit how many separate charging segments may be used per day. The planner keeps the cheapest segments within this limit."
}
}
}
},
"selector": {
@ -2224,6 +2473,13 @@
"next_24h": "Neste 24 timer",
"next_48h": "Neste 48 timer"
}
},
"charging_deadline_event": {
"options": {
"midnight": "Midnight",
"next_peak_period": "Next Peak Period",
"next_best_period_end": "End of Next Best-Price Period"
}
}
}
}

View file

@ -1237,7 +1237,7 @@
"message": "Het eindtijdstip ({search_end}) moet na het starttijdstip ({search_start}) liggen. Controleer je tijdparameters en eventuele day-offsets."
},
"price_fetch_failed": {
"message": "Kon prijsgegevens niet ophalen bij de Tibber API. Probeer het later opnieuw."
"message": "Unable to fetch price data for the requested search range."
},
"invalid_search_scope": {
"message": "Ongeldig zoekbereik. Geldige waarden zijn: today, tomorrow, remaining_today, next_24h, next_48h."
@ -1307,6 +1307,51 @@
},
"entity_value_conversion_failed": {
"message": "Kan waarde '{raw_value}' van '{entity_id}' ({attribute}) niet converteren naar {expected_type}. Controleer of de entiteit een compatibele waarde biedt."
},
"capacity_required_for_percent": {
"message": "battery_capacity_kwh is required when current or target SoC is provided as a percentage."
},
"ambiguous_soc_input": {
"message": "The field {field} was provided both as percent and as kWh. Use only one representation."
},
"already_at_target": {
"message": "Current state of charge is already at or above the target. No charging schedule is needed."
},
"target_exceeds_capacity": {
"message": "Target SoC ({target_soc_kwh} kWh) exceeds battery capacity ({capacity_kwh} kWh)."
},
"energy_unreachable": {
"message": "The requested charging target cannot be reached within the available search window and charging constraints."
},
"missing_current_soc": {
"message": "Provide either current_soc_percent or current_soc_kwh."
},
"missing_target_soc": {
"message": "Provide either target_soc_percent or target_soc_kwh."
},
"missing_deadline_for_must_reach": {
"message": "Provide must_reach_by or must_reach_by_event when using must_reach_soc_percent or must_reach_soc_kwh."
},
"missing_must_reach_soc": {
"message": "Provide must_reach_soc_percent or must_reach_soc_kwh when using a must_reach deadline."
},
"invalid_must_reach_soc": {
"message": "The minimum state of charge by deadline must be between current and target state of charge."
},
"power_strategy_conflict": {
"message": "Use either min_charge_power_w or charge_power_steps_w, not both at the same time."
},
"grid_limit_too_low": {
"message": "grid_import_limit_w is lower than the minimum required charging power."
},
"deadline_conflict": {
"message": "Use either must_reach_by or must_reach_by_event, not both at the same time."
},
"deadline_event_not_available": {
"message": "The selected deadline event is not available in the current coordinator data."
},
"deadline_outside_search_range": {
"message": "The resolved deadline must be inside the selected search range."
}
},
"services": {
@ -2115,6 +2160,210 @@
"description": "Maximale minuten waarmee de duur mag worden verkort bij versoepeling (0120, stap 15). Leeg laten voor automatische berekening (~20% van de duur, max 60 min)."
}
}
},
"plan_charging": {
"name": "Plan Charging",
"description": "Creates a lowest-cost charging schedule from battery parameters instead of a fixed duration. Supports fixed, continuous, or stepped charging power, optional deadline-aware minimum SoC planning, and economic filtering for later discharge use cases. Returns a per-interval charging plan with power, energy, SoC progression, segment grouping, total cost, and profitability details. If no schedule is found, the response includes a stable reason code in the reason field (for example: already_at_target, no_data_in_range, no_intervals_matching_level_filter, no_intervals_after_economic_filter, energy_unreachable, energy_unreachable_by_deadline, selection_above_distance_threshold).",
"sections": {
"battery": {
"name": "Battery Parameters",
"description": "Describe the battery: usable capacity, current and target SoC, and charging efficiency. You can provide SoC in percent (requires capacity) or directly in kWh."
},
"charging": {
"name": "Charging Strategy",
"description": "Configure variable-power charging behavior, grid limits, and minimum run-time constraints."
},
"deadline": {
"name": "Deadline Planning",
"description": "Require a minimum state of charge by a specific moment (for example before a peak period). Set both the Minimum SoC by Deadline and when it must be reached — either as an absolute time (Must Reach By) or from a known event (Deadline Event)."
},
"search_range": {
"name": "Custom Search Range",
"description": "Define precise start and end times for the search. Overrides Search Scope when set."
},
"time_alternatives": {
"name": "Advanced Time Options",
"description": "Alternative ways to define the search range using time-of-day and minute offsets."
},
"price_filter": {
"name": "Price Level Filter",
"description": "Restrict search to intervals within the specified Tibber price level range."
},
"search_tuning": {
"name": "Search Algorithm Tuning",
"description": "Fine-tune how the search handles outliers, minimum quality thresholds, and fallback behavior."
},
"economics": {
"name": "Economic Filters",
"description": "Filter charging intervals by maximum cost or expected later discharge value."
},
"output": {
"name": "Output Options",
"description": "Control output format: comparison details and currency unit."
}
},
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
},
"max_charge_power_w": {
"name": "Maximum Charge Power",
"description": "Maximum charging power in watts. This defines the upper bound for fixed, continuous, or stepped charging schedules."
},
"battery_capacity_kwh": {
"name": "Battery Capacity",
"description": "Usable battery capacity in kWh. Required when current or target SoC is provided as a percentage."
},
"current_soc_percent": {
"name": "Current SoC (%)",
"description": "Current battery state of charge in percent. Cannot be combined with Current SoC (kWh)."
},
"current_soc_kwh": {
"name": "Current SoC (kWh)",
"description": "Current battery state of charge in kWh. Cannot be combined with Current SoC (%)."
},
"target_soc_percent": {
"name": "Target SoC (%)",
"description": "Desired battery state of charge in percent. Cannot be combined with Target SoC (kWh)."
},
"target_soc_kwh": {
"name": "Target SoC (kWh)",
"description": "Desired battery state of charge in kWh. Cannot be combined with Target SoC (%)."
},
"charging_efficiency": {
"name": "Charging Efficiency",
"description": "Fraction of grid energy that is stored in the battery. Example: 0.92 means 8% charging losses."
},
"search_scope": {
"name": "Search Scope",
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
},
"include_current_interval": {
"name": "Include Current Interval",
"description": "Include the currently running 15-minute interval in the search. When enabled, charging may begin in the current interval if it is part of the cheapest result."
},
"search_start": {
"name": "Search Start",
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
},
"search_end": {
"name": "Search End",
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
},
"must_finish_by": {
"name": "Must Finish By",
"description": "Deadline: charging must be finished by this time. The search range ends at this deadline — the service finds the cheapest intervals that complete before it. Cannot be combined with Search End, Search End Time, or Search End Offset."
},
"search_start_time": {
"name": "Search Start Time",
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
},
"search_start_day_offset": {
"name": "Search Start Day Offset",
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search Start Time."
},
"search_end_time": {
"name": "Search End Time",
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
},
"search_end_day_offset": {
"name": "Search End Day Offset",
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search End Time."
},
"search_start_offset_minutes": {
"name": "Search Start Offset (minutes)",
"description": "Alternative: start searching this many minutes from now. Positive = future, negative = past. Ignored if Search Start or Search Start Time is set."
},
"search_end_offset_minutes": {
"name": "Search End Offset (minutes)",
"description": "Alternative: stop searching this many minutes from now. Positive = future, negative = past. Ignored if Search End or Search End Time is set."
},
"max_price_level": {
"name": "Maximum Price Level",
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
},
"min_price_level": {
"name": "Minimum Price Level",
"description": "Only consider intervals at or above this Tibber price level. Useful to exclude unrealistically cheap negative-price-only windows from mixed searches."
},
"include_comparison_details": {
"name": "Include Comparison Details",
"description": "Enrich the price_comparison result with additional fields: comparison_price_min and comparison_price_max."
},
"use_base_unit": {
"name": "Use Base Currency Unit",
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
},
"smooth_outliers": {
"name": "Smooth Outliers",
"description": "Smooth price outliers before searching. Outlier intervals are temporarily replaced by the average of their neighbors, so a single spike or dip does not dominate the result. The response always shows the original (unsmoothed) prices. Default: enabled."
},
"min_distance_from_avg": {
"name": "Min. Distance from Average",
"description": "Require the selected charging intervals to be at least this percentage below the average price of the full search range. Leave empty to disable."
},
"allow_relaxation": {
"name": "Allow relaxation",
"description": "Progressively relax filters to guarantee a result when possible. Phases: 1) Reduce/remove distance threshold 2) Expand level filters 3) Reduce duration. Default: enabled."
},
"duration_flexibility_minutes": {
"name": "Duration flexibility",
"description": "Maximum minutes the automatically calculated charging duration may be shortened during relaxation (0120, step 15). Leave empty for automatic calculation."
},
"must_reach_soc_percent": {
"name": "Minimum SoC by Deadline (%)",
"description": "Minimum battery state of charge that must be reached by the deadline. Cannot be combined with Minimum SoC by Deadline (kWh)."
},
"must_reach_soc_kwh": {
"name": "Minimum SoC by Deadline (kWh)",
"description": "Minimum battery state of charge that must be reached by the deadline. Cannot be combined with Minimum SoC by Deadline (%)."
},
"min_charge_power_w": {
"name": "Minimum Charge Power",
"description": "Enable continuous charging mode. The planner may reduce the last interval down to this minimum power instead of always using the maximum."
},
"charge_power_steps_w": {
"name": "Charge Power Steps",
"description": "Enable stepped charging mode. Provide the allowed power steps in watts as a list, for example [1380, 4140, 11000]. The planner picks the smallest step that still covers the remaining energy in the final interval. Mutually exclusive with Minimum Charge Power."
},
"grid_import_limit_w": {
"name": "Grid Import Limit",
"description": "Upper limit for charging power drawn from the grid in watts. Useful when the charger must share available power with other loads."
},
"must_reach_by": {
"name": "Must Reach By",
"description": "Absolute deadline for must_reach_soc_*. The planner first guarantees the minimum SoC before this moment, then continues with the remaining target if possible."
},
"must_reach_by_event": {
"name": "Deadline Event",
"description": "Alternative deadline derived from coordinator data. Use this instead of Must Reach By to plan around midnight, the next peak period, or the next best-price period end."
},
"discharging_efficiency": {
"name": "Discharging Efficiency",
"description": "Fraction of stored battery energy that is still usable when later discharged. Used for profitability calculations."
},
"expected_discharge_price": {
"name": "Expected Discharge Price",
"description": "Expected value of each discharged kWh. Intervals above the break-even price can be filtered when Reserve For Discharge is enabled."
},
"reserve_for_discharge": {
"name": "Reserve For Discharge",
"description": "Keep only intervals that are economically sensible for a later discharge based on expected discharge price and round-trip efficiency."
},
"max_cost_per_kwh": {
"name": "Maximum Charge Price",
"description": "Discard candidate intervals above this price per kWh before scheduling. Uses the selected price unit."
},
"min_charge_duration_minutes": {
"name": "Minimum Charge Duration",
"description": "Merge short isolated intervals into longer charging blocks where possible. Useful for chargers that should avoid very short runs."
},
"max_cycles_per_day": {
"name": "Maximum Charge Cycles Per Day",
"description": "Limit how many separate charging segments may be used per day. The planner keeps the cheapest segments within this limit."
}
}
}
},
"selector": {
@ -2224,6 +2473,13 @@
"next_24h": "Komende 24 uur",
"next_48h": "Komende 48 uur"
}
},
"charging_deadline_event": {
"options": {
"midnight": "Midnight",
"next_peak_period": "Next Peak Period",
"next_best_period_end": "End of Next Best-Price Period"
}
}
}
}

View file

@ -1237,7 +1237,7 @@
"message": "Sluttidpunkten ({search_end}) måste vara efter starttidpunkten ({search_start}). Kontrollera tidsparametrarna och eventuella day-offsets."
},
"price_fetch_failed": {
"message": "Kunde inte hämta prisdata från Tibber API. Försök igen senare."
"message": "Unable to fetch price data for the requested search range."
},
"invalid_search_scope": {
"message": "Ogiltigt sökområde. Giltiga värden är: today, tomorrow, remaining_today, next_24h, next_48h."
@ -1307,6 +1307,51 @@
},
"entity_value_conversion_failed": {
"message": "Kan inte konvertera värdet '{raw_value}' från '{entity_id}' ({attribute}) till {expected_type}. Kontrollera att entiteten ger ett kompatibelt värde."
},
"capacity_required_for_percent": {
"message": "battery_capacity_kwh is required when current or target SoC is provided as a percentage."
},
"ambiguous_soc_input": {
"message": "The field {field} was provided both as percent and as kWh. Use only one representation."
},
"already_at_target": {
"message": "Current state of charge is already at or above the target. No charging schedule is needed."
},
"target_exceeds_capacity": {
"message": "Target SoC ({target_soc_kwh} kWh) exceeds battery capacity ({capacity_kwh} kWh)."
},
"energy_unreachable": {
"message": "The requested charging target cannot be reached within the available search window and charging constraints."
},
"missing_current_soc": {
"message": "Provide either current_soc_percent or current_soc_kwh."
},
"missing_target_soc": {
"message": "Provide either target_soc_percent or target_soc_kwh."
},
"missing_deadline_for_must_reach": {
"message": "Provide must_reach_by or must_reach_by_event when using must_reach_soc_percent or must_reach_soc_kwh."
},
"missing_must_reach_soc": {
"message": "Provide must_reach_soc_percent or must_reach_soc_kwh when using a must_reach deadline."
},
"invalid_must_reach_soc": {
"message": "The minimum state of charge by deadline must be between current and target state of charge."
},
"power_strategy_conflict": {
"message": "Use either min_charge_power_w or charge_power_steps_w, not both at the same time."
},
"grid_limit_too_low": {
"message": "grid_import_limit_w is lower than the minimum required charging power."
},
"deadline_conflict": {
"message": "Use either must_reach_by or must_reach_by_event, not both at the same time."
},
"deadline_event_not_available": {
"message": "The selected deadline event is not available in the current coordinator data."
},
"deadline_outside_search_range": {
"message": "The resolved deadline must be inside the selected search range."
}
},
"services": {
@ -2115,6 +2160,210 @@
"description": "Max minuter varaktigheten kan förkortas under avslappning (0120, steg 15). Lämna tomt för automatisk beräkning (~20% av varaktigheten, max 60 min)."
}
}
},
"plan_charging": {
"name": "Plan Charging",
"description": "Creates a lowest-cost charging schedule from battery parameters instead of a fixed duration. Supports fixed, continuous, or stepped charging power, optional deadline-aware minimum SoC planning, and economic filtering for later discharge use cases. Returns a per-interval charging plan with power, energy, SoC progression, segment grouping, total cost, and profitability details. If no schedule is found, the response includes a stable reason code in the reason field (for example: already_at_target, no_data_in_range, no_intervals_matching_level_filter, no_intervals_after_economic_filter, energy_unreachable, energy_unreachable_by_deadline, selection_above_distance_threshold).",
"sections": {
"battery": {
"name": "Battery Parameters",
"description": "Describe the battery: usable capacity, current and target SoC, and charging efficiency. You can provide SoC in percent (requires capacity) or directly in kWh."
},
"charging": {
"name": "Charging Strategy",
"description": "Configure variable-power charging behavior, grid limits, and minimum run-time constraints."
},
"deadline": {
"name": "Deadline Planning",
"description": "Require a minimum state of charge by a specific moment (for example before a peak period). Set both the Minimum SoC by Deadline and when it must be reached — either as an absolute time (Must Reach By) or from a known event (Deadline Event)."
},
"search_range": {
"name": "Custom Search Range",
"description": "Define precise start and end times for the search. Overrides Search Scope when set."
},
"time_alternatives": {
"name": "Advanced Time Options",
"description": "Alternative ways to define the search range using time-of-day and minute offsets."
},
"price_filter": {
"name": "Price Level Filter",
"description": "Restrict search to intervals within the specified Tibber price level range."
},
"search_tuning": {
"name": "Search Algorithm Tuning",
"description": "Fine-tune how the search handles outliers, minimum quality thresholds, and fallback behavior."
},
"economics": {
"name": "Economic Filters",
"description": "Filter charging intervals by maximum cost or expected later discharge value."
},
"output": {
"name": "Output Options",
"description": "Control output format: comparison details and currency unit."
}
},
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
},
"max_charge_power_w": {
"name": "Maximum Charge Power",
"description": "Maximum charging power in watts. This defines the upper bound for fixed, continuous, or stepped charging schedules."
},
"battery_capacity_kwh": {
"name": "Battery Capacity",
"description": "Usable battery capacity in kWh. Required when current or target SoC is provided as a percentage."
},
"current_soc_percent": {
"name": "Current SoC (%)",
"description": "Current battery state of charge in percent. Cannot be combined with Current SoC (kWh)."
},
"current_soc_kwh": {
"name": "Current SoC (kWh)",
"description": "Current battery state of charge in kWh. Cannot be combined with Current SoC (%)."
},
"target_soc_percent": {
"name": "Target SoC (%)",
"description": "Desired battery state of charge in percent. Cannot be combined with Target SoC (kWh)."
},
"target_soc_kwh": {
"name": "Target SoC (kWh)",
"description": "Desired battery state of charge in kWh. Cannot be combined with Target SoC (%)."
},
"charging_efficiency": {
"name": "Charging Efficiency",
"description": "Fraction of grid energy that is stored in the battery. Example: 0.92 means 8% charging losses."
},
"search_scope": {
"name": "Search Scope",
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
},
"include_current_interval": {
"name": "Include Current Interval",
"description": "Include the currently running 15-minute interval in the search. When enabled, charging may begin in the current interval if it is part of the cheapest result."
},
"search_start": {
"name": "Search Start",
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
},
"search_end": {
"name": "Search End",
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
},
"must_finish_by": {
"name": "Must Finish By",
"description": "Deadline: charging must be finished by this time. The search range ends at this deadline — the service finds the cheapest intervals that complete before it. Cannot be combined with Search End, Search End Time, or Search End Offset."
},
"search_start_time": {
"name": "Search Start Time",
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
},
"search_start_day_offset": {
"name": "Search Start Day Offset",
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search Start Time."
},
"search_end_time": {
"name": "Search End Time",
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
},
"search_end_day_offset": {
"name": "Search End Day Offset",
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search End Time."
},
"search_start_offset_minutes": {
"name": "Search Start Offset (minutes)",
"description": "Alternative: start searching this many minutes from now. Positive = future, negative = past. Ignored if Search Start or Search Start Time is set."
},
"search_end_offset_minutes": {
"name": "Search End Offset (minutes)",
"description": "Alternative: stop searching this many minutes from now. Positive = future, negative = past. Ignored if Search End or Search End Time is set."
},
"max_price_level": {
"name": "Maximum Price Level",
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
},
"min_price_level": {
"name": "Minimum Price Level",
"description": "Only consider intervals at or above this Tibber price level. Useful to exclude unrealistically cheap negative-price-only windows from mixed searches."
},
"include_comparison_details": {
"name": "Include Comparison Details",
"description": "Enrich the price_comparison result with additional fields: comparison_price_min and comparison_price_max."
},
"use_base_unit": {
"name": "Use Base Currency Unit",
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
},
"smooth_outliers": {
"name": "Smooth Outliers",
"description": "Smooth price outliers before searching. Outlier intervals are temporarily replaced by the average of their neighbors, so a single spike or dip does not dominate the result. The response always shows the original (unsmoothed) prices. Default: enabled."
},
"min_distance_from_avg": {
"name": "Min. Distance from Average",
"description": "Require the selected charging intervals to be at least this percentage below the average price of the full search range. Leave empty to disable."
},
"allow_relaxation": {
"name": "Allow relaxation",
"description": "Progressively relax filters to guarantee a result when possible. Phases: 1) Reduce/remove distance threshold 2) Expand level filters 3) Reduce duration. Default: enabled."
},
"duration_flexibility_minutes": {
"name": "Duration flexibility",
"description": "Maximum minutes the automatically calculated charging duration may be shortened during relaxation (0120, step 15). Leave empty for automatic calculation."
},
"must_reach_soc_percent": {
"name": "Minimum SoC by Deadline (%)",
"description": "Minimum battery state of charge that must be reached by the deadline. Cannot be combined with Minimum SoC by Deadline (kWh)."
},
"must_reach_soc_kwh": {
"name": "Minimum SoC by Deadline (kWh)",
"description": "Minimum battery state of charge that must be reached by the deadline. Cannot be combined with Minimum SoC by Deadline (%)."
},
"min_charge_power_w": {
"name": "Minimum Charge Power",
"description": "Enable continuous charging mode. The planner may reduce the last interval down to this minimum power instead of always using the maximum."
},
"charge_power_steps_w": {
"name": "Charge Power Steps",
"description": "Enable stepped charging mode. Provide the allowed power steps in watts as a list, for example [1380, 4140, 11000]. The planner picks the smallest step that still covers the remaining energy in the final interval. Mutually exclusive with Minimum Charge Power."
},
"grid_import_limit_w": {
"name": "Grid Import Limit",
"description": "Upper limit for charging power drawn from the grid in watts. Useful when the charger must share available power with other loads."
},
"must_reach_by": {
"name": "Must Reach By",
"description": "Absolute deadline for must_reach_soc_*. The planner first guarantees the minimum SoC before this moment, then continues with the remaining target if possible."
},
"must_reach_by_event": {
"name": "Deadline Event",
"description": "Alternative deadline derived from coordinator data. Use this instead of Must Reach By to plan around midnight, the next peak period, or the next best-price period end."
},
"discharging_efficiency": {
"name": "Discharging Efficiency",
"description": "Fraction of stored battery energy that is still usable when later discharged. Used for profitability calculations."
},
"expected_discharge_price": {
"name": "Expected Discharge Price",
"description": "Expected value of each discharged kWh. Intervals above the break-even price can be filtered when Reserve For Discharge is enabled."
},
"reserve_for_discharge": {
"name": "Reserve For Discharge",
"description": "Keep only intervals that are economically sensible for a later discharge based on expected discharge price and round-trip efficiency."
},
"max_cost_per_kwh": {
"name": "Maximum Charge Price",
"description": "Discard candidate intervals above this price per kWh before scheduling. Uses the selected price unit."
},
"min_charge_duration_minutes": {
"name": "Minimum Charge Duration",
"description": "Merge short isolated intervals into longer charging blocks where possible. Useful for chargers that should avoid very short runs."
},
"max_cycles_per_day": {
"name": "Maximum Charge Cycles Per Day",
"description": "Limit how many separate charging segments may be used per day. The planner keeps the cheapest segments within this limit."
}
}
}
},
"selector": {
@ -2224,6 +2473,13 @@
"next_24h": "Naesta 24 timmar",
"next_48h": "Naesta 48 timmar"
}
},
"charging_deadline_event": {
"options": {
"midnight": "Midnight",
"next_peak_period": "Next Peak Period",
"next_best_period_end": "End of Next Best-Price Period"
}
}
}
}

View file

@ -41,8 +41,10 @@ Find the cheapest (or most expensive) time windows for your appliances. Ideal fo
| [`find_cheapest_schedule`](scheduling-actions.md#find-cheapest-schedule) | Multiple appliances, no overlap | Dishwasher + washing machine overnight |
| [`find_most_expensive_block`](scheduling-actions.md#find-most-expensive-block) | Most expensive contiguous window | Peak avoidance, battery discharge |
| [`find_most_expensive_hours`](scheduling-actions.md#find-most-expensive-hours) | Most expensive N hours | Demand response, consumption shifting |
| [`plan_charging`](plan-charging-action.md) | Battery/EV schedule from SoC + power | Home battery, EV, deadline-aware charging |
**→ [Scheduling Actions — Full Guide](scheduling-actions.md)** with parameters, response formats, decision flowchart, and automation examples.
**→ [Plan Charging Action — Guide](plan-charging-action.md)** for battery/EV charging scheduled from SoC and power (not duration).
### Chart & Visualization Actions

View file

@ -0,0 +1,168 @@
# Plan Charging Action
The `plan_charging` action turns **battery parameters** into a complete **cost-minimized charging schedule**. Instead of manually computing energy, duration, and power, you describe the battery (capacity, current SoC, target SoC, max power) and the action returns a per-interval plan with SoC progression, cost totals, and segment grouping.
:::tip When to use this
If you already know the duration in minutes and just need the cheapest time window, use [`find_cheapest_hours`](scheduling-actions.md#find-cheapest-hours) or [`find_cheapest_block`](scheduling-actions.md#find-cheapest-block). Use `plan_charging` when you know your battery/EV parameters and want the integration to compute the duration, account for charging losses, and produce a SoC progression.
:::
## At a Glance
| Situation | Example |
|-----------|---------|
| Home battery: "Charge from 20% to 80%, efficiency 0.92" | `current_soc_percent: 20`, `target_soc_percent: 80`, `battery_capacity_kwh: 10` |
| EV with 3-phase charger: "Use 1/2/3 phases as needed" | `charge_power_steps_w: [1380, 4140, 11000]` |
| Battery with modulation: "30 W 1200 W continuous" | `min_charge_power_w: 30`, `max_charge_power_w: 1200` |
| Deadline-aware: "At least 50% before next peak" | `must_reach_soc_percent: 50`, `must_reach_by_event: next_peak_period` |
| Arbitrage: "Only charge if later discharge is profitable" | `expected_discharge_price: 0.28`, `reserve_for_discharge: true` |
## Required Inputs
| Field | Description |
|-------|-------------|
| `max_charge_power_w` | Maximum charging power in watts (upper bound for every interval). |
| `current_soc_percent` **or** `current_soc_kwh` | Current battery state of charge. |
| `target_soc_percent` **or** `target_soc_kwh` | Desired battery state of charge. |
| `battery_capacity_kwh` | Required when you use percent values. |
All other inputs (deadline, power steps, grid limit, economics, search range) are optional.
## Choosing Between Fixed / Continuous / Stepped Power
| Mode | Trigger | Behavior |
|------|---------|----------|
| **Fixed** | Only `max_charge_power_w` set | Every selected interval charges at full power. Last interval may over-shoot the target slightly (rounding up). |
| **Continuous** | Add `min_charge_power_w` | Planner can reduce the final partial interval down to the minimum power — no over-shoot. |
| **Stepped** | Add `charge_power_steps_w: [a, b, c]` | Planner picks the smallest allowed step that covers the remaining energy. Mutually exclusive with `min_charge_power_w`. |
## Deadlines
Combine a **minimum SoC** with a **deadline**:
- `must_reach_soc_percent` / `must_reach_soc_kwh` — the minimum you need by the deadline.
- Then pick one of:
- `must_reach_by` — absolute datetime.
- `must_reach_by_event` — one of `midnight`, `next_peak_period`, `next_best_period_end`.
The planner runs a two-pass schedule: first guarantee the minimum SoC before the deadline using the cheapest pre-deadline intervals, then fill the remaining target with the cheapest intervals from the full search range.
## Economics (Arbitrage)
For "charge cheap now, discharge expensive later" use cases:
- `discharging_efficiency` — fraction still usable when discharged (default `1.0`).
- `expected_discharge_price` — expected price per kWh at discharge time (in your configured display unit).
- `reserve_for_discharge` — when `true`, discards intervals that are unprofitable given the round-trip efficiency.
- `max_cost_per_kwh` — a hard ceiling; any interval above this price is discarded before scheduling.
The response's `economics.break_even_price` tells you the maximum charging price at which the round-trip still breaks even.
## Examples
### Home battery — charge from 20% to 80% overnight
<details>
<summary>Show YAML</summary>
```yaml
service: tibber_prices.plan_charging
data:
battery_capacity_kwh: 10
current_soc_percent: 20
target_soc_percent: 80
charging_efficiency: 0.92
max_charge_power_w: 2500
search_scope: remaining_today
response_variable: plan
```
</details>
### EV — 3-phase, at least 50% before next peak period
<details>
<summary>Show YAML</summary>
```yaml
service: tibber_prices.plan_charging
data:
battery_capacity_kwh: 60
current_soc_percent: 30
target_soc_percent: 80
must_reach_soc_percent: 50
must_reach_by_event: next_peak_period
max_charge_power_w: 11000
charge_power_steps_w: [1380, 4140, 11000]
grid_import_limit_w: 16000
response_variable: plan
```
</details>
### Battery arbitrage — only if profitable
<details>
<summary>Show YAML</summary>
```yaml
service: tibber_prices.plan_charging
data:
battery_capacity_kwh: 10
current_soc_percent: 10
target_soc_percent: 100
charging_efficiency: 0.92
discharging_efficiency: 0.92
expected_discharge_price: 0.28 # ct/kWh value expected when discharging
reserve_for_discharge: true
max_charge_power_w: 3000
search_scope: next_48h
response_variable: plan
```
</details>
## Response Structure
The response contains the following top-level keys:
| Key | Description |
|-----|-------------|
| `intervals_found` | `true` when a schedule was produced. |
| `battery` | Normalized SoC / capacity / efficiency / `achieved_soc_kwh` (what you actually reach with the returned schedule). |
| `charging` | Mode, total duration, total energy, total cost, and the `schedule` block. |
| `charging.schedule` | `segments[]`, `intervals[]`, `segment_count`, `seconds_until_start`, `seconds_until_end`, and price statistics. |
| `deadline` | Present when a deadline was set — includes `must_reach_by`, `must_reach_soc_kwh`, `achieved_soc_kwh`, `deadline_met`. |
| `economics` | Present when any economic parameter was set — includes `break_even_price`, `expected_net_savings`, `round_trip_efficiency`. |
| `price_comparison` | Difference between the selected schedule and the most expensive equivalent window. |
| `relaxation_applied` / `relaxation_steps` | Whether the schedule was relaxed to fit available data. |
| `reason` | Stable reason code when no schedule was found (see below). |
### Per-Interval Fields
Each entry in `charging.schedule.intervals[]` includes:
- `starts_at`, `ends_at`, `price`, `level`, `rating_level`
- `power_w` — power assigned to this interval (watts)
- `grid_energy_kwh` — energy drawn from the grid
- `stored_energy_kwh` — energy actually stored after losses
- `soc_after_kwh`, `soc_after_percent` — cumulative SoC after this interval
## Reason Codes
When no schedule is found, `reason` contains one of:
| Code | Meaning |
|------|---------|
| `already_at_target` | Current SoC is already at or above target — no charging needed. |
| `no_data_in_range` | The search range has no price data. |
| `no_intervals_matching_level_filter` | `min_price_level` / `max_price_level` filtered everything out. |
| `no_intervals_after_economic_filter` | `max_cost_per_kwh` or `reserve_for_discharge` filtered everything out. |
| `energy_unreachable` | The energy needed cannot be charged within the available intervals + power limits. |
| `energy_unreachable_by_deadline` | The minimum SoC cannot be reached before the deadline with the available intervals. |
| `selection_above_distance_threshold` | `min_distance_from_avg` is not satisfied by the cheapest selection. |
## Related
- [`find_cheapest_hours`](scheduling-actions.md#find-cheapest-hours) — when you already know the duration in minutes.
- [`find_cheapest_block`](scheduling-actions.md#find-cheapest-block) — for appliances that must run uninterrupted.
- [Scheduling Actions](scheduling-actions.md) — shared parameters (search range, price filters, relaxation).

View file

@ -97,7 +97,7 @@ const sidebars: SidebarsConfig = {
type: 'category',
label: '⚡ Actions',
link: { type: 'doc', id: 'actions' },
items: ['actions', 'scheduling-actions', 'chart-actions', 'data-actions'],
items: ['actions', 'scheduling-actions', 'plan-charging-action', 'chart-actions', 'data-actions'],
collapsible: true,
collapsed: false,
},

View file

@ -0,0 +1,48 @@
"""Unit tests for charging energy calculation helpers."""
from __future__ import annotations
from custom_components.tibber_prices.services.charging.energy_calculator import (
build_soc_progression,
calculate_duration_intervals,
calculate_energy_needed,
soc_percent_to_kwh,
)
def test_soc_percent_to_kwh() -> None:
"""Convert percent SoC to kWh."""
assert soc_percent_to_kwh(25.0, 12.0) == 3.0
def test_calculate_energy_needed_accounts_for_efficiency() -> None:
"""Grid energy should include charging losses."""
assert calculate_energy_needed(2.0, 6.0, 0.8) == 5.0
def test_calculate_duration_intervals_rounds_up() -> None:
"""Charging duration should round up to the next full interval."""
assert calculate_duration_intervals(2.1, 4000) == 3
def test_build_soc_progression_adds_soc_fields() -> None:
"""Each interval should include power, energy, and SoC after charging."""
intervals = [
{"startsAt": "2026-01-01T00:00:00+00:00", "total": 0.10, "level": "NORMAL"},
{"startsAt": "2026-01-01T00:15:00+00:00", "total": 0.12, "level": "NORMAL"},
]
result = build_soc_progression(
intervals,
power_w=4000,
start_soc_kwh=2.0,
capacity_kwh=10.0,
charging_efficiency=1.0,
)
assert result[0]["power_w"] == 4000
assert result[0]["energy_kwh"] == 1.0
assert result[0]["soc_after_kwh"] == 3.0
assert result[0]["soc_after_percent"] == 30.0
assert result[1]["soc_after_kwh"] == 4.0
assert result[1]["soc_after_percent"] == 40.0

View file

@ -0,0 +1,333 @@
"""Tests for the plan_charging service handler."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
import pytest
from custom_components.tibber_prices.services import plan_charging as charging_module
from custom_components.tibber_prices.services.plan_charging import handle_plan_charging
from homeassistant.exceptions import ServiceValidationError
class _FakePool:
"""Minimal async interval pool for plan_charging tests."""
def __init__(self, intervals: list[dict[str, Any]]) -> None:
self._intervals = intervals
async def get_intervals(self, **_kwargs: object) -> tuple[list[dict[str, Any]], bool]:
return self._intervals, False
def _make_intervals(prices: list[float], start: datetime | None = None) -> list[dict[str, Any]]:
"""Create quarter-hour intervals for tests."""
base = start or datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
return [
{
"startsAt": (base + timedelta(minutes=15 * index)).isoformat(),
"total": price,
"level": "NORMAL",
}
for index, price in enumerate(prices)
]
def _build_fake_entry_and_coordinator(
intervals: list[dict[str, Any]],
*,
price_periods: dict[str, Any] | None = None,
) -> tuple[SimpleNamespace, SimpleNamespace, dict]:
"""Build a minimal entry/coordinator/data tuple used by service handlers."""
pool = _FakePool(intervals)
entry = SimpleNamespace(
data={"home_id": "home_1", "currency": "EUR"},
runtime_data=SimpleNamespace(interval_pool=pool),
)
coordinator = SimpleNamespace(
api=object(),
_cached_user_data={"viewer": {"homes": [{"id": "home_1", "timeZone": "UTC"}]}},
)
data = {"priceInfo": intervals, "pricePeriods": price_periods or {}}
return entry, coordinator, data
@pytest.mark.asyncio
async def test_plan_charging_returns_schedule_with_soc_progression(monkeypatch: pytest.MonkeyPatch) -> None:
"""Service should calculate duration and return cheapest intervals with SoC progression."""
intervals = _make_intervals([0.50, 0.10, 0.11, 0.60, 0.12])
fake_tuple = _build_fake_entry_and_coordinator(intervals)
monkeypatch.setattr(charging_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
monkeypatch.setattr(charging_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
monkeypatch.setattr(
charging_module,
"resolve_search_range",
lambda _call_data, _now, _home_tz: (
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
datetime(2026, 1, 1, 2, 0, tzinfo=UTC),
),
)
monkeypatch.setattr(charging_module, "get_display_unit_factor", lambda _entry: 1)
monkeypatch.setattr(charging_module, "get_display_unit_string", lambda _entry, _currency: "EUR/kWh")
monkeypatch.setattr(charging_module.dt_util, "now", lambda: datetime(2026, 1, 1, 0, 0, tzinfo=UTC))
call = SimpleNamespace(
hass=object(),
data={
"battery_capacity_kwh": 10.0,
"current_soc_percent": 20.0,
"target_soc_percent": 40.0,
"max_charge_power_w": 4000,
"charging_efficiency": 1.0,
"use_base_unit": True,
"allow_relaxation": False,
},
)
response = cast("dict[str, Any]", await handle_plan_charging(cast("ServiceCall", call)))
assert response["intervals_found"] is True
assert response["battery"]["current_soc_kwh"] == 2.0
assert response["battery"]["target_soc_kwh"] == 4.0
assert response["charging"]["total_duration_minutes"] == 30
assert response["charging"]["total_energy_kwh"] == 2.0
assert response["charging"]["schedule"]["segment_count"] == 1
scheduled_intervals = cast("list[dict[str, Any]]", response["charging"]["schedule"]["intervals"])
assert [iv["price"] for iv in scheduled_intervals] == [0.1, 0.11]
assert scheduled_intervals[0]["soc_after_kwh"] == 3.0
assert scheduled_intervals[1]["soc_after_kwh"] == 4.0
assert scheduled_intervals[1]["soc_after_percent"] == 40.0
@pytest.mark.asyncio
async def test_plan_charging_returns_already_at_target(monkeypatch: pytest.MonkeyPatch) -> None:
"""Service should return a stable reason when no charging is needed."""
intervals = _make_intervals([0.10, 0.12, 0.14])
fake_tuple = _build_fake_entry_and_coordinator(intervals)
monkeypatch.setattr(charging_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
monkeypatch.setattr(charging_module, "get_display_unit_string", lambda _entry, _currency: "EUR/kWh")
call = SimpleNamespace(
hass=object(),
data={
"battery_capacity_kwh": 10.0,
"current_soc_percent": 80.0,
"target_soc_percent": 60.0,
"max_charge_power_w": 4000,
"use_base_unit": True,
},
)
response = cast("dict[str, Any]", await handle_plan_charging(cast("ServiceCall", call)))
assert response["intervals_found"] is False
assert response["reason"] == "already_at_target"
assert response["charging"] is None
@pytest.mark.asyncio
async def test_plan_charging_requires_current_soc() -> None:
"""Service should reject requests without current SoC input."""
call = SimpleNamespace(
hass=object(),
data={
"battery_capacity_kwh": 10.0,
"target_soc_percent": 80.0,
"max_charge_power_w": 4000,
},
)
with pytest.raises(ServiceValidationError):
await handle_plan_charging(cast("ServiceCall", call))
@pytest.mark.asyncio
async def test_plan_charging_can_meet_deadline_before_peak_period(monkeypatch: pytest.MonkeyPatch) -> None:
"""Service should split charging so the required minimum SoC is reached before the next peak period."""
intervals = _make_intervals([0.50, 0.40, 0.10, 0.12, 0.11, 0.60])
price_periods = {
"peak_price": {
"periods": [
{
"start": datetime(2026, 1, 1, 1, 0, tzinfo=UTC),
"end": datetime(2026, 1, 1, 1, 30, tzinfo=UTC),
}
]
}
}
fake_tuple = _build_fake_entry_and_coordinator(intervals, price_periods=price_periods)
monkeypatch.setattr(charging_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
monkeypatch.setattr(charging_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
monkeypatch.setattr(
charging_module,
"resolve_search_range",
lambda _call_data, _now, _home_tz: (
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
datetime(2026, 1, 1, 2, 0, tzinfo=UTC),
),
)
monkeypatch.setattr(charging_module, "get_display_unit_factor", lambda _entry: 1)
monkeypatch.setattr(charging_module, "get_display_unit_string", lambda _entry, _currency: "EUR/kWh")
monkeypatch.setattr(charging_module.dt_util, "now", lambda: datetime(2026, 1, 1, 0, 0, tzinfo=UTC))
call = SimpleNamespace(
hass=object(),
data={
"battery_capacity_kwh": 10.0,
"current_soc_percent": 20.0,
"target_soc_percent": 60.0,
"must_reach_soc_percent": 40.0,
"must_reach_by_event": "next_peak_period",
"max_charge_power_w": 4000,
"charging_efficiency": 1.0,
"use_base_unit": True,
"allow_relaxation": False,
},
)
response = cast("dict[str, Any]", await handle_plan_charging(cast("ServiceCall", call)))
assert response["intervals_found"] is True
assert response["deadline"]["deadline_met"] is True
assert response["deadline"]["must_reach_soc_kwh"] == 4.0
assert response["deadline"]["achieved_soc_kwh"] >= 4.0
@pytest.mark.asyncio
async def test_plan_charging_can_filter_by_profitability(monkeypatch: pytest.MonkeyPatch) -> None:
"""Economic filtering should keep only profitable intervals when reserve_for_discharge is enabled."""
intervals = _make_intervals([0.05, 0.08, 0.12, 0.15])
fake_tuple = _build_fake_entry_and_coordinator(intervals)
monkeypatch.setattr(charging_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
monkeypatch.setattr(charging_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
monkeypatch.setattr(
charging_module,
"resolve_search_range",
lambda _call_data, _now, _home_tz: (
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
datetime(2026, 1, 1, 2, 0, tzinfo=UTC),
),
)
monkeypatch.setattr(charging_module, "get_display_unit_factor", lambda _entry: 1)
monkeypatch.setattr(charging_module, "get_display_unit_string", lambda _entry, _currency: "EUR/kWh")
call = SimpleNamespace(
hass=object(),
data={
"battery_capacity_kwh": 10.0,
"current_soc_percent": 20.0,
"target_soc_percent": 40.0,
"max_charge_power_w": 4000,
"charging_efficiency": 1.0,
"discharging_efficiency": 0.5,
"expected_discharge_price": 0.20,
"reserve_for_discharge": True,
"use_base_unit": True,
"allow_relaxation": False,
},
)
response = cast("dict[str, Any]", await handle_plan_charging(cast("ServiceCall", call)))
assert response["intervals_found"] is True
assert response["economics"]["break_even_price"] == 0.1
prices = [interval["price"] for interval in response["charging"]["schedule"]["intervals"]]
assert prices == [0.05, 0.08]
@pytest.mark.asyncio
async def test_plan_charging_respects_min_charge_duration(monkeypatch: pytest.MonkeyPatch) -> None:
"""A single cheap interval should be extended to satisfy minimum charge duration."""
intervals = _make_intervals([0.50, 0.10, 0.20, 0.70])
fake_tuple = _build_fake_entry_and_coordinator(intervals)
monkeypatch.setattr(charging_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
monkeypatch.setattr(charging_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
monkeypatch.setattr(
charging_module,
"resolve_search_range",
lambda _call_data, _now, _home_tz: (
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
datetime(2026, 1, 1, 2, 0, tzinfo=UTC),
),
)
monkeypatch.setattr(charging_module, "get_display_unit_factor", lambda _entry: 1)
monkeypatch.setattr(charging_module, "get_display_unit_string", lambda _entry, _currency: "EUR/kWh")
call = SimpleNamespace(
hass=object(),
data={
"battery_capacity_kwh": 10.0,
"current_soc_percent": 20.0,
"target_soc_percent": 30.0,
"max_charge_power_w": 4000,
"min_charge_duration_minutes": 30,
"charging_efficiency": 1.0,
"use_base_unit": True,
"allow_relaxation": False,
},
)
response = cast("dict[str, Any]", await handle_plan_charging(cast("ServiceCall", call)))
assert response["intervals_found"] is True
assert response["charging"]["total_duration_minutes"] == 30
assert response["charging"]["schedule"]["segment_count"] == 1
scheduled = cast("list[dict[str, Any]]", response["charging"]["schedule"]["intervals"])
assert [interval["price"] for interval in scheduled] == [0.1, 0.2]
assert response["warnings"] is None
@pytest.mark.asyncio
async def test_plan_charging_respects_max_cycles_per_day(monkeypatch: pytest.MonkeyPatch) -> None:
"""Multiple cheap isolated intervals should be bridged to satisfy max cycle limits."""
intervals = _make_intervals([0.10, 0.80, 0.11, 0.90, 0.12, 0.95, 0.50, 0.60])
fake_tuple = _build_fake_entry_and_coordinator(intervals)
monkeypatch.setattr(charging_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
monkeypatch.setattr(charging_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
monkeypatch.setattr(
charging_module,
"resolve_search_range",
lambda _call_data, _now, _home_tz: (
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
datetime(2026, 1, 1, 2, 0, tzinfo=UTC),
),
)
monkeypatch.setattr(charging_module, "get_display_unit_factor", lambda _entry: 1)
monkeypatch.setattr(charging_module, "get_display_unit_string", lambda _entry, _currency: "EUR/kWh")
call = SimpleNamespace(
hass=object(),
data={
"battery_capacity_kwh": 10.0,
"current_soc_percent": 20.0,
"target_soc_percent": 50.0,
"max_charge_power_w": 4000,
"max_cycles_per_day": 1,
"charging_efficiency": 1.0,
"use_base_unit": True,
"allow_relaxation": False,
},
)
response = cast("dict[str, Any]", await handle_plan_charging(cast("ServiceCall", call)))
assert response["intervals_found"] is True
assert response["charging"]["schedule"]["segment_count"] == 1
scheduled = cast("list[dict[str, Any]]", response["charging"]["schedule"]["intervals"])
assert [interval["price"] for interval in scheduled[:5]] == [0.1, 0.8, 0.11, 0.9, 0.12]
assert response["warnings"] is None

View file

@ -0,0 +1,77 @@
"""Unit tests for charging power scheduling helpers."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from custom_components.tibber_prices.services.charging.deadline_solver import resolve_deadline
from custom_components.tibber_prices.services.charging.power_scheduler import build_power_schedule
def _make_intervals(prices: list[float]) -> list[dict[str, object]]:
base = datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
return [
{
"startsAt": (base + timedelta(minutes=15 * index)).isoformat(),
"total": price,
"level": "NORMAL",
}
for index, price in enumerate(prices)
]
def test_continuous_mode_uses_partial_last_interval_power() -> None:
"""Continuous mode should reduce the last interval power when possible."""
result = build_power_schedule(
_make_intervals([0.20, 0.10, 0.15]),
2.5,
max_charge_power_w=4000,
min_charge_power_w=1000,
charging_efficiency=1.0,
)
assert result["mode"] == "continuous"
assert result["total_grid_energy_kwh"] == 2.5
assert sorted(interval["power_w"] for interval in result["intervals"]) == [2000, 4000, 4000]
def test_stepped_mode_uses_smallest_sufficient_step() -> None:
"""Stepped mode should choose the smallest allowed step that finishes the plan."""
result = build_power_schedule(
_make_intervals([0.20, 0.10, 0.15]),
2.5,
max_charge_power_w=4000,
charge_power_steps_w=[1000, 2000, 4000],
charging_efficiency=1.0,
)
assert result["mode"] == "stepped"
assert result["total_grid_energy_kwh"] == 2.5
assert sorted(interval["power_w"] for interval in result["intervals"]) == [2000, 4000, 4000]
def test_resolve_deadline_next_peak_period() -> None:
"""Deadline helper should resolve the next future peak period start."""
now = datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
coordinator_data = {
"pricePeriods": {
"peak_price": {
"periods": [
{
"start": datetime(2026, 1, 1, 1, 0, tzinfo=UTC),
"end": datetime(2026, 1, 1, 2, 0, tzinfo=UTC),
}
]
}
}
}
deadline, source = resolve_deadline(
coordinator_data=coordinator_data,
now=now,
home_tz=UTC,
must_reach_by_event="next_peak_period",
)
assert deadline == datetime(2026, 1, 1, 1, 0, tzinfo=UTC)
assert source == "next_peak_period"