mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Compare commits
2 commits
093e904329
...
df746bf892
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df746bf892 | ||
|
|
96f36a3339 |
21 changed files with 4053 additions and 7 deletions
|
|
@ -74,6 +74,20 @@
|
||||||
"search_tuning": "mdi:cog-outline",
|
"search_tuning": "mdi:cog-outline",
|
||||||
"output": "mdi:tune-variant"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1113,6 +1113,354 @@ find_cheapest_schedule:
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
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:
|
debug_clear_tomorrow:
|
||||||
fields:
|
fields:
|
||||||
entry_id:
|
entry_id:
|
||||||
|
|
|
||||||
|
|
@ -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_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_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 .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 (
|
from .refresh_user_data import (
|
||||||
REFRESH_USER_DATA_SERVICE_NAME,
|
REFRESH_USER_DATA_SERVICE_NAME,
|
||||||
REFRESH_USER_DATA_SERVICE_SCHEMA,
|
REFRESH_USER_DATA_SERVICE_SCHEMA,
|
||||||
|
|
@ -129,6 +130,13 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
schema=FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA,
|
schema=FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA,
|
||||||
supports_response=SupportsResponse.ONLY,
|
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(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
REFRESH_USER_DATA_SERVICE_NAME,
|
REFRESH_USER_DATA_SERVICE_NAME,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
104
custom_components/tibber_prices/services/charging/economics.py
Normal file
104
custom_components/tibber_prices/services/charging/economics.py
Normal 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),
|
||||||
|
}
|
||||||
|
|
@ -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 (0–100).
|
||||||
|
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.5–1.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.5–1.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
|
||||||
|
|
@ -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
|
||||||
973
custom_components/tibber_prices/services/plan_charging.py
Normal file
973
custom_components/tibber_prices/services/plan_charging.py
Normal 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
|
||||||
|
|
@ -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."
|
"message": "Der Endzeitpunkt ({search_end}) muss nach dem Startzeitpunkt ({search_start}) liegen. Überprüfe die Zeit-Parameter und eventuelle Day-Offsets."
|
||||||
},
|
},
|
||||||
"price_fetch_failed": {
|
"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": {
|
"invalid_search_scope": {
|
||||||
"message": "Ungültiger Suchbereich. Gültige Werte sind: today, tomorrow, remaining_today, next_24h, next_48h."
|
"message": "Ungültiger Suchbereich. Gültige Werte sind: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||||
|
|
@ -1307,6 +1307,51 @@
|
||||||
},
|
},
|
||||||
"entity_value_conversion_failed": {
|
"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."
|
"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": {
|
"services": {
|
||||||
|
|
@ -2115,6 +2160,210 @@
|
||||||
"description": "Maximale Minuten, um die die Dauer bei der Lockerung verkürzt werden darf (0–120, Schritt 15). Leer lassen für automatische Berechnung (~20% der Dauer, max. 60 Min.)."
|
"description": "Maximale Minuten, um die die Dauer bei der Lockerung verkürzt werden darf (0–120, 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 (0–120, 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": {
|
"selector": {
|
||||||
|
|
@ -2224,6 +2473,13 @@
|
||||||
"next_24h": "Nächste 24 Stunden",
|
"next_24h": "Nächste 24 Stunden",
|
||||||
"next_48h": "Nächste 48 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1237,7 +1237,7 @@
|
||||||
"message": "End time ({search_end}) must be after start time ({search_start}). Check your time parameters and any day offsets."
|
"message": "End time ({search_end}) must be after start time ({search_start}). Check your time parameters and any day offsets."
|
||||||
},
|
},
|
||||||
"price_fetch_failed": {
|
"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": {
|
"invalid_search_scope": {
|
||||||
"message": "Invalid search scope value. Valid scopes are: today, tomorrow, remaining_today, next_24h, next_48h."
|
"message": "Invalid search scope value. Valid scopes are: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||||
|
|
@ -1307,6 +1307,51 @@
|
||||||
},
|
},
|
||||||
"entity_value_conversion_failed": {
|
"entity_value_conversion_failed": {
|
||||||
"message": "Cannot convert value '{raw_value}' from '{entity_id}' ({attribute}) to {expected_type}. Verify the entity provides a compatible value."
|
"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": {
|
"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 (0–120, 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": {
|
"debug_clear_tomorrow": {
|
||||||
"name": "Debug: Clear Tomorrow Data",
|
"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.",
|
"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_24h": "Next 24 Hours",
|
||||||
"next_48h": "Next 48 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1237,7 +1237,7 @@
|
||||||
"message": "Sluttidspunktet ({search_end}) må være etter starttidspunktet ({search_start}). Sjekk tid-parameterne og eventuelle day-offsets."
|
"message": "Sluttidspunktet ({search_end}) må være etter starttidspunktet ({search_start}). Sjekk tid-parameterne og eventuelle day-offsets."
|
||||||
},
|
},
|
||||||
"price_fetch_failed": {
|
"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": {
|
"invalid_search_scope": {
|
||||||
"message": "Ugyldig søkeområde. Gyldige verdier er: today, tomorrow, remaining_today, next_24h, next_48h."
|
"message": "Ugyldig søkeområde. Gyldige verdier er: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||||
|
|
@ -1307,6 +1307,51 @@
|
||||||
},
|
},
|
||||||
"entity_value_conversion_failed": {
|
"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."
|
"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": {
|
"services": {
|
||||||
|
|
@ -2115,6 +2160,210 @@
|
||||||
"description": "Maks minutter varigheten kan forkortes under slakking (0–120, steg 15). La stå tom for automatisk beregning (~20% av varigheten, maks 60 min)."
|
"description": "Maks minutter varigheten kan forkortes under slakking (0–120, 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 (0–120, 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": {
|
"selector": {
|
||||||
|
|
@ -2224,6 +2473,13 @@
|
||||||
"next_24h": "Neste 24 timer",
|
"next_24h": "Neste 24 timer",
|
||||||
"next_48h": "Neste 48 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1237,7 +1237,7 @@
|
||||||
"message": "Het eindtijdstip ({search_end}) moet na het starttijdstip ({search_start}) liggen. Controleer je tijdparameters en eventuele day-offsets."
|
"message": "Het eindtijdstip ({search_end}) moet na het starttijdstip ({search_start}) liggen. Controleer je tijdparameters en eventuele day-offsets."
|
||||||
},
|
},
|
||||||
"price_fetch_failed": {
|
"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": {
|
"invalid_search_scope": {
|
||||||
"message": "Ongeldig zoekbereik. Geldige waarden zijn: today, tomorrow, remaining_today, next_24h, next_48h."
|
"message": "Ongeldig zoekbereik. Geldige waarden zijn: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||||
|
|
@ -1307,6 +1307,51 @@
|
||||||
},
|
},
|
||||||
"entity_value_conversion_failed": {
|
"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."
|
"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": {
|
"services": {
|
||||||
|
|
@ -2115,6 +2160,210 @@
|
||||||
"description": "Maximale minuten waarmee de duur mag worden verkort bij versoepeling (0–120, stap 15). Leeg laten voor automatische berekening (~20% van de duur, max 60 min)."
|
"description": "Maximale minuten waarmee de duur mag worden verkort bij versoepeling (0–120, 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 (0–120, 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": {
|
"selector": {
|
||||||
|
|
@ -2224,6 +2473,13 @@
|
||||||
"next_24h": "Komende 24 uur",
|
"next_24h": "Komende 24 uur",
|
||||||
"next_48h": "Komende 48 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1237,7 +1237,7 @@
|
||||||
"message": "Sluttidpunkten ({search_end}) måste vara efter starttidpunkten ({search_start}). Kontrollera tidsparametrarna och eventuella day-offsets."
|
"message": "Sluttidpunkten ({search_end}) måste vara efter starttidpunkten ({search_start}). Kontrollera tidsparametrarna och eventuella day-offsets."
|
||||||
},
|
},
|
||||||
"price_fetch_failed": {
|
"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": {
|
"invalid_search_scope": {
|
||||||
"message": "Ogiltigt sökområde. Giltiga värden är: today, tomorrow, remaining_today, next_24h, next_48h."
|
"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": {
|
"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."
|
"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": {
|
"services": {
|
||||||
|
|
@ -2115,6 +2160,210 @@
|
||||||
"description": "Max minuter varaktigheten kan förkortas under avslappning (0–120, steg 15). Lämna tomt för automatisk beräkning (~20% av varaktigheten, max 60 min)."
|
"description": "Max minuter varaktigheten kan förkortas under avslappning (0–120, 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 (0–120, 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": {
|
"selector": {
|
||||||
|
|
@ -2224,6 +2473,13 @@
|
||||||
"next_24h": "Naesta 24 timmar",
|
"next_24h": "Naesta 24 timmar",
|
||||||
"next_48h": "Naesta 48 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_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_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 |
|
| [`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.
|
**→ [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
|
### Chart & Visualization Actions
|
||||||
|
|
||||||
|
|
|
||||||
168
docs/user/docs/plan-charging-action.md
Normal file
168
docs/user/docs/plan-charging-action.md
Normal 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).
|
||||||
|
|
@ -97,7 +97,7 @@ const sidebars: SidebarsConfig = {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
label: '⚡ Actions',
|
label: '⚡ Actions',
|
||||||
link: { type: 'doc', id: '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,
|
collapsible: true,
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -290,4 +290,4 @@ property-decorators = ["propcache.api.cached_property"]
|
||||||
keep-runtime-typing = true
|
keep-runtime-typing = true
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
test = ["pytest-homeassistant-custom-component>=0.13.323"]
|
test = ["pytest-homeassistant-custom-component>=0.13.324"]
|
||||||
|
|
|
||||||
48
tests/services/test_energy_calculator.py
Normal file
48
tests/services/test_energy_calculator.py
Normal 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
|
||||||
333
tests/services/test_plan_charging.py
Normal file
333
tests/services/test_plan_charging.py
Normal 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
|
||||||
77
tests/services/test_power_scheduler.py
Normal file
77
tests/services/test_power_scheduler.py
Normal 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"
|
||||||
Loading…
Reference in a new issue