mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
feat(services): add find_best_start and plan_charging services
Add two new service actions for intelligent device scheduling: - find_best_start: Find optimal start time for run-once appliances - Considers price, optional energy estimates, optional PV power - Supports flexible time windows (HH:MM, ISO datetime, with/without timezone) - Prefers future candidates over past ones - Includes current interval by default (configurable) - Returns recommended start time with cost analysis - plan_charging: Create optimized charging schedule for energy storage - Supports EV, home battery, balcony battery use cases - Energy target or duration-based planning - Split or continuous charging modes - Efficiency factor support - Includes current interval by default (configurable) - Returns detailed slot-by-slot charging plan Common improvements: - Flexible datetime parsing (ISO 8601, with/without timezone, microseconds) - Time selector in GUI (better UX than text field) - Currency display based on config entry settings - Comprehensive error handling and validation - Detailed response envelopes with warnings/errors Impact: Users can automate appliance scheduling based on electricity prices without external automation rules.
This commit is contained in:
parent
4ceff6cf5f
commit
95950f48c1
12 changed files with 2063 additions and 38 deletions
|
|
@ -20,6 +20,28 @@
|
|||
},
|
||||
"refresh_user_data": {
|
||||
"service": "mdi:refresh"
|
||||
},
|
||||
"find_best_start": {
|
||||
"service": "mdi:clock-start",
|
||||
"sections": {
|
||||
"window": "mdi:calendar-range",
|
||||
"job": "mdi:washing-machine",
|
||||
"costing": "mdi:cash-multiple",
|
||||
"pv": "mdi:solar-power",
|
||||
"preferences": "mdi:tune"
|
||||
}
|
||||
},
|
||||
"plan_charging": {
|
||||
"service": "mdi:battery-charging",
|
||||
"sections": {
|
||||
"window": "mdi:calendar-range",
|
||||
"charge": "mdi:ev-station",
|
||||
"pv": "mdi:solar-power",
|
||||
"preferences": "mdi:tune"
|
||||
}
|
||||
},
|
||||
"debug_clear_tomorrow": {
|
||||
"service": "mdi:bug"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
get_price:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
|
|
@ -19,7 +19,7 @@ get_price:
|
|||
get_apexcharts_yaml:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
|
|
@ -63,7 +63,7 @@ get_chartdata:
|
|||
general:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
|
|
@ -246,11 +246,232 @@ get_chartdata:
|
|||
refresh_user_data:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
integration: tibber_prices
|
||||
find_best_start:
|
||||
fields:
|
||||
entry_id:
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
integration: tibber_prices
|
||||
window:
|
||||
collapsed: false
|
||||
fields:
|
||||
start:
|
||||
required: false
|
||||
example: "14:00"
|
||||
selector:
|
||||
time:
|
||||
end:
|
||||
required: false
|
||||
example: "23:00"
|
||||
selector:
|
||||
time:
|
||||
horizon_hours:
|
||||
required: false
|
||||
default: 36
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 72
|
||||
mode: box
|
||||
unit_of_measurement: h
|
||||
job:
|
||||
collapsed: false
|
||||
fields:
|
||||
duration_minutes:
|
||||
required: true
|
||||
example: 120
|
||||
selector:
|
||||
number:
|
||||
min: 15
|
||||
max: 1440
|
||||
step: 15
|
||||
mode: box
|
||||
unit_of_measurement: min
|
||||
rounding:
|
||||
required: false
|
||||
default: ceil
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- nearest
|
||||
- floor
|
||||
- ceil
|
||||
translation_key: rounding
|
||||
costing:
|
||||
collapsed: true
|
||||
fields:
|
||||
estimate_energy_kwh:
|
||||
required: false
|
||||
example: 1.2
|
||||
selector:
|
||||
number:
|
||||
min: 0.01
|
||||
max: 100
|
||||
step: 0.01
|
||||
mode: box
|
||||
unit_of_measurement: kWh
|
||||
estimate_avg_power_w:
|
||||
required: false
|
||||
example: 600
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 50000
|
||||
step: 1
|
||||
mode: box
|
||||
unit_of_measurement: W
|
||||
pv:
|
||||
collapsed: true
|
||||
fields:
|
||||
pv_entity_id:
|
||||
required: false
|
||||
selector:
|
||||
entity:
|
||||
domain: sensor
|
||||
preferences:
|
||||
collapsed: true
|
||||
fields:
|
||||
prefer_earlier_start_on_tie:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
include_current_interval:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
plan_charging:
|
||||
fields:
|
||||
entry_id:
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
integration: tibber_prices
|
||||
window:
|
||||
collapsed: false
|
||||
fields:
|
||||
start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
end:
|
||||
required: false
|
||||
example: "06:00"
|
||||
selector:
|
||||
time:
|
||||
horizon_hours:
|
||||
required: false
|
||||
default: 36
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 72
|
||||
mode: box
|
||||
unit_of_measurement: h
|
||||
charge:
|
||||
collapsed: false
|
||||
fields:
|
||||
energy_target_kwh:
|
||||
required: false
|
||||
example: 2.0
|
||||
selector:
|
||||
number:
|
||||
min: 0.01
|
||||
max: 200
|
||||
step: 0.01
|
||||
mode: box
|
||||
unit_of_measurement: kWh
|
||||
duration_minutes:
|
||||
required: false
|
||||
example: 120
|
||||
selector:
|
||||
number:
|
||||
min: 15
|
||||
max: 1440
|
||||
step: 15
|
||||
mode: box
|
||||
unit_of_measurement: min
|
||||
max_power_w:
|
||||
required: true
|
||||
example: 1200
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 50000
|
||||
step: 1
|
||||
mode: box
|
||||
unit_of_measurement: W
|
||||
min_power_w:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 50000
|
||||
step: 1
|
||||
mode: box
|
||||
unit_of_measurement: W
|
||||
allow_split:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
rounding:
|
||||
required: false
|
||||
default: ceil
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- nearest
|
||||
- floor
|
||||
- ceil
|
||||
translation_key: rounding
|
||||
efficiency:
|
||||
required: false
|
||||
default: 1.0
|
||||
example: 0.92
|
||||
selector:
|
||||
number:
|
||||
min: 0.01
|
||||
max: 1.0
|
||||
step: 0.01
|
||||
mode: box
|
||||
pv:
|
||||
collapsed: true
|
||||
fields:
|
||||
pv_entity_id:
|
||||
required: false
|
||||
selector:
|
||||
entity:
|
||||
domain: sensor
|
||||
preferences:
|
||||
collapsed: true
|
||||
fields:
|
||||
prefer_fewer_splits:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
prefer_earlier_completion:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
include_current_interval:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
debug_clear_tomorrow:
|
||||
fields:
|
||||
|
|
|
|||
|
|
@ -5,14 +5,19 @@ This package provides service endpoints for external integrations and data expor
|
|||
- Chart data export (get_chartdata)
|
||||
- ApexCharts YAML generation (get_apexcharts_yaml)
|
||||
- User data refresh (refresh_user_data)
|
||||
- Find best start time (find_best_start) - Planning for run-once devices
|
||||
- Plan charging (plan_charging) - Charging plan for energy storage
|
||||
- Debug: Clear tomorrow data (debug_clear_tomorrow) - DevContainer only
|
||||
|
||||
Architecture:
|
||||
- helpers.py: Common utilities (get_entry_and_data)
|
||||
- common.py: Shared planning utilities (window parsing, response envelope)
|
||||
- formatters.py: Data transformation and formatting functions
|
||||
- chartdata.py: Main data export service handler
|
||||
- apexcharts.py: ApexCharts card YAML generator
|
||||
- refresh_user_data.py: User data refresh handler
|
||||
- find_best_start.py: Best start time finder for appliances
|
||||
- plan_charging.py: Charging plan generator for batteries/EVs
|
||||
- debug_clear_tomorrow.py: Debug tool for testing tomorrow refresh (dev only)
|
||||
|
||||
"""
|
||||
|
|
@ -25,6 +30,11 @@ from typing import TYPE_CHECKING
|
|||
from custom_components.tibber_prices.const import DOMAIN
|
||||
from homeassistant.core import SupportsResponse, callback
|
||||
|
||||
from .find_best_start import (
|
||||
FIND_BEST_START_SERVICE_NAME,
|
||||
FIND_BEST_START_SERVICE_SCHEMA,
|
||||
handle_find_best_start,
|
||||
)
|
||||
from .get_apexcharts_yaml import (
|
||||
APEXCHARTS_SERVICE_SCHEMA,
|
||||
APEXCHARTS_YAML_SERVICE_NAME,
|
||||
|
|
@ -32,6 +42,11 @@ from .get_apexcharts_yaml import (
|
|||
)
|
||||
from .get_chartdata import CHARTDATA_SERVICE_NAME, CHARTDATA_SERVICE_SCHEMA, handle_chartdata
|
||||
from .get_price import GET_PRICE_SERVICE_NAME, GET_PRICE_SERVICE_SCHEMA, handle_get_price
|
||||
from .plan_charging import (
|
||||
PLAN_CHARGING_SERVICE_NAME,
|
||||
PLAN_CHARGING_SERVICE_SCHEMA,
|
||||
handle_plan_charging,
|
||||
)
|
||||
from .refresh_user_data import (
|
||||
REFRESH_USER_DATA_SERVICE_NAME,
|
||||
REFRESH_USER_DATA_SERVICE_SCHEMA,
|
||||
|
|
@ -80,6 +95,20 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
|||
schema=REFRESH_USER_DATA_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
FIND_BEST_START_SERVICE_NAME,
|
||||
handle_find_best_start,
|
||||
schema=FIND_BEST_START_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
PLAN_CHARGING_SERVICE_NAME,
|
||||
handle_plan_charging,
|
||||
schema=PLAN_CHARGING_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# Debug services - only available in DevContainer (TIBBER_PRICES_DEV=1)
|
||||
if _IS_DEV_MODE:
|
||||
|
|
|
|||
444
custom_components/tibber_prices/services/common.py
Normal file
444
custom_components/tibber_prices/services/common.py
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
"""
|
||||
Common utilities for planning services (find_best_start, plan_charging).
|
||||
|
||||
This module provides shared functionality for:
|
||||
- Response envelope building
|
||||
- Window parsing (datetime vs HH:MM)
|
||||
- Time rounding and normalization
|
||||
- Currency formatting
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.const import get_currency_info
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
# API Version for response envelope
|
||||
SERVICE_API_VERSION = "0.1"
|
||||
|
||||
DEFAULT_HORIZON_HOURS = 36 # Default horizon for planning services (hours)
|
||||
RESOLUTION_MINUTES = 15 # Resolution in minutes (quarter-hourly)
|
||||
RESOLUTION_HALF = 8 # Half the resolution for nearest rounding threshold (15/2 rounded up)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceResponse:
|
||||
"""Response envelope for planning services."""
|
||||
|
||||
ok: bool = True
|
||||
service: str = ""
|
||||
version: str = SERVICE_API_VERSION
|
||||
generated_at: str = ""
|
||||
entry_id: str = ""
|
||||
resolution_minutes: int = RESOLUTION_MINUTES
|
||||
currency: str = "EUR"
|
||||
currency_subunit: str = "ct"
|
||||
window_start: str = ""
|
||||
window_end: str = ""
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
result: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for service response."""
|
||||
return {
|
||||
"ok": self.ok,
|
||||
"service": self.service,
|
||||
"version": self.version,
|
||||
"generated_at": self.generated_at,
|
||||
"entry_id": self.entry_id,
|
||||
"resolution_minutes": self.resolution_minutes,
|
||||
"currency": self.currency,
|
||||
"currency_subunit": self.currency_subunit,
|
||||
"window": {
|
||||
"start": self.window_start,
|
||||
"end": self.window_end,
|
||||
},
|
||||
"warnings": self.warnings,
|
||||
"errors": self.errors,
|
||||
"result": self.result,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedWindow:
|
||||
"""Parsed and validated time window."""
|
||||
|
||||
start: datetime
|
||||
end: datetime
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def create_response_envelope(
|
||||
service_name: str,
|
||||
entry_id: str,
|
||||
currency: str,
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
) -> ServiceResponse:
|
||||
"""
|
||||
Create a response envelope with common fields.
|
||||
|
||||
Args:
|
||||
service_name: Full service name (e.g., "tibber_prices.find_best_start")
|
||||
entry_id: Config entry ID
|
||||
currency: Currency code (e.g., "EUR")
|
||||
window_start: Start of time window
|
||||
window_end: End of time window
|
||||
|
||||
Returns:
|
||||
ServiceResponse with common fields populated
|
||||
|
||||
"""
|
||||
_, subunit_symbol, _ = get_currency_info(currency)
|
||||
|
||||
return ServiceResponse(
|
||||
service=service_name,
|
||||
generated_at=dt_util.now().isoformat(),
|
||||
entry_id=entry_id,
|
||||
currency=currency,
|
||||
currency_subunit=subunit_symbol,
|
||||
window_start=window_start.isoformat(),
|
||||
window_end=window_end.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def is_hhmm_format(time_str: str) -> bool:
|
||||
"""
|
||||
Check if string is in HH:MM or HH:MM:SS format (time-only, no date).
|
||||
|
||||
The time selector in HA services.yaml returns HH:MM:SS format (e.g., "14:00:00").
|
||||
|
||||
Args:
|
||||
time_str: Time string to check
|
||||
|
||||
Returns:
|
||||
True if HH:MM or HH:MM:SS format (with optional microseconds), False otherwise
|
||||
|
||||
"""
|
||||
if not time_str:
|
||||
return False
|
||||
# Match HH:MM or HH:MM:SS format with optional microseconds
|
||||
# Examples: "14:00", "14:00:00", "14:00:00.123456"
|
||||
pattern = r"^([01]?[0-9]|2[0-3]):([0-5][0-9])(?::([0-5][0-9])(?:\.(\d+))?)?$"
|
||||
return bool(re.match(pattern, time_str.strip()))
|
||||
|
||||
|
||||
def parse_datetime_string(time_str: str) -> datetime | None:
|
||||
"""
|
||||
Parse a datetime string flexibly, accepting many common formats.
|
||||
|
||||
Supports:
|
||||
- ISO 8601 with timezone: "2025-12-28T14:00:00+01:00"
|
||||
- ISO 8601 with Z: "2025-12-28T14:00:00Z"
|
||||
- ISO 8601 without timezone: "2025-12-28T14:00:00"
|
||||
- With microseconds: "2025-12-28T14:00:00.123456+01:00"
|
||||
- Date with space separator: "2025-12-28 14:00:00"
|
||||
- Without seconds: "2025-12-28T14:00" or "2025-12-28 14:00"
|
||||
|
||||
Args:
|
||||
time_str: Datetime string in various formats
|
||||
|
||||
Returns:
|
||||
Parsed datetime (timezone-aware in HA timezone) or None if unparseable
|
||||
|
||||
"""
|
||||
if not time_str:
|
||||
return None
|
||||
|
||||
time_str = time_str.strip()
|
||||
|
||||
# Replace Z with +00:00 for UTC
|
||||
if time_str.endswith("Z"):
|
||||
time_str = time_str[:-1] + "+00:00"
|
||||
|
||||
# Replace space with T for ISO compatibility
|
||||
# Handle "2025-12-28 14:00:00" format
|
||||
if " " in time_str and "T" not in time_str:
|
||||
time_str = time_str.replace(" ", "T", 1)
|
||||
|
||||
# Try parsing with fromisoformat (handles most ISO 8601 variants)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(time_str)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
|
||||
if parsed is not None:
|
||||
# Add timezone if missing
|
||||
if parsed.tzinfo is None:
|
||||
parsed = dt_util.as_local(parsed)
|
||||
return parsed
|
||||
|
||||
# Try additional formats that fromisoformat might not handle
|
||||
# Format without seconds: "2025-12-28T14:00"
|
||||
try:
|
||||
if len(time_str) == 16 and time_str[10] == "T": # noqa: PLR2004
|
||||
parsed = datetime.strptime(time_str, "%Y-%m-%dT%H:%M") # noqa: DTZ007
|
||||
return dt_util.as_local(parsed)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_hhmm_to_datetime(time_str: str, reference_time: datetime) -> datetime:
|
||||
"""
|
||||
Parse HH:MM or HH:MM:SS string to datetime, finding next occurrence from reference.
|
||||
|
||||
Seconds are ignored as we round to 15-minute boundaries anyway.
|
||||
|
||||
Args:
|
||||
time_str: Time in HH:MM or HH:MM:SS format
|
||||
reference_time: Reference datetime (typically now)
|
||||
|
||||
Returns:
|
||||
Timezone-aware datetime for next occurrence of this time
|
||||
|
||||
"""
|
||||
parts = time_str.strip().split(":")
|
||||
hour = int(parts[0])
|
||||
minute = int(parts[1])
|
||||
# Ignore seconds if present (parts[2])
|
||||
|
||||
# Create datetime for today with given time
|
||||
candidate = reference_time.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
|
||||
# If candidate is in the past, move to next day
|
||||
if candidate <= reference_time:
|
||||
candidate += timedelta(days=1)
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
def parse_window( # noqa: PLR0912, PLR0915
|
||||
_hass: HomeAssistant,
|
||||
start_input: str | datetime | None,
|
||||
end_input: str | datetime | None,
|
||||
horizon_hours: int = DEFAULT_HORIZON_HOURS,
|
||||
duration_minutes: int | None = None,
|
||||
) -> ParsedWindow:
|
||||
"""
|
||||
Parse window start/end with automatic format detection.
|
||||
|
||||
Supports:
|
||||
- HH:MM or HH:MM:SS format (e.g., "14:00", "14:00:00") - time-only, next occurrence
|
||||
- ISO datetime strings with/without timezone (e.g., "2025-12-28T14:00:00+01:00")
|
||||
- ISO datetime with Z for UTC (e.g., "2025-12-28T14:00:00Z")
|
||||
- Datetime with space separator (e.g., "2025-12-28 14:00:00")
|
||||
- With or without microseconds
|
||||
- datetime objects
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
start_input: Start time (HH:MM, ISO string, datetime, or None for now)
|
||||
end_input: End time (HH:MM, ISO string, datetime, or None for horizon)
|
||||
horizon_hours: Maximum look-ahead hours (default: 36)
|
||||
duration_minutes: Optional duration - if provided and > window, window is extended
|
||||
|
||||
Returns:
|
||||
ParsedWindow with start/end datetimes and any warnings/errors
|
||||
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
now = dt_util.now()
|
||||
horizon_end = now + timedelta(hours=horizon_hours)
|
||||
|
||||
# Parse start
|
||||
start: datetime
|
||||
start_is_hhmm = False
|
||||
|
||||
if start_input is None:
|
||||
start = now
|
||||
elif isinstance(start_input, datetime):
|
||||
start = start_input
|
||||
if start.tzinfo is None:
|
||||
start = dt_util.as_local(start)
|
||||
elif isinstance(start_input, str):
|
||||
if is_hhmm_format(start_input):
|
||||
start = parse_hhmm_to_datetime(start_input, now)
|
||||
start_is_hhmm = True
|
||||
else:
|
||||
# Try to parse as datetime string (flexible format)
|
||||
parsed = parse_datetime_string(start_input)
|
||||
if parsed is not None:
|
||||
start = parsed
|
||||
else:
|
||||
errors.append("invalid_parameters")
|
||||
return ParsedWindow(start=now, end=horizon_end, errors=errors)
|
||||
else:
|
||||
errors.append("invalid_parameters")
|
||||
return ParsedWindow(start=now, end=horizon_end, errors=errors)
|
||||
|
||||
# Parse end
|
||||
end: datetime
|
||||
end_is_hhmm = False
|
||||
|
||||
if end_input is None:
|
||||
end = horizon_end
|
||||
elif isinstance(end_input, datetime):
|
||||
end = end_input
|
||||
if end.tzinfo is None:
|
||||
end = dt_util.as_local(end)
|
||||
elif isinstance(end_input, str):
|
||||
if is_hhmm_format(end_input):
|
||||
end_is_hhmm = True
|
||||
end_parts = end_input.strip().split(":")
|
||||
end_hour = int(end_parts[0])
|
||||
end_minute = int(end_parts[1])
|
||||
|
||||
# Create end datetime based on start
|
||||
end = start.replace(hour=end_hour, minute=end_minute, second=0, microsecond=0)
|
||||
|
||||
# Handle midnight wrap logic for HH:MM
|
||||
start_time_of_day = start.hour * 60 + start.minute
|
||||
end_time_of_day = end_hour * 60 + end_minute
|
||||
|
||||
if end_time_of_day == start_time_of_day:
|
||||
# Same time -> +24h
|
||||
end += timedelta(days=1)
|
||||
elif end_time_of_day < start_time_of_day:
|
||||
# End is earlier in day -> wrap to next day
|
||||
end += timedelta(days=1)
|
||||
# else: end is later same day, keep as-is
|
||||
else:
|
||||
# Try to parse as datetime string (flexible format)
|
||||
parsed = parse_datetime_string(end_input)
|
||||
if parsed is not None:
|
||||
end = parsed
|
||||
else:
|
||||
errors.append("invalid_parameters")
|
||||
return ParsedWindow(start=start, end=horizon_end, errors=errors)
|
||||
else:
|
||||
errors.append("invalid_parameters")
|
||||
return ParsedWindow(start=start, end=horizon_end, errors=errors)
|
||||
|
||||
# Validate: end < start only allowed for HH:MM (already handled above)
|
||||
if end <= start and not (start_is_hhmm and end_is_hhmm):
|
||||
errors.append("window_end_before_start")
|
||||
return ParsedWindow(start=start, end=end, errors=errors)
|
||||
|
||||
# Check if window extends duration (if provided)
|
||||
if duration_minutes is not None:
|
||||
window_duration = (end - start).total_seconds() / 60
|
||||
if duration_minutes > window_duration:
|
||||
# Extend window to fit duration
|
||||
extension_minutes = duration_minutes - window_duration
|
||||
end = end + timedelta(minutes=extension_minutes)
|
||||
warnings.append("window_extended_for_duration")
|
||||
|
||||
# Clamp end to horizon
|
||||
if end > horizon_end:
|
||||
end = horizon_end
|
||||
warnings.append("window_end_clamped_to_horizon")
|
||||
|
||||
return ParsedWindow(start=start, end=end, warnings=warnings, errors=errors)
|
||||
|
||||
|
||||
def round_to_quarter(dt: datetime, mode: str = "ceil") -> datetime:
|
||||
"""
|
||||
Round datetime to 15-minute boundary.
|
||||
|
||||
Args:
|
||||
dt: Datetime to round
|
||||
mode: "nearest", "floor", or "ceil"
|
||||
|
||||
Returns:
|
||||
Rounded datetime
|
||||
|
||||
"""
|
||||
minute = dt.minute
|
||||
quarter = minute // 15
|
||||
|
||||
if mode == "floor":
|
||||
new_minute = quarter * 15
|
||||
elif mode == "ceil":
|
||||
new_minute = minute if minute % 15 == 0 else (quarter + 1) * 15
|
||||
else: # nearest
|
||||
remainder = minute % 15
|
||||
new_minute = quarter * 15 if remainder < RESOLUTION_HALF else (quarter + 1) * 15
|
||||
|
||||
# Handle hour overflow
|
||||
hour_add = new_minute // 60
|
||||
new_minute = new_minute % 60
|
||||
|
||||
result = dt.replace(minute=new_minute, second=0, microsecond=0)
|
||||
if hour_add:
|
||||
result += timedelta(hours=hour_add)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def format_price(price: float, decimals: int = 4) -> float:
|
||||
"""Round price to specified decimals."""
|
||||
return round(price, decimals)
|
||||
|
||||
|
||||
def price_to_subunit(price_eur: float) -> float:
|
||||
"""Convert price from main unit to subunit (e.g., EUR to ct)."""
|
||||
return round(price_eur * 100, 2)
|
||||
|
||||
|
||||
def get_intervals_in_window(
|
||||
all_intervals: list[dict[str, Any]],
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Filter intervals to those within the specified window.
|
||||
|
||||
Args:
|
||||
all_intervals: List of all available price intervals
|
||||
window_start: Start of window (inclusive)
|
||||
window_end: End of window (exclusive)
|
||||
|
||||
Returns:
|
||||
List of intervals within window
|
||||
|
||||
"""
|
||||
result = []
|
||||
for interval in all_intervals:
|
||||
starts_at = interval.get("startsAt")
|
||||
if not starts_at:
|
||||
continue
|
||||
|
||||
# Parse interval start time
|
||||
interval_start = datetime.fromisoformat(starts_at) if isinstance(starts_at, str) else starts_at
|
||||
|
||||
# Interval is within window if its start is >= window_start and < window_end
|
||||
if window_start <= interval_start < window_end:
|
||||
result.append(interval)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def calculate_intervals_needed(duration_minutes: int, rounding: str = "ceil") -> int:
|
||||
"""
|
||||
Calculate number of 15-minute intervals needed for a duration.
|
||||
|
||||
Args:
|
||||
duration_minutes: Duration in minutes
|
||||
rounding: "nearest", "floor", or "ceil"
|
||||
|
||||
Returns:
|
||||
Number of intervals
|
||||
|
||||
"""
|
||||
exact = duration_minutes / RESOLUTION_MINUTES
|
||||
|
||||
if rounding == "floor":
|
||||
return int(exact)
|
||||
if rounding == "ceil":
|
||||
return int(exact) if exact == int(exact) else int(exact) + 1
|
||||
# nearest
|
||||
return round(exact)
|
||||
479
custom_components/tibber_prices/services/find_best_start.py
Normal file
479
custom_components/tibber_prices/services/find_best_start.py
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
"""
|
||||
Service handler for find_best_start service.
|
||||
|
||||
This service finds the optimal start time for run-once devices
|
||||
(e.g., washing machine, dishwasher, dryer) within a time window.
|
||||
|
||||
The algorithm:
|
||||
1. Generates all possible start times on 15-min boundaries
|
||||
2. Scores each candidate by average price (or expected cost if energy estimate provided)
|
||||
3. Returns the best candidate with lowest cost/price
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
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 homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .common import (
|
||||
DEFAULT_HORIZON_HOURS,
|
||||
RESOLUTION_MINUTES,
|
||||
calculate_intervals_needed,
|
||||
create_response_envelope,
|
||||
get_intervals_in_window,
|
||||
parse_window,
|
||||
round_to_quarter,
|
||||
)
|
||||
from .helpers import get_entry_and_data
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FIND_BEST_START_SERVICE_NAME = "find_best_start"
|
||||
|
||||
# Schema for find_best_start service - FLAT structure
|
||||
# Note: services.yaml sections are UI-only groupings, HA sends data flat
|
||||
FIND_BEST_START_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
# General / entry_id (optional - auto-resolved if single entry)
|
||||
vol.Optional("entry_id"): cv.string,
|
||||
# Window section (UI grouping only)
|
||||
vol.Optional("start"): cv.string,
|
||||
vol.Optional("end"): cv.string,
|
||||
vol.Optional("horizon_hours", default=DEFAULT_HORIZON_HOURS): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1, max=72)
|
||||
),
|
||||
# Job section (UI grouping only)
|
||||
vol.Required("duration_minutes"): vol.All(vol.Coerce(int), vol.Range(min=15, max=1440)),
|
||||
vol.Optional("rounding", default="ceil"): vol.In(["nearest", "floor", "ceil"]),
|
||||
# Costing section (UI grouping only)
|
||||
vol.Optional("estimate_energy_kwh"): vol.All(vol.Coerce(float), vol.Range(min=0.01, max=100)),
|
||||
vol.Optional("estimate_avg_power_w"): vol.All(vol.Coerce(float), vol.Range(min=1, max=50000)),
|
||||
# PV section (UI grouping only) - reserved for future use
|
||||
vol.Optional("pv_entity_id"): cv.entity_id,
|
||||
# Preferences section (UI grouping only)
|
||||
vol.Optional("prefer_earlier_start_on_tie", default=True): cv.boolean,
|
||||
vol.Optional("include_current_interval", default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_find_best_start( # noqa: PLR0915
|
||||
call: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""
|
||||
Handle find_best_start service call.
|
||||
|
||||
Finds the optimal start time for a run-once device within a time window.
|
||||
|
||||
Args:
|
||||
call: Service call with parameters
|
||||
|
||||
Returns:
|
||||
Dict with recommended start time and scoring details
|
||||
|
||||
"""
|
||||
hass: HomeAssistant = call.hass
|
||||
entry_id: str | None = call.data.get("entry_id")
|
||||
|
||||
# Extract parameters (flat structure - HA sends all fields at top level)
|
||||
# Window parameters
|
||||
start = call.data.get("start")
|
||||
end = call.data.get("end")
|
||||
horizon_hours = call.data.get("horizon_hours", DEFAULT_HORIZON_HOURS)
|
||||
|
||||
# Job parameters
|
||||
duration_minutes = call.data["duration_minutes"]
|
||||
rounding = call.data.get("rounding", "ceil")
|
||||
|
||||
# Costing parameters
|
||||
estimate_energy_kwh = call.data.get("estimate_energy_kwh")
|
||||
estimate_avg_power_w = call.data.get("estimate_avg_power_w")
|
||||
|
||||
# PV parameters (reserved for future use)
|
||||
_pv_entity_id = call.data.get("pv_entity_id")
|
||||
|
||||
# Preferences
|
||||
prefer_earlier = call.data.get("prefer_earlier_start_on_tie", True)
|
||||
include_current = call.data.get("include_current_interval", True)
|
||||
|
||||
# Derive energy from power if only power provided
|
||||
if estimate_energy_kwh is None and estimate_avg_power_w is not None:
|
||||
duration_hours = duration_minutes / 60
|
||||
estimate_energy_kwh = (estimate_avg_power_w * duration_hours) / 1000
|
||||
|
||||
# Validate and get entry data (auto-resolves entry_id if single entry)
|
||||
entry, _coordinator, data = get_entry_and_data(hass, entry_id)
|
||||
resolved_entry_id = entry.entry_id # Use resolved entry_id
|
||||
|
||||
# Get currency from coordinator data
|
||||
currency = data.get("currency", "EUR")
|
||||
|
||||
# Get currency display settings from config
|
||||
display_factor = get_display_unit_factor(entry)
|
||||
price_unit = get_display_unit_string(entry, currency)
|
||||
|
||||
# Parse window
|
||||
parsed_window = parse_window(
|
||||
hass,
|
||||
start,
|
||||
end,
|
||||
horizon_hours=horizon_hours,
|
||||
duration_minutes=duration_minutes,
|
||||
)
|
||||
|
||||
# Create response envelope
|
||||
response = create_response_envelope(
|
||||
service_name=f"{DOMAIN}.{FIND_BEST_START_SERVICE_NAME}",
|
||||
entry_id=resolved_entry_id,
|
||||
currency=currency,
|
||||
window_start=parsed_window.start,
|
||||
window_end=parsed_window.end,
|
||||
)
|
||||
|
||||
# Add any parsing warnings/errors
|
||||
response.warnings.extend(parsed_window.warnings)
|
||||
response.errors.extend(parsed_window.errors)
|
||||
|
||||
# If there were parsing errors, return early
|
||||
if parsed_window.errors:
|
||||
response.ok = False
|
||||
return response.to_dict()
|
||||
|
||||
# Round window to quarter boundaries
|
||||
# If include_current_interval is True, use floor to include the current interval
|
||||
# (e.g., 14:05 -> 14:00), otherwise ceil to start at next interval (14:05 -> 14:15)
|
||||
start_rounding = "floor" if include_current else "ceil"
|
||||
window_start = round_to_quarter(parsed_window.start, start_rounding)
|
||||
window_end = round_to_quarter(parsed_window.end, "floor")
|
||||
|
||||
# Update response with rounded window
|
||||
response.window_start = window_start.isoformat()
|
||||
response.window_end = window_end.isoformat()
|
||||
|
||||
# Get price intervals from coordinator
|
||||
all_intervals = data.get("priceInfo", [])
|
||||
if not all_intervals:
|
||||
response.ok = False
|
||||
response.errors.append("no_price_data_available_in_window")
|
||||
return response.to_dict()
|
||||
|
||||
# Filter intervals to window
|
||||
window_intervals = get_intervals_in_window(all_intervals, window_start, window_end)
|
||||
|
||||
if not window_intervals:
|
||||
response.ok = False
|
||||
response.errors.append("no_price_data_available_in_window")
|
||||
return response.to_dict()
|
||||
|
||||
# Calculate number of intervals needed
|
||||
intervals_needed = calculate_intervals_needed(duration_minutes, rounding)
|
||||
|
||||
# Get PV power if entity provided
|
||||
pv_power_w = 0.0
|
||||
if _pv_entity_id:
|
||||
pv_state = hass.states.get(_pv_entity_id)
|
||||
if pv_state and pv_state.state not in ("unknown", "unavailable"):
|
||||
try:
|
||||
pv_power_w = float(pv_state.state)
|
||||
except (ValueError, TypeError):
|
||||
response.warnings.append("pv_entity_unavailable_used_zero")
|
||||
else:
|
||||
response.warnings.append("pv_entity_unavailable_used_zero")
|
||||
|
||||
# Generate and score candidates
|
||||
candidates = _generate_candidates(
|
||||
window_intervals=window_intervals,
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
intervals_needed=intervals_needed,
|
||||
duration_minutes=duration_minutes,
|
||||
estimate_energy_kwh=estimate_energy_kwh,
|
||||
pv_power_w=pv_power_w,
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
response.ok = False
|
||||
response.errors.append("no_price_data_available_in_window")
|
||||
response.warnings.append("some_prices_missing_used_partial_window")
|
||||
return response.to_dict()
|
||||
|
||||
# Sort and select best candidate (prefers future starts, closest to now if all past)
|
||||
now = dt_util.now()
|
||||
best_candidate = _select_best_candidate(candidates, prefer_earlier=prefer_earlier, now=now)
|
||||
|
||||
# Warn if recommended start is in the past
|
||||
recommended_start: datetime = best_candidate["start"]
|
||||
if recommended_start < now:
|
||||
response.warnings.append("recommended_start_in_past")
|
||||
|
||||
# Build result
|
||||
response.result = _build_result(
|
||||
candidate=best_candidate,
|
||||
total_candidates=len(candidates),
|
||||
duration_minutes=duration_minutes,
|
||||
display_factor=display_factor,
|
||||
price_unit=price_unit,
|
||||
)
|
||||
|
||||
return response.to_dict()
|
||||
|
||||
|
||||
def _generate_candidates( # noqa: PLR0913
|
||||
window_intervals: list[dict[str, Any]],
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
intervals_needed: int,
|
||||
duration_minutes: int,
|
||||
estimate_energy_kwh: float | None,
|
||||
pv_power_w: float,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Generate all possible start time candidates with scoring.
|
||||
|
||||
Args:
|
||||
window_intervals: Available price intervals in window
|
||||
window_start: Start of window
|
||||
window_end: End of window
|
||||
intervals_needed: Number of 15-min intervals for the job
|
||||
duration_minutes: Job duration in minutes
|
||||
estimate_energy_kwh: Optional energy estimate for cost calculation
|
||||
pv_power_w: Current PV power in watts
|
||||
|
||||
Returns:
|
||||
List of scored candidates
|
||||
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
# Build lookup for intervals by start time
|
||||
interval_lookup: dict[str, dict[str, Any]] = {}
|
||||
for interval in window_intervals:
|
||||
starts_at = interval.get("startsAt", "")
|
||||
if starts_at:
|
||||
# Normalize to ISO string for lookup
|
||||
key = starts_at.isoformat() if isinstance(starts_at, datetime) else starts_at
|
||||
interval_lookup[key] = interval
|
||||
|
||||
# Generate candidates on 15-min boundaries
|
||||
current_start = window_start
|
||||
duration_delta = timedelta(minutes=duration_minutes)
|
||||
|
||||
while current_start + duration_delta <= window_end:
|
||||
# Collect intervals for this candidate
|
||||
candidate_intervals = []
|
||||
slot_start = current_start
|
||||
|
||||
for i in range(intervals_needed):
|
||||
slot_time = slot_start + timedelta(minutes=i * RESOLUTION_MINUTES)
|
||||
slot_key = slot_time.isoformat()
|
||||
|
||||
# Also try without microseconds
|
||||
slot_key_no_micro = slot_time.replace(microsecond=0).isoformat()
|
||||
|
||||
interval = interval_lookup.get(slot_key) or interval_lookup.get(slot_key_no_micro)
|
||||
|
||||
if interval:
|
||||
candidate_intervals.append(interval)
|
||||
|
||||
# Only consider candidates with all intervals available
|
||||
if len(candidate_intervals) == intervals_needed:
|
||||
# Calculate average price
|
||||
prices = [iv.get("total", 0) for iv in candidate_intervals]
|
||||
avg_price = sum(prices) / len(prices) if prices else 0
|
||||
|
||||
# Calculate expected cost if energy estimate provided
|
||||
expected_cost = None
|
||||
expected_grid_kwh = None
|
||||
|
||||
if estimate_energy_kwh is not None:
|
||||
# Distribute energy evenly across intervals
|
||||
energy_per_interval = estimate_energy_kwh / intervals_needed
|
||||
pv_kwh_per_interval = (pv_power_w / 1000) * (RESOLUTION_MINUTES / 60)
|
||||
|
||||
total_grid_kwh = 0
|
||||
total_cost = 0
|
||||
|
||||
for interval in candidate_intervals:
|
||||
price = interval.get("total", 0)
|
||||
grid_kwh = max(energy_per_interval - pv_kwh_per_interval, 0)
|
||||
total_grid_kwh += grid_kwh
|
||||
total_cost += grid_kwh * price
|
||||
|
||||
expected_cost = total_cost
|
||||
expected_grid_kwh = total_grid_kwh
|
||||
|
||||
candidate_end = current_start + duration_delta
|
||||
|
||||
candidates.append(
|
||||
{
|
||||
"start": current_start,
|
||||
"end": candidate_end,
|
||||
"intervals": candidate_intervals,
|
||||
"avg_price": avg_price,
|
||||
"expected_cost": expected_cost,
|
||||
"expected_grid_kwh": expected_grid_kwh,
|
||||
"expected_energy_kwh": estimate_energy_kwh,
|
||||
}
|
||||
)
|
||||
|
||||
# Move to next 15-min slot
|
||||
current_start += timedelta(minutes=RESOLUTION_MINUTES)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _select_best_candidate(
|
||||
candidates: list[dict[str, Any]],
|
||||
*,
|
||||
prefer_earlier: bool,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Select the best candidate from scored list.
|
||||
|
||||
Selection priority:
|
||||
1. Prefer candidates starting in the future over past candidates
|
||||
2. Among future candidates: lowest cost/price wins
|
||||
3. Among past candidates: closest to now wins (to minimize "too late" impact)
|
||||
4. Tie-breaker: earlier or later start based on prefer_earlier
|
||||
|
||||
Args:
|
||||
candidates: List of scored candidates
|
||||
prefer_earlier: If True, earlier start wins ties
|
||||
now: Current time (defaults to dt_util.now())
|
||||
|
||||
Returns:
|
||||
Best candidate
|
||||
|
||||
"""
|
||||
current_time = now if now is not None else dt_util.now()
|
||||
|
||||
# Separate future and past candidates
|
||||
future_candidates = [c for c in candidates if c["start"] >= current_time]
|
||||
past_candidates = [c for c in candidates if c["start"] < current_time]
|
||||
|
||||
# Sort by expected_cost (if available) or avg_price
|
||||
# Secondary sort by start time for tie-breaking
|
||||
def sort_key_by_price(c: dict[str, Any]) -> tuple[float, float]:
|
||||
cost = c.get("expected_cost")
|
||||
primary = cost if cost is not None else c["avg_price"]
|
||||
|
||||
# Use timestamp for tie-breaking
|
||||
start: datetime = c["start"]
|
||||
secondary = start.timestamp() if prefer_earlier else -start.timestamp()
|
||||
return (primary, secondary)
|
||||
|
||||
# If we have future candidates, pick best by price among them
|
||||
if future_candidates:
|
||||
sorted_candidates = sorted(future_candidates, key=sort_key_by_price)
|
||||
return sorted_candidates[0]
|
||||
|
||||
# All candidates are in the past - pick the one closest to now
|
||||
# (minimizes how late the recommendation is)
|
||||
def sort_key_closest_to_now(c: dict[str, Any]) -> tuple[float, float]:
|
||||
start: datetime = c["start"]
|
||||
# Primary: distance from now (smaller = better, so closest to now wins)
|
||||
distance_from_now = abs((start - current_time).total_seconds())
|
||||
# Secondary: still prefer lower price for ties
|
||||
cost = c.get("expected_cost")
|
||||
price = cost if cost is not None else c["avg_price"]
|
||||
return (distance_from_now, price)
|
||||
|
||||
sorted_past = sorted(past_candidates, key=sort_key_closest_to_now)
|
||||
return sorted_past[0]
|
||||
|
||||
|
||||
def _build_result(
|
||||
candidate: dict[str, Any],
|
||||
total_candidates: int,
|
||||
duration_minutes: int,
|
||||
display_factor: int,
|
||||
price_unit: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build the result dictionary from best candidate.
|
||||
|
||||
Args:
|
||||
candidate: Best candidate
|
||||
total_candidates: Total number of candidates considered
|
||||
duration_minutes: Job duration
|
||||
display_factor: Currency display factor (1 for base, 100 for subunit)
|
||||
price_unit: Currency unit string (e.g., 'ct/kWh' or '€/kWh')
|
||||
|
||||
Returns:
|
||||
Result dictionary
|
||||
|
||||
"""
|
||||
start: datetime = candidate["start"]
|
||||
end: datetime = candidate["end"]
|
||||
avg_price = candidate["avg_price"]
|
||||
expected_cost = candidate.get("expected_cost")
|
||||
expected_grid_kwh = candidate.get("expected_grid_kwh")
|
||||
expected_energy_kwh = candidate.get("expected_energy_kwh")
|
||||
intervals = candidate["intervals"]
|
||||
|
||||
# Build intervals list for response
|
||||
response_intervals = []
|
||||
for iv in intervals:
|
||||
price = iv.get("total", 0)
|
||||
response_intervals.append(
|
||||
{
|
||||
"start": iv.get("startsAt"),
|
||||
"end": _calculate_interval_end(iv.get("startsAt")),
|
||||
"price_per_kwh": round(price * display_factor, 4),
|
||||
}
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"recommended_start": start.isoformat(),
|
||||
"recommended_end": end.isoformat(),
|
||||
"duration_minutes": duration_minutes,
|
||||
"price_unit": price_unit,
|
||||
"score": {
|
||||
"avg_price_per_kwh": round(avg_price * display_factor, 4),
|
||||
"rank": f"1/{total_candidates}",
|
||||
"tie_breaker": "earlier_start",
|
||||
},
|
||||
"intervals": response_intervals,
|
||||
"debug": {
|
||||
"candidates_considered": total_candidates,
|
||||
"missing_price_intervals": 0,
|
||||
},
|
||||
}
|
||||
|
||||
# Add cost info if available
|
||||
if expected_cost is not None:
|
||||
result["cost"] = {
|
||||
"expected_energy_kwh": round(expected_energy_kwh, 4) if expected_energy_kwh else None,
|
||||
"expected_grid_energy_kwh": round(expected_grid_kwh, 4) if expected_grid_kwh else None,
|
||||
"expected_cost": round(expected_cost * display_factor, 4),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _calculate_interval_end(starts_at: str | None) -> str | None:
|
||||
"""Calculate interval end time (start + 15 minutes)."""
|
||||
if not starts_at:
|
||||
return None
|
||||
try:
|
||||
start = datetime.fromisoformat(starts_at)
|
||||
end = start + timedelta(minutes=RESOLUTION_MINUTES)
|
||||
return end.isoformat()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
|
@ -25,7 +25,6 @@ import voluptuous as vol
|
|||
from custom_components.tibber_prices.const import (
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
DISPLAY_MODE_SUBUNIT,
|
||||
DOMAIN,
|
||||
PRICE_LEVEL_CHEAP,
|
||||
PRICE_LEVEL_EXPENSIVE,
|
||||
PRICE_LEVEL_NORMAL,
|
||||
|
|
@ -37,7 +36,6 @@ from custom_components.tibber_prices.const import (
|
|||
get_display_unit_string,
|
||||
get_translation,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_registry import (
|
||||
EntityRegistry,
|
||||
|
|
@ -60,7 +58,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
|
|||
# Service schema
|
||||
APEXCHARTS_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTRY_ID): cv.string,
|
||||
vol.Optional(ATTR_ENTRY_ID): cv.string,
|
||||
vol.Optional("day"): vol.In(["yesterday", "today", "tomorrow", "rolling_window", "rolling_window_autozoom"]),
|
||||
vol.Optional("level_type", default="rating_level"): vol.In(["rating_level", "level"]),
|
||||
vol.Optional("highlight_best_price", default=True): cv.boolean,
|
||||
|
|
@ -285,14 +283,11 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
|
|||
Dictionary with ApexCharts card configuration
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If entry_id is missing or invalid
|
||||
ServiceValidationError: If entry_id cannot be resolved or is invalid
|
||||
|
||||
"""
|
||||
hass = call.hass
|
||||
entry_id_raw = call.data.get(ATTR_ENTRY_ID)
|
||||
if entry_id_raw is None:
|
||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
|
||||
entry_id: str = str(entry_id_raw)
|
||||
entry_id = call.data.get(ATTR_ENTRY_ID) # Optional - auto-resolved if single entry
|
||||
|
||||
day = call.data.get("day") # Can be None (rolling window mode)
|
||||
level_type = call.data.get("level_type", "rating_level")
|
||||
|
|
@ -303,7 +298,9 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
|
|||
user_language = hass.config.language or "en"
|
||||
|
||||
# Get coordinator to access price data (for currency) and config entry for display settings
|
||||
# get_entry_and_data auto-resolves entry_id if only one entry exists
|
||||
config_entry, coordinator, _ = get_entry_and_data(hass, entry_id)
|
||||
entry_id = config_entry.entry_id # Use resolved entry_id for entity lookups
|
||||
# Get currency from coordinator data
|
||||
currency = coordinator.data.get("currency", "EUR")
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ from custom_components.tibber_prices.coordinator.helpers import (
|
|||
get_intervals_for_day_offsets,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .formatters import aggregate_hourly_exact, get_period_data, normalize_level_filter, normalize_rating_level_filter
|
||||
from .helpers import get_entry_and_data, has_tomorrow_data
|
||||
|
|
@ -265,7 +266,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
|
|||
# Service schema
|
||||
CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTRY_ID): str,
|
||||
vol.Optional(ATTR_ENTRY_ID): cv.string,
|
||||
vol.Optional(ATTR_DAY): vol.All(vol.Coerce(list), [vol.In(["yesterday", "today", "tomorrow"])]),
|
||||
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
|
||||
vol.Optional("output_format", default="array_of_objects"): vol.In(["array_of_objects", "array_of_arrays"]),
|
||||
|
|
@ -333,16 +334,14 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
Dictionary with chart data in requested format
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If entry_id is missing or invalid
|
||||
ServiceValidationError: If entry_id cannot be resolved or is invalid
|
||||
|
||||
"""
|
||||
hass = call.hass
|
||||
entry_id_raw = call.data.get(ATTR_ENTRY_ID)
|
||||
if entry_id_raw is None:
|
||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
|
||||
entry_id: str = str(entry_id_raw)
|
||||
entry_id = call.data.get(ATTR_ENTRY_ID) # Optional - auto-resolved if single entry
|
||||
|
||||
# Get coordinator to check data availability
|
||||
# get_entry_and_data auto-resolves entry_id if only one entry exists
|
||||
_, coordinator, _ = get_entry_and_data(hass, entry_id)
|
||||
|
||||
days_raw = call.data.get(ATTR_DAY)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ GET_PRICE_SERVICE_NAME = "get_price"
|
|||
|
||||
GET_PRICE_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("entry_id"): cv.string,
|
||||
vol.Optional("entry_id"): cv.string,
|
||||
vol.Required("start_time"): cv.datetime,
|
||||
vol.Required("end_time"): cv.datetime,
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
|
|||
|
||||
"""
|
||||
hass: HomeAssistant = call.hass
|
||||
entry_id: str = call.data["entry_id"]
|
||||
entry_id: str | None = call.data.get("entry_id")
|
||||
start_time: datetime = call.data["start_time"]
|
||||
end_time: datetime = call.data["end_time"]
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,15 @@ This module provides common helper functions used across multiple service handle
|
|||
such as entry validation and data extraction.
|
||||
|
||||
Functions:
|
||||
resolve_entry_id: Auto-resolve entry_id when only one config entry exists
|
||||
get_entry_and_data: Validate config entry and extract coordinator data
|
||||
|
||||
Used by:
|
||||
- services/chartdata.py: Chart data export service
|
||||
- services/apexcharts.py: ApexCharts YAML generation
|
||||
- services/refresh_user_data.py: User data refresh
|
||||
- services/find_best_start.py: Find best start time
|
||||
- services/plan_charging.py: Plan charging
|
||||
|
||||
"""
|
||||
|
||||
|
|
@ -27,25 +30,69 @@ if TYPE_CHECKING:
|
|||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
def get_entry_and_data(hass: HomeAssistant, entry_id: str) -> tuple[Any, Any, dict]:
|
||||
def resolve_entry_id(hass: HomeAssistant, entry_id: str | None) -> str:
|
||||
"""
|
||||
Validate entry and extract coordinator and data.
|
||||
Resolve entry_id, auto-selecting if only one config entry exists.
|
||||
|
||||
This provides a user-friendly experience where entry_id is optional
|
||||
when only a single Tibber home is configured.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
entry_id: Config entry ID to validate
|
||||
entry_id: Config entry ID (optional)
|
||||
|
||||
Returns:
|
||||
Resolved entry_id string
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If no entries exist or multiple entries exist without entry_id
|
||||
|
||||
"""
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
|
||||
if not entries:
|
||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="no_entries_configured")
|
||||
|
||||
# If entry_id provided, use it (will be validated by get_entry_and_data)
|
||||
if entry_id:
|
||||
return entry_id
|
||||
|
||||
# Auto-select if only one entry exists
|
||||
if len(entries) == 1:
|
||||
return entries[0].entry_id
|
||||
|
||||
# Multiple entries: require explicit entry_id
|
||||
# Build a helpful error message listing available entries
|
||||
entry_list = ", ".join(f"{e.title} ({e.entry_id})" for e in entries)
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="multiple_entries_require_entry_id",
|
||||
translation_placeholders={"entries": entry_list},
|
||||
)
|
||||
|
||||
|
||||
def get_entry_and_data(hass: HomeAssistant, entry_id: str | None) -> tuple[Any, Any, dict]:
|
||||
"""
|
||||
Validate entry and extract coordinator and data.
|
||||
|
||||
If entry_id is None or empty, auto-resolves when only one entry exists.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
entry_id: Config entry ID to validate (optional if single entry)
|
||||
|
||||
Returns:
|
||||
Tuple of (entry, coordinator, data)
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If entry_id is missing or invalid
|
||||
ServiceValidationError: If entry_id cannot be resolved or is invalid
|
||||
|
||||
"""
|
||||
if not entry_id:
|
||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
|
||||
# Auto-resolve entry_id if not provided
|
||||
resolved_entry_id = resolve_entry_id(hass, entry_id)
|
||||
|
||||
entry = next(
|
||||
(e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id),
|
||||
(e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == resolved_entry_id),
|
||||
None,
|
||||
)
|
||||
if not entry or not hasattr(entry, "runtime_data") or not entry.runtime_data:
|
||||
|
|
|
|||
627
custom_components/tibber_prices/services/plan_charging.py
Normal file
627
custom_components/tibber_prices/services/plan_charging.py
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
"""
|
||||
Service handler for plan_charging service.
|
||||
|
||||
This service creates a charging plan for energy storage devices
|
||||
(EV, house battery, balcony battery) within a time window.
|
||||
|
||||
The algorithm:
|
||||
1. Calculates required intervals based on energy target or duration
|
||||
2. Finds optimal intervals (contiguous or split based on allow_split)
|
||||
3. Merges adjacent intervals into slots
|
||||
4. Optionally reduces last slot power for precise energy targeting
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
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 homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .common import (
|
||||
DEFAULT_HORIZON_HOURS,
|
||||
RESOLUTION_MINUTES,
|
||||
calculate_intervals_needed,
|
||||
create_response_envelope,
|
||||
get_intervals_in_window,
|
||||
parse_window,
|
||||
round_to_quarter,
|
||||
)
|
||||
from .helpers import get_entry_and_data
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLAN_CHARGING_SERVICE_NAME = "plan_charging"
|
||||
|
||||
# Schema for plan_charging service - FLAT structure
|
||||
# Note: services.yaml sections are UI-only groupings, HA sends data flat
|
||||
PLAN_CHARGING_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
# General / entry_id (optional - auto-resolved if single entry)
|
||||
vol.Optional("entry_id"): cv.string,
|
||||
# Window section (UI grouping only)
|
||||
vol.Optional("start"): cv.string,
|
||||
vol.Optional("end"): cv.string,
|
||||
vol.Optional("horizon_hours", default=DEFAULT_HORIZON_HOURS): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1, max=72)
|
||||
),
|
||||
# Charge section (UI grouping only)
|
||||
vol.Exclusive("energy_target_kwh", "charge_mode"): vol.All(vol.Coerce(float), vol.Range(min=0.01, max=200)),
|
||||
vol.Exclusive("duration_minutes", "charge_mode"): vol.All(vol.Coerce(int), vol.Range(min=15, max=1440)),
|
||||
vol.Required("max_power_w"): vol.All(vol.Coerce(float), vol.Range(min=1, max=50000)),
|
||||
vol.Optional("min_power_w", default=0): vol.All(vol.Coerce(float), vol.Range(min=0, max=50000)),
|
||||
vol.Optional("allow_split", default=False): cv.boolean,
|
||||
vol.Optional("rounding", default="ceil"): vol.In(["nearest", "floor", "ceil"]),
|
||||
vol.Optional("efficiency", default=1.0): vol.All(vol.Coerce(float), vol.Range(min=0.01, max=1.0)),
|
||||
# PV section (UI grouping only) - reserved for future use
|
||||
vol.Optional("pv_entity_id"): cv.entity_id,
|
||||
# Preferences section (UI grouping only)
|
||||
vol.Optional("prefer_fewer_splits", default=True): cv.boolean,
|
||||
vol.Optional("prefer_earlier_completion", default=True): cv.boolean,
|
||||
vol.Optional("include_current_interval", default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_plan_charging( # noqa: PLR0912, PLR0915
|
||||
call: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""
|
||||
Handle plan_charging service call.
|
||||
|
||||
Creates a charging plan for energy storage within a time window.
|
||||
|
||||
Args:
|
||||
call: Service call with parameters
|
||||
|
||||
Returns:
|
||||
Dict with charging plan including slots, energy, and cost details
|
||||
|
||||
"""
|
||||
hass: HomeAssistant = call.hass
|
||||
entry_id: str | None = call.data.get("entry_id")
|
||||
|
||||
# Extract parameters (flat structure - HA sends all fields at top level)
|
||||
# Window parameters
|
||||
start = call.data.get("start")
|
||||
end = call.data.get("end")
|
||||
horizon_hours = call.data.get("horizon_hours", DEFAULT_HORIZON_HOURS)
|
||||
|
||||
# Charge parameters
|
||||
energy_target_kwh = call.data.get("energy_target_kwh")
|
||||
duration_minutes = call.data.get("duration_minutes")
|
||||
max_power_w = call.data["max_power_w"]
|
||||
min_power_w = call.data.get("min_power_w", 0)
|
||||
allow_split = call.data.get("allow_split", False)
|
||||
rounding = call.data.get("rounding", "ceil")
|
||||
efficiency = call.data.get("efficiency", 1.0)
|
||||
|
||||
# PV parameters (reserved for future use)
|
||||
_pv_entity_id = call.data.get("pv_entity_id")
|
||||
|
||||
# Preferences
|
||||
_prefer_fewer_splits = call.data.get("prefer_fewer_splits", True)
|
||||
prefer_earlier = call.data.get("prefer_earlier_completion", True)
|
||||
include_current = call.data.get("include_current_interval", True)
|
||||
|
||||
# Validate: exactly one of energy_target_kwh or duration_minutes must be set
|
||||
if energy_target_kwh is None and duration_minutes is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_parameters",
|
||||
)
|
||||
|
||||
# Validate min_power <= max_power
|
||||
if min_power_w > max_power_w:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_parameters",
|
||||
)
|
||||
|
||||
# Validate and get entry data (auto-resolves entry_id if single entry)
|
||||
entry, _coordinator, data = get_entry_and_data(hass, entry_id)
|
||||
resolved_entry_id = entry.entry_id # Use resolved entry_id
|
||||
|
||||
# Get currency from coordinator data
|
||||
currency = data.get("currency", "EUR")
|
||||
|
||||
# Get currency display settings from config
|
||||
display_factor = get_display_unit_factor(entry)
|
||||
price_unit = get_display_unit_string(entry, currency)
|
||||
|
||||
# Calculate duration if energy target provided
|
||||
if energy_target_kwh is not None:
|
||||
# Calculate needed charge energy (accounting for efficiency)
|
||||
needed_charge_kwh = energy_target_kwh / efficiency
|
||||
# Energy per interval at max power
|
||||
interval_kwh = (max_power_w / 1000) * (RESOLUTION_MINUTES / 60)
|
||||
# Intervals needed
|
||||
intervals_needed = calculate_intervals_needed(
|
||||
int((needed_charge_kwh / interval_kwh) * RESOLUTION_MINUTES),
|
||||
rounding,
|
||||
)
|
||||
# Recalculate duration from intervals
|
||||
effective_duration_minutes = intervals_needed * RESOLUTION_MINUTES
|
||||
else:
|
||||
# Type guard: We already validated that at least one of energy_target_kwh or duration_minutes is set
|
||||
# This assert satisfies the type checker
|
||||
assert duration_minutes is not None # noqa: S101
|
||||
intervals_needed = calculate_intervals_needed(duration_minutes, rounding)
|
||||
effective_duration_minutes = duration_minutes
|
||||
needed_charge_kwh = None
|
||||
|
||||
# Parse window
|
||||
parsed_window = parse_window(
|
||||
hass,
|
||||
start,
|
||||
end,
|
||||
horizon_hours=horizon_hours,
|
||||
duration_minutes=effective_duration_minutes,
|
||||
)
|
||||
|
||||
# Create response envelope
|
||||
response = create_response_envelope(
|
||||
service_name=f"{DOMAIN}.{PLAN_CHARGING_SERVICE_NAME}",
|
||||
entry_id=resolved_entry_id,
|
||||
currency=currency,
|
||||
window_start=parsed_window.start,
|
||||
window_end=parsed_window.end,
|
||||
)
|
||||
|
||||
# Add any parsing warnings/errors
|
||||
response.warnings.extend(parsed_window.warnings)
|
||||
response.errors.extend(parsed_window.errors)
|
||||
|
||||
# If there were parsing errors, return early
|
||||
if parsed_window.errors:
|
||||
response.ok = False
|
||||
return response.to_dict()
|
||||
|
||||
# Round window to quarter boundaries
|
||||
# If include_current_interval is True, use floor to include the current interval
|
||||
# (e.g., 14:05 -> 14:00), otherwise ceil to start at next interval (14:05 -> 14:15)
|
||||
start_rounding = "floor" if include_current else "ceil"
|
||||
window_start = round_to_quarter(parsed_window.start, start_rounding)
|
||||
window_end = round_to_quarter(parsed_window.end, "floor")
|
||||
|
||||
# Update response with rounded window
|
||||
response.window_start = window_start.isoformat()
|
||||
response.window_end = window_end.isoformat()
|
||||
|
||||
# Get price intervals from coordinator
|
||||
all_intervals = data.get("priceInfo", [])
|
||||
if not all_intervals:
|
||||
response.ok = False
|
||||
response.errors.append("no_price_data_available_in_window")
|
||||
return response.to_dict()
|
||||
|
||||
# Filter intervals to window
|
||||
window_intervals = get_intervals_in_window(all_intervals, window_start, window_end)
|
||||
|
||||
if not window_intervals:
|
||||
response.ok = False
|
||||
response.errors.append("no_price_data_available_in_window")
|
||||
return response.to_dict()
|
||||
|
||||
# Check if we have enough intervals
|
||||
if len(window_intervals) < intervals_needed:
|
||||
response.warnings.append("some_prices_missing_used_partial_window")
|
||||
# Adjust intervals_needed to what's available
|
||||
intervals_needed = len(window_intervals)
|
||||
|
||||
# Get PV power if entity provided
|
||||
pv_power_w = 0.0
|
||||
pv_source = "none"
|
||||
if _pv_entity_id:
|
||||
pv_state = hass.states.get(_pv_entity_id)
|
||||
if pv_state and pv_state.state not in ("unknown", "unavailable"):
|
||||
try:
|
||||
pv_power_w = float(pv_state.state)
|
||||
pv_source = _pv_entity_id
|
||||
except (ValueError, TypeError):
|
||||
response.warnings.append("pv_entity_unavailable_used_zero")
|
||||
else:
|
||||
response.warnings.append("pv_entity_unavailable_used_zero")
|
||||
|
||||
# Select intervals based on allow_split
|
||||
if allow_split:
|
||||
selected_intervals = _select_cheapest_intervals(
|
||||
window_intervals, intervals_needed, prefer_earlier=prefer_earlier
|
||||
)
|
||||
else:
|
||||
selected_intervals = _find_best_contiguous_block(
|
||||
window_intervals, intervals_needed, prefer_earlier=prefer_earlier
|
||||
)
|
||||
|
||||
if not selected_intervals:
|
||||
response.ok = False
|
||||
response.errors.append("no_price_data_available_in_window")
|
||||
return response.to_dict()
|
||||
|
||||
# Merge adjacent intervals into slots
|
||||
slots = _merge_intervals_to_slots(
|
||||
selected_intervals,
|
||||
max_power_w,
|
||||
min_power_w,
|
||||
pv_power_w,
|
||||
energy_target_kwh,
|
||||
efficiency,
|
||||
)
|
||||
|
||||
# Build result
|
||||
response.result = _build_result(
|
||||
slots=slots,
|
||||
selected_intervals=selected_intervals,
|
||||
energy_target_kwh=energy_target_kwh,
|
||||
_needed_charge_kwh=needed_charge_kwh,
|
||||
efficiency=efficiency,
|
||||
pv_source=pv_source,
|
||||
display_factor=display_factor,
|
||||
price_unit=price_unit,
|
||||
)
|
||||
|
||||
return response.to_dict()
|
||||
|
||||
|
||||
def _select_cheapest_intervals(
|
||||
intervals: list[dict[str, Any]],
|
||||
count: int,
|
||||
*,
|
||||
prefer_earlier: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Select the N cheapest intervals (for allow_split=true).
|
||||
|
||||
Args:
|
||||
intervals: Available intervals
|
||||
count: Number to select
|
||||
prefer_earlier: Prefer earlier intervals on tie
|
||||
|
||||
Returns:
|
||||
Selected intervals sorted by time
|
||||
|
||||
"""
|
||||
|
||||
# Sort by price, then by time for tie-breaking
|
||||
def sort_key(iv: dict[str, Any]) -> tuple[float, float]:
|
||||
price = iv.get("total", 0)
|
||||
starts_at = iv.get("startsAt", "")
|
||||
if isinstance(starts_at, str):
|
||||
try:
|
||||
ts = datetime.fromisoformat(starts_at).timestamp()
|
||||
except ValueError:
|
||||
ts = 0
|
||||
else:
|
||||
ts = starts_at.timestamp() if starts_at else 0
|
||||
return (price, ts if prefer_earlier else -ts)
|
||||
|
||||
sorted_intervals = sorted(intervals, key=sort_key)
|
||||
selected = sorted_intervals[:count]
|
||||
|
||||
# Re-sort by time for output
|
||||
def time_key(iv: dict[str, Any]) -> float:
|
||||
starts_at = iv.get("startsAt", "")
|
||||
if isinstance(starts_at, str):
|
||||
try:
|
||||
return datetime.fromisoformat(starts_at).timestamp()
|
||||
except ValueError:
|
||||
return 0
|
||||
return starts_at.timestamp() if starts_at else 0
|
||||
|
||||
return sorted(selected, key=time_key)
|
||||
|
||||
|
||||
def _find_best_contiguous_block( # noqa: PLR0912
|
||||
intervals: list[dict[str, Any]],
|
||||
count: int,
|
||||
*,
|
||||
prefer_earlier: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Find the best contiguous block of N intervals (for allow_split=false).
|
||||
|
||||
Args:
|
||||
intervals: Available intervals (should be sorted by time)
|
||||
count: Number of contiguous intervals needed
|
||||
prefer_earlier: Prefer earlier block on tie
|
||||
|
||||
Returns:
|
||||
Best contiguous block of intervals
|
||||
|
||||
"""
|
||||
if len(intervals) < count:
|
||||
return []
|
||||
|
||||
# Sort intervals by time
|
||||
def time_key(iv: dict[str, Any]) -> float:
|
||||
starts_at = iv.get("startsAt", "")
|
||||
if isinstance(starts_at, str):
|
||||
try:
|
||||
return datetime.fromisoformat(starts_at).timestamp()
|
||||
except ValueError:
|
||||
return 0
|
||||
return starts_at.timestamp() if starts_at else 0
|
||||
|
||||
sorted_intervals = sorted(intervals, key=time_key)
|
||||
|
||||
# Find all contiguous blocks
|
||||
blocks: list[list[dict[str, Any]]] = []
|
||||
current_block: list[dict[str, Any]] = []
|
||||
|
||||
for i, interval in enumerate(sorted_intervals):
|
||||
if not current_block:
|
||||
current_block = [interval]
|
||||
else:
|
||||
# Check if this interval is contiguous with previous
|
||||
prev_start = sorted_intervals[i - 1].get("startsAt", "")
|
||||
curr_start = interval.get("startsAt", "")
|
||||
|
||||
prev_dt = datetime.fromisoformat(prev_start) if isinstance(prev_start, str) else prev_start
|
||||
curr_dt = datetime.fromisoformat(curr_start) if isinstance(curr_start, str) else curr_start
|
||||
|
||||
expected_next = prev_dt + timedelta(minutes=RESOLUTION_MINUTES)
|
||||
|
||||
if curr_dt == expected_next:
|
||||
current_block.append(interval)
|
||||
else:
|
||||
# Gap found, start new block
|
||||
if len(current_block) >= count:
|
||||
blocks.append(current_block)
|
||||
current_block = [interval]
|
||||
|
||||
# Don't forget the last block
|
||||
if len(current_block) >= count:
|
||||
blocks.append(current_block)
|
||||
|
||||
if not blocks:
|
||||
return []
|
||||
|
||||
# For each block, find the best sub-block of exactly 'count' intervals
|
||||
best_candidates: list[tuple[float, float, list[dict[str, Any]]]] = []
|
||||
|
||||
for block in blocks:
|
||||
for i in range(len(block) - count + 1):
|
||||
sub_block = block[i : i + count]
|
||||
avg_price = sum(iv.get("total", 0) for iv in sub_block) / count
|
||||
|
||||
# Get start time for tie-breaking
|
||||
first_start = sub_block[0].get("startsAt", "")
|
||||
if isinstance(first_start, str):
|
||||
try:
|
||||
ts = datetime.fromisoformat(first_start).timestamp()
|
||||
except ValueError:
|
||||
ts = 0
|
||||
else:
|
||||
ts = first_start.timestamp() if first_start else 0
|
||||
|
||||
best_candidates.append((avg_price, ts if prefer_earlier else -ts, sub_block))
|
||||
|
||||
# Sort by price, then by time
|
||||
best_candidates.sort(key=lambda x: (x[0], x[1]))
|
||||
|
||||
return best_candidates[0][2] if best_candidates else []
|
||||
|
||||
|
||||
def _merge_intervals_to_slots( # noqa: PLR0913
|
||||
intervals: list[dict[str, Any]],
|
||||
max_power_w: float,
|
||||
min_power_w: float,
|
||||
pv_power_w: float,
|
||||
energy_target_kwh: float | None,
|
||||
efficiency: float,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Merge adjacent intervals into charging slots.
|
||||
|
||||
Args:
|
||||
intervals: Selected intervals (sorted by time)
|
||||
max_power_w: Maximum charging power
|
||||
min_power_w: Minimum charging power
|
||||
pv_power_w: Current PV power
|
||||
energy_target_kwh: Target energy (if set)
|
||||
efficiency: Charging efficiency
|
||||
|
||||
Returns:
|
||||
List of slots with merged intervals
|
||||
|
||||
"""
|
||||
if not intervals:
|
||||
return []
|
||||
|
||||
slots: list[dict[str, Any]] = []
|
||||
current_slot_intervals: list[dict[str, Any]] = []
|
||||
|
||||
for _i, interval in enumerate(intervals):
|
||||
if not current_slot_intervals:
|
||||
current_slot_intervals = [interval]
|
||||
else:
|
||||
# Check if contiguous
|
||||
prev_start = current_slot_intervals[-1].get("startsAt", "")
|
||||
curr_start = interval.get("startsAt", "")
|
||||
|
||||
prev_dt = datetime.fromisoformat(prev_start) if isinstance(prev_start, str) else prev_start
|
||||
curr_dt = datetime.fromisoformat(curr_start) if isinstance(curr_start, str) else curr_start
|
||||
|
||||
expected_next = prev_dt + timedelta(minutes=RESOLUTION_MINUTES)
|
||||
|
||||
if curr_dt == expected_next:
|
||||
current_slot_intervals.append(interval)
|
||||
else:
|
||||
# Gap found, finalize current slot
|
||||
slots.append(_create_slot(current_slot_intervals, max_power_w, pv_power_w))
|
||||
current_slot_intervals = [interval]
|
||||
|
||||
# Finalize last slot
|
||||
if current_slot_intervals:
|
||||
slots.append(_create_slot(current_slot_intervals, max_power_w, pv_power_w))
|
||||
|
||||
# Adjust last slot power for energy target if needed
|
||||
if energy_target_kwh is not None and slots:
|
||||
_adjust_last_slot_power(slots, energy_target_kwh, efficiency, max_power_w, min_power_w)
|
||||
|
||||
return slots
|
||||
|
||||
|
||||
def _create_slot(
|
||||
intervals: list[dict[str, Any]],
|
||||
power_w: float,
|
||||
pv_power_w: float,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a slot from a list of contiguous intervals."""
|
||||
first_start = intervals[0].get("startsAt", "")
|
||||
last_start = intervals[-1].get("startsAt", "")
|
||||
|
||||
# Calculate end time (last interval start + 15 min)
|
||||
last_dt = datetime.fromisoformat(last_start) if isinstance(last_start, str) else last_start
|
||||
end_dt = last_dt + timedelta(minutes=RESOLUTION_MINUTES)
|
||||
|
||||
# Calculate average price (raw, in base currency)
|
||||
prices = [iv.get("total", 0) for iv in intervals]
|
||||
avg_price = sum(prices) / len(prices) if prices else 0
|
||||
|
||||
# Calculate expected grid power (reduced by PV)
|
||||
expected_grid_w = max(power_w - pv_power_w, 0)
|
||||
|
||||
return {
|
||||
"start": first_start if isinstance(first_start, str) else first_start.isoformat(),
|
||||
"end": end_dt.isoformat(),
|
||||
"duration_minutes": len(intervals) * RESOLUTION_MINUTES,
|
||||
"intervals": len(intervals),
|
||||
"target_power_w": power_w,
|
||||
"expected_pv_w": pv_power_w,
|
||||
"expected_grid_w": expected_grid_w,
|
||||
"_avg_price": avg_price, # Raw price for later formatting
|
||||
"_interval_prices": prices, # Keep for calculations, remove later
|
||||
}
|
||||
|
||||
|
||||
def _adjust_last_slot_power(
|
||||
slots: list[dict[str, Any]],
|
||||
energy_target_kwh: float,
|
||||
efficiency: float,
|
||||
max_power_w: float,
|
||||
min_power_w: float,
|
||||
) -> None:
|
||||
"""Adjust last slot power to achieve precise energy target."""
|
||||
# Calculate total energy from all slots except last
|
||||
total_energy = 0
|
||||
for slot in slots[:-1]:
|
||||
slot_hours = slot["duration_minutes"] / 60
|
||||
slot_energy = (slot["target_power_w"] / 1000) * slot_hours * efficiency
|
||||
total_energy += slot_energy
|
||||
|
||||
# Calculate remaining energy needed
|
||||
remaining_kwh = energy_target_kwh - total_energy
|
||||
|
||||
if remaining_kwh <= 0:
|
||||
# Already have enough, remove last slot
|
||||
slots.pop()
|
||||
return
|
||||
|
||||
# Calculate needed power for last slot
|
||||
last_slot = slots[-1]
|
||||
last_slot_hours = last_slot["duration_minutes"] / 60
|
||||
|
||||
# Account for efficiency: needed_charge = remaining / efficiency
|
||||
needed_charge_kwh = remaining_kwh / efficiency
|
||||
needed_power_w = (needed_charge_kwh / last_slot_hours) * 1000
|
||||
|
||||
# Clamp to min/max
|
||||
adjusted_power = max(min_power_w, min(max_power_w, needed_power_w))
|
||||
|
||||
# Update last slot
|
||||
last_slot["target_power_w"] = round(adjusted_power, 0)
|
||||
last_slot["expected_grid_w"] = max(adjusted_power - last_slot["expected_pv_w"], 0)
|
||||
|
||||
|
||||
def _build_result( # noqa: PLR0913
|
||||
slots: list[dict[str, Any]],
|
||||
selected_intervals: list[dict[str, Any]],
|
||||
energy_target_kwh: float | None,
|
||||
_needed_charge_kwh: float | None, # Reserved for future use
|
||||
efficiency: float,
|
||||
pv_source: str,
|
||||
display_factor: int,
|
||||
price_unit: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the result dictionary."""
|
||||
# Clean up slots (remove internal fields) and add formatted prices
|
||||
clean_slots = []
|
||||
for slot in slots:
|
||||
clean_slot = {k: v for k, v in slot.items() if not k.startswith("_")}
|
||||
# Add formatted avg_price
|
||||
avg_price = slot.get("_avg_price", 0)
|
||||
clean_slot["avg_price_per_kwh"] = round(avg_price * display_factor, 4)
|
||||
clean_slots.append(clean_slot)
|
||||
|
||||
# Calculate totals
|
||||
total_charge_kwh = 0
|
||||
total_grid_kwh = 0
|
||||
total_cost = 0
|
||||
all_prices = []
|
||||
|
||||
for slot in slots:
|
||||
slot_hours = slot["duration_minutes"] / 60
|
||||
slot_charge_kwh = (slot["target_power_w"] / 1000) * slot_hours
|
||||
slot_grid_kwh = (slot["expected_grid_w"] / 1000) * slot_hours
|
||||
|
||||
total_charge_kwh += slot_charge_kwh
|
||||
total_grid_kwh += slot_grid_kwh
|
||||
|
||||
# Calculate cost per interval in this slot
|
||||
interval_prices = slot.get("_interval_prices", [])
|
||||
interval_hours = RESOLUTION_MINUTES / 60
|
||||
interval_kwh = (slot["expected_grid_w"] / 1000) * interval_hours
|
||||
|
||||
for price in interval_prices:
|
||||
total_cost += interval_kwh * price
|
||||
all_prices.append(price)
|
||||
|
||||
# Calculate average and worst price
|
||||
avg_price = sum(all_prices) / len(all_prices) if all_prices else 0
|
||||
worst_price = max(all_prices) if all_prices else 0
|
||||
|
||||
# Determine next slot start
|
||||
next_slot_start = clean_slots[0]["start"] if clean_slots else None
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"plan": {
|
||||
"mode": "CHARGE",
|
||||
"slots": clean_slots,
|
||||
"total_slots": len(clean_slots),
|
||||
"next_slot_start": next_slot_start,
|
||||
},
|
||||
"price_unit": price_unit,
|
||||
"energy": {
|
||||
"target_kwh": energy_target_kwh,
|
||||
"expected_charge_kwh": round(total_charge_kwh, 4),
|
||||
"expected_grid_kwh": round(total_grid_kwh, 4),
|
||||
"efficiency_applied": efficiency,
|
||||
},
|
||||
"cost": {
|
||||
"expected_cost": round(total_cost * display_factor, 4),
|
||||
"avg_price_per_kwh": round(avg_price * display_factor, 4),
|
||||
"worst_price_per_kwh": round(worst_price * display_factor, 4),
|
||||
},
|
||||
"debug": {
|
||||
"intervals_selected": len(selected_intervals),
|
||||
"missing_price_intervals": 0,
|
||||
"split_count": len(clean_slots),
|
||||
"pv_source": pv_source,
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
@ -27,6 +27,7 @@ from custom_components.tibber_prices.api import (
|
|||
TibberPricesApiClientError,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .helpers import get_entry_and_data
|
||||
|
||||
|
|
@ -40,7 +41,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
|
|||
# Service schema
|
||||
REFRESH_USER_DATA_SERVICE_SCHEMA: Final = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTRY_ID): str,
|
||||
vol.Optional(ATTR_ENTRY_ID): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -67,12 +68,6 @@ async def handle_refresh_user_data(call: ServiceCall) -> dict[str, Any]:
|
|||
entry_id = call.data.get(ATTR_ENTRY_ID)
|
||||
hass = call.hass
|
||||
|
||||
if not entry_id:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Entry ID is required",
|
||||
}
|
||||
|
||||
# Get the entry and coordinator
|
||||
try:
|
||||
_, coordinator, _ = get_entry_and_data(hass, entry_id)
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@
|
|||
"invalid_yaml_syntax": "Invalid YAML syntax. Please check indentation, colons, and special characters.",
|
||||
"invalid_yaml_structure": "YAML must be a dictionary/object (key: value pairs), not a list or plain text.",
|
||||
"service_call_failed": "Service call validation failed: {error_detail}",
|
||||
"no_entries_configured": "No Tibber Prices entries configured. Please set up the integration first.",
|
||||
"missing_entry_id": "Entry ID is required but was not provided.",
|
||||
"multiple_entries_require_entry_id": "Multiple Tibber homes configured. Please specify which one to use with the entry_id parameter. Available entries: {entries}",
|
||||
"invalid_entry_id": "Invalid entry ID or entry not found.",
|
||||
"missing_home_id": "Home ID is missing from the configuration entry.",
|
||||
"user_data_not_available": "User data is not available. Please refresh user data first.",
|
||||
|
|
@ -891,7 +893,7 @@
|
|||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry ID",
|
||||
"description": "The config entry ID for the Tibber integration."
|
||||
"description": "Optional if only one Tibber home is configured. Required when multiple homes are set up to specify which one to use."
|
||||
},
|
||||
"start_time": {
|
||||
"name": "Start Time",
|
||||
|
|
@ -909,7 +911,7 @@
|
|||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry ID",
|
||||
"description": "The config entry ID for the Tibber integration."
|
||||
"description": "Optional if only one Tibber home is configured. Required when multiple homes are set up to specify which one to use."
|
||||
},
|
||||
"day": {
|
||||
"name": "Day",
|
||||
|
|
@ -961,7 +963,7 @@
|
|||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry ID",
|
||||
"description": "The config entry ID for the Tibber integration."
|
||||
"description": "Optional if only one Tibber home is configured. Required when multiple homes are set up to specify which one to use."
|
||||
},
|
||||
"day": {
|
||||
"name": "Day",
|
||||
|
|
@ -1063,7 +1065,163 @@
|
|||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry ID",
|
||||
"description": "The config entry ID for the Tibber integration."
|
||||
"description": "Optional if only one Tibber home is configured. Required when multiple homes are set up to specify which one to use."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_best_start": {
|
||||
"name": "Find Best Start Time",
|
||||
"description": "Finds the optimal start time for appliances that run once and cannot be interrupted (e.g., washing machine, dishwasher, dryer). Evaluates all possible start times within the window and returns the one with the lowest average electricity price or expected cost.",
|
||||
"sections": {
|
||||
"window": {
|
||||
"name": "Time Window",
|
||||
"description": "Define when the appliance can be started."
|
||||
},
|
||||
"job": {
|
||||
"name": "Job Settings",
|
||||
"description": "Configure the appliance's run duration."
|
||||
},
|
||||
"costing": {
|
||||
"name": "Cost Estimation",
|
||||
"description": "Optional: Provide energy consumption to calculate actual costs instead of just comparing prices."
|
||||
},
|
||||
"pv": {
|
||||
"name": "Solar Power",
|
||||
"description": "Optional: Account for solar generation to reduce grid import estimates."
|
||||
},
|
||||
"preferences": {
|
||||
"name": "Preferences",
|
||||
"description": "How to handle ties when multiple slots have the same price."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry ID",
|
||||
"description": "Optional if only one Tibber home is configured. Required when multiple homes are set up to specify which one to use."
|
||||
},
|
||||
"start": {
|
||||
"name": "Start Time",
|
||||
"description": "Earliest allowed start time. Accepts HH:MM (e.g., '14:00') or ISO datetime. Defaults to now if not specified."
|
||||
},
|
||||
"end": {
|
||||
"name": "End Time",
|
||||
"description": "Latest time by which the appliance must finish. Accepts HH:MM (e.g., '23:00') or ISO datetime. If HH:MM is earlier than start, it's interpreted as the next day."
|
||||
},
|
||||
"horizon_hours": {
|
||||
"name": "Horizon",
|
||||
"description": "Maximum look-ahead in hours (1-72). Limits how far into the future to search. Default: 36 hours."
|
||||
},
|
||||
"duration_minutes": {
|
||||
"name": "Duration",
|
||||
"description": "How long the appliance runs in minutes (15-1440). For best results, use a multiple of 15."
|
||||
},
|
||||
"rounding": {
|
||||
"name": "Rounding",
|
||||
"description": "How to round duration to 15-minute intervals. 'ceil' (default): round up, 'floor': round down, 'nearest': round to nearest."
|
||||
},
|
||||
"estimate_energy_kwh": {
|
||||
"name": "Estimated Energy",
|
||||
"description": "Expected energy consumption in kWh. When provided, enables actual cost calculation instead of price-only comparison."
|
||||
},
|
||||
"estimate_avg_power_w": {
|
||||
"name": "Estimated Power",
|
||||
"description": "Alternative to energy: average power draw in Watts. Automatically converted to energy based on the duration."
|
||||
},
|
||||
"pv_entity_id": {
|
||||
"name": "PV Power Entity",
|
||||
"description": "Sensor entity providing current solar power output in Watts. Used to estimate reduced grid import."
|
||||
},
|
||||
"prefer_earlier_start_on_tie": {
|
||||
"name": "Prefer Earlier Start",
|
||||
"description": "When multiple time slots have identical costs, prefer starting earlier rather than later."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Include Current Interval",
|
||||
"description": "Allow the currently running 15-minute interval as a potential start time. If disabled, only future intervals are considered. Enabling this may result in starting slightly late (up to 14 minutes into the interval) but can avoid waiting for a later slot."
|
||||
}
|
||||
}
|
||||
},
|
||||
"plan_charging": {
|
||||
"name": "Plan Charging",
|
||||
"description": "Creates an optimized charging schedule for energy storage systems (EV, home battery, balcony battery). Finds the cheapest intervals within the time window. Can either find one continuous charging block or split charging across multiple cheap periods.",
|
||||
"sections": {
|
||||
"window": {
|
||||
"name": "Time Window",
|
||||
"description": "Define when charging is allowed to occur."
|
||||
},
|
||||
"charge": {
|
||||
"name": "Charging Parameters",
|
||||
"description": "Configure power limits and energy targets."
|
||||
},
|
||||
"pv": {
|
||||
"name": "Solar Power",
|
||||
"description": "Optional: Account for solar generation to reduce grid import estimates."
|
||||
},
|
||||
"preferences": {
|
||||
"name": "Preferences",
|
||||
"description": "Fine-tune the charging schedule optimization."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry ID",
|
||||
"description": "Optional if only one Tibber home is configured. Required when multiple homes are set up to specify which one to use."
|
||||
},
|
||||
"start": {
|
||||
"name": "Start Time",
|
||||
"description": "Earliest allowed charging start. Accepts HH:MM (e.g., '00:00') or ISO datetime. Defaults to now if not specified."
|
||||
},
|
||||
"end": {
|
||||
"name": "End Time",
|
||||
"description": "Time by which charging must be complete (ready by). Accepts HH:MM (e.g., '06:00') or ISO datetime. If HH:MM is earlier than start, it's interpreted as the next day."
|
||||
},
|
||||
"horizon_hours": {
|
||||
"name": "Horizon",
|
||||
"description": "Maximum look-ahead in hours (1-72). Default: 36 hours."
|
||||
},
|
||||
"energy_target_kwh": {
|
||||
"name": "Energy Target",
|
||||
"description": "Amount of energy to charge in kWh. Specify either this OR duration, not both."
|
||||
},
|
||||
"duration_minutes": {
|
||||
"name": "Duration",
|
||||
"description": "Fixed charging duration in minutes. Specify either this OR energy target, not both."
|
||||
},
|
||||
"max_power_w": {
|
||||
"name": "Maximum Power",
|
||||
"description": "Maximum charging power in Watts. Required."
|
||||
},
|
||||
"min_power_w": {
|
||||
"name": "Minimum Power",
|
||||
"description": "Minimum charging power in Watts. Used when throttling the final slot to hit an exact energy target. Default: 0."
|
||||
},
|
||||
"allow_split": {
|
||||
"name": "Allow Split Charging",
|
||||
"description": "When enabled, charging can be split across multiple non-contiguous cheap periods for better prices. When disabled (default), finds one continuous charging block."
|
||||
},
|
||||
"rounding": {
|
||||
"name": "Rounding",
|
||||
"description": "How to round duration to 15-minute intervals. 'ceil' (default): round up, 'floor': round down, 'nearest': round to nearest."
|
||||
},
|
||||
"efficiency": {
|
||||
"name": "Efficiency",
|
||||
"description": "Charging efficiency factor (0.01-1.0). Accounts for conversion losses: efficiency = energy_stored / grid_energy_drawn. Default: 1.0 (no losses)."
|
||||
},
|
||||
"pv_entity_id": {
|
||||
"name": "PV Power Entity",
|
||||
"description": "Sensor entity providing current solar power output in Watts. Used to estimate reduced grid import."
|
||||
},
|
||||
"prefer_fewer_splits": {
|
||||
"name": "Prefer Fewer Splits",
|
||||
"description": "When split charging is enabled, prefer fewer longer sessions over many short ones."
|
||||
},
|
||||
"prefer_earlier_completion": {
|
||||
"name": "Prefer Earlier Completion",
|
||||
"description": "When multiple schedules have the same total cost, prefer the one that finishes earlier."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Include Current Interval",
|
||||
"description": "Allow the currently running 15-minute interval as a potential start time. If disabled, only future intervals are considered. Enabling this may result in starting slightly late (up to 14 minutes into the interval) but can avoid waiting for a later slot."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -1176,6 +1334,13 @@
|
|||
"median": "Median",
|
||||
"mean": "Arithmetic Mean"
|
||||
}
|
||||
},
|
||||
"rounding": {
|
||||
"options": {
|
||||
"ceil": "Round Up (ceil)",
|
||||
"floor": "Round Down (floor)",
|
||||
"nearest": "Round to Nearest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Tibber Price Information & Ratings"
|
||||
|
|
|
|||
Loading…
Reference in a new issue