mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
feat(services): add 5 scheduling services for price-optimized time windows
New services for finding optimal electricity price windows: - find_cheapest_block: Cheapest contiguous time block (e.g., dishwasher) - find_cheapest_hours: Cheapest N hours, non-contiguous (e.g., EV charging) - find_cheapest_schedule: Multi-task scheduling with no-overlap (e.g., shared circuit) - find_most_expensive_block: Most expensive contiguous block (peak avoidance) - find_most_expensive_hours: Most expensive N hours (consumption shifting) Key features: - Flexible search range (today, tomorrow, today+tomorrow, rolling window) - Power profile support for variable consumption patterns - Price level filtering (e.g., only CHEAP/VERY_CHEAP intervals) - Comparison details showing savings vs. alternatives - Sliding window algorithm (O(n)) for block search, greedy scheduling for multi-task optimization Also includes: - Shared validation utilities (search range, price level, power profile) - entry_id now optional on all services (auto-selects single home) - Input validation for existing services (time range, filter conflicts) - Service icons for all new and existing services - Translations for all 5 languages (en, de, nb, nl, sv) - Removed 10 unused config.error translation keys (replaced by exceptions) - Tests for price window algorithms and search range resolution Impact: Users can find optimal time windows for appliances, EV charging, and multi-device scheduling via HA service calls. Existing services improved with optional entry_id and better input validation.
This commit is contained in:
parent
8aa5769784
commit
6e0613c055
23 changed files with 6294 additions and 80 deletions
|
|
@ -257,6 +257,52 @@ def add_detail_attributes(attributes: dict, current_period: dict) -> None:
|
||||||
attributes["periods_remaining"] = current_period["periods_remaining"]
|
attributes["periods_remaining"] = current_period["periods_remaining"]
|
||||||
|
|
||||||
|
|
||||||
|
def add_period_count_attributes(
|
||||||
|
attributes: dict,
|
||||||
|
period_summaries: list[dict],
|
||||||
|
time: TibberPricesTimeService,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Add per-day period count attributes (priority 5.5).
|
||||||
|
|
||||||
|
Counts how many periods fall on today and tomorrow so automations can check
|
||||||
|
things like "only charge if there are at least 2 cheap periods today".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attributes: Target dict to add attributes to
|
||||||
|
period_summaries: All period summaries (already filtered to today+tomorrow)
|
||||||
|
time: TibberPricesTimeService instance for date comparison
|
||||||
|
|
||||||
|
"""
|
||||||
|
now = time.now()
|
||||||
|
today = time.get_local_date()
|
||||||
|
tomorrow = time.get_local_date(offset_days=1)
|
||||||
|
|
||||||
|
count_today = 0
|
||||||
|
count_tomorrow = 0
|
||||||
|
|
||||||
|
for period in period_summaries:
|
||||||
|
start = period.get("start")
|
||||||
|
if start is None:
|
||||||
|
continue
|
||||||
|
if hasattr(start, "date"):
|
||||||
|
period_date = start.date()
|
||||||
|
else:
|
||||||
|
from datetime import datetime # noqa: PLC0415
|
||||||
|
|
||||||
|
period_date = datetime.fromisoformat(str(start)).date()
|
||||||
|
|
||||||
|
if period_date == today:
|
||||||
|
count_today += 1
|
||||||
|
elif period_date == tomorrow:
|
||||||
|
count_tomorrow += 1
|
||||||
|
|
||||||
|
_ = now # used for clarity only
|
||||||
|
if count_today > 0 or count_tomorrow > 0:
|
||||||
|
attributes["period_count_today"] = count_today
|
||||||
|
attributes["period_count_tomorrow"] = count_tomorrow
|
||||||
|
|
||||||
|
|
||||||
def add_relaxation_attributes(attributes: dict, current_period: dict) -> None:
|
def add_relaxation_attributes(attributes: dict, current_period: dict) -> None:
|
||||||
"""
|
"""
|
||||||
Add relaxation information attributes (priority 6).
|
Add relaxation information attributes (priority 6).
|
||||||
|
|
@ -412,6 +458,9 @@ def build_final_attributes_simple(
|
||||||
# 5. Detail information
|
# 5. Detail information
|
||||||
add_detail_attributes(attributes, current_period)
|
add_detail_attributes(attributes, current_period)
|
||||||
|
|
||||||
|
# 5.5 Per-day period counts (how many cheap/peak periods per day)
|
||||||
|
add_period_count_attributes(attributes, period_summaries, time)
|
||||||
|
|
||||||
# 6. Relaxation information (only if current period was relaxed)
|
# 6. Relaxation information (only if current period was relaxed)
|
||||||
add_relaxation_attributes(attributes, current_period)
|
add_relaxation_attributes(attributes, current_period)
|
||||||
|
|
||||||
|
|
@ -429,6 +478,7 @@ def build_final_attributes_simple(
|
||||||
result: dict = {
|
result: dict = {
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
}
|
}
|
||||||
|
add_period_count_attributes(result, period_summaries, time)
|
||||||
if period_metadata:
|
if period_metadata:
|
||||||
add_calculation_summary_attributes(result, period_metadata)
|
add_calculation_summary_attributes(result, period_metadata)
|
||||||
result["periods"] = _convert_periods_to_display_units(period_summaries, factor)
|
result["periods"] = _convert_periods_to_display_units(period_summaries, factor)
|
||||||
|
|
|
||||||
|
|
@ -788,6 +788,18 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
else:
|
else:
|
||||||
# Check for repair conditions after successful update
|
# Check for repair conditions after successful update
|
||||||
await self._check_repair_conditions(result, current_time)
|
await self._check_repair_conditions(result, current_time)
|
||||||
|
|
||||||
|
# Fire event when new data was fetched from API (not cached)
|
||||||
|
if api_called and result and "priceInfo" in result and len(result["priceInfo"]) > 0:
|
||||||
|
self.hass.bus.async_fire(
|
||||||
|
"tibber_prices_data_updated",
|
||||||
|
{
|
||||||
|
"home_id": self._home_id,
|
||||||
|
"entry_id": self.config_entry.entry_id,
|
||||||
|
"interval_count": len(result["priceInfo"]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def _track_rate_limit_error(self, error: Exception) -> None:
|
async def _track_rate_limit_error(self, error: Exception) -> None:
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,52 @@
|
||||||
},
|
},
|
||||||
"refresh_user_data": {
|
"refresh_user_data": {
|
||||||
"service": "mdi:refresh"
|
"service": "mdi:refresh"
|
||||||
|
},
|
||||||
|
"find_cheapest_block": {
|
||||||
|
"service": "mdi:washing-machine",
|
||||||
|
"sections": {
|
||||||
|
"search_range": "mdi:calendar-search",
|
||||||
|
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||||
|
"price_filter": "mdi:filter-variant",
|
||||||
|
"output": "mdi:tune-variant"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_most_expensive_block": {
|
||||||
|
"service": "mdi:lightning-bolt-circle",
|
||||||
|
"sections": {
|
||||||
|
"search_range": "mdi:calendar-search",
|
||||||
|
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||||
|
"price_filter": "mdi:filter-variant",
|
||||||
|
"output": "mdi:tune-variant"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_hours": {
|
||||||
|
"service": "mdi:ev-station",
|
||||||
|
"sections": {
|
||||||
|
"search_range": "mdi:calendar-search",
|
||||||
|
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||||
|
"price_filter": "mdi:filter-variant",
|
||||||
|
"output": "mdi:tune-variant"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_most_expensive_hours": {
|
||||||
|
"service": "mdi:flash-alert",
|
||||||
|
"sections": {
|
||||||
|
"search_range": "mdi:calendar-search",
|
||||||
|
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||||
|
"price_filter": "mdi:filter-variant",
|
||||||
|
"output": "mdi:tune-variant"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_schedule": {
|
||||||
|
"service": "mdi:calendar-check",
|
||||||
|
"sections": {
|
||||||
|
"scheduling_options": "mdi:format-list-numbered",
|
||||||
|
"search_range": "mdi:calendar-search",
|
||||||
|
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||||
|
"price_filter": "mdi:filter-variant",
|
||||||
|
"output": "mdi:tune-variant"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
get_price:
|
get_price:
|
||||||
fields:
|
fields:
|
||||||
entry_id:
|
entry_id:
|
||||||
required: true
|
required: false
|
||||||
example: "1234567890abcdef"
|
example: "1234567890abcdef"
|
||||||
selector:
|
selector:
|
||||||
config_entry:
|
config_entry:
|
||||||
|
|
@ -19,7 +19,7 @@ get_price:
|
||||||
get_apexcharts_yaml:
|
get_apexcharts_yaml:
|
||||||
fields:
|
fields:
|
||||||
entry_id:
|
entry_id:
|
||||||
required: true
|
required: false
|
||||||
example: "1234567890abcdef"
|
example: "1234567890abcdef"
|
||||||
selector:
|
selector:
|
||||||
config_entry:
|
config_entry:
|
||||||
|
|
@ -73,7 +73,7 @@ get_chartdata:
|
||||||
general:
|
general:
|
||||||
fields:
|
fields:
|
||||||
entry_id:
|
entry_id:
|
||||||
required: true
|
required: false
|
||||||
example: "1234567890abcdef"
|
example: "1234567890abcdef"
|
||||||
selector:
|
selector:
|
||||||
config_entry:
|
config_entry:
|
||||||
|
|
@ -274,12 +274,772 @@ get_chartdata:
|
||||||
refresh_user_data:
|
refresh_user_data:
|
||||||
fields:
|
fields:
|
||||||
entry_id:
|
entry_id:
|
||||||
required: true
|
required: false
|
||||||
example: "1234567890abcdef"
|
example: "1234567890abcdef"
|
||||||
selector:
|
selector:
|
||||||
config_entry:
|
config_entry:
|
||||||
integration: tibber_prices
|
integration: tibber_prices
|
||||||
|
|
||||||
|
find_cheapest_block:
|
||||||
|
fields:
|
||||||
|
entry_id:
|
||||||
|
required: false
|
||||||
|
example: "1234567890abcdef"
|
||||||
|
selector:
|
||||||
|
config_entry:
|
||||||
|
integration: tibber_prices
|
||||||
|
duration:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
duration:
|
||||||
|
search_range:
|
||||||
|
fields:
|
||||||
|
search_scope:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- today
|
||||||
|
- tomorrow
|
||||||
|
- remaining_today
|
||||||
|
- next_24h
|
||||||
|
- next_48h
|
||||||
|
translation_key: search_scope
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
include_current_interval:
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
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
|
||||||
|
output:
|
||||||
|
collapsed: true
|
||||||
|
fields:
|
||||||
|
power_profile:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
include_comparison_details:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
use_base_unit:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
|
||||||
|
find_most_expensive_block:
|
||||||
|
fields:
|
||||||
|
entry_id:
|
||||||
|
required: false
|
||||||
|
example: "1234567890abcdef"
|
||||||
|
selector:
|
||||||
|
config_entry:
|
||||||
|
integration: tibber_prices
|
||||||
|
duration:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
duration:
|
||||||
|
search_range:
|
||||||
|
fields:
|
||||||
|
search_scope:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- today
|
||||||
|
- tomorrow
|
||||||
|
- remaining_today
|
||||||
|
- next_24h
|
||||||
|
- next_48h
|
||||||
|
translation_key: search_scope
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
include_current_interval:
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
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
|
||||||
|
output:
|
||||||
|
collapsed: true
|
||||||
|
fields:
|
||||||
|
power_profile:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
include_comparison_details:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
use_base_unit:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
|
||||||
|
find_cheapest_hours:
|
||||||
|
fields:
|
||||||
|
entry_id:
|
||||||
|
required: false
|
||||||
|
example: "1234567890abcdef"
|
||||||
|
selector:
|
||||||
|
config_entry:
|
||||||
|
integration: tibber_prices
|
||||||
|
duration:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
duration:
|
||||||
|
min_segment_duration:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
duration:
|
||||||
|
search_range:
|
||||||
|
fields:
|
||||||
|
search_scope:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- today
|
||||||
|
- tomorrow
|
||||||
|
- remaining_today
|
||||||
|
- next_24h
|
||||||
|
- next_48h
|
||||||
|
translation_key: search_scope
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
include_current_interval:
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
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
|
||||||
|
output:
|
||||||
|
collapsed: true
|
||||||
|
fields:
|
||||||
|
power_profile:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
include_comparison_details:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
use_base_unit:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
|
||||||
|
find_most_expensive_hours:
|
||||||
|
fields:
|
||||||
|
entry_id:
|
||||||
|
required: false
|
||||||
|
example: "1234567890abcdef"
|
||||||
|
selector:
|
||||||
|
config_entry:
|
||||||
|
integration: tibber_prices
|
||||||
|
duration:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
duration:
|
||||||
|
min_segment_duration:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
duration:
|
||||||
|
search_range:
|
||||||
|
fields:
|
||||||
|
search_scope:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- today
|
||||||
|
- tomorrow
|
||||||
|
- remaining_today
|
||||||
|
- next_24h
|
||||||
|
- next_48h
|
||||||
|
translation_key: search_scope
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
include_current_interval:
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
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
|
||||||
|
output:
|
||||||
|
collapsed: true
|
||||||
|
fields:
|
||||||
|
power_profile:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
include_comparison_details:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
use_base_unit:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
|
||||||
|
find_cheapest_schedule:
|
||||||
|
fields:
|
||||||
|
entry_id:
|
||||||
|
required: false
|
||||||
|
example: "1234567890abcdef"
|
||||||
|
selector:
|
||||||
|
config_entry:
|
||||||
|
integration: tibber_prices
|
||||||
|
tasks:
|
||||||
|
required: true
|
||||||
|
example: '[{"name": "dishwasher", "duration": "02:00:00"}, {"name": "washing_machine", "duration": "01:30:00"}]'
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
scheduling_options:
|
||||||
|
fields:
|
||||||
|
gap_minutes:
|
||||||
|
required: false
|
||||||
|
default: 0
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 0
|
||||||
|
max: 120
|
||||||
|
unit_of_measurement: min
|
||||||
|
mode: box
|
||||||
|
search_range:
|
||||||
|
fields:
|
||||||
|
search_scope:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- today
|
||||||
|
- tomorrow
|
||||||
|
- remaining_today
|
||||||
|
- next_24h
|
||||||
|
- next_48h
|
||||||
|
translation_key: search_scope
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
output:
|
||||||
|
collapsed: true
|
||||||
|
fields:
|
||||||
|
use_base_unit:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
search_scope:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- today
|
||||||
|
- tomorrow
|
||||||
|
- remaining_today
|
||||||
|
- next_24h
|
||||||
|
- next_48h
|
||||||
|
max_price_level:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- very_cheap
|
||||||
|
- cheap
|
||||||
|
- normal
|
||||||
|
- expensive
|
||||||
|
- very_expensive
|
||||||
|
min_price_level:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- very_cheap
|
||||||
|
- cheap
|
||||||
|
- normal
|
||||||
|
- expensive
|
||||||
|
- very_expensive
|
||||||
|
include_comparison_details:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
power_profile:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
include_current_interval:
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
use_base_unit:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
debug_clear_tomorrow:
|
debug_clear_tomorrow:
|
||||||
fields:
|
fields:
|
||||||
entry_id:
|
entry_id:
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,31 @@ from typing import TYPE_CHECKING
|
||||||
from custom_components.tibber_prices.const import DOMAIN
|
from custom_components.tibber_prices.const import DOMAIN
|
||||||
from homeassistant.core import SupportsResponse, callback
|
from homeassistant.core import SupportsResponse, callback
|
||||||
|
|
||||||
|
from .find_cheapest_block import (
|
||||||
|
FIND_CHEAPEST_BLOCK_SERVICE_NAME,
|
||||||
|
FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA,
|
||||||
|
handle_find_cheapest_block,
|
||||||
|
)
|
||||||
|
from .find_cheapest_hours import (
|
||||||
|
FIND_CHEAPEST_HOURS_SERVICE_NAME,
|
||||||
|
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA,
|
||||||
|
handle_find_cheapest_hours,
|
||||||
|
)
|
||||||
|
from .find_cheapest_schedule import (
|
||||||
|
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME,
|
||||||
|
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA,
|
||||||
|
handle_find_cheapest_schedule,
|
||||||
|
)
|
||||||
|
from .find_most_expensive_block import (
|
||||||
|
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_NAME,
|
||||||
|
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_SCHEMA,
|
||||||
|
handle_find_most_expensive_block,
|
||||||
|
)
|
||||||
|
from .find_most_expensive_hours import (
|
||||||
|
FIND_MOST_EXPENSIVE_HOURS_SERVICE_NAME,
|
||||||
|
FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA,
|
||||||
|
handle_find_most_expensive_hours,
|
||||||
|
)
|
||||||
from .get_apexcharts_yaml import (
|
from .get_apexcharts_yaml import (
|
||||||
APEXCHARTS_SERVICE_SCHEMA,
|
APEXCHARTS_SERVICE_SCHEMA,
|
||||||
APEXCHARTS_YAML_SERVICE_NAME,
|
APEXCHARTS_YAML_SERVICE_NAME,
|
||||||
|
|
@ -73,6 +98,41 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
schema=GET_PRICE_SERVICE_SCHEMA,
|
schema=GET_PRICE_SERVICE_SCHEMA,
|
||||||
supports_response=SupportsResponse.ONLY,
|
supports_response=SupportsResponse.ONLY,
|
||||||
)
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
FIND_CHEAPEST_BLOCK_SERVICE_NAME,
|
||||||
|
handle_find_cheapest_block,
|
||||||
|
schema=FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.ONLY,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
FIND_CHEAPEST_HOURS_SERVICE_NAME,
|
||||||
|
handle_find_cheapest_hours,
|
||||||
|
schema=FIND_CHEAPEST_HOURS_SERVICE_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.ONLY,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME,
|
||||||
|
handle_find_cheapest_schedule,
|
||||||
|
schema=FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.ONLY,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_NAME,
|
||||||
|
handle_find_most_expensive_block,
|
||||||
|
schema=FIND_MOST_EXPENSIVE_BLOCK_SERVICE_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.ONLY,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
FIND_MOST_EXPENSIVE_HOURS_SERVICE_NAME,
|
||||||
|
handle_find_most_expensive_hours,
|
||||||
|
schema=FIND_MOST_EXPENSIVE_HOURS_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,
|
||||||
|
|
|
||||||
296
custom_components/tibber_prices/services/find_cheapest_block.py
Normal file
296
custom_components/tibber_prices/services/find_cheapest_block.py
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
"""
|
||||||
|
Service handler for find_cheapest_block and find_most_expensive_block services.
|
||||||
|
|
||||||
|
Finds the cheapest (or most expensive) contiguous window of a given duration
|
||||||
|
within a search range. Designed for appliance scheduling (dishwasher, washing
|
||||||
|
machine, dryer).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
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_contiguous_window,
|
||||||
|
)
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.util import dt as dt_utils
|
||||||
|
|
||||||
|
from .helpers import (
|
||||||
|
INTERVAL_MINUTES,
|
||||||
|
PRICE_LEVEL_ORDER,
|
||||||
|
VALID_SEARCH_SCOPES,
|
||||||
|
build_rating_lookup,
|
||||||
|
build_response_interval,
|
||||||
|
filter_intervals_by_price_level,
|
||||||
|
get_entry_and_data,
|
||||||
|
resolve_home_timezone,
|
||||||
|
resolve_search_range,
|
||||||
|
validate_power_profile_length,
|
||||||
|
validate_price_level_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FIND_CHEAPEST_BLOCK_SERVICE_NAME = "find_cheapest_block"
|
||||||
|
|
||||||
|
_COMMON_BLOCK_SCHEMA = {
|
||||||
|
vol.Optional("entry_id", default=""): cv.string,
|
||||||
|
vol.Required("duration"): vol.All(
|
||||||
|
cv.positive_time_period,
|
||||||
|
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)),
|
||||||
|
),
|
||||||
|
vol.Optional("search_start"): cv.datetime,
|
||||||
|
vol.Optional("search_end"): cv.datetime,
|
||||||
|
vol.Optional("search_start_time"): cv.time,
|
||||||
|
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
|
||||||
|
vol.Optional("search_end_time"): cv.time,
|
||||||
|
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
|
||||||
|
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
|
||||||
|
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
|
||||||
|
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
|
||||||
|
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||||
|
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||||
|
vol.Optional("include_comparison_details", default=False): cv.boolean,
|
||||||
|
vol.Optional("power_profile"): vol.All(
|
||||||
|
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
|
||||||
|
vol.Length(min=1, max=48),
|
||||||
|
),
|
||||||
|
vol.Optional("include_current_interval", default=True): cv.boolean,
|
||||||
|
vol.Optional("use_base_unit", default=False): cv.boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA = vol.Schema(_COMMON_BLOCK_SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_price_comparison(
|
||||||
|
comparison_result: dict | None,
|
||||||
|
unit_factor: int,
|
||||||
|
stats: dict,
|
||||||
|
*,
|
||||||
|
reverse: bool,
|
||||||
|
include_details: bool = False,
|
||||||
|
) -> dict[str, float | str | None] | None:
|
||||||
|
"""Compute price comparison between the selected and opposite-direction window."""
|
||||||
|
if comparison_result is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
comparison_stats = calculate_window_statistics(
|
||||||
|
comparison_result["intervals"], unit_factor=unit_factor, round_decimals=4
|
||||||
|
)
|
||||||
|
if stats.get("price_mean") is None or comparison_stats.get("price_mean") is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
diff = round(comparison_stats["price_mean"] - stats["price_mean"], 4)
|
||||||
|
if reverse:
|
||||||
|
diff = -diff
|
||||||
|
|
||||||
|
result: dict[str, float | str | None] = {
|
||||||
|
"comparison_price_mean": comparison_stats["price_mean"],
|
||||||
|
"price_difference": abs(diff),
|
||||||
|
"comparison_window_start": (
|
||||||
|
comparison_result["intervals"][0]["startsAt"]
|
||||||
|
if isinstance(comparison_result["intervals"][0]["startsAt"], str)
|
||||||
|
else comparison_result["intervals"][0]["startsAt"].isoformat()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional enrichment (P6)
|
||||||
|
if include_details:
|
||||||
|
result["comparison_price_min"] = comparison_stats.get("price_min")
|
||||||
|
result["comparison_price_max"] = comparison_stats.get("price_max")
|
||||||
|
last_start = comparison_result["intervals"][-1]["startsAt"]
|
||||||
|
if not isinstance(last_start, str):
|
||||||
|
last_start = last_start.isoformat()
|
||||||
|
result["comparison_window_end"] = (
|
||||||
|
datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_find_block( # noqa: PLR0915
|
||||||
|
call: ServiceCall,
|
||||||
|
*,
|
||||||
|
reverse: bool = False,
|
||||||
|
) -> ServiceResponse:
|
||||||
|
"""
|
||||||
|
Core handler for finding price blocks (cheapest or most expensive).
|
||||||
|
|
||||||
|
Finds the cheapest/most expensive contiguous window of the requested
|
||||||
|
duration within the search range using a sliding window algorithm.
|
||||||
|
"""
|
||||||
|
service_label = "find_most_expensive_block" if reverse else "find_cheapest_block"
|
||||||
|
hass: HomeAssistant = call.hass
|
||||||
|
entry_id: str = call.data.get("entry_id", "")
|
||||||
|
duration_td: timedelta = call.data["duration"]
|
||||||
|
use_base_unit: bool = call.data.get("use_base_unit", False)
|
||||||
|
max_price_level: str | None = call.data.get("max_price_level")
|
||||||
|
min_price_level: str | None = call.data.get("min_price_level")
|
||||||
|
include_comparison_details: bool = call.data.get("include_comparison_details", False)
|
||||||
|
power_profile: list[int] | None = call.data.get("power_profile")
|
||||||
|
|
||||||
|
duration_minutes_requested = int(duration_td.total_seconds() / 60)
|
||||||
|
# Round up to nearest quarter-hour interval
|
||||||
|
duration_minutes = math.ceil(duration_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES
|
||||||
|
|
||||||
|
entry, coordinator, data = get_entry_and_data(hass, entry_id)
|
||||||
|
rating_lookup = build_rating_lookup(data)
|
||||||
|
|
||||||
|
home_id = entry.data.get("home_id")
|
||||||
|
if not home_id:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="missing_home_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve timezone
|
||||||
|
home_timezone = resolve_home_timezone(coordinator, home_id)
|
||||||
|
home_tz: ZoneInfo
|
||||||
|
from zoneinfo import ZoneInfo # noqa: PLC0415
|
||||||
|
|
||||||
|
home_tz = ZoneInfo(home_timezone)
|
||||||
|
|
||||||
|
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
|
||||||
|
now = dt_utils.now().astimezone(home_tz)
|
||||||
|
search_start, search_end = resolve_search_range(call.data, now, home_tz)
|
||||||
|
|
||||||
|
duration_intervals = duration_minutes // INTERVAL_MINUTES
|
||||||
|
|
||||||
|
# Validate parameter combinations
|
||||||
|
validate_price_level_range(min_price_level, max_price_level)
|
||||||
|
validate_power_profile_length(power_profile, duration_intervals)
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s called: duration=%dmin, range=%s to %s",
|
||||||
|
service_label,
|
||||||
|
duration_minutes,
|
||||||
|
search_start,
|
||||||
|
search_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch intervals via pool
|
||||||
|
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", service_label)
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="price_fetch_failed",
|
||||||
|
) from error
|
||||||
|
|
||||||
|
# Determine currency and unit
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Apply optional price level filter (P5)
|
||||||
|
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
|
||||||
|
|
||||||
|
# Find cheapest/most expensive window
|
||||||
|
result = find_cheapest_contiguous_window(filtered_price_info, duration_intervals, reverse=reverse)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s: no window found (need %d intervals, have %d after level filter)",
|
||||||
|
service_label,
|
||||||
|
duration_intervals,
|
||||||
|
len(filtered_price_info),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"home_id": home_id,
|
||||||
|
"search_start": search_start.isoformat(),
|
||||||
|
"search_end": search_end.isoformat(),
|
||||||
|
"duration_minutes_requested": duration_minutes_requested,
|
||||||
|
"duration_minutes": duration_minutes,
|
||||||
|
"currency": currency,
|
||||||
|
"price_unit": price_unit,
|
||||||
|
"window_found": False,
|
||||||
|
"window": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find the opposite-direction window for price comparison (from full unfiltered list)
|
||||||
|
comparison_result = find_cheapest_contiguous_window(price_info, duration_intervals, reverse=not reverse)
|
||||||
|
|
||||||
|
# Calculate statistics and build response
|
||||||
|
stats = calculate_window_statistics(
|
||||||
|
result["intervals"], unit_factor=unit_factor, round_decimals=4, power_profile=power_profile
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate price comparison (difference to opposite-direction window)
|
||||||
|
price_comparison = _compute_price_comparison(
|
||||||
|
comparison_result, unit_factor, stats, reverse=reverse, include_details=include_comparison_details
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build interval list with converted prices
|
||||||
|
response_intervals = [build_response_interval(iv, unit_factor, rating_lookup) for iv in result["intervals"]]
|
||||||
|
|
||||||
|
# Calculate end time (last interval start + 15 min)
|
||||||
|
last_start = result["intervals"][-1]["startsAt"]
|
||||||
|
if isinstance(last_start, str):
|
||||||
|
end_time = datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
|
||||||
|
else:
|
||||||
|
end_time = last_start + timedelta(minutes=INTERVAL_MINUTES)
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"home_id": home_id,
|
||||||
|
"search_start": search_start.isoformat(),
|
||||||
|
"search_end": search_end.isoformat(),
|
||||||
|
"duration_minutes_requested": duration_minutes_requested,
|
||||||
|
"duration_minutes": duration_minutes,
|
||||||
|
"currency": currency,
|
||||||
|
"price_unit": price_unit,
|
||||||
|
"window_found": True,
|
||||||
|
"window": {
|
||||||
|
"start": result["intervals"][0]["startsAt"]
|
||||||
|
if isinstance(result["intervals"][0]["startsAt"], str)
|
||||||
|
else result["intervals"][0]["startsAt"].isoformat(),
|
||||||
|
"end": end_time.isoformat() if hasattr(end_time, "isoformat") else end_time,
|
||||||
|
"duration_minutes": duration_minutes,
|
||||||
|
"interval_count": len(result["intervals"]),
|
||||||
|
**stats,
|
||||||
|
"intervals": response_intervals,
|
||||||
|
},
|
||||||
|
"price_comparison": price_comparison or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s: found window at %s, mean=%.4f %s",
|
||||||
|
service_label,
|
||||||
|
response["window"]["start"],
|
||||||
|
stats.get("price_mean", 0) or 0,
|
||||||
|
price_unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_find_cheapest_block(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Handle find_cheapest_block service call."""
|
||||||
|
return await _handle_find_block(call, reverse=False)
|
||||||
334
custom_components/tibber_prices/services/find_cheapest_hours.py
Normal file
334
custom_components/tibber_prices/services/find_cheapest_hours.py
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
"""
|
||||||
|
Service handler for find_cheapest_hours and find_most_expensive_hours services.
|
||||||
|
|
||||||
|
Finds the cheapest (or most expensive) N minutes of intervals within a search range.
|
||||||
|
Intervals need not be contiguous — designed for flexible loads
|
||||||
|
(battery charging, EV, water heater with thermostat).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.util import dt as dt_utils
|
||||||
|
|
||||||
|
from .helpers import (
|
||||||
|
INTERVAL_MINUTES,
|
||||||
|
PRICE_LEVEL_ORDER,
|
||||||
|
VALID_SEARCH_SCOPES,
|
||||||
|
build_rating_lookup,
|
||||||
|
build_response_interval,
|
||||||
|
filter_intervals_by_price_level,
|
||||||
|
get_entry_and_data,
|
||||||
|
resolve_home_timezone,
|
||||||
|
resolve_search_range,
|
||||||
|
validate_power_profile_length,
|
||||||
|
validate_price_level_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FIND_CHEAPEST_HOURS_SERVICE_NAME = "find_cheapest_hours"
|
||||||
|
|
||||||
|
_COMMON_HOURS_SCHEMA = {
|
||||||
|
vol.Optional("entry_id", default=""): cv.string,
|
||||||
|
vol.Required("duration"): vol.All(
|
||||||
|
cv.positive_time_period,
|
||||||
|
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=24)),
|
||||||
|
),
|
||||||
|
vol.Optional("search_start"): cv.datetime,
|
||||||
|
vol.Optional("search_end"): cv.datetime,
|
||||||
|
vol.Optional("search_start_time"): cv.time,
|
||||||
|
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
|
||||||
|
vol.Optional("search_end_time"): cv.time,
|
||||||
|
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
|
||||||
|
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
|
||||||
|
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
|
||||||
|
vol.Optional("min_segment_duration"): vol.All(
|
||||||
|
cv.positive_time_period,
|
||||||
|
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=4)),
|
||||||
|
),
|
||||||
|
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
|
||||||
|
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||||
|
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||||
|
vol.Optional("include_comparison_details", default=False): cv.boolean,
|
||||||
|
vol.Optional("power_profile"): vol.All(
|
||||||
|
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
|
||||||
|
vol.Length(min=1, max=96),
|
||||||
|
),
|
||||||
|
vol.Optional("include_current_interval", default=True): cv.boolean,
|
||||||
|
vol.Optional("use_base_unit", default=False): cv.boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_found_response( # noqa: PLR0913
|
||||||
|
*,
|
||||||
|
result: dict,
|
||||||
|
comparison_result: dict | None,
|
||||||
|
reverse: bool,
|
||||||
|
home_id: str,
|
||||||
|
search_start: datetime,
|
||||||
|
search_end: datetime,
|
||||||
|
total_minutes_requested: int,
|
||||||
|
total_minutes: int,
|
||||||
|
min_segment_minutes_requested: int,
|
||||||
|
min_segment_minutes: int,
|
||||||
|
currency: str,
|
||||||
|
price_unit: str,
|
||||||
|
unit_factor: int,
|
||||||
|
service_label: str,
|
||||||
|
rating_lookup: dict[str, str | None],
|
||||||
|
include_comparison_details: bool = False,
|
||||||
|
power_profile: list[int] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Build the service response when intervals are found."""
|
||||||
|
stats = calculate_window_statistics(
|
||||||
|
result["intervals"], unit_factor=unit_factor, round_decimals=4, power_profile=power_profile
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate price comparison (difference to opposite-direction selection)
|
||||||
|
price_comparison: dict[str, float | str | None] = {}
|
||||||
|
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")
|
||||||
|
comp_mean = comparison_stats.get("price_mean")
|
||||||
|
if own_mean is not None and comp_mean is not None:
|
||||||
|
diff = round(float(comp_mean) - float(own_mean), 4)
|
||||||
|
if reverse:
|
||||||
|
diff = -diff
|
||||||
|
price_comparison = {
|
||||||
|
"comparison_price_mean": comp_mean,
|
||||||
|
"price_difference": abs(round(diff, 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")
|
||||||
|
|
||||||
|
response_intervals = [build_response_interval(iv, unit_factor, rating_lookup) for iv in result["intervals"]]
|
||||||
|
|
||||||
|
response_segments = []
|
||||||
|
for seg in result["segments"]:
|
||||||
|
seg_stats = calculate_window_statistics(seg["intervals"], unit_factor=unit_factor, round_decimals=4)
|
||||||
|
last_start = seg["intervals"][-1]["startsAt"]
|
||||||
|
if isinstance(last_start, str):
|
||||||
|
seg_end = datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
|
||||||
|
else:
|
||||||
|
seg_end = last_start + timedelta(minutes=INTERVAL_MINUTES)
|
||||||
|
|
||||||
|
response_segments.append(
|
||||||
|
{
|
||||||
|
"start": seg["start"],
|
||||||
|
"end": seg_end.isoformat() if hasattr(seg_end, "isoformat") else seg_end,
|
||||||
|
"duration_minutes": seg["duration_minutes"],
|
||||||
|
"interval_count": seg["interval_count"],
|
||||||
|
"price_mean": seg_stats.get("price_mean"),
|
||||||
|
"intervals": [build_response_interval(iv, unit_factor, rating_lookup) for iv in seg["intervals"]],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
actual_minutes = len(result["intervals"]) * INTERVAL_MINUTES
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s: found %d intervals in %d segments, mean=%.4f %s",
|
||||||
|
service_label,
|
||||||
|
len(result["intervals"]),
|
||||||
|
len(response_segments),
|
||||||
|
stats.get("price_mean", 0) or 0,
|
||||||
|
price_unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"home_id": home_id,
|
||||||
|
"search_start": search_start.isoformat(),
|
||||||
|
"search_end": search_end.isoformat(),
|
||||||
|
"total_minutes_requested": total_minutes_requested,
|
||||||
|
"total_minutes": total_minutes,
|
||||||
|
"min_segment_minutes_requested": min_segment_minutes_requested,
|
||||||
|
"min_segment_minutes": min_segment_minutes,
|
||||||
|
"currency": currency,
|
||||||
|
"price_unit": price_unit,
|
||||||
|
"intervals_found": True,
|
||||||
|
"schedule": {
|
||||||
|
"total_minutes": actual_minutes,
|
||||||
|
"interval_count": len(result["intervals"]),
|
||||||
|
**stats,
|
||||||
|
"segment_count": len(response_segments),
|
||||||
|
"segments": response_segments,
|
||||||
|
"intervals": response_intervals,
|
||||||
|
},
|
||||||
|
"price_comparison": price_comparison or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_find_hours(
|
||||||
|
call: ServiceCall,
|
||||||
|
*,
|
||||||
|
reverse: bool = False,
|
||||||
|
) -> ServiceResponse:
|
||||||
|
"""
|
||||||
|
Core handler for finding price hours (cheapest or most expensive).
|
||||||
|
|
||||||
|
Finds the cheapest/most expensive N intervals (not necessarily contiguous)
|
||||||
|
within the search range. Results are grouped into contiguous segments for
|
||||||
|
scheduling convenience.
|
||||||
|
"""
|
||||||
|
service_label = "find_most_expensive_hours" if reverse else "find_cheapest_hours"
|
||||||
|
hass: HomeAssistant = call.hass
|
||||||
|
entry_id: str = call.data.get("entry_id", "")
|
||||||
|
duration_td: timedelta = call.data["duration"]
|
||||||
|
min_segment_td: timedelta | None = call.data.get("min_segment_duration")
|
||||||
|
use_base_unit: bool = call.data.get("use_base_unit", False)
|
||||||
|
max_price_level: str | None = call.data.get("max_price_level")
|
||||||
|
min_price_level: str | None = call.data.get("min_price_level")
|
||||||
|
include_comparison_details: bool = call.data.get("include_comparison_details", False)
|
||||||
|
power_profile: list[int] | None = call.data.get("power_profile")
|
||||||
|
|
||||||
|
total_minutes_requested = int(duration_td.total_seconds() / 60)
|
||||||
|
min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES
|
||||||
|
|
||||||
|
# Round up to nearest quarter-hour intervals
|
||||||
|
total_minutes = math.ceil(total_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES
|
||||||
|
min_segment_minutes = math.ceil(min_segment_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES
|
||||||
|
|
||||||
|
entry, coordinator, data = get_entry_and_data(hass, entry_id)
|
||||||
|
rating_lookup = build_rating_lookup(data)
|
||||||
|
|
||||||
|
home_id = entry.data.get("home_id")
|
||||||
|
if not home_id:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="missing_home_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve timezone
|
||||||
|
home_timezone = resolve_home_timezone(coordinator, home_id)
|
||||||
|
home_tz: ZoneInfo
|
||||||
|
from zoneinfo import ZoneInfo # noqa: PLC0415
|
||||||
|
|
||||||
|
home_tz = ZoneInfo(home_timezone)
|
||||||
|
|
||||||
|
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
|
||||||
|
now = dt_utils.now().astimezone(home_tz)
|
||||||
|
search_start, search_end = resolve_search_range(call.data, now, home_tz)
|
||||||
|
|
||||||
|
total_intervals = total_minutes // INTERVAL_MINUTES
|
||||||
|
min_segment_intervals = min_segment_minutes // INTERVAL_MINUTES
|
||||||
|
|
||||||
|
# Validate parameter combinations
|
||||||
|
validate_price_level_range(min_price_level, max_price_level)
|
||||||
|
validate_power_profile_length(power_profile, total_intervals)
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s called: total=%dmin, min_segment=%dmin, range=%s to %s",
|
||||||
|
service_label,
|
||||||
|
total_minutes,
|
||||||
|
min_segment_minutes,
|
||||||
|
search_start,
|
||||||
|
search_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch intervals via pool
|
||||||
|
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", service_label)
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="price_fetch_failed",
|
||||||
|
) from error
|
||||||
|
|
||||||
|
# Determine currency and unit
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Apply optional price level filter (P5)
|
||||||
|
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
|
||||||
|
|
||||||
|
# Find cheapest/most expensive intervals
|
||||||
|
result = find_cheapest_n_intervals(filtered_price_info, total_intervals, min_segment_intervals, reverse=reverse)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s: not enough intervals (need %d, have %d after level filter)",
|
||||||
|
service_label,
|
||||||
|
total_intervals,
|
||||||
|
len(filtered_price_info),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"home_id": home_id,
|
||||||
|
"search_start": search_start.isoformat(),
|
||||||
|
"search_end": search_end.isoformat(),
|
||||||
|
"total_minutes_requested": total_minutes_requested,
|
||||||
|
"total_minutes": total_minutes,
|
||||||
|
"min_segment_minutes_requested": min_segment_minutes_requested,
|
||||||
|
"min_segment_minutes": min_segment_minutes,
|
||||||
|
"currency": currency,
|
||||||
|
"price_unit": price_unit,
|
||||||
|
"intervals_found": False,
|
||||||
|
"schedule": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find opposite-direction selection for price comparison (from full unfiltered list)
|
||||||
|
comparison_result = find_cheapest_n_intervals(
|
||||||
|
price_info, total_intervals, min_segment_intervals, reverse=not reverse
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build_found_response(
|
||||||
|
result=result,
|
||||||
|
comparison_result=comparison_result,
|
||||||
|
reverse=reverse,
|
||||||
|
home_id=home_id,
|
||||||
|
search_start=search_start,
|
||||||
|
search_end=search_end,
|
||||||
|
total_minutes_requested=total_minutes_requested,
|
||||||
|
total_minutes=total_minutes,
|
||||||
|
min_segment_minutes_requested=min_segment_minutes_requested,
|
||||||
|
min_segment_minutes=min_segment_minutes,
|
||||||
|
currency=currency,
|
||||||
|
price_unit=price_unit,
|
||||||
|
unit_factor=unit_factor,
|
||||||
|
service_label=service_label,
|
||||||
|
rating_lookup=rating_lookup,
|
||||||
|
include_comparison_details=include_comparison_details,
|
||||||
|
power_profile=power_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_find_cheapest_hours(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Handle find_cheapest_hours service call."""
|
||||||
|
return await _handle_find_hours(call, reverse=False)
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
"""
|
||||||
|
Service handler for find_cheapest_schedule service.
|
||||||
|
|
||||||
|
Finds optimal non-overlapping blocks for multiple tasks within a search range.
|
||||||
|
Uses a greedy algorithm: tasks are sorted by duration (longest first), then
|
||||||
|
each task claims the cheapest available contiguous window in the remaining pool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
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 custom_components.tibber_prices.utils.price_window import (
|
||||||
|
calculate_window_statistics,
|
||||||
|
)
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.util import dt as dt_utils
|
||||||
|
|
||||||
|
from .helpers import (
|
||||||
|
INTERVAL_MINUTES,
|
||||||
|
PRICE_LEVEL_ORDER,
|
||||||
|
VALID_SEARCH_SCOPES,
|
||||||
|
build_rating_lookup,
|
||||||
|
build_response_interval,
|
||||||
|
filter_intervals_by_price_level,
|
||||||
|
get_entry_and_data,
|
||||||
|
resolve_home_timezone,
|
||||||
|
resolve_search_range,
|
||||||
|
validate_power_profile_length,
|
||||||
|
validate_price_level_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME = "find_cheapest_schedule"
|
||||||
|
|
||||||
|
_TASK_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("name"): cv.string,
|
||||||
|
vol.Required("duration"): vol.All(
|
||||||
|
cv.positive_time_period,
|
||||||
|
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)),
|
||||||
|
),
|
||||||
|
vol.Optional("power_profile"): vol.All(
|
||||||
|
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
|
||||||
|
vol.Length(min=1, max=48),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional("entry_id", default=""): cv.string,
|
||||||
|
vol.Required("tasks"): vol.All(
|
||||||
|
[_TASK_SCHEMA],
|
||||||
|
vol.Length(min=1, max=4),
|
||||||
|
),
|
||||||
|
vol.Optional("gap_minutes", default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
|
||||||
|
vol.Optional("search_start"): cv.datetime,
|
||||||
|
vol.Optional("search_end"): cv.datetime,
|
||||||
|
vol.Optional("search_start_time"): cv.time,
|
||||||
|
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
|
||||||
|
vol.Optional("search_end_time"): cv.time,
|
||||||
|
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
|
||||||
|
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
|
||||||
|
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
|
||||||
|
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
|
||||||
|
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||||
|
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||||
|
vol.Optional("use_base_unit", default=False): cv.boolean,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_cheapest_window_in_pool(
|
||||||
|
pool: list[dict[str, Any]],
|
||||||
|
duration_intervals: int,
|
||||||
|
available: list[bool],
|
||||||
|
) -> tuple[int, int] | None:
|
||||||
|
"""
|
||||||
|
Find the cheapest contiguous window of `duration_intervals` in available pool slots.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pool: Full sorted interval list.
|
||||||
|
duration_intervals: Required contiguous count.
|
||||||
|
available: Boolean mask, same length as pool. True = still available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(start_index, end_index_exclusive) of the best window, or None if not found.
|
||||||
|
|
||||||
|
"""
|
||||||
|
n = len(pool)
|
||||||
|
best_sum: float | None = None
|
||||||
|
best_start: int = -1
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i <= n - duration_intervals:
|
||||||
|
# Check if a contiguous block starting at i is fully available
|
||||||
|
# and all intervals are contiguous in time (no gaps)
|
||||||
|
block: list[dict[str, Any]] = []
|
||||||
|
j = i
|
||||||
|
while j < n and len(block) < duration_intervals:
|
||||||
|
if not available[j]:
|
||||||
|
break
|
||||||
|
if block:
|
||||||
|
# Check temporal contiguity
|
||||||
|
prev_start = block[-1]["startsAt"]
|
||||||
|
curr_start = pool[j]["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
|
||||||
|
if curr_dt - prev_dt != timedelta(minutes=INTERVAL_MINUTES):
|
||||||
|
# Gap in time — can't extend this block, skip to j+1
|
||||||
|
break
|
||||||
|
block.append(pool[j])
|
||||||
|
j += 1
|
||||||
|
|
||||||
|
if len(block) == duration_intervals:
|
||||||
|
window_sum = sum(iv["total"] for iv in block)
|
||||||
|
if best_sum is None or window_sum < best_sum:
|
||||||
|
best_sum = window_sum
|
||||||
|
best_start = i
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
# Skip past the blocking unavailable/non-contiguous slot
|
||||||
|
i = j + 1
|
||||||
|
|
||||||
|
if best_start == -1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (best_start, best_start + duration_intervals)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: # noqa: PLR0915
|
||||||
|
"""Handle find_cheapest_schedule service call."""
|
||||||
|
service_label = "find_cheapest_schedule"
|
||||||
|
hass: HomeAssistant = call.hass
|
||||||
|
entry_id: str = call.data.get("entry_id", "")
|
||||||
|
tasks_raw: list[dict[str, Any]] = call.data["tasks"]
|
||||||
|
gap_minutes: int = call.data.get("gap_minutes", 0)
|
||||||
|
use_base_unit: bool = call.data.get("use_base_unit", False)
|
||||||
|
max_price_level: str | None = call.data.get("max_price_level")
|
||||||
|
min_price_level: str | None = call.data.get("min_price_level")
|
||||||
|
|
||||||
|
# Round gap up to nearest quarter interval
|
||||||
|
gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0
|
||||||
|
|
||||||
|
entry, coordinator, data = get_entry_and_data(hass, entry_id)
|
||||||
|
rating_lookup = build_rating_lookup(data)
|
||||||
|
|
||||||
|
home_id = entry.data.get("home_id")
|
||||||
|
if not home_id:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="missing_home_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
home_timezone = resolve_home_timezone(coordinator, home_id)
|
||||||
|
home_tz: ZoneInfo
|
||||||
|
from zoneinfo import ZoneInfo # noqa: PLC0415
|
||||||
|
|
||||||
|
home_tz = ZoneInfo(home_timezone)
|
||||||
|
|
||||||
|
now = dt_utils.now().astimezone(home_tz)
|
||||||
|
search_start, search_end = resolve_search_range(call.data, now, home_tz)
|
||||||
|
|
||||||
|
# Resolve task durations (round up to intervals)
|
||||||
|
tasks: list[dict[str, Any]] = []
|
||||||
|
for task in tasks_raw:
|
||||||
|
dur_td: timedelta = task["duration"]
|
||||||
|
dur_minutes_req = int(dur_td.total_seconds() / 60)
|
||||||
|
dur_minutes = math.ceil(dur_minutes_req / INTERVAL_MINUTES) * INTERVAL_MINUTES
|
||||||
|
dur_intervals = dur_minutes // INTERVAL_MINUTES
|
||||||
|
validate_power_profile_length(task.get("power_profile"), dur_intervals)
|
||||||
|
tasks.append(
|
||||||
|
{
|
||||||
|
"name": task["name"],
|
||||||
|
"duration_minutes_requested": dur_minutes_req,
|
||||||
|
"duration_minutes": dur_minutes,
|
||||||
|
"duration_intervals": dur_intervals,
|
||||||
|
"power_profile": task.get("power_profile"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate parameter combinations
|
||||||
|
validate_price_level_range(min_price_level, max_price_level)
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s called: %d tasks, gap=%dmin, range=%s to %s",
|
||||||
|
service_label,
|
||||||
|
len(tasks),
|
||||||
|
gap_minutes,
|
||||||
|
search_start,
|
||||||
|
search_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch intervals
|
||||||
|
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", service_label)
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="price_fetch_failed",
|
||||||
|
) from error
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Apply optional level filter
|
||||||
|
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
|
||||||
|
|
||||||
|
if not filtered_price_info:
|
||||||
|
return {
|
||||||
|
"home_id": home_id,
|
||||||
|
"search_start": search_start.isoformat(),
|
||||||
|
"search_end": search_end.isoformat(),
|
||||||
|
"currency": currency,
|
||||||
|
"price_unit": price_unit,
|
||||||
|
"all_tasks_scheduled": False,
|
||||||
|
"tasks": [],
|
||||||
|
"total_estimated_cost": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Greedy assignment: longest task first
|
||||||
|
tasks_sorted = sorted(tasks, key=lambda t: t["duration_intervals"], reverse=True)
|
||||||
|
available = [True] * len(filtered_price_info)
|
||||||
|
assignments: list[dict[str, Any]] = []
|
||||||
|
unscheduled: list[str] = []
|
||||||
|
|
||||||
|
for task in tasks_sorted:
|
||||||
|
dur_intervals = task["duration_intervals"]
|
||||||
|
window = _find_cheapest_window_in_pool(filtered_price_info, dur_intervals, available)
|
||||||
|
|
||||||
|
if window is None:
|
||||||
|
_LOGGER.info("%s: no window found for task '%s'", service_label, task["name"])
|
||||||
|
unscheduled.append(task["name"])
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_idx, end_idx = window
|
||||||
|
task_intervals = filtered_price_info[start_idx:end_idx]
|
||||||
|
|
||||||
|
# Mark task intervals + trailing gap as unavailable
|
||||||
|
gap_end = min(end_idx + gap_intervals, len(filtered_price_info))
|
||||||
|
for k in range(start_idx, gap_end):
|
||||||
|
available[k] = False
|
||||||
|
|
||||||
|
stats = calculate_window_statistics(
|
||||||
|
task_intervals,
|
||||||
|
unit_factor=unit_factor,
|
||||||
|
round_decimals=4,
|
||||||
|
power_profile=task.get("power_profile"),
|
||||||
|
)
|
||||||
|
|
||||||
|
first_start = task_intervals[0]["startsAt"]
|
||||||
|
last_start = task_intervals[-1]["startsAt"]
|
||||||
|
first_dt = datetime.fromisoformat(first_start) if isinstance(first_start, str) else first_start
|
||||||
|
last_dt = datetime.fromisoformat(last_start) if isinstance(last_start, str) else last_start
|
||||||
|
end_dt = last_dt + timedelta(minutes=INTERVAL_MINUTES)
|
||||||
|
|
||||||
|
# Build enriched interval list for this task
|
||||||
|
task_response_intervals = [build_response_interval(iv, unit_factor, rating_lookup) for iv in task_intervals]
|
||||||
|
|
||||||
|
assignments.append(
|
||||||
|
{
|
||||||
|
"name": task["name"],
|
||||||
|
"start": first_dt.isoformat(),
|
||||||
|
"end": end_dt.isoformat(),
|
||||||
|
"duration_minutes_requested": task["duration_minutes_requested"],
|
||||||
|
"duration_minutes": task["duration_minutes"],
|
||||||
|
**stats,
|
||||||
|
"intervals": task_response_intervals,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort final assignments by start time
|
||||||
|
assignments.sort(key=lambda a: a["start"])
|
||||||
|
|
||||||
|
# Sum estimated costs
|
||||||
|
total_cost_values: list[float] = [
|
||||||
|
a["estimated_total_cost"] for a in assignments if a.get("estimated_total_cost") is not None
|
||||||
|
]
|
||||||
|
total_estimated_cost = round(sum(total_cost_values), 4) if total_cost_values else None
|
||||||
|
|
||||||
|
all_scheduled = len(unscheduled) == 0
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s: scheduled %d/%d tasks, total_cost=%s",
|
||||||
|
service_label,
|
||||||
|
len(assignments),
|
||||||
|
len(tasks),
|
||||||
|
total_estimated_cost,
|
||||||
|
)
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"home_id": home_id,
|
||||||
|
"search_start": search_start.isoformat(),
|
||||||
|
"search_end": search_end.isoformat(),
|
||||||
|
"currency": currency,
|
||||||
|
"price_unit": price_unit,
|
||||||
|
"all_tasks_scheduled": all_scheduled,
|
||||||
|
"unscheduled_tasks": unscheduled or None,
|
||||||
|
"tasks": assignments,
|
||||||
|
"total_estimated_cost": total_estimated_cost,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""
|
||||||
|
Service handler for find_most_expensive_block service.
|
||||||
|
|
||||||
|
Finds the most expensive contiguous window of a given duration within a search range.
|
||||||
|
Mirror of find_cheapest_block with reversed price selection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from .find_cheapest_block import _COMMON_BLOCK_SCHEMA, _handle_find_block
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from homeassistant.core import ServiceCall, ServiceResponse
|
||||||
|
|
||||||
|
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_NAME = "find_most_expensive_block"
|
||||||
|
|
||||||
|
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_SCHEMA = vol.Schema(_COMMON_BLOCK_SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_find_most_expensive_block(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Handle find_most_expensive_block service call."""
|
||||||
|
return await _handle_find_block(call, reverse=True)
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""
|
||||||
|
Service handler for find_most_expensive_hours service.
|
||||||
|
|
||||||
|
Finds the most expensive N minutes of intervals within a search range.
|
||||||
|
Mirror of find_cheapest_hours with reversed price selection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from .find_cheapest_hours import _COMMON_HOURS_SCHEMA, _handle_find_hours
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from homeassistant.core import ServiceCall, ServiceResponse
|
||||||
|
|
||||||
|
FIND_MOST_EXPENSIVE_HOURS_SERVICE_NAME = "find_most_expensive_hours"
|
||||||
|
|
||||||
|
FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_find_most_expensive_hours(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Handle find_most_expensive_hours service call."""
|
||||||
|
return await _handle_find_hours(call, reverse=True)
|
||||||
|
|
@ -25,7 +25,6 @@ import voluptuous as vol
|
||||||
from custom_components.tibber_prices.const import (
|
from custom_components.tibber_prices.const import (
|
||||||
CONF_CURRENCY_DISPLAY_MODE,
|
CONF_CURRENCY_DISPLAY_MODE,
|
||||||
DISPLAY_MODE_SUBUNIT,
|
DISPLAY_MODE_SUBUNIT,
|
||||||
DOMAIN,
|
|
||||||
PRICE_LEVEL_CHEAP,
|
PRICE_LEVEL_CHEAP,
|
||||||
PRICE_LEVEL_EXPENSIVE,
|
PRICE_LEVEL_EXPENSIVE,
|
||||||
PRICE_LEVEL_NORMAL,
|
PRICE_LEVEL_NORMAL,
|
||||||
|
|
@ -37,7 +36,6 @@ from custom_components.tibber_prices.const import (
|
||||||
get_display_unit_string,
|
get_display_unit_string,
|
||||||
get_translation,
|
get_translation,
|
||||||
)
|
)
|
||||||
from homeassistant.exceptions import ServiceValidationError
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_registry import (
|
from homeassistant.helpers.entity_registry import (
|
||||||
EntityRegistry,
|
EntityRegistry,
|
||||||
|
|
@ -60,7 +58,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
|
||||||
# Service schema
|
# Service schema
|
||||||
APEXCHARTS_SERVICE_SCHEMA = vol.Schema(
|
APEXCHARTS_SERVICE_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_ENTRY_ID): cv.string,
|
vol.Optional(ATTR_ENTRY_ID, default=""): cv.string,
|
||||||
vol.Optional("day"): vol.In(["yesterday", "today", "tomorrow", "rolling_window", "rolling_window_autozoom"]),
|
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("level_type", default="rating_level"): vol.In(["rating_level", "level"]),
|
||||||
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
|
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
|
||||||
|
|
@ -290,10 +288,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
hass = call.hass
|
hass = call.hass
|
||||||
entry_id_raw = call.data.get(ATTR_ENTRY_ID)
|
entry_id_input: str = 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)
|
|
||||||
|
|
||||||
day = call.data.get("day") # Can be None (rolling window mode)
|
day = call.data.get("day") # Can be None (rolling window mode)
|
||||||
level_type = call.data.get("level_type", "rating_level")
|
level_type = call.data.get("level_type", "rating_level")
|
||||||
|
|
@ -305,7 +300,8 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
|
||||||
user_language = hass.config.language or "en"
|
user_language = hass.config.language or "en"
|
||||||
|
|
||||||
# Get coordinator to access price data (for currency) and config entry for display settings
|
# Get coordinator to access price data (for currency) and config entry for display settings
|
||||||
config_entry, coordinator, _ = get_entry_and_data(hass, entry_id)
|
config_entry, coordinator, _ = get_entry_and_data(hass, entry_id_input)
|
||||||
|
entry_id: str = config_entry.entry_id
|
||||||
# Get currency from coordinator data
|
# Get currency from coordinator data
|
||||||
currency = coordinator.data.get("currency", "EUR")
|
currency = coordinator.data.get("currency", "EUR")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -270,7 +270,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
|
||||||
# Service schema
|
# Service schema
|
||||||
CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
|
CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_ENTRY_ID): str,
|
vol.Optional(ATTR_ENTRY_ID, default=""): str,
|
||||||
vol.Optional(ATTR_DAY): vol.All(vol.Coerce(list), [vol.In(["yesterday", "today", "tomorrow"])]),
|
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("resolution", default="interval"): vol.In(["interval", "hourly"]),
|
||||||
vol.Optional("output_format", default="array_of_objects"): vol.In(["array_of_objects", "array_of_arrays"]),
|
vol.Optional("output_format", default="array_of_objects"): vol.In(["array_of_objects", "array_of_arrays"]),
|
||||||
|
|
@ -346,10 +346,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
||||||
|
|
||||||
"""
|
"""
|
||||||
hass = call.hass
|
hass = call.hass
|
||||||
entry_id_raw = call.data.get(ATTR_ENTRY_ID)
|
entry_id: str = 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)
|
|
||||||
|
|
||||||
# Get coordinator to check data availability
|
# Get coordinator to check data availability
|
||||||
_, coordinator, _ = get_entry_and_data(hass, entry_id)
|
_, coordinator, _ = get_entry_and_data(hass, entry_id)
|
||||||
|
|
@ -393,6 +390,39 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
||||||
level_filter = call.data.get("level_filter")
|
level_filter = call.data.get("level_filter")
|
||||||
rating_level_filter = call.data.get("rating_level_filter")
|
rating_level_filter = call.data.get("rating_level_filter")
|
||||||
|
|
||||||
|
# --- Parameter dependency validation ---
|
||||||
|
|
||||||
|
# level_filter and rating_level_filter are mutually exclusive
|
||||||
|
if level_filter and rating_level_filter:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="level_and_rating_filter_conflict",
|
||||||
|
)
|
||||||
|
|
||||||
|
has_filter = bool(level_filter or rating_level_filter)
|
||||||
|
|
||||||
|
# insert_nulls modes "segments"/"all" require a level or rating filter
|
||||||
|
if insert_nulls != "none" and not has_filter:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="insert_nulls_requires_filter",
|
||||||
|
translation_placeholders={"mode": insert_nulls},
|
||||||
|
)
|
||||||
|
|
||||||
|
# connect_segments requires insert_nulls="segments" (with a filter)
|
||||||
|
if connect_segments and insert_nulls != "segments":
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="connect_segments_requires_segments_mode",
|
||||||
|
)
|
||||||
|
|
||||||
|
# array_fields is only meaningful with array_of_arrays format
|
||||||
|
if call.data.get("array_fields") and output_format != "array_of_arrays":
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="array_fields_requires_array_format",
|
||||||
|
)
|
||||||
|
|
||||||
# === METADATA-ONLY MODE ===
|
# === METADATA-ONLY MODE ===
|
||||||
# Early return: calculate and return only metadata, skip all data processing
|
# Early return: calculate and return only metadata, skip all data processing
|
||||||
if metadata == "only":
|
if metadata == "only":
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ GET_PRICE_SERVICE_NAME = "get_price"
|
||||||
|
|
||||||
GET_PRICE_SERVICE_SCHEMA = vol.Schema(
|
GET_PRICE_SERVICE_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("entry_id"): cv.string,
|
vol.Optional("entry_id", default=""): cv.string,
|
||||||
vol.Required("start_time"): cv.datetime,
|
vol.Required("start_time"): cv.datetime,
|
||||||
vol.Required("end_time"): cv.datetime,
|
vol.Required("end_time"): cv.datetime,
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +70,7 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
hass: HomeAssistant = call.hass
|
hass: HomeAssistant = call.hass
|
||||||
entry_id: str = call.data["entry_id"]
|
entry_id: str = call.data.get("entry_id", "")
|
||||||
start_time: datetime = call.data["start_time"]
|
start_time: datetime = call.data["start_time"]
|
||||||
end_time: datetime = call.data["end_time"]
|
end_time: datetime = call.data["end_time"]
|
||||||
|
|
||||||
|
|
@ -126,6 +126,13 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
|
||||||
# Step 2: Convert to home timezone
|
# Step 2: Convert to home timezone
|
||||||
end_time = end_time.astimezone(home_tz)
|
end_time = end_time.astimezone(home_tz)
|
||||||
|
|
||||||
|
# Validate: end must be after start
|
||||||
|
if end_time <= start_time:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="end_before_start",
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"get_price service called: entry_id=%s, home_id=%s, range=%s to %s",
|
"get_price service called: entry_id=%s, home_id=%s, range=%s to %s",
|
||||||
entry_id,
|
entry_id,
|
||||||
|
|
|
||||||
|
|
@ -2,52 +2,198 @@
|
||||||
Shared utilities for service handlers.
|
Shared utilities for service handlers.
|
||||||
|
|
||||||
This module provides common helper functions used across multiple service handlers,
|
This module provides common helper functions used across multiple service handlers,
|
||||||
such as entry validation and data extraction.
|
such as entry validation, data extraction, timezone resolution, and search range handling.
|
||||||
|
|
||||||
Functions:
|
Functions:
|
||||||
get_entry_and_data: Validate config entry and extract coordinator data
|
get_entry_and_data: Validate config entry and extract coordinator data
|
||||||
|
has_tomorrow_data: Check if tomorrow's price data is available
|
||||||
|
resolve_home_timezone: Extract home timezone from coordinator
|
||||||
|
localize_to_home_tz: Localize datetime to Tibber home timezone
|
||||||
|
calculate_end_of_tomorrow: Calculate end of tomorrow in home timezone
|
||||||
|
floor_to_quarter_hour: Floor datetime to quarter-hour boundary
|
||||||
|
resolve_search_range: Resolve search start/end from various input formats
|
||||||
|
filter_intervals_by_price_level: Filter intervals by Tibber price level
|
||||||
|
VALID_SEARCH_SCOPES: Set of valid search_scope shorthand values
|
||||||
|
PRICE_LEVEL_ORDER: Ordered tuple of price levels (lowest to highest)
|
||||||
|
|
||||||
Used by:
|
Used by:
|
||||||
- services/chartdata.py: Chart data export service
|
- services/chartdata.py: Chart data export service
|
||||||
- services/apexcharts.py: ApexCharts YAML generation
|
- services/apexcharts.py: ApexCharts YAML generation
|
||||||
- services/refresh_user_data.py: User data refresh
|
- services/refresh_user_data.py: User data refresh
|
||||||
|
- services/find_cheapest_block.py: Block service (cheapest + most expensive)
|
||||||
|
- services/find_cheapest_hours.py: Hours service (cheapest + most expensive)
|
||||||
|
- services/find_most_expensive_block.py: Most expensive block wrapper
|
||||||
|
- services/find_most_expensive_hours.py: Most expensive hours wrapper
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from datetime import time as dt_time
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from custom_components.tibber_prices.const import DOMAIN
|
from custom_components.tibber_prices.const import DOMAIN
|
||||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||||
from homeassistant.exceptions import ServiceValidationError
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
from homeassistant.util import dt as dt_utils
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
# Interval duration in minutes (quarter-hourly resolution)
|
||||||
|
INTERVAL_MINUTES = 15
|
||||||
|
|
||||||
|
# Valid scopes for the search_scope shorthand parameter
|
||||||
|
VALID_SEARCH_SCOPES = frozenset({"today", "tomorrow", "remaining_today", "next_24h", "next_48h"})
|
||||||
|
|
||||||
|
# Price level hierarchy (lowest to highest)
|
||||||
|
PRICE_LEVEL_ORDER = ("VERY_CHEAP", "CHEAP", "NORMAL", "EXPENSIVE", "VERY_EXPENSIVE")
|
||||||
|
_PRICE_LEVEL_RANK: dict[str, int] = {lvl: i for i, lvl in enumerate(PRICE_LEVEL_ORDER)}
|
||||||
|
|
||||||
|
|
||||||
|
# Parameters that define explicit search range boundaries
|
||||||
|
_EXPLICIT_RANGE_PARAMS = frozenset(
|
||||||
|
{
|
||||||
|
"search_start",
|
||||||
|
"search_end",
|
||||||
|
"search_start_time",
|
||||||
|
"search_end_time",
|
||||||
|
"search_start_offset_minutes",
|
||||||
|
"search_end_offset_minutes",
|
||||||
|
"search_start_day_offset",
|
||||||
|
"search_end_day_offset",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_search_params(call_data: dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Validate search range parameter combinations.
|
||||||
|
|
||||||
|
Checks for mutually exclusive parameters and required co-dependencies.
|
||||||
|
Must be called before resolve_search_range().
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ServiceValidationError: If parameter combinations are invalid
|
||||||
|
|
||||||
|
"""
|
||||||
|
has_scope = "search_scope" in call_data
|
||||||
|
|
||||||
|
# search_scope conflicts with all explicit range parameters
|
||||||
|
if has_scope:
|
||||||
|
# day_offset params always appear (voluptuous defaults to 0), exclude from conflict check
|
||||||
|
conflicts = _EXPLICIT_RANGE_PARAMS - {"search_start_day_offset", "search_end_day_offset"}
|
||||||
|
conflicting = [p for p in conflicts if p in call_data]
|
||||||
|
if conflicting:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="scope_conflicts_with_range",
|
||||||
|
translation_placeholders={"params": ", ".join(sorted(conflicting))},
|
||||||
|
)
|
||||||
|
|
||||||
|
# day_offset without matching time parameter is meaningless
|
||||||
|
# Schema defaults provide 0, but user explicitly setting non-zero without time is an error.
|
||||||
|
# We detect explicit usage by checking for non-default values when time is absent.
|
||||||
|
if "search_start_time" not in call_data and call_data.get("search_start_day_offset", 0) != 0:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="day_offset_requires_time",
|
||||||
|
translation_placeholders={"offset_param": "search_start_day_offset", "time_param": "search_start_time"},
|
||||||
|
)
|
||||||
|
if "search_end_time" not in call_data and call_data.get("search_end_day_offset", 0) != 0:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="day_offset_requires_time",
|
||||||
|
translation_placeholders={"offset_param": "search_end_day_offset", "time_param": "search_end_time"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_price_level_range(
|
||||||
|
min_price_level: str | None,
|
||||||
|
max_price_level: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Validate that min_price_level <= max_price_level in the level hierarchy.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ServiceValidationError: If min level is higher than max level
|
||||||
|
|
||||||
|
"""
|
||||||
|
if min_price_level is None or max_price_level is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
min_rank = _PRICE_LEVEL_RANK.get(min_price_level.upper(), 0)
|
||||||
|
max_rank = _PRICE_LEVEL_RANK.get(max_price_level.upper(), len(PRICE_LEVEL_ORDER) - 1)
|
||||||
|
|
||||||
|
if min_rank > max_rank:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="min_level_exceeds_max",
|
||||||
|
translation_placeholders={"min_level": min_price_level, "max_level": max_price_level},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_power_profile_length(
|
||||||
|
power_profile: list[int] | None,
|
||||||
|
duration_intervals: int,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Validate that power_profile length matches the number of intervals.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ServiceValidationError: If lengths don't match
|
||||||
|
|
||||||
|
"""
|
||||||
|
if power_profile is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(power_profile) != duration_intervals:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="power_profile_length_mismatch",
|
||||||
|
translation_placeholders={
|
||||||
|
"profile_length": str(len(power_profile)),
|
||||||
|
"interval_count": str(duration_intervals),
|
||||||
|
"duration_minutes": str(duration_intervals * INTERVAL_MINUTES),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_entry_and_data(hass: HomeAssistant, entry_id: str) -> tuple[Any, Any, dict]:
|
def get_entry_and_data(hass: HomeAssistant, entry_id: str) -> tuple[Any, Any, dict]:
|
||||||
"""
|
"""
|
||||||
Validate entry and extract coordinator and data.
|
Validate entry and extract coordinator and data.
|
||||||
|
|
||||||
|
If entry_id is empty, auto-selects the single config entry for this domain.
|
||||||
|
Raises an error if there are zero or multiple entries and no entry_id is given.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hass: Home Assistant instance
|
hass: Home Assistant instance
|
||||||
entry_id: Config entry ID to validate
|
entry_id: Config entry ID to validate (empty string to auto-select)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (entry, coordinator, data)
|
Tuple of (entry, coordinator, data)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ServiceValidationError: If entry_id is missing or invalid
|
ServiceValidationError: If entry cannot be resolved
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not entry_id:
|
if not entry_id:
|
||||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
entry = next(
|
if len(entries) == 1:
|
||||||
(e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id),
|
entry = entries[0]
|
||||||
None,
|
elif len(entries) == 0:
|
||||||
)
|
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="no_entries_found")
|
||||||
|
else:
|
||||||
|
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="multiple_entries_no_entry_id")
|
||||||
|
else:
|
||||||
|
entry = next(
|
||||||
|
(e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
if not entry or not hasattr(entry, "runtime_data") or not entry.runtime_data:
|
if not entry or not hasattr(entry, "runtime_data") or not entry.runtime_data:
|
||||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="invalid_entry_id")
|
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="invalid_entry_id")
|
||||||
coordinator = entry.runtime_data.coordinator
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
|
@ -72,3 +218,276 @@ def has_tomorrow_data(coordinator: TibberPricesDataUpdateCoordinator) -> bool:
|
||||||
coordinator_data = coordinator.data or {}
|
coordinator_data = coordinator.data or {}
|
||||||
tomorrow_intervals = get_intervals_for_day_offsets(coordinator_data, [1])
|
tomorrow_intervals = get_intervals_for_day_offsets(coordinator_data, [1])
|
||||||
return len(tomorrow_intervals) > 0
|
return len(tomorrow_intervals) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_home_timezone(
|
||||||
|
coordinator: Any,
|
||||||
|
home_id: str,
|
||||||
|
) -> str:
|
||||||
|
"""Extract home timezone from coordinator's cached user data."""
|
||||||
|
user_data = coordinator._cached_user_data # noqa: SLF001
|
||||||
|
if not user_data:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="user_data_not_available",
|
||||||
|
)
|
||||||
|
|
||||||
|
if "viewer" in user_data:
|
||||||
|
for home in user_data["viewer"].get("homes", []):
|
||||||
|
if home.get("id") == home_id:
|
||||||
|
tz = home.get("timeZone")
|
||||||
|
if tz:
|
||||||
|
return tz
|
||||||
|
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="timezone_not_found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def localize_to_home_tz(dt_value: datetime, home_tz: ZoneInfo) -> datetime:
|
||||||
|
"""
|
||||||
|
Localize a datetime to the Tibber home timezone.
|
||||||
|
|
||||||
|
Handles the critical two-step process:
|
||||||
|
1. GUI naive datetime → localize to HA server timezone
|
||||||
|
2. Convert from HA timezone to home timezone
|
||||||
|
"""
|
||||||
|
if dt_value.tzinfo is None:
|
||||||
|
dt_value = dt_utils.as_local(dt_value)
|
||||||
|
return dt_value.astimezone(home_tz)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_end_of_tomorrow(home_tz: ZoneInfo) -> datetime:
|
||||||
|
"""Calculate end of tomorrow in home timezone."""
|
||||||
|
now_home = dt_utils.now().astimezone(home_tz)
|
||||||
|
tomorrow = (now_home + timedelta(days=1)).date()
|
||||||
|
# End of tomorrow = midnight at start of day after tomorrow
|
||||||
|
return now_home.replace(
|
||||||
|
year=tomorrow.year,
|
||||||
|
month=tomorrow.month,
|
||||||
|
day=tomorrow.day,
|
||||||
|
hour=0,
|
||||||
|
minute=0,
|
||||||
|
second=0,
|
||||||
|
microsecond=0,
|
||||||
|
) + timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def floor_to_quarter_hour(dt_value: datetime) -> datetime:
|
||||||
|
"""Floor a datetime to the current quarter-hour boundary."""
|
||||||
|
return dt_value.replace(minute=(dt_value.minute // INTERVAL_MINUTES) * INTERVAL_MINUTES, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_time_with_day_offset(
|
||||||
|
time_value: dt_time,
|
||||||
|
day_offset: int,
|
||||||
|
home_tz: ZoneInfo,
|
||||||
|
) -> datetime:
|
||||||
|
"""Resolve a time-of-day + day offset to a full datetime in home timezone."""
|
||||||
|
now_home = dt_utils.now().astimezone(home_tz)
|
||||||
|
target_date = (now_home + timedelta(days=day_offset)).date()
|
||||||
|
return datetime(
|
||||||
|
year=target_date.year,
|
||||||
|
month=target_date.month,
|
||||||
|
day=target_date.day,
|
||||||
|
hour=time_value.hour,
|
||||||
|
minute=time_value.minute,
|
||||||
|
second=time_value.second,
|
||||||
|
tzinfo=home_tz,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_scope(scope: str, now: datetime, _home_tz: ZoneInfo) -> tuple[datetime, datetime]:
|
||||||
|
"""
|
||||||
|
Convert a search_scope shorthand into explicit start/end datetimes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scope: One of "today", "tomorrow", "remaining_today", "next_24h", "next_48h"
|
||||||
|
now: Current datetime in home timezone
|
||||||
|
home_tz: Home timezone for date calculations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (start, end) datetimes in home timezone
|
||||||
|
|
||||||
|
"""
|
||||||
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
tomorrow_start = today_start + timedelta(days=1)
|
||||||
|
day_after_start = today_start + timedelta(days=2)
|
||||||
|
|
||||||
|
if scope == "today":
|
||||||
|
return today_start, tomorrow_start
|
||||||
|
if scope == "tomorrow":
|
||||||
|
return tomorrow_start, day_after_start
|
||||||
|
if scope == "remaining_today":
|
||||||
|
return floor_to_quarter_hour(now), tomorrow_start
|
||||||
|
if scope == "next_24h":
|
||||||
|
return floor_to_quarter_hour(now), now + timedelta(hours=24)
|
||||||
|
if scope == "next_48h":
|
||||||
|
return floor_to_quarter_hour(now), now + timedelta(hours=48)
|
||||||
|
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="invalid_search_scope",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_intervals_by_price_level(
|
||||||
|
intervals: list[dict[str, Any]],
|
||||||
|
min_price_level: str | None,
|
||||||
|
max_price_level: str | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Filter intervals by Tibber price level.
|
||||||
|
|
||||||
|
Keeps only intervals whose 'level' field is within the requested range.
|
||||||
|
If an interval has no 'level' field it is kept (avoids silently dropping data on API changes).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
intervals: Price interval dicts with optional 'level' key
|
||||||
|
min_price_level: Lower bound level (inclusive), e.g. "CHEAP"
|
||||||
|
max_price_level: Upper bound level (inclusive), e.g. "NORMAL"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered list; same list reference if no filter is active
|
||||||
|
|
||||||
|
"""
|
||||||
|
if min_price_level is None and max_price_level is None:
|
||||||
|
return intervals
|
||||||
|
|
||||||
|
min_rank = _PRICE_LEVEL_RANK.get(min_price_level.upper(), 0) if min_price_level else 0
|
||||||
|
max_rank = (
|
||||||
|
_PRICE_LEVEL_RANK.get(max_price_level.upper(), len(PRICE_LEVEL_ORDER) - 1)
|
||||||
|
if max_price_level
|
||||||
|
else len(PRICE_LEVEL_ORDER) - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for iv in intervals:
|
||||||
|
level = iv.get("level")
|
||||||
|
if level is None:
|
||||||
|
result.append(iv)
|
||||||
|
continue
|
||||||
|
rank = _PRICE_LEVEL_RANK.get(str(level).upper())
|
||||||
|
if rank is None:
|
||||||
|
result.append(iv)
|
||||||
|
continue
|
||||||
|
if min_rank <= rank <= max_rank:
|
||||||
|
result.append(iv)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_rating_lookup(coordinator_data: dict[str, Any]) -> dict[str, str | None]:
|
||||||
|
"""
|
||||||
|
Build a startsAt → rating_level lookup from enriched coordinator data.
|
||||||
|
|
||||||
|
The coordinator's priceInfo contains rating_level (LOW/NORMAL/HIGH) computed
|
||||||
|
from trailing 24h averages with hysteresis. Pool intervals lack this field,
|
||||||
|
so this lookup allows annotating service responses with rating_level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coordinator_data: coordinator.data dict with enriched priceInfo
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping startsAt ISO string to lowercase rating_level (or None)
|
||||||
|
|
||||||
|
"""
|
||||||
|
lookup: dict[str, str | None] = {}
|
||||||
|
for iv in coordinator_data.get("priceInfo", []):
|
||||||
|
starts_at = iv.get("startsAt")
|
||||||
|
rating = iv.get("rating_level")
|
||||||
|
if starts_at:
|
||||||
|
lookup[starts_at] = rating.lower() if isinstance(rating, str) else None
|
||||||
|
return lookup
|
||||||
|
|
||||||
|
|
||||||
|
def build_response_interval(
|
||||||
|
iv: dict[str, Any],
|
||||||
|
unit_factor: int,
|
||||||
|
rating_lookup: dict[str, str | None],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build an enriched interval dict for service responses.
|
||||||
|
|
||||||
|
Converts a raw pool interval into a companion-friendly format with
|
||||||
|
ends_at, level, and rating_level fields.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
iv: Raw interval dict from pool (startsAt, total, level, ...)
|
||||||
|
unit_factor: Price unit multiplier (1 for base unit, 100 for cents, etc.)
|
||||||
|
rating_lookup: startsAt → rating_level mapping from coordinator data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Enriched interval dict for service response
|
||||||
|
|
||||||
|
"""
|
||||||
|
starts_at = iv["startsAt"]
|
||||||
|
if isinstance(starts_at, str):
|
||||||
|
ends_at = (datetime.fromisoformat(starts_at) + timedelta(minutes=INTERVAL_MINUTES)).isoformat()
|
||||||
|
else:
|
||||||
|
ends_at = (starts_at + timedelta(minutes=INTERVAL_MINUTES)).isoformat()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"starts_at": starts_at,
|
||||||
|
"ends_at": ends_at,
|
||||||
|
"price": round(iv["total"] * unit_factor, 4),
|
||||||
|
"level": (iv.get("level") or "").lower() or None,
|
||||||
|
"rating_level": rating_lookup.get(starts_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_search_range(
|
||||||
|
call_data: dict[str, Any],
|
||||||
|
now: datetime,
|
||||||
|
home_tz: ZoneInfo,
|
||||||
|
) -> tuple[datetime, datetime]:
|
||||||
|
"""
|
||||||
|
Resolve search start/end from scope shorthand, explicit datetime, time+offset, or defaults.
|
||||||
|
|
||||||
|
Priority (highest to lowest):
|
||||||
|
0. search_scope shorthand (today, tomorrow, remaining_today, next_24h, next_48h)
|
||||||
|
1. Explicit datetime (search_start / search_end)
|
||||||
|
2. Time-of-day + day offset (search_start_time + search_start_day_offset)
|
||||||
|
3. Minutes offset (search_start_offset_minutes / search_end_offset_minutes)
|
||||||
|
4. Default (now for start, end of tomorrow for end)
|
||||||
|
|
||||||
|
Calls validate_search_params() first to check for conflicting combinations.
|
||||||
|
"""
|
||||||
|
validate_search_params(call_data)
|
||||||
|
include_current = call_data.get("include_current_interval", True)
|
||||||
|
|
||||||
|
# Priority 0: search_scope shorthand
|
||||||
|
if "search_scope" in call_data:
|
||||||
|
return _resolve_scope(call_data["search_scope"], now, home_tz)
|
||||||
|
|
||||||
|
# --- Resolve start ---
|
||||||
|
if "search_start" in call_data:
|
||||||
|
search_start = localize_to_home_tz(call_data["search_start"], home_tz)
|
||||||
|
elif "search_start_time" in call_data:
|
||||||
|
day_offset = call_data.get("search_start_day_offset", 0)
|
||||||
|
search_start = _resolve_time_with_day_offset(call_data["search_start_time"], day_offset, home_tz)
|
||||||
|
elif "search_start_offset_minutes" in call_data:
|
||||||
|
search_start = now + timedelta(minutes=call_data["search_start_offset_minutes"])
|
||||||
|
if include_current:
|
||||||
|
search_start = floor_to_quarter_hour(search_start)
|
||||||
|
else:
|
||||||
|
search_start = floor_to_quarter_hour(now) if include_current else now
|
||||||
|
|
||||||
|
# --- Resolve end ---
|
||||||
|
if "search_end" in call_data:
|
||||||
|
search_end = localize_to_home_tz(call_data["search_end"], home_tz)
|
||||||
|
elif "search_end_time" in call_data:
|
||||||
|
day_offset = call_data.get("search_end_day_offset", 0)
|
||||||
|
search_end = _resolve_time_with_day_offset(call_data["search_end_time"], day_offset, home_tz)
|
||||||
|
elif "search_end_offset_minutes" in call_data:
|
||||||
|
search_end = now + timedelta(minutes=call_data["search_end_offset_minutes"])
|
||||||
|
else:
|
||||||
|
search_end = calculate_end_of_tomorrow(home_tz)
|
||||||
|
|
||||||
|
if search_end <= search_start:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="end_before_start",
|
||||||
|
)
|
||||||
|
|
||||||
|
return search_start, search_end
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
|
||||||
# Service schema
|
# Service schema
|
||||||
REFRESH_USER_DATA_SERVICE_SCHEMA: Final = vol.Schema(
|
REFRESH_USER_DATA_SERVICE_SCHEMA: Final = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_ENTRY_ID): str,
|
vol.Optional(ATTR_ENTRY_ID, default=""): str,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -64,15 +64,9 @@ async def handle_refresh_user_data(call: ServiceCall) -> dict[str, Any]:
|
||||||
ServiceValidationError: If entry_id is missing or invalid
|
ServiceValidationError: If entry_id is missing or invalid
|
||||||
|
|
||||||
"""
|
"""
|
||||||
entry_id = call.data.get(ATTR_ENTRY_ID)
|
entry_id = call.data.get(ATTR_ENTRY_ID, "")
|
||||||
hass = call.hass
|
hass = call.hass
|
||||||
|
|
||||||
if not entry_id:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"message": "Entry ID is required",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get the entry and coordinator
|
# Get the entry and coordinator
|
||||||
try:
|
try:
|
||||||
_, coordinator, _ = get_entry_and_data(hass, entry_id)
|
_, coordinator, _ = get_entry_and_data(hass, entry_id)
|
||||||
|
|
|
||||||
|
|
@ -59,15 +59,7 @@
|
||||||
"home_already_configured": "Dieses Zuhause ist bereits in einem anderen Eintrag konfiguriert. Jedes Zuhause kann nur einmal konfiguriert werden.",
|
"home_already_configured": "Dieses Zuhause ist bereits in einem anderen Eintrag konfiguriert. Jedes Zuhause kann nur einmal konfiguriert werden.",
|
||||||
"no_active_subscription": "Dieses Zuhause hat keinen aktiven Tibber-Vertrag. Nur Häuser mit aktivem Stromvertrag können zu Home Assistant hinzugefügt werden.",
|
"no_active_subscription": "Dieses Zuhause hat keinen aktiven Tibber-Vertrag. Nur Häuser mit aktivem Stromvertrag können zu Home Assistant hinzugefügt werden.",
|
||||||
"subscription_expired": "Der Tibber-Vertrag für dieses Zuhause ist abgelaufen. Nur Häuser mit aktivem oder zukünftigem Stromvertrag können zu Home Assistant hinzugefügt werden.",
|
"subscription_expired": "Der Tibber-Vertrag für dieses Zuhause ist abgelaufen. Nur Häuser mit aktivem oder zukünftigem Stromvertrag können zu Home Assistant hinzugefügt werden.",
|
||||||
"future_subscription_warning": "Hinweis: Der Tibber-Vertrag für dieses Zuhause hat noch nicht begonnen. Die Funktionalität ist möglicherweise eingeschränkt, bis der Vertrag aktiv wird.",
|
"future_subscription_warning": "Hinweis: Der Tibber-Vertrag für dieses Zuhause hat noch nicht begonnen. Die Funktionalität ist möglicherweise eingeschränkt, bis der Vertrag aktiv wird."
|
||||||
"invalid_yaml_syntax": "Ungültige YAML-Syntax. Bitte überprüfe Einrückungen, Doppelpunkte und Sonderzeichen.",
|
|
||||||
"invalid_yaml_structure": "YAML muss ein Dictionary/Objekt sein (Schlüssel: Wert-Paare), keine Liste oder reiner Text.",
|
|
||||||
"service_call_failed": "Service-Aufruf-Validierung fehlgeschlagen: {error_detail}",
|
|
||||||
"missing_entry_id": "Eintrag-ID wird benötigt, wurde aber nicht bereitgestellt.",
|
|
||||||
"invalid_entry_id": "Ungültige Eintrag-ID oder Eintrag nicht gefunden.",
|
|
||||||
"missing_home_id": "Home-ID fehlt in der Konfiguration.",
|
|
||||||
"user_data_not_available": "Benutzerdaten nicht verfügbar. Bitte aktualisiere zuerst die Benutzerdaten.",
|
|
||||||
"price_fetch_failed": "Preisdaten konnten nicht abgerufen werden. Bitte prüfe die Logs für Details."
|
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Alle verfügbaren Tibber-Zuhause sind bereits konfiguriert. Jedes Zuhause kann nur einmal konfiguriert werden.",
|
"already_configured": "Alle verfügbaren Tibber-Zuhause sind bereits konfiguriert. Jedes Zuhause kann nur einmal konfiguriert werden.",
|
||||||
|
|
@ -1055,6 +1047,62 @@
|
||||||
"description": "Dieses Update enthält Breaking Changes, die automatisch angewendet wurden.\n\n**Umbenannte Entitäten ({count})**\n\nDie folgenden Entity-Keys wurden umbenannt. Deine bestehenden Entity-IDs und Automationen bleiben erhalten:\n\n{entity_list}\n\n**Geänderte Dauer-Sensorwerte**\n\nAlle Dauer-Sensoren (verbleibende Zeit, startet in, Periodendauer, Trendänderungs-Countdown) geben ihren Zustandswert jetzt in **Minuten** statt Stunden an. Die Anzeigeeinheit in Dashboards bleibt standardmäßig Stunden.\n\nWenn du Automationen mit numerischen Vergleichen auf diesen Sensoren hast, aktualisiere deine Schwellwerte:\n- Alt: `state < 0.25` (15 Minuten als Stunden)\n- Neu: `state < 15` (15 Minuten)\n\nSchließe diesen Hinweis, nachdem du deine Automationen überprüft hast."
|
"description": "Dieses Update enthält Breaking Changes, die automatisch angewendet wurden.\n\n**Umbenannte Entitäten ({count})**\n\nDie folgenden Entity-Keys wurden umbenannt. Deine bestehenden Entity-IDs und Automationen bleiben erhalten:\n\n{entity_list}\n\n**Geänderte Dauer-Sensorwerte**\n\nAlle Dauer-Sensoren (verbleibende Zeit, startet in, Periodendauer, Trendänderungs-Countdown) geben ihren Zustandswert jetzt in **Minuten** statt Stunden an. Die Anzeigeeinheit in Dashboards bleibt standardmäßig Stunden.\n\nWenn du Automationen mit numerischen Vergleichen auf diesen Sensoren hast, aktualisiere deine Schwellwerte:\n- Alt: `state < 0.25` (15 Minuten als Stunden)\n- Neu: `state < 15` (15 Minuten)\n\nSchließe diesen Hinweis, nachdem du deine Automationen überprüft hast."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"no_entries_found": {
|
||||||
|
"message": "Keine Tibber Prices Integrationseinträge gefunden. Bitte richte die Integration zuerst ein."
|
||||||
|
},
|
||||||
|
"multiple_entries_no_entry_id": {
|
||||||
|
"message": "Mehrere Tibber Prices Einträge gefunden. Bitte gib 'entry_id' an, um den gewünschten Eintrag auszuwählen."
|
||||||
|
},
|
||||||
|
"invalid_entry_id": {
|
||||||
|
"message": "Ungültige oder nicht verfügbare Konfigurations-Entry. Bitte überprüfe die Entry-ID und stelle sicher, dass die Integration geladen ist."
|
||||||
|
},
|
||||||
|
"missing_home_id": {
|
||||||
|
"message": "Home-ID nicht im Konfigurationseintrag gefunden. Bitte konfiguriere die Integration neu."
|
||||||
|
},
|
||||||
|
"user_data_not_available": {
|
||||||
|
"message": "Benutzerdaten sind noch nicht verfügbar. Bitte warte, bis das erste Datenupdate abgeschlossen ist."
|
||||||
|
},
|
||||||
|
"timezone_not_found": {
|
||||||
|
"message": "Zeitzone des Zuhauses konnte nicht ermittelt werden. Bitte überprüfe die Konfiguration in deinem Tibber-Konto."
|
||||||
|
},
|
||||||
|
"end_before_start": {
|
||||||
|
"message": "Endzeit muss nach der Startzeit liegen."
|
||||||
|
},
|
||||||
|
"price_fetch_failed": {
|
||||||
|
"message": "Preisdaten konnten nicht von der Tibber-API abgerufen werden. Bitte versuche es später erneut."
|
||||||
|
},
|
||||||
|
"invalid_search_scope": {
|
||||||
|
"message": "Ungültiger Suchbereich. Gültige Werte sind: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||||
|
},
|
||||||
|
"scope_conflicts_with_range": {
|
||||||
|
"message": "search_scope kann nicht mit expliziten Bereichsparametern kombiniert werden: {params}. Verwende entweder search_scope ODER explizite Start-/Endparameter."
|
||||||
|
},
|
||||||
|
"day_offset_requires_time": {
|
||||||
|
"message": "{offset_param} erfordert, dass {time_param} gesetzt ist. Der Tagesoffset ändert nur das Datum eines expliziten Zeitparameters."
|
||||||
|
},
|
||||||
|
"min_level_exceeds_max": {
|
||||||
|
"message": "min_price_level '{min_level}' ist höher als max_price_level '{max_level}'. Das Mindestlevel muss gleich oder niedriger als das Maximallevel sein."
|
||||||
|
},
|
||||||
|
"power_profile_length_mismatch": {
|
||||||
|
"message": "power_profile hat {profile_length} Einträge, aber die Dauer erfordert {interval_count} Intervalle ({duration_minutes} Minuten). Das power_profile muss genau einen Eintrag pro 15-Minuten-Intervall haben."
|
||||||
|
},
|
||||||
|
"level_and_rating_filter_conflict": {
|
||||||
|
"message": "level_filter und rating_level_filter können nicht gleichzeitig verwendet werden. Verwende nur einen Filtertyp pro Anfrage."
|
||||||
|
},
|
||||||
|
"insert_nulls_requires_filter": {
|
||||||
|
"message": "insert_nulls-Modus '{mode}' erfordert einen level_filter oder rating_level_filter zur Segmentdefinition. Ohne Filter verwende insert_nulls: none."
|
||||||
|
},
|
||||||
|
"connect_segments_requires_segments_mode": {
|
||||||
|
"message": "connect_segments erfordert, dass insert_nulls auf 'segments' gesetzt ist. Setze insert_nulls: segments, um Segmentverbindung zu nutzen."
|
||||||
|
},
|
||||||
|
"array_fields_requires_array_format": {
|
||||||
|
"message": "array_fields kann nur mit output_format: array_of_arrays verwendet werden. Ändere das Ausgabeformat oder entferne array_fields."
|
||||||
|
},
|
||||||
|
"invalid_array_fields": {
|
||||||
|
"message": "Ungültige array_fields-Vorlage. Verwende Feldnamen in geschweiften Klammern, z.B. '{start_time}, {price_per_kwh}, {level}'."
|
||||||
|
}
|
||||||
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"get_price": {
|
"get_price": {
|
||||||
"name": "Preisdaten abrufen",
|
"name": "Preisdaten abrufen",
|
||||||
|
|
@ -1261,6 +1309,474 @@
|
||||||
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
|
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_block": {
|
||||||
|
"name": "Günstigsten Block finden",
|
||||||
|
"description": "Findet das günstigste zusammenhängende Zeitfenster einer bestimmten Dauer. Gedacht für Geräteplanung: Spülmaschine, Waschmaschine, Trockner usw. Gibt das günstigste Fenster mit Start-/Endzeiten und Preisstatistiken zurück.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Suchbereich",
|
||||||
|
"description": "Zeitfenster fuer die Suche festlegen."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative Zeitbereich-Optionen",
|
||||||
|
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Preisstufen-Filter",
|
||||||
|
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Ausgabeoptionen",
|
||||||
|
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Eintrag-ID",
|
||||||
|
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Dauer",
|
||||||
|
"description": "Länge des gewünschten zusammenhängenden Fensters. Wird automatisch auf die nächste Viertelstunde aufgerundet. Maximum: 12 Stunden."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Suchbeginn",
|
||||||
|
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Suchende",
|
||||||
|
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Suchbeginn-Uhrzeit",
|
||||||
|
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Suchbeginn Tages-Versatz",
|
||||||
|
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Suchende-Uhrzeit",
|
||||||
|
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Suchende Tages-Versatz",
|
||||||
|
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Suchbeginn-Versatz (Minuten)",
|
||||||
|
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Suchende-Versatz (Minuten)",
|
||||||
|
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Aktuelles Intervall einbeziehen",
|
||||||
|
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Basiswährung verwenden",
|
||||||
|
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Suchbereich (Shortcut)",
|
||||||
|
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximale Preisstufe",
|
||||||
|
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimale Preisstufe",
|
||||||
|
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Vergleichsdetails einschliessen",
|
||||||
|
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Leistungsprofil",
|
||||||
|
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsaechlichen Verbrauch statt einer festen 1-kW-Last an."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_most_expensive_block": {
|
||||||
|
"name": "Teuersten Block finden",
|
||||||
|
"description": "Findet das teuerste zusammenhängende Zeitfenster einer bestimmten Dauer. Nützlich zur Erkennung von Spitzenpreiszeiträumen, die vermieden werden sollten. Gibt das teuerste Fenster mit Start-/Endzeiten und Preisstatistiken zurück.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Suchbereich",
|
||||||
|
"description": "Zeitfenster fuer die Suche festlegen."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative Zeitbereich-Optionen",
|
||||||
|
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Preisstufen-Filter",
|
||||||
|
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Ausgabeoptionen",
|
||||||
|
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Eintrag-ID",
|
||||||
|
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Dauer",
|
||||||
|
"description": "Länge des gewünschten zusammenhängenden Fensters. Wird automatisch auf die nächste Viertelstunde aufgerundet. Maximum: 12 Stunden."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Suchbeginn",
|
||||||
|
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Suchende",
|
||||||
|
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Suchbeginn-Uhrzeit",
|
||||||
|
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Suchbeginn Tages-Versatz",
|
||||||
|
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Suchende-Uhrzeit",
|
||||||
|
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Suchende Tages-Versatz",
|
||||||
|
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Suchbeginn-Versatz (Minuten)",
|
||||||
|
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Suchende-Versatz (Minuten)",
|
||||||
|
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Aktuelles Intervall einbeziehen",
|
||||||
|
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Basiswährung verwenden",
|
||||||
|
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Suchbereich (Shortcut)",
|
||||||
|
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximale Preisstufe",
|
||||||
|
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimale Preisstufe",
|
||||||
|
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Vergleichsdetails einschliessen",
|
||||||
|
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Leistungsprofil",
|
||||||
|
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsaechlichen Verbrauch statt einer festen 1-kW-Last an."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_hours": {
|
||||||
|
"name": "Günstigste Stunden finden",
|
||||||
|
"description": "Findet die günstigsten Intervalle für eine bestimmte Gesamtdauer, nicht unbedingt zusammenhängend. Gedacht für flexible Lasten: Batterieladung, E-Auto, Warmwasserspeicher. Gibt einen Zeitplan mit Intervallen gruppiert in zusammenhängende Segmente zurück.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Suchbereich",
|
||||||
|
"description": "Zeitfenster fuer die Suche festlegen."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative Zeitbereich-Optionen",
|
||||||
|
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Preisstufen-Filter",
|
||||||
|
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Ausgabeoptionen",
|
||||||
|
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Eintrag-ID",
|
||||||
|
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Dauer",
|
||||||
|
"description": "Benötigte günstige Gesamtzeit. Wird automatisch auf die nächste Viertelstunde aufgerundet. Maximum: 24 Stunden."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Suchbeginn",
|
||||||
|
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Suchende",
|
||||||
|
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Suchbeginn-Uhrzeit",
|
||||||
|
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Suchbeginn Tages-Versatz",
|
||||||
|
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Suchende-Uhrzeit",
|
||||||
|
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Suchende Tages-Versatz",
|
||||||
|
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Suchbeginn-Versatz (Minuten)",
|
||||||
|
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Suchende-Versatz (Minuten)",
|
||||||
|
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Aktuelles Intervall einbeziehen",
|
||||||
|
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
|
||||||
|
},
|
||||||
|
"min_segment_duration": {
|
||||||
|
"name": "Minimale Segmentdauer",
|
||||||
|
"description": "Minimale zusammenhängende Laufzeit. Verhindert schnelles Ein-/Ausschalten bei Geräten mit Mindestlaufzeiten. Wird automatisch auf die nächste Viertelstunde aufgerundet. Standard: 15 Minuten. Maximum: 4 Stunden."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Basiswährung verwenden",
|
||||||
|
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Suchbereich (Shortcut)",
|
||||||
|
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximale Preisstufe",
|
||||||
|
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimale Preisstufe",
|
||||||
|
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Vergleichsdetails einschliessen",
|
||||||
|
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Leistungsprofil",
|
||||||
|
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsaechlichen Verbrauch statt einer festen 1-kW-Last an."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_most_expensive_hours": {
|
||||||
|
"name": "Teuerste Stunden finden",
|
||||||
|
"description": "Findet die teuersten Intervalle für eine bestimmte Gesamtdauer, nicht unbedingt zusammenhängend. Nützlich zur Erkennung von Spitzenpreiszeiträumen, die vermieden werden sollten. Gibt einen Zeitplan mit Intervallen gruppiert in zusammenhängende Segmente zurück.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Suchbereich",
|
||||||
|
"description": "Zeitfenster fuer die Suche festlegen."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative Zeitbereich-Optionen",
|
||||||
|
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Preisstufen-Filter",
|
||||||
|
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Ausgabeoptionen",
|
||||||
|
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Eintrag-ID",
|
||||||
|
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Dauer",
|
||||||
|
"description": "Zu findende teure Gesamtzeit. Wird automatisch auf die nächste Viertelstunde aufgerundet. Maximum: 24 Stunden."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Suchbeginn",
|
||||||
|
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Suchende",
|
||||||
|
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Suchbeginn-Uhrzeit",
|
||||||
|
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Suchbeginn Tages-Versatz",
|
||||||
|
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Suchende-Uhrzeit",
|
||||||
|
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Suchende Tages-Versatz",
|
||||||
|
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Suchbeginn-Versatz (Minuten)",
|
||||||
|
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Suchende-Versatz (Minuten)",
|
||||||
|
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Aktuelles Intervall einbeziehen",
|
||||||
|
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
|
||||||
|
},
|
||||||
|
"min_segment_duration": {
|
||||||
|
"name": "Minimale Segmentdauer",
|
||||||
|
"description": "Minimale zusammenhängende Laufzeit. Verhindert schnelles Ein-/Ausschalten bei Geräten mit Mindestlaufzeiten. Wird automatisch auf die nächste Viertelstunde aufgerundet. Standard: 15 Minuten. Maximum: 4 Stunden."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Basiswährung verwenden",
|
||||||
|
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Suchbereich (Shortcut)",
|
||||||
|
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximale Preisstufe",
|
||||||
|
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimale Preisstufe",
|
||||||
|
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Vergleichsdetails einschliessen",
|
||||||
|
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Leistungsprofil",
|
||||||
|
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsaechlichen Verbrauch statt einer festen 1-kW-Last an."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_schedule": {
|
||||||
|
"name": "Guenstigstes Programm planen",
|
||||||
|
"description": "Plant mehrere Geraete optimal ohne Zeitueberschneidung. Jede Aufgabe erhaelt das guenstigste verfuegbare zusammenhaengende Zeitfenster.",
|
||||||
|
"sections": {
|
||||||
|
"scheduling_options": {
|
||||||
|
"name": "Planungsoptionen",
|
||||||
|
"description": "Aufgaben und Puffer zwischen den Geraeteaufzeiten konfigurieren."
|
||||||
|
},
|
||||||
|
"search_range": {
|
||||||
|
"name": "Suchbereich",
|
||||||
|
"description": "Zeitfenster fuer die Suche festlegen."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative Zeitbereich-Optionen",
|
||||||
|
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Preisstufen-Filter",
|
||||||
|
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Ausgabeoptionen",
|
||||||
|
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Eintrag-ID",
|
||||||
|
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"name": "Aufgaben",
|
||||||
|
"description": "Liste der zu planenden Aufgaben. Jede Aufgabe benoetigt name (Text) und duration (hh:mm:ss). Optional: power_profile (Watt pro 15-min-Intervall). Maximal 4 Aufgaben."
|
||||||
|
},
|
||||||
|
"gap_minutes": {
|
||||||
|
"name": "Pause zwischen Aufgaben (Minuten)",
|
||||||
|
"description": "Mindestpause in Minuten zwischen aufeinanderfolgenden Aufgaben. Wird auf 15 Minuten aufgerundet. Standard: 0 (keine Pause)."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Suchbereich (Shortcut)",
|
||||||
|
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Suchbeginn",
|
||||||
|
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Suchende",
|
||||||
|
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Suchbeginn-Uhrzeit",
|
||||||
|
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Suchbeginn Tages-Versatz",
|
||||||
|
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Suchende-Uhrzeit",
|
||||||
|
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Suchende Tages-Versatz",
|
||||||
|
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Suchbeginn-Versatz (Minuten)",
|
||||||
|
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Suchende-Versatz (Minuten)",
|
||||||
|
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Aktuelles Intervall einbeziehen",
|
||||||
|
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximale Preisstufe",
|
||||||
|
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimale Preisstufe",
|
||||||
|
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Basiswährung verwenden",
|
||||||
|
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|
@ -1361,6 +1877,15 @@
|
||||||
"median": "Median",
|
"median": "Median",
|
||||||
"mean": "Arithmetisches Mittel"
|
"mean": "Arithmetisches Mittel"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"options": {
|
||||||
|
"today": "Heute",
|
||||||
|
"tomorrow": "Morgen",
|
||||||
|
"remaining_today": "Rest heute",
|
||||||
|
"next_24h": "Naechste 24 Stunden",
|
||||||
|
"next_48h": "Naechste 48 Stunden"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Tibber Preisinformationen & Bewertungen"
|
"title": "Tibber Preisinformationen & Bewertungen"
|
||||||
|
|
|
||||||
|
|
@ -59,15 +59,7 @@
|
||||||
"home_already_configured": "This home is already configured in another entry. Each home can only be configured once.",
|
"home_already_configured": "This home is already configured in another entry. Each home can only be configured once.",
|
||||||
"no_active_subscription": "This home does not have an active Tibber contract. Only homes with active electricity contracts can be added to Home Assistant.",
|
"no_active_subscription": "This home does not have an active Tibber contract. Only homes with active electricity contracts can be added to Home Assistant.",
|
||||||
"subscription_expired": "The Tibber contract for this home has expired. Only homes with active or future electricity contracts can be added to Home Assistant.",
|
"subscription_expired": "The Tibber contract for this home has expired. Only homes with active or future electricity contracts can be added to Home Assistant.",
|
||||||
"future_subscription_warning": "Note: This home's Tibber contract has not started yet. Functionality may be limited until the contract becomes active.",
|
"future_subscription_warning": "Note: This home's Tibber contract has not started yet. Functionality may be limited until the contract becomes active."
|
||||||
"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}",
|
|
||||||
"missing_entry_id": "Entry ID is required but was not provided.",
|
|
||||||
"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.",
|
|
||||||
"price_fetch_failed": "Failed to fetch price data. Please check logs for details."
|
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "All available Tibber homes are already configured. Each home can only be configured once.",
|
"already_configured": "All available Tibber homes are already configured. Each home can only be configured once.",
|
||||||
|
|
@ -1055,6 +1047,62 @@
|
||||||
"description": "This update includes breaking changes that were applied automatically.\n\n**Renamed Entities ({count})**\n\nThe following entity keys were renamed. Your existing entity IDs and automations remain intact:\n\n{entity_list}\n\n**Duration Sensor Value Change**\n\nAll duration sensors (remaining time, starts in, period duration, trend change countdown) now report their state value in **minutes** instead of hours. The display unit in dashboards remains hours by default.\n\nIf you have automations using numeric comparisons on these sensors, update your thresholds:\n- Old: `state < 0.25` (15 minutes as hours)\n- New: `state < 15` (15 minutes)\n\nDismiss this notice after reviewing your automations."
|
"description": "This update includes breaking changes that were applied automatically.\n\n**Renamed Entities ({count})**\n\nThe following entity keys were renamed. Your existing entity IDs and automations remain intact:\n\n{entity_list}\n\n**Duration Sensor Value Change**\n\nAll duration sensors (remaining time, starts in, period duration, trend change countdown) now report their state value in **minutes** instead of hours. The display unit in dashboards remains hours by default.\n\nIf you have automations using numeric comparisons on these sensors, update your thresholds:\n- Old: `state < 0.25` (15 minutes as hours)\n- New: `state < 15` (15 minutes)\n\nDismiss this notice after reviewing your automations."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"no_entries_found": {
|
||||||
|
"message": "No Tibber Prices integration entries found. Please set up the integration first."
|
||||||
|
},
|
||||||
|
"multiple_entries_no_entry_id": {
|
||||||
|
"message": "Multiple Tibber Prices entries found. Please specify 'entry_id' to select which entry to use."
|
||||||
|
},
|
||||||
|
"invalid_entry_id": {
|
||||||
|
"message": "Invalid or unavailable config entry. Please check the entry ID and ensure the integration is loaded."
|
||||||
|
},
|
||||||
|
"missing_home_id": {
|
||||||
|
"message": "Home ID not found in the config entry. Please reconfigure the integration."
|
||||||
|
},
|
||||||
|
"user_data_not_available": {
|
||||||
|
"message": "User data is not yet available. Please wait for the first data update to complete."
|
||||||
|
},
|
||||||
|
"timezone_not_found": {
|
||||||
|
"message": "Could not determine the home timezone. Please verify the home configuration in your Tibber account."
|
||||||
|
},
|
||||||
|
"end_before_start": {
|
||||||
|
"message": "End time must be after start time."
|
||||||
|
},
|
||||||
|
"price_fetch_failed": {
|
||||||
|
"message": "Failed to fetch price data from the Tibber API. Please try again later."
|
||||||
|
},
|
||||||
|
"invalid_search_scope": {
|
||||||
|
"message": "Invalid search scope value. Valid scopes are: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||||
|
},
|
||||||
|
"scope_conflicts_with_range": {
|
||||||
|
"message": "search_scope cannot be combined with explicit range parameters: {params}. Use either search_scope OR explicit start/end parameters."
|
||||||
|
},
|
||||||
|
"day_offset_requires_time": {
|
||||||
|
"message": "{offset_param} requires {time_param} to be set. Day offset only modifies the date of an explicit time parameter."
|
||||||
|
},
|
||||||
|
"min_level_exceeds_max": {
|
||||||
|
"message": "min_price_level '{min_level}' is higher than max_price_level '{max_level}'. The minimum level must be equal to or lower than the maximum level."
|
||||||
|
},
|
||||||
|
"power_profile_length_mismatch": {
|
||||||
|
"message": "power_profile has {profile_length} entries but the duration requires {interval_count} intervals ({duration_minutes} minutes). The power_profile must have exactly one entry per 15-minute interval."
|
||||||
|
},
|
||||||
|
"level_and_rating_filter_conflict": {
|
||||||
|
"message": "level_filter and rating_level_filter cannot be used together. Use only one filter type per request."
|
||||||
|
},
|
||||||
|
"insert_nulls_requires_filter": {
|
||||||
|
"message": "insert_nulls mode '{mode}' requires a level_filter or rating_level_filter to define segments. Without a filter, use insert_nulls: none."
|
||||||
|
},
|
||||||
|
"connect_segments_requires_segments_mode": {
|
||||||
|
"message": "connect_segments requires insert_nulls to be set to 'segments'. Set insert_nulls: segments to use segment connection."
|
||||||
|
},
|
||||||
|
"array_fields_requires_array_format": {
|
||||||
|
"message": "array_fields can only be used with output_format: array_of_arrays. Change the output format or remove array_fields."
|
||||||
|
},
|
||||||
|
"invalid_array_fields": {
|
||||||
|
"message": "Invalid array_fields template. Use field names in curly braces, e.g. '{start_time}, {price_per_kwh}, {level}'."
|
||||||
|
}
|
||||||
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"get_price": {
|
"get_price": {
|
||||||
"name": "Get Price Data",
|
"name": "Get Price Data",
|
||||||
|
|
@ -1262,6 +1310,474 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"find_cheapest_block": {
|
||||||
|
"name": "Find Cheapest Block",
|
||||||
|
"description": "Finds the cheapest contiguous time window of a given duration. Designed for appliance scheduling: dishwasher, washing machine, dryer, etc. Returns the single cheapest window with start/end times and price statistics.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Search Range",
|
||||||
|
"description": "Define the time window to search within."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative Time Range Options",
|
||||||
|
"description": "Alternative ways to define the search range using time-of-day and offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Price Level Filter",
|
||||||
|
"description": "Restrict search to intervals within the specified price level range."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Output Options",
|
||||||
|
"description": "Control cost estimation and comparison output."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Entry ID",
|
||||||
|
"description": "The config entry ID for the Tibber integration."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Duration",
|
||||||
|
"description": "Length of the desired contiguous window. Automatically rounded up to the next quarter-hour. Maximum: 12 hours."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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. Negative values search in the past. 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. Negative values search in the past. 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 (60 = in 1 hour), negative = past (-60 = 1 hour ago). 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 (480 = in 8 hours), negative = past (-60 = 1 hour ago). Ignored if Search End or Search End Time is set."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Include Current Interval",
|
||||||
|
"description": "Include the currently running 15-minute interval in the search. When enabled (default), the search starts at the beginning of the current interval so it can be part of the result."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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 for find_most_expensive to focus on truly expensive intervals."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Include Comparison Details",
|
||||||
|
"description": "Enrich the price_comparison result with additional fields: comparison_price_min, comparison_price_max, and (block only) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Power Profile",
|
||||||
|
"description": "Variable power draw in watts per 15-minute interval. When set, estimated_total_cost reflects actual consumption instead of a flat 1 kW load. The profile is extended by repeating the last value if shorter than the window."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_most_expensive_block": {
|
||||||
|
"name": "Find Most Expensive Block",
|
||||||
|
"description": "Finds the most expensive contiguous time window of a given duration. Useful for identifying peak price periods to avoid. Returns the single most expensive window with start/end times and price statistics.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Search Range",
|
||||||
|
"description": "Define the time window to search within."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative Time Range Options",
|
||||||
|
"description": "Alternative ways to define the search range using time-of-day and offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Price Level Filter",
|
||||||
|
"description": "Restrict search to intervals within the specified price level range."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Output Options",
|
||||||
|
"description": "Control cost estimation and comparison output."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Entry ID",
|
||||||
|
"description": "The config entry ID for the Tibber integration."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Duration",
|
||||||
|
"description": "Length of the desired contiguous window. Automatically rounded up to the next quarter-hour. Maximum: 12 hours."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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. Negative values search in the past. 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. Negative values search in the past. 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 (60 = in 1 hour), negative = past (-60 = 1 hour ago). 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 (480 = in 8 hours), negative = past (-60 = 1 hour ago). Ignored if Search End or Search End Time is set."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Include Current Interval",
|
||||||
|
"description": "Include the currently running 15-minute interval in the search. When enabled (default), the search starts at the beginning of the current interval so it can be part of the result."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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 for find_most_expensive to focus on truly expensive intervals."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Include Comparison Details",
|
||||||
|
"description": "Enrich the price_comparison result with additional fields: comparison_price_min, comparison_price_max, and (block only) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Power Profile",
|
||||||
|
"description": "Variable power draw in watts per 15-minute interval. When set, estimated_total_cost reflects actual consumption instead of a flat 1 kW load. The profile is extended by repeating the last value if shorter than the window."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_hours": {
|
||||||
|
"name": "Find Cheapest Hours",
|
||||||
|
"description": "Finds the cheapest intervals totaling a given duration, not necessarily contiguous. Designed for flexible loads: battery charging, EV, water heater. Returns a schedule of intervals grouped into contiguous segments.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Search Range",
|
||||||
|
"description": "Define the time window to search within."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative Time Range Options",
|
||||||
|
"description": "Alternative ways to define the search range using time-of-day and offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Price Level Filter",
|
||||||
|
"description": "Restrict search to intervals within the specified price level range."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Output Options",
|
||||||
|
"description": "Control cost estimation and comparison output."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Entry ID",
|
||||||
|
"description": "The config entry ID for the Tibber integration."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Duration",
|
||||||
|
"description": "Total cheap time needed. Automatically rounded up to the next quarter-hour. Maximum: 24 hours."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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. Negative values search in the past. 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. Negative values search in the past. 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 (60 = in 1 hour), negative = past (-60 = 1 hour ago). 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 (480 = in 8 hours), negative = past (-60 = 1 hour ago). Ignored if Search End or Search End Time is set."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Include Current Interval",
|
||||||
|
"description": "Include the currently running 15-minute interval in the search. When enabled (default), the search starts at the beginning of the current interval so it can be part of the result."
|
||||||
|
},
|
||||||
|
"min_segment_duration": {
|
||||||
|
"name": "Minimum Segment Duration",
|
||||||
|
"description": "Minimum contiguous run length. Prevents rapid on/off cycling for devices with minimum run times. Automatically rounded up to the next quarter-hour. Default: 15 minutes. Maximum: 4 hours."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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 for find_most_expensive to focus on truly expensive intervals."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Include Comparison Details",
|
||||||
|
"description": "Enrich the price_comparison result with additional fields: comparison_price_min, comparison_price_max, and (block only) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Power Profile",
|
||||||
|
"description": "Variable power draw in watts per 15-minute interval. When set, estimated_total_cost reflects actual consumption instead of a flat 1 kW load. The profile is extended by repeating the last value if shorter than the window."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_most_expensive_hours": {
|
||||||
|
"name": "Find Most Expensive Hours",
|
||||||
|
"description": "Finds the most expensive intervals totaling a given duration, not necessarily contiguous. Useful for identifying peak price periods to avoid. Returns a schedule of intervals grouped into contiguous segments.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Search Range",
|
||||||
|
"description": "Define the time window to search within."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative Time Range Options",
|
||||||
|
"description": "Alternative ways to define the search range using time-of-day and offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Price Level Filter",
|
||||||
|
"description": "Restrict search to intervals within the specified price level range."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Output Options",
|
||||||
|
"description": "Control cost estimation and comparison output."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Entry ID",
|
||||||
|
"description": "The config entry ID for the Tibber integration."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Duration",
|
||||||
|
"description": "Total expensive time to find. Automatically rounded up to the next quarter-hour. Maximum: 24 hours."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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. Negative values search in the past. 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. Negative values search in the past. 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 (60 = in 1 hour), negative = past (-60 = 1 hour ago). 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 (480 = in 8 hours), negative = past (-60 = 1 hour ago). Ignored if Search End or Search End Time is set."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Include Current Interval",
|
||||||
|
"description": "Include the currently running 15-minute interval in the search. When enabled (default), the search starts at the beginning of the current interval so it can be part of the result."
|
||||||
|
},
|
||||||
|
"min_segment_duration": {
|
||||||
|
"name": "Minimum Segment Duration",
|
||||||
|
"description": "Minimum contiguous run length. Prevents rapid on/off cycling for devices with minimum run times. Automatically rounded up to the next quarter-hour. Default: 15 minutes. Maximum: 4 hours."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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 for find_most_expensive to focus on truly expensive intervals."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Include Comparison Details",
|
||||||
|
"description": "Enrich the price_comparison result with additional fields: comparison_price_min, comparison_price_max, and (block only) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Power Profile",
|
||||||
|
"description": "Variable power draw in watts per 15-minute interval. When set, estimated_total_cost reflects actual consumption instead of a flat 1 kW load. The profile is extended by repeating the last value if shorter than the window."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_schedule": {
|
||||||
|
"name": "Find Cheapest Schedule",
|
||||||
|
"description": "Schedules multiple appliances optimally without time overlap. Each task gets the cheapest available contiguous window; tasks are placed greedily in ascending cost order. Returns a per-task schedule with start/end times and price stats.",
|
||||||
|
"sections": {
|
||||||
|
"scheduling_options": {
|
||||||
|
"name": "Scheduling Options",
|
||||||
|
"description": "Configure tasks and gap constraints between them."
|
||||||
|
},
|
||||||
|
"search_range": {
|
||||||
|
"name": "Search Range",
|
||||||
|
"description": "Define the time window to search within."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative Time Range Options",
|
||||||
|
"description": "Alternative ways to define the search range using time-of-day and offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Price Level Filter",
|
||||||
|
"description": "Restrict search to intervals within the specified price level range."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Output Options",
|
||||||
|
"description": "Control output currency unit."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Entry ID",
|
||||||
|
"description": "The config entry ID for the Tibber integration."
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"name": "Tasks",
|
||||||
|
"description": "List of tasks to schedule. Each task requires name (string) and duration (hh:mm:ss). Optionally add power_profile (list of watts per 15-min interval). Maximum 4 tasks."
|
||||||
|
},
|
||||||
|
"gap_minutes": {
|
||||||
|
"name": "Gap Between Tasks (minutes)",
|
||||||
|
"description": "Minimum gap in minutes between consecutive scheduled tasks. Rounded up to 15 minutes. Default: 0 (no gap)."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Include Current Interval",
|
||||||
|
"description": "Include the currently running 15-minute interval in the search."
|
||||||
|
},
|
||||||
|
"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 for find_most_expensive to focus on truly expensive intervals."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"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.",
|
||||||
|
|
@ -1371,6 +1887,15 @@
|
||||||
"median": "Median",
|
"median": "Median",
|
||||||
"mean": "Arithmetic Mean"
|
"mean": "Arithmetic Mean"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"options": {
|
||||||
|
"today": "Today",
|
||||||
|
"tomorrow": "Tomorrow",
|
||||||
|
"remaining_today": "Remaining Today",
|
||||||
|
"next_24h": "Next 24 Hours",
|
||||||
|
"next_48h": "Next 48 Hours"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Tibber Price Information & Ratings"
|
"title": "Tibber Price Information & Ratings"
|
||||||
|
|
|
||||||
|
|
@ -59,15 +59,7 @@
|
||||||
"home_already_configured": "Dette hjemmet er allerede konfigurert i en annen oppføring. Hvert hjem kan kun konfigureres én gang.",
|
"home_already_configured": "Dette hjemmet er allerede konfigurert i en annen oppføring. Hvert hjem kan kun konfigureres én gang.",
|
||||||
"no_active_subscription": "Dette hjemmet har ikke en aktiv Tibber-kontrakt. Bare hjem med aktive strømkontrakter kan legges til Home Assistant.",
|
"no_active_subscription": "Dette hjemmet har ikke en aktiv Tibber-kontrakt. Bare hjem med aktive strømkontrakter kan legges til Home Assistant.",
|
||||||
"subscription_expired": "Tibber-kontrakten for dette hjemmet har utløpt. Bare hjem med aktive eller fremtidige strømkontrakter kan legges til Home Assistant.",
|
"subscription_expired": "Tibber-kontrakten for dette hjemmet har utløpt. Bare hjem med aktive eller fremtidige strømkontrakter kan legges til Home Assistant.",
|
||||||
"future_subscription_warning": "Merk: Tibber-kontrakten for dette hjemmet har ikke startet ennå. Funksjonaliteten kan være begrenset til kontrakten blir aktiv.",
|
"future_subscription_warning": "Merk: Tibber-kontrakten for dette hjemmet har ikke startet ennå. Funksjonaliteten kan være begrenset til kontrakten blir aktiv."
|
||||||
"invalid_yaml_syntax": "Ugyldig YAML-syntaks. Vennligst sjekk innrykk, kolon og spesialtegn.",
|
|
||||||
"invalid_yaml_structure": "YAML må være en ordbok/objekt (nøkkel: verdi-par), ikke en liste eller ren tekst.",
|
|
||||||
"service_call_failed": "Service-kall validering feilet: {error_detail}",
|
|
||||||
"missing_entry_id": "Oppførings-ID er påkrevd, men ble ikke oppgitt.",
|
|
||||||
"invalid_entry_id": "Ugyldig oppførings-ID eller oppføring ikke funnet.",
|
|
||||||
"missing_home_id": "Hjem-ID mangler fra konfigurasjonsoppføringen.",
|
|
||||||
"user_data_not_available": "Brukerdata er ikke tilgjengelig. Vennligst oppdater brukerdata først.",
|
|
||||||
"price_fetch_failed": "Kunne ikke hente prisdata. Vennligst sjekk loggene for detaljer."
|
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Alle tilgjengelige Tibber-hjem er allerede konfigurert. Hvert hjem kan kun konfigureres én gang.",
|
"already_configured": "Alle tilgjengelige Tibber-hjem er allerede konfigurert. Hvert hjem kan kun konfigureres én gang.",
|
||||||
|
|
@ -1055,6 +1047,62 @@
|
||||||
"description": "Denne oppdateringen inkluderer endringer som ble brukt automatisk.\n\n**Omdøpte entiteter ({count})**\n\nFølgende entity-nøkler ble omdøpt. Dine eksisterende entity-ID-er og automatiseringer forblir intakte:\n\n{entity_list}\n\n**Endrede varighetssensorverdier**\n\nAlle varighetssensorer (gjenværende tid, starter om, periodevarighet, trendendrings-nedtelling) rapporterer nå tilstandsverdien i **minutter** i stedet for timer. Visningsenheten i dashboards forblir timer som standard.\n\nHvis du har automatiseringer med numeriske sammenligninger på disse sensorene, oppdater tersklene:\n- Gammelt: `state < 0.25` (15 minutter som timer)\n- Nytt: `state < 15` (15 minutter)\n\nAvvis dette varselet etter å ha gjennomgått automatiseringene dine."
|
"description": "Denne oppdateringen inkluderer endringer som ble brukt automatisk.\n\n**Omdøpte entiteter ({count})**\n\nFølgende entity-nøkler ble omdøpt. Dine eksisterende entity-ID-er og automatiseringer forblir intakte:\n\n{entity_list}\n\n**Endrede varighetssensorverdier**\n\nAlle varighetssensorer (gjenværende tid, starter om, periodevarighet, trendendrings-nedtelling) rapporterer nå tilstandsverdien i **minutter** i stedet for timer. Visningsenheten i dashboards forblir timer som standard.\n\nHvis du har automatiseringer med numeriske sammenligninger på disse sensorene, oppdater tersklene:\n- Gammelt: `state < 0.25` (15 minutter som timer)\n- Nytt: `state < 15` (15 minutter)\n\nAvvis dette varselet etter å ha gjennomgått automatiseringene dine."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"no_entries_found": {
|
||||||
|
"message": "Ingen Tibber Prices integrasjonsoppføringer funnet. Vennligst sett opp integrasjonen først."
|
||||||
|
},
|
||||||
|
"multiple_entries_no_entry_id": {
|
||||||
|
"message": "Flere Tibber Prices oppføringer funnet. Vennligst angi 'entry_id' for å velge hvilken oppføring som skal brukes."
|
||||||
|
},
|
||||||
|
"invalid_entry_id": {
|
||||||
|
"message": "Ugyldig eller utilgjengelig konfigurasjonsoppføring. Vennligst sjekk oppførings-ID-en og sørg for at integrasjonen er lastet."
|
||||||
|
},
|
||||||
|
"missing_home_id": {
|
||||||
|
"message": "Hjemme-ID ikke funnet i konfigurasjonsoppføringen. Vennligst rekonfigurer integrasjonen."
|
||||||
|
},
|
||||||
|
"user_data_not_available": {
|
||||||
|
"message": "Brukerdata er ikke tilgjengelig ennå. Vennligst vent til den første dataoppdateringen er fullført."
|
||||||
|
},
|
||||||
|
"timezone_not_found": {
|
||||||
|
"message": "Kunne ikke bestemme hjemmets tidssone. Vennligst sjekk hjemmekonfigurasjonen i Tibber-kontoen din."
|
||||||
|
},
|
||||||
|
"end_before_start": {
|
||||||
|
"message": "Sluttid må være etter starttid."
|
||||||
|
},
|
||||||
|
"price_fetch_failed": {
|
||||||
|
"message": "Kunne ikke hente prisdata fra Tibber API. Vennligst prøv igjen senere."
|
||||||
|
},
|
||||||
|
"invalid_search_scope": {
|
||||||
|
"message": "Ugyldig søkeområde. Gyldige verdier er: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||||
|
},
|
||||||
|
"scope_conflicts_with_range": {
|
||||||
|
"message": "search_scope kan ikke kombineres med eksplisitte områdeparametre: {params}. Bruk enten search_scope ELLER eksplisitte start-/sluttparametre."
|
||||||
|
},
|
||||||
|
"day_offset_requires_time": {
|
||||||
|
"message": "{offset_param} krever at {time_param} er satt. Dagsforskyvning endrer kun datoen til en eksplisitt tidsparameter."
|
||||||
|
},
|
||||||
|
"min_level_exceeds_max": {
|
||||||
|
"message": "min_price_level '{min_level}' er høyere enn max_price_level '{max_level}'. Minimumsnivået må være lik eller lavere enn maksimumsnivået."
|
||||||
|
},
|
||||||
|
"power_profile_length_mismatch": {
|
||||||
|
"message": "power_profile har {profile_length} oppføringer, men varigheten krever {interval_count} intervaller ({duration_minutes} minutter). power_profile må ha nøyaktig én oppføring per 15-minutters intervall."
|
||||||
|
},
|
||||||
|
"level_and_rating_filter_conflict": {
|
||||||
|
"message": "level_filter og rating_level_filter kan ikke brukes sammen. Bruk kun én filtertype per forespørsel."
|
||||||
|
},
|
||||||
|
"insert_nulls_requires_filter": {
|
||||||
|
"message": "insert_nulls-modus '{mode}' krever en level_filter eller rating_level_filter for å definere segmenter. Uten filter, bruk insert_nulls: none."
|
||||||
|
},
|
||||||
|
"connect_segments_requires_segments_mode": {
|
||||||
|
"message": "connect_segments krever at insert_nulls er satt til 'segments'. Sett insert_nulls: segments for å bruke segmentforbindelse."
|
||||||
|
},
|
||||||
|
"array_fields_requires_array_format": {
|
||||||
|
"message": "array_fields kan kun brukes med output_format: array_of_arrays. Endre utdataformatet eller fjern array_fields."
|
||||||
|
},
|
||||||
|
"invalid_array_fields": {
|
||||||
|
"message": "Ugyldig array_fields-mal. Bruk feltnavn i krøllparenteser, f.eks. '{start_time}, {price_per_kwh}, {level}'."
|
||||||
|
}
|
||||||
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"get_price": {
|
"get_price": {
|
||||||
"name": "Hent prisdata",
|
"name": "Hent prisdata",
|
||||||
|
|
@ -1261,6 +1309,474 @@
|
||||||
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
|
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_block": {
|
||||||
|
"name": "Finn billigste blokk",
|
||||||
|
"description": "Finner det billigste sammenhengende tidsvinduet med en gitt varighet. Designet for apparatplanlegging: oppvaskmaskin, vaskemaskin, tørketrommel osv. Returnerer det billigste vinduet med start-/sluttider og prisstatistikk.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Soekeomraade",
|
||||||
|
"description": "Definer tidsvinduet for soeket."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative tidsalternativer",
|
||||||
|
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prisnivaae-filter",
|
||||||
|
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Utdata-alternativer",
|
||||||
|
"description": "Styr kostnadsestimat og sammenligningsutdata."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Oppførings-ID",
|
||||||
|
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Varighet",
|
||||||
|
"description": "Lengden på det ønskede sammenhengende vinduet. Rundes automatisk opp til nærmeste kvarter. Maksimum: 12 timer."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Søkestart",
|
||||||
|
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Søkeslutt",
|
||||||
|
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Søkestart-klokkeslett",
|
||||||
|
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Søkestart dagsforskyvning",
|
||||||
|
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Søkeslutt-klokkeslett",
|
||||||
|
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Søkeslutt dagsforskyvning",
|
||||||
|
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Søkestart-forskyvning (minutter)",
|
||||||
|
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Søkeslutt-forskyvning (minutter)",
|
||||||
|
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Inkluder gjeldende intervall",
|
||||||
|
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Bruk basisvaluta",
|
||||||
|
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Soekeomfang (snarvei)",
|
||||||
|
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maksimalt prisnivaae",
|
||||||
|
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimalt prisnivaae",
|
||||||
|
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Inkluder sammenligningsdetaljer",
|
||||||
|
"description": "Berik price_comparison-resultatet med tilleggsfelter: comparison_price_min, comparison_price_max og (kun blokk) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Effektprofil",
|
||||||
|
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_most_expensive_block": {
|
||||||
|
"name": "Finn dyreste blokk",
|
||||||
|
"description": "Finner det dyreste sammenhengende tidsvinduet med en gitt varighet. Nyttig for å identifisere topprisperioder som bør unngås. Returnerer det dyreste vinduet med start-/sluttider og prisstatistikk.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Soekeomraade",
|
||||||
|
"description": "Definer tidsvinduet for soeket."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative tidsalternativer",
|
||||||
|
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prisnivaae-filter",
|
||||||
|
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Utdata-alternativer",
|
||||||
|
"description": "Styr kostnadsestimat og sammenligningsutdata."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Oppførings-ID",
|
||||||
|
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Varighet",
|
||||||
|
"description": "Lengden på det ønskede sammenhengende vinduet. Rundes automatisk opp til nærmeste kvarter. Maksimum: 12 timer."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Søkestart",
|
||||||
|
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Søkeslutt",
|
||||||
|
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Søkestart-klokkeslett",
|
||||||
|
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Søkestart dagsforskyvning",
|
||||||
|
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Søkeslutt-klokkeslett",
|
||||||
|
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Søkeslutt dagsforskyvning",
|
||||||
|
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Søkestart-forskyvning (minutter)",
|
||||||
|
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Søkeslutt-forskyvning (minutter)",
|
||||||
|
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Inkluder gjeldende intervall",
|
||||||
|
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Bruk basisvaluta",
|
||||||
|
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Soekeomfang (snarvei)",
|
||||||
|
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maksimalt prisnivaae",
|
||||||
|
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimalt prisnivaae",
|
||||||
|
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Inkluder sammenligningsdetaljer",
|
||||||
|
"description": "Berik price_comparison-resultatet med tilleggsfelter: comparison_price_min, comparison_price_max og (kun blokk) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Effektprofil",
|
||||||
|
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_hours": {
|
||||||
|
"name": "Finn billigste timer",
|
||||||
|
"description": "Finner de billigste intervallene for en gitt total varighet, ikke nødvendigvis sammenhengende. Designet for fleksible laster: batterilading, elbil, varmtvannsbereder. Returnerer en tidsplan med intervaller gruppert i sammenhengende segmenter.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Soekeomraade",
|
||||||
|
"description": "Definer tidsvinduet for soeket."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative tidsalternativer",
|
||||||
|
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prisnivaae-filter",
|
||||||
|
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Utdata-alternativer",
|
||||||
|
"description": "Styr kostnadsestimat og sammenligningsutdata."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Oppførings-ID",
|
||||||
|
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Varighet",
|
||||||
|
"description": "Nødvendig billig total tid. Rundes automatisk opp til nærmeste kvarter. Maksimum: 24 timer."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Søkestart",
|
||||||
|
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Søkeslutt",
|
||||||
|
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Søkestart-klokkeslett",
|
||||||
|
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Søkestart dagsforskyvning",
|
||||||
|
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Søkeslutt-klokkeslett",
|
||||||
|
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Søkeslutt dagsforskyvning",
|
||||||
|
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Søkestart-forskyvning (minutter)",
|
||||||
|
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Søkeslutt-forskyvning (minutter)",
|
||||||
|
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Inkluder gjeldende intervall",
|
||||||
|
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
|
||||||
|
},
|
||||||
|
"min_segment_duration": {
|
||||||
|
"name": "Minimum segmentvarighet",
|
||||||
|
"description": "Minimum sammenhengende kjøretid. Forhindrer rask av/på-sykling for enheter med minimum kjøretid. Rundes automatisk opp til nærmeste kvarter. Standard: 15 minutter. Maksimum: 4 timer."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Bruk basisvaluta",
|
||||||
|
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Soekeomfang (snarvei)",
|
||||||
|
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maksimalt prisnivaae",
|
||||||
|
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimalt prisnivaae",
|
||||||
|
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Inkluder sammenligningsdetaljer",
|
||||||
|
"description": "Berik price_comparison-resultatet med tilleggsfelter: comparison_price_min, comparison_price_max og (kun blokk) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Effektprofil",
|
||||||
|
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_most_expensive_hours": {
|
||||||
|
"name": "Finn dyreste timer",
|
||||||
|
"description": "Finner de dyreste intervallene for en gitt total varighet, ikke nødvendigvis sammenhengende. Nyttig for å identifisere topprisperioder som bør unngås. Returnerer en tidsplan med intervaller gruppert i sammenhengende segmenter.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Soekeomraade",
|
||||||
|
"description": "Definer tidsvinduet for soeket."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative tidsalternativer",
|
||||||
|
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prisnivaae-filter",
|
||||||
|
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Utdata-alternativer",
|
||||||
|
"description": "Styr kostnadsestimat og sammenligningsutdata."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Oppførings-ID",
|
||||||
|
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Varighet",
|
||||||
|
"description": "Dyr total tid som skal finnes. Rundes automatisk opp til nærmeste kvarter. Maksimum: 24 timer."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Søkestart",
|
||||||
|
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Søkeslutt",
|
||||||
|
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Søkestart-klokkeslett",
|
||||||
|
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Søkestart dagsforskyvning",
|
||||||
|
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Søkeslutt-klokkeslett",
|
||||||
|
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Søkeslutt dagsforskyvning",
|
||||||
|
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Søkestart-forskyvning (minutter)",
|
||||||
|
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Søkeslutt-forskyvning (minutter)",
|
||||||
|
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Inkluder gjeldende intervall",
|
||||||
|
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
|
||||||
|
},
|
||||||
|
"min_segment_duration": {
|
||||||
|
"name": "Minimum segmentvarighet",
|
||||||
|
"description": "Minimum sammenhengende kjøretid. Forhindrer rask av/på-sykling for enheter med minimum kjøretid. Rundes automatisk opp til nærmeste kvarter. Standard: 15 minutter. Maksimum: 4 timer."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Bruk basisvaluta",
|
||||||
|
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Soekeomfang (snarvei)",
|
||||||
|
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maksimalt prisnivaae",
|
||||||
|
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimalt prisnivaae",
|
||||||
|
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Inkluder sammenligningsdetaljer",
|
||||||
|
"description": "Berik price_comparison-resultatet med tilleggsfelter: comparison_price_min, comparison_price_max og (kun blokk) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Effektprofil",
|
||||||
|
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_schedule": {
|
||||||
|
"name": "Finn billigste tidsplan",
|
||||||
|
"description": "Planlegger flere apparater optimalt uten tidsoverlapp. Hver oppgave tildeles det billigste tilgjengelige sammenhengende tidsvinduet.",
|
||||||
|
"sections": {
|
||||||
|
"scheduling_options": {
|
||||||
|
"name": "Planleggingsalternativer",
|
||||||
|
"description": "Konfigurer oppgaver og pause mellom dem."
|
||||||
|
},
|
||||||
|
"search_range": {
|
||||||
|
"name": "Soekeomraade",
|
||||||
|
"description": "Definer tidsvinduet for soeket."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternative tidsalternativer",
|
||||||
|
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prisnivaae-filter",
|
||||||
|
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Utdata-alternativer",
|
||||||
|
"description": "Styr kostnadsestimat og sammenligningsutdata."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Oppførings-ID",
|
||||||
|
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"name": "Oppgaver",
|
||||||
|
"description": "Liste over oppgaver som skal planlegges. Hver oppgave trenger name (tekst) og duration (hh:mm:ss). Eventuelt power_profile (watt per 15-min-intervall). Maks 4 oppgaver."
|
||||||
|
},
|
||||||
|
"gap_minutes": {
|
||||||
|
"name": "Pause mellom oppgaver (minutter)",
|
||||||
|
"description": "Minimum pause i minutter mellom paafoeglende planlagte oppgaver. Avrundes opp til 15 minutter. Standard: 0 (ingen pause)."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Soekeomfang (snarvei)",
|
||||||
|
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Søkestart",
|
||||||
|
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Søkeslutt",
|
||||||
|
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Søkestart-klokkeslett",
|
||||||
|
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Søkestart dagsforskyvning",
|
||||||
|
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Søkeslutt-klokkeslett",
|
||||||
|
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Søkeslutt dagsforskyvning",
|
||||||
|
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Søkestart-forskyvning (minutter)",
|
||||||
|
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Søkeslutt-forskyvning (minutter)",
|
||||||
|
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Inkluder gjeldende intervall",
|
||||||
|
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maksimalt prisnivaae",
|
||||||
|
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimalt prisnivaae",
|
||||||
|
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Bruk basisvaluta",
|
||||||
|
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|
@ -1361,6 +1877,15 @@
|
||||||
"median": "Median",
|
"median": "Median",
|
||||||
"mean": "Aritmetisk gjennomsnitt"
|
"mean": "Aritmetisk gjennomsnitt"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"options": {
|
||||||
|
"today": "I dag",
|
||||||
|
"tomorrow": "I morgen",
|
||||||
|
"remaining_today": "Resten av dagen",
|
||||||
|
"next_24h": "Neste 24 timer",
|
||||||
|
"next_48h": "Neste 48 timer"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Tibber Prisinformasjon & Vurderinger"
|
"title": "Tibber Prisinformasjon & Vurderinger"
|
||||||
|
|
|
||||||
|
|
@ -59,15 +59,7 @@
|
||||||
"home_already_configured": "Dit huis is al geconfigureerd in een ander item. Elk huis kan slechts één keer worden geconfigureerd.",
|
"home_already_configured": "Dit huis is al geconfigureerd in een ander item. Elk huis kan slechts één keer worden geconfigureerd.",
|
||||||
"no_active_subscription": "Dit huis heeft geen actief Tibber-contract. Alleen huizen met actieve elektriciteitscontracten kunnen worden toegevoegd aan Home Assistant.",
|
"no_active_subscription": "Dit huis heeft geen actief Tibber-contract. Alleen huizen met actieve elektriciteitscontracten kunnen worden toegevoegd aan Home Assistant.",
|
||||||
"subscription_expired": "Het Tibber-contract voor dit huis is verlopen. Alleen huizen met actieve of toekomstige elektriciteitscontracten kunnen worden toegevoegd aan Home Assistant.",
|
"subscription_expired": "Het Tibber-contract voor dit huis is verlopen. Alleen huizen met actieve of toekomstige elektriciteitscontracten kunnen worden toegevoegd aan Home Assistant.",
|
||||||
"future_subscription_warning": "Let op: Het Tibber-contract van dit huis is nog niet gestart. De functionaliteit kan beperkt zijn totdat het contract actief wordt.",
|
"future_subscription_warning": "Let op: Het Tibber-contract van dit huis is nog niet gestart. De functionaliteit kan beperkt zijn totdat het contract actief wordt."
|
||||||
"invalid_yaml_syntax": "Ongeldige YAML-syntaxis. Controleer inspringing, dubbele punten en speciale tekens.",
|
|
||||||
"invalid_yaml_structure": "YAML moet een dictionary/object zijn (sleutel: waarde paren), geen lijst of platte tekst.",
|
|
||||||
"service_call_failed": "Service call validatie mislukt: {error_detail}",
|
|
||||||
"missing_entry_id": "Entry-ID is verplicht maar werd niet opgegeven.",
|
|
||||||
"invalid_entry_id": "Ongeldige entry-ID of item niet gevonden.",
|
|
||||||
"missing_home_id": "Huis-ID ontbreekt in het configuratie-item.",
|
|
||||||
"user_data_not_available": "Gebruikersgegevens zijn niet beschikbaar. Ververs eerst de gebruikersgegevens.",
|
|
||||||
"price_fetch_failed": "Ophalen van prijsgegevens mislukt. Controleer de logs voor details."
|
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Alle beschikbare Tibber-huizen zijn al geconfigureerd. Elk huis kan slechts één keer worden geconfigureerd.",
|
"already_configured": "Alle beschikbare Tibber-huizen zijn al geconfigureerd. Elk huis kan slechts één keer worden geconfigureerd.",
|
||||||
|
|
@ -1055,6 +1047,62 @@
|
||||||
"description": "Deze update bevat wijzigingen die automatisch zijn toegepast.\n\n**Hernoemde entiteiten ({count})**\n\nDe volgende entity-sleutels zijn hernoemd. Je bestaande entity-ID's en automatiseringen blijven intact:\n\n{entity_list}\n\n**Gewijzigde duur-sensorwaarden**\n\nAlle duur-sensoren (resterende tijd, start over, periodeduur, trendwijzigings-aftelling) rapporteren hun statuswaarde nu in **minuten** in plaats van uren. De weergave-eenheid in dashboards blijft standaard uren.\n\nAls je automatiseringen hebt met numerieke vergelijkingen op deze sensoren, werk dan je drempelwaarden bij:\n- Oud: `state < 0.25` (15 minuten als uren)\n- Nieuw: `state < 15` (15 minuten)\n\nSluit deze melding nadat je je automatiseringen hebt gecontroleerd."
|
"description": "Deze update bevat wijzigingen die automatisch zijn toegepast.\n\n**Hernoemde entiteiten ({count})**\n\nDe volgende entity-sleutels zijn hernoemd. Je bestaande entity-ID's en automatiseringen blijven intact:\n\n{entity_list}\n\n**Gewijzigde duur-sensorwaarden**\n\nAlle duur-sensoren (resterende tijd, start over, periodeduur, trendwijzigings-aftelling) rapporteren hun statuswaarde nu in **minuten** in plaats van uren. De weergave-eenheid in dashboards blijft standaard uren.\n\nAls je automatiseringen hebt met numerieke vergelijkingen op deze sensoren, werk dan je drempelwaarden bij:\n- Oud: `state < 0.25` (15 minuten als uren)\n- Nieuw: `state < 15` (15 minuten)\n\nSluit deze melding nadat je je automatiseringen hebt gecontroleerd."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"no_entries_found": {
|
||||||
|
"message": "Geen Tibber Prices integratie-items gevonden. Stel de integratie eerst in."
|
||||||
|
},
|
||||||
|
"multiple_entries_no_entry_id": {
|
||||||
|
"message": "Meerdere Tibber Prices items gevonden. Geef 'entry_id' op om het gewenste item te selecteren."
|
||||||
|
},
|
||||||
|
"invalid_entry_id": {
|
||||||
|
"message": "Ongeldige of niet-beschikbare configuratie-item. Controleer de item-ID en zorg dat de integratie geladen is."
|
||||||
|
},
|
||||||
|
"missing_home_id": {
|
||||||
|
"message": "Huis-ID niet gevonden in het configuratie-item. Configureer de integratie opnieuw."
|
||||||
|
},
|
||||||
|
"user_data_not_available": {
|
||||||
|
"message": "Gebruikersgegevens zijn nog niet beschikbaar. Wacht tot de eerste gegevensupdate is voltooid."
|
||||||
|
},
|
||||||
|
"timezone_not_found": {
|
||||||
|
"message": "Kon de tijdzone van het huis niet bepalen. Controleer de huisconfiguratie in je Tibber-account."
|
||||||
|
},
|
||||||
|
"end_before_start": {
|
||||||
|
"message": "Eindtijd moet na de starttijd liggen."
|
||||||
|
},
|
||||||
|
"price_fetch_failed": {
|
||||||
|
"message": "Kon prijsgegevens niet ophalen bij de Tibber API. Probeer het later opnieuw."
|
||||||
|
},
|
||||||
|
"invalid_search_scope": {
|
||||||
|
"message": "Ongeldig zoekbereik. Geldige waarden zijn: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||||
|
},
|
||||||
|
"scope_conflicts_with_range": {
|
||||||
|
"message": "search_scope kan niet gecombineerd worden met expliciete bereikparameters: {params}. Gebruik search_scope OF expliciete start-/eindparameters."
|
||||||
|
},
|
||||||
|
"day_offset_requires_time": {
|
||||||
|
"message": "{offset_param} vereist dat {time_param} ingesteld is. Dagoffset wijzigt alleen de datum van een expliciete tijdparameter."
|
||||||
|
},
|
||||||
|
"min_level_exceeds_max": {
|
||||||
|
"message": "min_price_level '{min_level}' is hoger dan max_price_level '{max_level}'. Het minimumniveau moet gelijk aan of lager zijn dan het maximumniveau."
|
||||||
|
},
|
||||||
|
"power_profile_length_mismatch": {
|
||||||
|
"message": "power_profile heeft {profile_length} items maar de duur vereist {interval_count} intervallen ({duration_minutes} minuten). Het power_profile moet precies één item per 15-minuten-interval hebben."
|
||||||
|
},
|
||||||
|
"level_and_rating_filter_conflict": {
|
||||||
|
"message": "level_filter en rating_level_filter kunnen niet samen gebruikt worden. Gebruik slechts één filtertype per verzoek."
|
||||||
|
},
|
||||||
|
"insert_nulls_requires_filter": {
|
||||||
|
"message": "insert_nulls-modus '{mode}' vereist een level_filter of rating_level_filter om segmenten te definiëren. Zonder filter, gebruik insert_nulls: none."
|
||||||
|
},
|
||||||
|
"connect_segments_requires_segments_mode": {
|
||||||
|
"message": "connect_segments vereist dat insert_nulls op 'segments' staat. Stel insert_nulls: segments in om segmentverbinding te gebruiken."
|
||||||
|
},
|
||||||
|
"array_fields_requires_array_format": {
|
||||||
|
"message": "array_fields kan alleen gebruikt worden met output_format: array_of_arrays. Wijzig het uitvoerformaat of verwijder array_fields."
|
||||||
|
},
|
||||||
|
"invalid_array_fields": {
|
||||||
|
"message": "Ongeldig array_fields-sjabloon. Gebruik veldnamen tussen accolades, bijv. '{start_time}, {price_per_kwh}, {level}'."
|
||||||
|
}
|
||||||
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"get_price": {
|
"get_price": {
|
||||||
"name": "Prijsgegevens Ophalen",
|
"name": "Prijsgegevens Ophalen",
|
||||||
|
|
@ -1261,6 +1309,474 @@
|
||||||
"description": "De config-item-ID voor de Tibber-integratie."
|
"description": "De config-item-ID voor de Tibber-integratie."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_block": {
|
||||||
|
"name": "Goedkoopste blok vinden",
|
||||||
|
"description": "Vindt het goedkoopste aaneengesloten tijdvenster van een bepaalde duur. Ontworpen voor apparaatplanning: vaatwasser, wasmachine, droger enz. Retourneert het goedkoopste venster met start-/eindtijden en prijsstatistieken.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Zoekbereik",
|
||||||
|
"description": "Definieer het tijdvenster voor het zoeken."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternatieve tijdbereiksopties",
|
||||||
|
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prijsniveau-filter",
|
||||||
|
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Uitvoeropties",
|
||||||
|
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Item-ID",
|
||||||
|
"description": "De config-item-ID voor de Tibber-integratie."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Duur",
|
||||||
|
"description": "Lengte van het gewenste aaneengesloten venster. Automatisch afgerond naar het volgende kwartier. Maximum: 12 uur."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Zoekstart",
|
||||||
|
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Zoekeinde",
|
||||||
|
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Zoekstart-tijd",
|
||||||
|
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Zoekstart dagoffset",
|
||||||
|
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Zoekeinde-tijd",
|
||||||
|
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Zoekeinde dagoffset",
|
||||||
|
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Zoekstart-offset (minuten)",
|
||||||
|
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Zoekeinde-offset (minuten)",
|
||||||
|
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Huidig interval opnemen",
|
||||||
|
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Basisvaluta gebruiken",
|
||||||
|
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Zoekbereik (snelkoppeling)",
|
||||||
|
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximaal prijsniveau",
|
||||||
|
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimaal prijsniveau",
|
||||||
|
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Vergelijkingsdetails meenemen",
|
||||||
|
"description": "Verrijk het price_comparison-resultaat met extra velden: comparison_price_min, comparison_price_max en (alleen blok) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Vermogensprofiel",
|
||||||
|
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_most_expensive_block": {
|
||||||
|
"name": "Duurste blok vinden",
|
||||||
|
"description": "Vindt het duurste aaneengesloten tijdvenster van een bepaalde duur. Nuttig voor het identificeren van piekprijsperioden die vermeden moeten worden. Retourneert het duurste venster met start-/eindtijden en prijsstatistieken.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Zoekbereik",
|
||||||
|
"description": "Definieer het tijdvenster voor het zoeken."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternatieve tijdbereiksopties",
|
||||||
|
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prijsniveau-filter",
|
||||||
|
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Uitvoeropties",
|
||||||
|
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Item-ID",
|
||||||
|
"description": "De config-item-ID voor de Tibber-integratie."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Duur",
|
||||||
|
"description": "Lengte van het gewenste aaneengesloten venster. Automatisch afgerond naar het volgende kwartier. Maximum: 12 uur."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Zoekstart",
|
||||||
|
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Zoekeinde",
|
||||||
|
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Zoekstart-tijd",
|
||||||
|
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Zoekstart dagoffset",
|
||||||
|
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Zoekeinde-tijd",
|
||||||
|
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Zoekeinde dagoffset",
|
||||||
|
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Zoekstart-offset (minuten)",
|
||||||
|
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Zoekeinde-offset (minuten)",
|
||||||
|
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Huidig interval opnemen",
|
||||||
|
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Basisvaluta gebruiken",
|
||||||
|
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Zoekbereik (snelkoppeling)",
|
||||||
|
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximaal prijsniveau",
|
||||||
|
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimaal prijsniveau",
|
||||||
|
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Vergelijkingsdetails meenemen",
|
||||||
|
"description": "Verrijk het price_comparison-resultaat met extra velden: comparison_price_min, comparison_price_max en (alleen blok) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Vermogensprofiel",
|
||||||
|
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_hours": {
|
||||||
|
"name": "Goedkoopste uren vinden",
|
||||||
|
"description": "Vindt de goedkoopste intervallen voor een bepaalde totale duur, niet noodzakelijk aaneengesloten. Ontworpen voor flexibele belastingen: batterijladen, elektrisch voertuig, warmwaterboiler. Retourneert een schema van intervallen gegroepeerd in aaneengesloten segmenten.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Zoekbereik",
|
||||||
|
"description": "Definieer het tijdvenster voor het zoeken."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternatieve tijdbereiksopties",
|
||||||
|
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prijsniveau-filter",
|
||||||
|
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Uitvoeropties",
|
||||||
|
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Item-ID",
|
||||||
|
"description": "De config-item-ID voor de Tibber-integratie."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Duur",
|
||||||
|
"description": "Benodigde goedkope totale tijd. Automatisch afgerond naar het volgende kwartier. Maximum: 24 uur."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Zoekstart",
|
||||||
|
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Zoekeinde",
|
||||||
|
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Zoekstart-tijd",
|
||||||
|
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Zoekstart dagoffset",
|
||||||
|
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Zoekeinde-tijd",
|
||||||
|
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Zoekeinde dagoffset",
|
||||||
|
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Zoekstart-offset (minuten)",
|
||||||
|
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Zoekeinde-offset (minuten)",
|
||||||
|
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Huidig interval opnemen",
|
||||||
|
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
|
||||||
|
},
|
||||||
|
"min_segment_duration": {
|
||||||
|
"name": "Minimale segmentduur",
|
||||||
|
"description": "Minimale aaneengesloten looptijd. Voorkomt snel aan-/uitschakelen bij apparaten met minimale looptijden. Automatisch afgerond naar het volgende kwartier. Standaard: 15 minuten. Maximum: 4 uur."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Basisvaluta gebruiken",
|
||||||
|
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Zoekbereik (snelkoppeling)",
|
||||||
|
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximaal prijsniveau",
|
||||||
|
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimaal prijsniveau",
|
||||||
|
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Vergelijkingsdetails meenemen",
|
||||||
|
"description": "Verrijk het price_comparison-resultaat met extra velden: comparison_price_min, comparison_price_max en (alleen blok) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Vermogensprofiel",
|
||||||
|
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_most_expensive_hours": {
|
||||||
|
"name": "Duurste uren vinden",
|
||||||
|
"description": "Vindt de duurste intervallen voor een bepaalde totale duur, niet noodzakelijk aaneengesloten. Nuttig voor het identificeren van piekprijsperioden die vermeden moeten worden. Retourneert een schema van intervallen gegroepeerd in aaneengesloten segmenten.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Zoekbereik",
|
||||||
|
"description": "Definieer het tijdvenster voor het zoeken."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternatieve tijdbereiksopties",
|
||||||
|
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prijsniveau-filter",
|
||||||
|
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Uitvoeropties",
|
||||||
|
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Item-ID",
|
||||||
|
"description": "De config-item-ID voor de Tibber-integratie."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Duur",
|
||||||
|
"description": "Te vinden dure totale tijd. Automatisch afgerond naar het volgende kwartier. Maximum: 24 uur."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Zoekstart",
|
||||||
|
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Zoekeinde",
|
||||||
|
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Zoekstart-tijd",
|
||||||
|
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Zoekstart dagoffset",
|
||||||
|
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Zoekeinde-tijd",
|
||||||
|
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Zoekeinde dagoffset",
|
||||||
|
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Zoekstart-offset (minuten)",
|
||||||
|
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Zoekeinde-offset (minuten)",
|
||||||
|
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Huidig interval opnemen",
|
||||||
|
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
|
||||||
|
},
|
||||||
|
"min_segment_duration": {
|
||||||
|
"name": "Minimale segmentduur",
|
||||||
|
"description": "Minimale aaneengesloten looptijd. Voorkomt snel aan-/uitschakelen bij apparaten met minimale looptijden. Automatisch afgerond naar het volgende kwartier. Standaard: 15 minuten. Maximum: 4 uur."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Basisvaluta gebruiken",
|
||||||
|
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Zoekbereik (snelkoppeling)",
|
||||||
|
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximaal prijsniveau",
|
||||||
|
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimaal prijsniveau",
|
||||||
|
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Vergelijkingsdetails meenemen",
|
||||||
|
"description": "Verrijk het price_comparison-resultaat met extra velden: comparison_price_min, comparison_price_max en (alleen blok) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Vermogensprofiel",
|
||||||
|
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_schedule": {
|
||||||
|
"name": "Goedkoopste schema vinden",
|
||||||
|
"description": "Plant meerdere apparaten optimaal zonder tijdoverlap. Elke taak krijgt het goedkoopste beschikbare aaneengesloten tijdvenster.",
|
||||||
|
"sections": {
|
||||||
|
"scheduling_options": {
|
||||||
|
"name": "Planningsopties",
|
||||||
|
"description": "Configureer taken en tussenpozen."
|
||||||
|
},
|
||||||
|
"search_range": {
|
||||||
|
"name": "Zoekbereik",
|
||||||
|
"description": "Definieer het tijdvenster voor het zoeken."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternatieve tijdbereiksopties",
|
||||||
|
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prijsniveau-filter",
|
||||||
|
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Uitvoeropties",
|
||||||
|
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Item-ID",
|
||||||
|
"description": "De config-item-ID voor de Tibber-integratie."
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"name": "Taken",
|
||||||
|
"description": "Lijst van in te plannen taken. Elke taak heeft name (tekst) en duration (hh:mm:ss). Optioneel power_profile (watt per 15-min-interval). Maximaal 4 taken."
|
||||||
|
},
|
||||||
|
"gap_minutes": {
|
||||||
|
"name": "Tussenpose tussen taken (minuten)",
|
||||||
|
"description": "Minimale tussenpose in minuten tussen opeenvolgende ingeplande taken. Afgerond omhoog tot 15 minuten. Standaard: 0 (geen tussenpose)."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Zoekbereik (snelkoppeling)",
|
||||||
|
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Zoekstart",
|
||||||
|
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Zoekeinde",
|
||||||
|
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Zoekstart-tijd",
|
||||||
|
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Zoekstart dagoffset",
|
||||||
|
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Zoekeinde-tijd",
|
||||||
|
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Zoekeinde dagoffset",
|
||||||
|
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Zoekstart-offset (minuten)",
|
||||||
|
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Zoekeinde-offset (minuten)",
|
||||||
|
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Huidig interval opnemen",
|
||||||
|
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximaal prijsniveau",
|
||||||
|
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimaal prijsniveau",
|
||||||
|
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Basisvaluta gebruiken",
|
||||||
|
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|
@ -1361,6 +1877,15 @@
|
||||||
"median": "Mediaan",
|
"median": "Mediaan",
|
||||||
"mean": "Rekenkundig Gemiddelde"
|
"mean": "Rekenkundig Gemiddelde"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"options": {
|
||||||
|
"today": "Vandaag",
|
||||||
|
"tomorrow": "Morgen",
|
||||||
|
"remaining_today": "Rest van vandaag",
|
||||||
|
"next_24h": "Komende 24 uur",
|
||||||
|
"next_48h": "Komende 48 uur"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Tibber Prijsinformatie & Beoordelingen"
|
"title": "Tibber Prijsinformatie & Beoordelingen"
|
||||||
|
|
|
||||||
|
|
@ -59,15 +59,7 @@
|
||||||
"home_already_configured": "Detta hem är redan konfigurerat i en annan post. Varje hem kan bara konfigureras en gång.",
|
"home_already_configured": "Detta hem är redan konfigurerat i en annan post. Varje hem kan bara konfigureras en gång.",
|
||||||
"no_active_subscription": "Detta hem har inget aktivt Tibber-avtal. Endast hem med aktiva elavtal kan läggas till i Home Assistant.",
|
"no_active_subscription": "Detta hem har inget aktivt Tibber-avtal. Endast hem med aktiva elavtal kan läggas till i Home Assistant.",
|
||||||
"subscription_expired": "Tibber-avtalet för detta hem har löpt ut. Endast hem med aktiva eller framtida elavtal kan läggas till i Home Assistant.",
|
"subscription_expired": "Tibber-avtalet för detta hem har löpt ut. Endast hem med aktiva eller framtida elavtal kan läggas till i Home Assistant.",
|
||||||
"future_subscription_warning": "Obs: Detta hems Tibber-avtal har inte börjat ännu. Funktionaliteten kan vara begränsad tills avtalet blir aktivt.",
|
"future_subscription_warning": "Obs: Detta hems Tibber-avtal har inte börjat ännu. Funktionaliteten kan vara begränsad tills avtalet blir aktivt."
|
||||||
"invalid_yaml_syntax": "Ogiltig YAML-syntax. Kontrollera indragning, kolon och specialtecken.",
|
|
||||||
"invalid_yaml_structure": "YAML måste vara en ordbok/objekt (nyckel: värde-par), inte en lista eller vanlig text.",
|
|
||||||
"service_call_failed": "Tjänsteanrop validering misslyckades: {error_detail}",
|
|
||||||
"missing_entry_id": "Post-ID krävs men angavs inte.",
|
|
||||||
"invalid_entry_id": "Ogiltigt post-ID eller post hittades inte.",
|
|
||||||
"missing_home_id": "Hem-ID saknas från konfigurationsposten.",
|
|
||||||
"user_data_not_available": "Användardata är inte tillgänglig. Uppdatera användardata först.",
|
|
||||||
"price_fetch_failed": "Misslyckades med att hämta prisdata. Kontrollera loggarna för detaljer."
|
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Alla tillgängliga Tibber-hem är redan konfigurerade. Varje hem kan bara konfigureras en gång.",
|
"already_configured": "Alla tillgängliga Tibber-hem är redan konfigurerade. Varje hem kan bara konfigureras en gång.",
|
||||||
|
|
@ -1055,6 +1047,62 @@
|
||||||
"description": "Denna uppdatering innehåller ändringar som tillämpades automatiskt.\n\n**Omdöpta entiteter ({count})**\n\nFöljande entity-nycklar döptes om automatiskt. Dina befintliga entity-ID:n och automatiseringar förblir intakta:\n\n{entity_list}\n\n**Ändrade varaktighetssensorvärden**\n\nAlla varaktighetssensorer (återstående tid, startar om, periodvaraktighet, trendändrings-nedräkning) rapporterar nu sitt tillståndsvärde i **minuter** istället för timmar. Visningsenheten i dashboards förblir timmar som standard.\n\nOm du har automatiseringar med numeriska jämförelser på dessa sensorer, uppdatera dina tröskelvärden:\n- Gammalt: `state < 0.25` (15 minuter som timmar)\n- Nytt: `state < 15` (15 minuter)\n\nStäng detta meddelande efter att du har granskat dina automatiseringar."
|
"description": "Denna uppdatering innehåller ändringar som tillämpades automatiskt.\n\n**Omdöpta entiteter ({count})**\n\nFöljande entity-nycklar döptes om automatiskt. Dina befintliga entity-ID:n och automatiseringar förblir intakta:\n\n{entity_list}\n\n**Ändrade varaktighetssensorvärden**\n\nAlla varaktighetssensorer (återstående tid, startar om, periodvaraktighet, trendändrings-nedräkning) rapporterar nu sitt tillståndsvärde i **minuter** istället för timmar. Visningsenheten i dashboards förblir timmar som standard.\n\nOm du har automatiseringar med numeriska jämförelser på dessa sensorer, uppdatera dina tröskelvärden:\n- Gammalt: `state < 0.25` (15 minuter som timmar)\n- Nytt: `state < 15` (15 minuter)\n\nStäng detta meddelande efter att du har granskat dina automatiseringar."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"no_entries_found": {
|
||||||
|
"message": "Inga Tibber Prices integrationsposter hittades. Konfigurera integrationen först."
|
||||||
|
},
|
||||||
|
"multiple_entries_no_entry_id": {
|
||||||
|
"message": "Flera Tibber Prices poster hittades. Ange 'entry_id' för att välja vilken post som ska användas."
|
||||||
|
},
|
||||||
|
"invalid_entry_id": {
|
||||||
|
"message": "Ogiltig eller otillgänglig konfigurationspost. Kontrollera post-ID:t och se till att integrationen är laddad."
|
||||||
|
},
|
||||||
|
"missing_home_id": {
|
||||||
|
"message": "Hem-ID hittades inte i konfigurationsposten. Konfigurera om integrationen."
|
||||||
|
},
|
||||||
|
"user_data_not_available": {
|
||||||
|
"message": "Användardata är inte tillgänglig ännu. Vänta tills den första datauppdateringen är klar."
|
||||||
|
},
|
||||||
|
"timezone_not_found": {
|
||||||
|
"message": "Kunde inte fastställa hemmets tidszon. Kontrollera hemkonfigurationen i ditt Tibber-konto."
|
||||||
|
},
|
||||||
|
"end_before_start": {
|
||||||
|
"message": "Sluttid måste vara efter starttid."
|
||||||
|
},
|
||||||
|
"price_fetch_failed": {
|
||||||
|
"message": "Kunde inte hämta prisdata från Tibber API. Försök igen senare."
|
||||||
|
},
|
||||||
|
"invalid_search_scope": {
|
||||||
|
"message": "Ogiltigt sökområde. Giltiga värden är: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||||
|
},
|
||||||
|
"scope_conflicts_with_range": {
|
||||||
|
"message": "search_scope kan inte kombineras med explicita områdesparametrar: {params}. Använd antingen search_scope ELLER explicita start-/slutparametrar."
|
||||||
|
},
|
||||||
|
"day_offset_requires_time": {
|
||||||
|
"message": "{offset_param} kräver att {time_param} är satt. Dagsförskjutning ändrar bara datumet för en explicit tidsparameter."
|
||||||
|
},
|
||||||
|
"min_level_exceeds_max": {
|
||||||
|
"message": "min_price_level '{min_level}' är högre än max_price_level '{max_level}'. Miniminivån måste vara lika med eller lägre än maximinivån."
|
||||||
|
},
|
||||||
|
"power_profile_length_mismatch": {
|
||||||
|
"message": "power_profile har {profile_length} poster men varaktigheten kräver {interval_count} intervaller ({duration_minutes} minuter). power_profile måste ha exakt en post per 15-minutersintervall."
|
||||||
|
},
|
||||||
|
"level_and_rating_filter_conflict": {
|
||||||
|
"message": "level_filter och rating_level_filter kan inte användas tillsammans. Använd bara en filtertyp per begäran."
|
||||||
|
},
|
||||||
|
"insert_nulls_requires_filter": {
|
||||||
|
"message": "insert_nulls-läge '{mode}' kräver ett level_filter eller rating_level_filter för att definiera segment. Utan filter, använd insert_nulls: none."
|
||||||
|
},
|
||||||
|
"connect_segments_requires_segments_mode": {
|
||||||
|
"message": "connect_segments kräver att insert_nulls är satt till 'segments'. Ställ in insert_nulls: segments för att använda segmentanslutning."
|
||||||
|
},
|
||||||
|
"array_fields_requires_array_format": {
|
||||||
|
"message": "array_fields kan bara användas med output_format: array_of_arrays. Ändra utdataformatet eller ta bort array_fields."
|
||||||
|
},
|
||||||
|
"invalid_array_fields": {
|
||||||
|
"message": "Ogiltig array_fields-mall. Använd fältnamn inom klammerparenteser, t.ex. '{start_time}, {price_per_kwh}, {level}'."
|
||||||
|
}
|
||||||
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"get_price": {
|
"get_price": {
|
||||||
"name": "Hämta prisdata",
|
"name": "Hämta prisdata",
|
||||||
|
|
@ -1261,6 +1309,474 @@
|
||||||
"description": "Config entry-ID för Tibber-integrationen."
|
"description": "Config entry-ID för Tibber-integrationen."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_block": {
|
||||||
|
"name": "Hitta billigaste blocket",
|
||||||
|
"description": "Hittar det billigaste sammanhängande tidsfönstret med en given varaktighet. Designat för apparatschemaläggning: diskmaskin, tvättmaskin, torktumlare osv. Returnerar det billigaste fönstret med start-/sluttider och prisstatistik.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Soekomraade",
|
||||||
|
"description": "Definiera tidsfoenstret att soeka inom."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternativa tidsinstaellningar",
|
||||||
|
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prisnivaaefilter",
|
||||||
|
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Utdataalternativ",
|
||||||
|
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Entry-ID",
|
||||||
|
"description": "Config entry-ID för Tibber-integrationen."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Varaktighet",
|
||||||
|
"description": "Längd på det önskade sammanhängande fönstret. Avrundas automatiskt uppåt till närmaste kvart. Maximum: 12 timmar."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Sökstart",
|
||||||
|
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Sökslut",
|
||||||
|
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Sökstart-klockslag",
|
||||||
|
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Sökstart dagförskjutning",
|
||||||
|
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Sökslut-klockslag",
|
||||||
|
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Sökslut dagförskjutning",
|
||||||
|
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Sökstart-förskjutning (minuter)",
|
||||||
|
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Sökslut-förskjutning (minuter)",
|
||||||
|
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Inkludera aktuellt intervall",
|
||||||
|
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Använd basvaluta",
|
||||||
|
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Soekumfaang (genvaeg)",
|
||||||
|
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximal prisnivaae",
|
||||||
|
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimal prisnivaae",
|
||||||
|
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Inkludera jaemfoerelsdetaljer",
|
||||||
|
"description": "Berika price_comparison-resultatet med ytterligare faelt: comparison_price_min, comparison_price_max och (endast block) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Effektprofil",
|
||||||
|
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_most_expensive_block": {
|
||||||
|
"name": "Hitta dyraste blocket",
|
||||||
|
"description": "Hittar det dyraste sammanhängande tidsfönstret med en given varaktighet. Användbart för att identifiera topprisperioder som bör undvikas. Returnerar det dyraste fönstret med start-/sluttider och prisstatistik.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Soekomraade",
|
||||||
|
"description": "Definiera tidsfoenstret att soeka inom."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternativa tidsinstaellningar",
|
||||||
|
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prisnivaaefilter",
|
||||||
|
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Utdataalternativ",
|
||||||
|
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Entry-ID",
|
||||||
|
"description": "Config entry-ID för Tibber-integrationen."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Varaktighet",
|
||||||
|
"description": "Längd på det önskade sammanhängande fönstret. Avrundas automatiskt uppåt till närmaste kvart. Maximum: 12 timmar."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Sökstart",
|
||||||
|
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Sökslut",
|
||||||
|
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Sökstart-klockslag",
|
||||||
|
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Sökstart dagförskjutning",
|
||||||
|
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Sökslut-klockslag",
|
||||||
|
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Sökslut dagförskjutning",
|
||||||
|
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Sökstart-förskjutning (minuter)",
|
||||||
|
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Sökslut-förskjutning (minuter)",
|
||||||
|
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Inkludera aktuellt intervall",
|
||||||
|
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Använd basvaluta",
|
||||||
|
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Soekumfaang (genvaeg)",
|
||||||
|
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximal prisnivaae",
|
||||||
|
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimal prisnivaae",
|
||||||
|
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Inkludera jaemfoerelsdetaljer",
|
||||||
|
"description": "Berika price_comparison-resultatet med ytterligare faelt: comparison_price_min, comparison_price_max och (endast block) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Effektprofil",
|
||||||
|
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_hours": {
|
||||||
|
"name": "Hitta billigaste timmarna",
|
||||||
|
"description": "Hittar de billigaste intervallen för en given total varaktighet, inte nödvändigtvis sammanhängande. Designat för flexibla laster: batteriladdning, elbil, varmvattenberedare. Returnerar ett schema av intervaller grupperade i sammanhängande segment.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Soekomraade",
|
||||||
|
"description": "Definiera tidsfoenstret att soeka inom."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternativa tidsinstaellningar",
|
||||||
|
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prisnivaaefilter",
|
||||||
|
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Utdataalternativ",
|
||||||
|
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Entry-ID",
|
||||||
|
"description": "Config entry-ID för Tibber-integrationen."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Varaktighet",
|
||||||
|
"description": "Behövd billig total tid. Avrundas automatiskt uppåt till närmaste kvart. Maximum: 24 timmar."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Sökstart",
|
||||||
|
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Sökslut",
|
||||||
|
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Sökstart-klockslag",
|
||||||
|
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Sökstart dagförskjutning",
|
||||||
|
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Sökslut-klockslag",
|
||||||
|
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Sökslut dagförskjutning",
|
||||||
|
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Sökstart-förskjutning (minuter)",
|
||||||
|
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Sökslut-förskjutning (minuter)",
|
||||||
|
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Inkludera aktuellt intervall",
|
||||||
|
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
|
||||||
|
},
|
||||||
|
"min_segment_duration": {
|
||||||
|
"name": "Minsta segmentvaraktighet",
|
||||||
|
"description": "Minsta sammanhängande körtid. Förhindrar snabb av/på-cykling för enheter med minsta körtider. Avrundas automatiskt uppåt till närmaste kvart. Standard: 15 minuter. Maximum: 4 timmar."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Använd basvaluta",
|
||||||
|
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Soekumfaang (genvaeg)",
|
||||||
|
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximal prisnivaae",
|
||||||
|
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimal prisnivaae",
|
||||||
|
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Inkludera jaemfoerelsdetaljer",
|
||||||
|
"description": "Berika price_comparison-resultatet med ytterligare faelt: comparison_price_min, comparison_price_max och (endast block) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Effektprofil",
|
||||||
|
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_most_expensive_hours": {
|
||||||
|
"name": "Hitta dyraste timmarna",
|
||||||
|
"description": "Hittar de dyraste intervallen för en given total varaktighet, inte nödvändigtvis sammanhängande. Användbart för att identifiera topprisperioder som bör undvikas. Returnerar ett schema av intervaller grupperade i sammanhängande segment.",
|
||||||
|
"sections": {
|
||||||
|
"search_range": {
|
||||||
|
"name": "Soekomraade",
|
||||||
|
"description": "Definiera tidsfoenstret att soeka inom."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternativa tidsinstaellningar",
|
||||||
|
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prisnivaaefilter",
|
||||||
|
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Utdataalternativ",
|
||||||
|
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Entry-ID",
|
||||||
|
"description": "Config entry-ID för Tibber-integrationen."
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "Varaktighet",
|
||||||
|
"description": "Dyr total tid att hitta. Avrundas automatiskt uppåt till närmaste kvart. Maximum: 24 timmar."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Sökstart",
|
||||||
|
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Sökslut",
|
||||||
|
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Sökstart-klockslag",
|
||||||
|
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Sökstart dagförskjutning",
|
||||||
|
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Sökslut-klockslag",
|
||||||
|
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Sökslut dagförskjutning",
|
||||||
|
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Sökstart-förskjutning (minuter)",
|
||||||
|
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Sökslut-förskjutning (minuter)",
|
||||||
|
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Inkludera aktuellt intervall",
|
||||||
|
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
|
||||||
|
},
|
||||||
|
"min_segment_duration": {
|
||||||
|
"name": "Minsta segmentvaraktighet",
|
||||||
|
"description": "Minsta sammanhängande körtid. Förhindrar snabb av/på-cykling för enheter med minsta körtider. Avrundas automatiskt uppåt till närmaste kvart. Standard: 15 minuter. Maximum: 4 timmar."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Använd basvaluta",
|
||||||
|
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Soekumfaang (genvaeg)",
|
||||||
|
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximal prisnivaae",
|
||||||
|
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimal prisnivaae",
|
||||||
|
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
|
||||||
|
},
|
||||||
|
"include_comparison_details": {
|
||||||
|
"name": "Inkludera jaemfoerelsdetaljer",
|
||||||
|
"description": "Berika price_comparison-resultatet med ytterligare faelt: comparison_price_min, comparison_price_max och (endast block) comparison_window_end."
|
||||||
|
},
|
||||||
|
"power_profile": {
|
||||||
|
"name": "Effektprofil",
|
||||||
|
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"find_cheapest_schedule": {
|
||||||
|
"name": "Hitta billigaste schema",
|
||||||
|
"description": "Schemalaggar flera apparater optimalt utan tidsoeverlapp. Varje uppgift tilldelas det billigaste tillgaengliga sammanhangande tidsfoenster.",
|
||||||
|
"sections": {
|
||||||
|
"scheduling_options": {
|
||||||
|
"name": "Schemalagningsalternativ",
|
||||||
|
"description": "Konfigurera uppgifter och pauser mellan dem."
|
||||||
|
},
|
||||||
|
"search_range": {
|
||||||
|
"name": "Soekomraade",
|
||||||
|
"description": "Definiera tidsfoenstret att soeka inom."
|
||||||
|
},
|
||||||
|
"time_alternatives": {
|
||||||
|
"name": "Alternativa tidsinstaellningar",
|
||||||
|
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
|
||||||
|
},
|
||||||
|
"price_filter": {
|
||||||
|
"name": "Prisnivaaefilter",
|
||||||
|
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"name": "Utdataalternativ",
|
||||||
|
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"entry_id": {
|
||||||
|
"name": "Entry-ID",
|
||||||
|
"description": "Config entry-ID för Tibber-integrationen."
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"name": "Uppgifter",
|
||||||
|
"description": "Lista med uppgifter att schemalagga. Varje uppgift kraever name (text) och duration (hh:mm:ss). Valfritt power_profile (watt per 15-min-intervall). Maximalt 4 uppgifter."
|
||||||
|
},
|
||||||
|
"gap_minutes": {
|
||||||
|
"name": "Paus mellan uppgifter (minuter)",
|
||||||
|
"description": "Minsta paus i minuter mellan paa varandra foeoljande schemalagda uppgifter. Avrundas uppaat till 15 minuter. Standard: 0 (ingen paus)."
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"name": "Soekumfaang (genvaeg)",
|
||||||
|
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
|
||||||
|
},
|
||||||
|
"search_start": {
|
||||||
|
"name": "Sökstart",
|
||||||
|
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
|
||||||
|
},
|
||||||
|
"search_end": {
|
||||||
|
"name": "Sökslut",
|
||||||
|
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
|
||||||
|
},
|
||||||
|
"search_start_time": {
|
||||||
|
"name": "Sökstart-klockslag",
|
||||||
|
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
|
||||||
|
},
|
||||||
|
"search_start_day_offset": {
|
||||||
|
"name": "Sökstart dagförskjutning",
|
||||||
|
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
|
||||||
|
},
|
||||||
|
"search_end_time": {
|
||||||
|
"name": "Sökslut-klockslag",
|
||||||
|
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
|
||||||
|
},
|
||||||
|
"search_end_day_offset": {
|
||||||
|
"name": "Sökslut dagförskjutning",
|
||||||
|
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
|
||||||
|
},
|
||||||
|
"search_start_offset_minutes": {
|
||||||
|
"name": "Sökstart-förskjutning (minuter)",
|
||||||
|
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
|
||||||
|
},
|
||||||
|
"search_end_offset_minutes": {
|
||||||
|
"name": "Sökslut-förskjutning (minuter)",
|
||||||
|
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
|
||||||
|
},
|
||||||
|
"include_current_interval": {
|
||||||
|
"name": "Inkludera aktuellt intervall",
|
||||||
|
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
|
||||||
|
},
|
||||||
|
"max_price_level": {
|
||||||
|
"name": "Maximal prisnivaae",
|
||||||
|
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
|
||||||
|
},
|
||||||
|
"min_price_level": {
|
||||||
|
"name": "Minimal prisnivaae",
|
||||||
|
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
|
||||||
|
},
|
||||||
|
"use_base_unit": {
|
||||||
|
"name": "Använd basvaluta",
|
||||||
|
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|
@ -1361,6 +1877,15 @@
|
||||||
"median": "Median",
|
"median": "Median",
|
||||||
"mean": "Aritmetiskt medelvärde"
|
"mean": "Aritmetiskt medelvärde"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"search_scope": {
|
||||||
|
"options": {
|
||||||
|
"today": "Idag",
|
||||||
|
"tomorrow": "Imorgon",
|
||||||
|
"remaining_today": "Aaterstoden av idag",
|
||||||
|
"next_24h": "Naesta 24 timmar",
|
||||||
|
"next_48h": "Naesta 48 timmar"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Tibber Prisinformation & Betyg"
|
"title": "Tibber Prisinformation & Betyg"
|
||||||
|
|
|
||||||
342
custom_components/tibber_prices/utils/price_window.py
Normal file
342
custom_components/tibber_prices/utils/price_window.py
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
"""
|
||||||
|
Pure algorithms for finding cheapest price windows.
|
||||||
|
|
||||||
|
Two independent algorithms:
|
||||||
|
1. find_cheapest_contiguous_window — Sliding window for appliance scheduling
|
||||||
|
2. find_cheapest_n_intervals — Cheapest N picks for flexible-load scheduling
|
||||||
|
|
||||||
|
These are stateless pure functions with no Home Assistant dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import statistics
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def find_cheapest_contiguous_window(
|
||||||
|
intervals: list[dict[str, Any]],
|
||||||
|
duration_intervals: int,
|
||||||
|
*,
|
||||||
|
reverse: bool = False,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Find the cheapest (or most expensive) contiguous window of exactly N intervals.
|
||||||
|
|
||||||
|
Uses a sliding window algorithm (O(n)) to find the window with the
|
||||||
|
lowest (or highest) average price.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
intervals: Sorted list of price interval dicts with 'startsAt' and 'total' keys.
|
||||||
|
Must be pre-sorted by startsAt in ascending order.
|
||||||
|
duration_intervals: Number of consecutive intervals required.
|
||||||
|
reverse: If True, find the most expensive window instead of cheapest.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with window details (start, end, intervals, statistics),
|
||||||
|
or None if not enough intervals available.
|
||||||
|
|
||||||
|
"""
|
||||||
|
n = len(intervals)
|
||||||
|
if n == 0 or duration_intervals <= 0 or n < duration_intervals:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate initial window sum
|
||||||
|
window_sum = sum(intervals[i]["total"] for i in range(duration_intervals))
|
||||||
|
best_sum = window_sum
|
||||||
|
best_start = 0
|
||||||
|
|
||||||
|
# Slide the window
|
||||||
|
for i in range(1, n - duration_intervals + 1):
|
||||||
|
window_sum += intervals[i + duration_intervals - 1]["total"]
|
||||||
|
window_sum -= intervals[i - 1]["total"]
|
||||||
|
if (window_sum > best_sum) if reverse else (window_sum < best_sum):
|
||||||
|
best_sum = window_sum
|
||||||
|
best_start = i
|
||||||
|
|
||||||
|
best_intervals = intervals[best_start : best_start + duration_intervals]
|
||||||
|
return {
|
||||||
|
"start": best_intervals[0]["startsAt"],
|
||||||
|
"end_interval_start": best_intervals[-1]["startsAt"],
|
||||||
|
"intervals": best_intervals,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_cheapest_n_intervals(
|
||||||
|
intervals: list[dict[str, Any]],
|
||||||
|
count: int,
|
||||||
|
min_segment_intervals: int = 1,
|
||||||
|
*,
|
||||||
|
reverse: bool = False,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Find the cheapest (or most expensive) N intervals, not necessarily contiguous.
|
||||||
|
|
||||||
|
Picks the cheapest (or most expensive) intervals by price, then groups them
|
||||||
|
into contiguous segments. If min_segment_intervals > 1, short segments are
|
||||||
|
discarded and replaced with next-cheapest/most-expensive available intervals
|
||||||
|
until all segments meet the minimum length.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
intervals: Sorted list of price interval dicts with 'startsAt' and 'total' keys.
|
||||||
|
Must be pre-sorted by startsAt in ascending order.
|
||||||
|
count: Number of intervals to select.
|
||||||
|
min_segment_intervals: Minimum contiguous length for each segment.
|
||||||
|
Default 1 means no constraint.
|
||||||
|
reverse: If True, find the most expensive intervals instead of cheapest.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with schedule details (segments, intervals, statistics),
|
||||||
|
or None if not enough intervals available.
|
||||||
|
|
||||||
|
"""
|
||||||
|
n = len(intervals)
|
||||||
|
if n == 0 or count <= 0 or n < count:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if min_segment_intervals <= 1:
|
||||||
|
# Simple case: pick cheapest/most expensive N, then sort chronologically
|
||||||
|
indexed = [(i, iv) for i, iv in enumerate(intervals)]
|
||||||
|
indexed.sort(key=lambda x: x[1]["total"], reverse=reverse)
|
||||||
|
selected_indices = sorted(idx for idx, _ in indexed[:count])
|
||||||
|
selected = [intervals[i] for i in selected_indices]
|
||||||
|
segments = group_intervals_into_segments(selected)
|
||||||
|
return {
|
||||||
|
"intervals": selected,
|
||||||
|
"segments": segments,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Complex case: enforce minimum segment length
|
||||||
|
return _find_with_min_segment(intervals, count, min_segment_intervals, reverse=reverse)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_with_min_segment(
|
||||||
|
intervals: list[dict[str, Any]],
|
||||||
|
count: int,
|
||||||
|
min_segment: int,
|
||||||
|
*,
|
||||||
|
reverse: bool = False,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Find cheapest/most expensive N intervals with minimum segment length constraint.
|
||||||
|
|
||||||
|
Iteratively picks intervals, discards segments that are too
|
||||||
|
short, and replaces them with next-best alternatives.
|
||||||
|
|
||||||
|
Converges in at most `count` iterations (worst case: every replacement
|
||||||
|
creates a new short segment that gets discarded).
|
||||||
|
"""
|
||||||
|
n = len(intervals)
|
||||||
|
|
||||||
|
# Build index lookup: interval original index → position
|
||||||
|
# Price-sorted indices for picking cheapest/most expensive available
|
||||||
|
price_order = sorted(range(n), key=lambda i: intervals[i]["total"], reverse=reverse)
|
||||||
|
|
||||||
|
selected: set[int] = set()
|
||||||
|
excluded: set[int] = set()
|
||||||
|
|
||||||
|
# Initial pick: cheapest 'count' intervals
|
||||||
|
picked = 0
|
||||||
|
for idx in price_order:
|
||||||
|
if picked >= count:
|
||||||
|
break
|
||||||
|
if idx not in excluded:
|
||||||
|
selected.add(idx)
|
||||||
|
picked += 1
|
||||||
|
|
||||||
|
if len(selected) < count:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Iterative refinement: discard short segments, replace with next-cheapest
|
||||||
|
max_iterations = count + 1 # Safety bound
|
||||||
|
for _ in range(max_iterations):
|
||||||
|
sorted_selected = sorted(selected)
|
||||||
|
segments = _group_indices_into_segments(sorted_selected)
|
||||||
|
|
||||||
|
short_segments = [seg for seg in segments if len(seg) < min_segment]
|
||||||
|
if not short_segments:
|
||||||
|
break # All segments meet minimum length
|
||||||
|
|
||||||
|
# Exclude all indices in short segments
|
||||||
|
for seg in short_segments:
|
||||||
|
for idx in seg:
|
||||||
|
selected.discard(idx)
|
||||||
|
excluded.add(idx)
|
||||||
|
|
||||||
|
# Refill from price order
|
||||||
|
needed = count - len(selected)
|
||||||
|
for idx in price_order:
|
||||||
|
if needed <= 0:
|
||||||
|
break
|
||||||
|
if idx not in selected and idx not in excluded:
|
||||||
|
selected.add(idx)
|
||||||
|
needed -= 1
|
||||||
|
|
||||||
|
if len(selected) < count:
|
||||||
|
# Not enough intervals available after exclusions
|
||||||
|
# Return best effort with what we have
|
||||||
|
break
|
||||||
|
|
||||||
|
sorted_selected = sorted(selected)
|
||||||
|
result_intervals = [intervals[i] for i in sorted_selected]
|
||||||
|
segments = group_intervals_into_segments(result_intervals)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"intervals": result_intervals,
|
||||||
|
"segments": segments,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _group_indices_into_segments(indices: list[int]) -> list[list[int]]:
|
||||||
|
"""Group sorted integer indices into contiguous runs."""
|
||||||
|
if not indices:
|
||||||
|
return []
|
||||||
|
|
||||||
|
segments: list[list[int]] = [[indices[0]]]
|
||||||
|
for i in range(1, len(indices)):
|
||||||
|
if indices[i] == indices[i - 1] + 1:
|
||||||
|
segments[-1].append(indices[i])
|
||||||
|
else:
|
||||||
|
segments.append([indices[i]])
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def group_intervals_into_segments(
|
||||||
|
intervals: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Group chronologically sorted intervals into contiguous segments.
|
||||||
|
|
||||||
|
Two intervals are contiguous if the second starts exactly 15 minutes
|
||||||
|
after the first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
intervals: Chronologically sorted interval dicts with 'startsAt' key.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of segment dicts, each containing:
|
||||||
|
- start: ISO timestamp of first interval
|
||||||
|
- end_interval_start: ISO timestamp of last interval in segment
|
||||||
|
- duration_minutes: Total segment duration
|
||||||
|
- interval_count: Number of intervals in segment
|
||||||
|
- intervals: The interval dicts in this segment
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not intervals:
|
||||||
|
return []
|
||||||
|
|
||||||
|
segments: list[dict[str, Any]] = []
|
||||||
|
current_segment: list[dict[str, Any]] = [intervals[0]]
|
||||||
|
|
||||||
|
for i in range(1, len(intervals)):
|
||||||
|
prev_start = _parse_timestamp(intervals[i - 1]["startsAt"])
|
||||||
|
curr_start = _parse_timestamp(intervals[i]["startsAt"])
|
||||||
|
|
||||||
|
if curr_start - prev_start == timedelta(minutes=15):
|
||||||
|
current_segment.append(intervals[i])
|
||||||
|
else:
|
||||||
|
segments.append(_build_segment(current_segment))
|
||||||
|
current_segment = [intervals[i]]
|
||||||
|
|
||||||
|
segments.append(_build_segment(current_segment))
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def _build_segment(intervals: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
"""Build a segment dict from a list of contiguous intervals."""
|
||||||
|
return {
|
||||||
|
"start": intervals[0]["startsAt"],
|
||||||
|
"end_interval_start": intervals[-1]["startsAt"],
|
||||||
|
"duration_minutes": len(intervals) * 15,
|
||||||
|
"interval_count": len(intervals),
|
||||||
|
"intervals": intervals,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_window_statistics(
|
||||||
|
intervals: list[dict[str, Any]],
|
||||||
|
unit_factor: int = 1,
|
||||||
|
round_decimals: int = 4,
|
||||||
|
interval_minutes: int = 15,
|
||||||
|
power_profile: list[int] | None = None,
|
||||||
|
) -> dict[str, float | None]:
|
||||||
|
"""
|
||||||
|
Calculate price statistics for a list of intervals.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
intervals: List of interval dicts with 'total' key (base currency).
|
||||||
|
unit_factor: Multiplication factor for display (100 for subunit, 1 for base).
|
||||||
|
round_decimals: Number of decimal places for rounding.
|
||||||
|
interval_minutes: Duration of each interval in minutes (for cost calculation).
|
||||||
|
power_profile: Optional list of power values in watts, one per interval.
|
||||||
|
If shorter than the interval list, the last value is repeated.
|
||||||
|
When provided, estimated_total_cost reflects variable power draw instead
|
||||||
|
of a constant 1 kW load, and estimated_load_kwh is added to the result.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with price_mean, price_median, price_min, price_max, price_spread,
|
||||||
|
and estimated_total_cost (total cost for the given or constant 1 kW load).
|
||||||
|
When power_profile is provided, also includes estimated_load_kwh.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not intervals:
|
||||||
|
result: dict[str, float | None] = {
|
||||||
|
"price_mean": None,
|
||||||
|
"price_median": None,
|
||||||
|
"price_min": None,
|
||||||
|
"price_max": None,
|
||||||
|
"price_spread": None,
|
||||||
|
"estimated_total_cost": None,
|
||||||
|
}
|
||||||
|
if power_profile is not None:
|
||||||
|
result["estimated_load_kwh"] = None
|
||||||
|
return result
|
||||||
|
|
||||||
|
prices = [iv["total"] * unit_factor for iv in intervals]
|
||||||
|
mean = round(statistics.mean(prices), round_decimals)
|
||||||
|
median = round(statistics.median(prices), round_decimals)
|
||||||
|
price_min = round(min(prices), round_decimals)
|
||||||
|
price_max = round(max(prices), round_decimals)
|
||||||
|
spread = round(price_max - price_min, round_decimals)
|
||||||
|
|
||||||
|
hours_per_interval = interval_minutes / 60
|
||||||
|
|
||||||
|
if power_profile is not None:
|
||||||
|
# Extend profile to cover all intervals by repeating the last value
|
||||||
|
last_watts = power_profile[-1] if power_profile else 1000
|
||||||
|
profile = list(power_profile) + [last_watts] * max(0, len(intervals) - len(power_profile))
|
||||||
|
load_kwh_per_interval = [w / 1000 * hours_per_interval for w in profile[: len(intervals)]]
|
||||||
|
estimated_cost = round(
|
||||||
|
sum(p * kwh for p, kwh in zip(prices, load_kwh_per_interval, strict=False)), round_decimals
|
||||||
|
)
|
||||||
|
estimated_load_kwh = round(sum(load_kwh_per_interval), round_decimals)
|
||||||
|
return {
|
||||||
|
"price_mean": mean,
|
||||||
|
"price_median": median,
|
||||||
|
"price_min": price_min,
|
||||||
|
"price_max": price_max,
|
||||||
|
"price_spread": spread,
|
||||||
|
"estimated_total_cost": estimated_cost,
|
||||||
|
"estimated_load_kwh": estimated_load_kwh,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Estimated cost for running a 1 kW constant load during all intervals
|
||||||
|
# Each interval covers interval_minutes/60 hours, price is per kWh
|
||||||
|
estimated_cost = round(sum(p * hours_per_interval for p in prices), round_decimals)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"price_mean": mean,
|
||||||
|
"price_median": median,
|
||||||
|
"price_min": price_min,
|
||||||
|
"price_max": price_max,
|
||||||
|
"price_spread": spread,
|
||||||
|
"estimated_total_cost": estimated_cost,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(ts: str | datetime) -> datetime:
|
||||||
|
"""Parse an ISO timestamp string or pass through a datetime object."""
|
||||||
|
if isinstance(ts, datetime):
|
||||||
|
return ts
|
||||||
|
return datetime.fromisoformat(ts)
|
||||||
258
tests/services/test_search_range.py
Normal file
258
tests/services/test_search_range.py
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
"""
|
||||||
|
Tests for resolve_search_range helper and negative offset support.
|
||||||
|
|
||||||
|
Verifies that services can search into the past using:
|
||||||
|
- Negative search_start_day_offset / search_end_day_offset
|
||||||
|
- Negative search_start_offset_minutes / search_end_offset_minutes
|
||||||
|
- Explicit past search_start / search_end datetimes
|
||||||
|
|
||||||
|
Also validates schema boundaries for all 4 services.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from datetime import time as dt_time
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from custom_components.tibber_prices.services.find_cheapest_block import (
|
||||||
|
_COMMON_BLOCK_SCHEMA,
|
||||||
|
)
|
||||||
|
from custom_components.tibber_prices.services.find_cheapest_hours import (
|
||||||
|
_COMMON_HOURS_SCHEMA,
|
||||||
|
)
|
||||||
|
from custom_components.tibber_prices.services.helpers import (
|
||||||
|
resolve_search_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
BERLIN = ZoneInfo("Europe/Berlin")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# resolve_search_range: Negative day offsets
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveSearchRangeNegativeDayOffset:
|
||||||
|
"""Test that negative day offsets correctly resolve to past dates."""
|
||||||
|
|
||||||
|
def test_negative_start_day_offset(self) -> None:
|
||||||
|
"""Start yesterday at 06:00."""
|
||||||
|
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
||||||
|
call_data = {
|
||||||
|
"search_start_time": dt_time(6, 0, 0),
|
||||||
|
"search_start_day_offset": -1,
|
||||||
|
}
|
||||||
|
start, _end = resolve_search_range(call_data, now, BERLIN)
|
||||||
|
# Should be yesterday 06:00
|
||||||
|
assert start.day == 10
|
||||||
|
assert start.hour == 6
|
||||||
|
assert start.minute == 0
|
||||||
|
|
||||||
|
def test_negative_both_day_offsets(self) -> None:
|
||||||
|
"""Full day in the past: yesterday 00:00 to yesterday 23:59."""
|
||||||
|
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
||||||
|
call_data = {
|
||||||
|
"search_start_time": dt_time(0, 0, 0),
|
||||||
|
"search_start_day_offset": -1,
|
||||||
|
"search_end_time": dt_time(23, 59, 0),
|
||||||
|
"search_end_day_offset": -1,
|
||||||
|
}
|
||||||
|
start, end = resolve_search_range(call_data, now, BERLIN)
|
||||||
|
assert start.day == 10
|
||||||
|
assert start.hour == 0
|
||||||
|
assert end.day == 10
|
||||||
|
assert end.hour == 23
|
||||||
|
|
||||||
|
def test_negative_7_day_offset(self) -> None:
|
||||||
|
"""Start 7 days ago."""
|
||||||
|
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
||||||
|
call_data = {
|
||||||
|
"search_start_time": dt_time(0, 0, 0),
|
||||||
|
"search_start_day_offset": -7,
|
||||||
|
"search_end_time": dt_time(23, 59, 0),
|
||||||
|
"search_end_day_offset": -7,
|
||||||
|
}
|
||||||
|
start, end = resolve_search_range(call_data, now, BERLIN)
|
||||||
|
assert start.day == 4
|
||||||
|
assert end.day == 4
|
||||||
|
|
||||||
|
def test_cross_day_range_past_to_today(self) -> None:
|
||||||
|
"""Start yesterday, end today."""
|
||||||
|
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
||||||
|
call_data = {
|
||||||
|
"search_start_time": dt_time(18, 0, 0),
|
||||||
|
"search_start_day_offset": -1,
|
||||||
|
"search_end_time": dt_time(6, 0, 0),
|
||||||
|
"search_end_day_offset": 0,
|
||||||
|
}
|
||||||
|
start, end = resolve_search_range(call_data, now, BERLIN)
|
||||||
|
assert start.day == 10
|
||||||
|
assert start.hour == 18
|
||||||
|
assert end.day == 11
|
||||||
|
assert end.hour == 6
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# resolve_search_range: Negative offset minutes
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveSearchRangeNegativeOffsetMinutes:
|
||||||
|
"""Test that negative offset minutes correctly resolve to past times."""
|
||||||
|
|
||||||
|
def test_negative_start_offset(self) -> None:
|
||||||
|
"""Start 2 hours ago."""
|
||||||
|
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
||||||
|
call_data = {
|
||||||
|
"search_start_offset_minutes": -120,
|
||||||
|
"include_current_interval": True,
|
||||||
|
}
|
||||||
|
start, _end = resolve_search_range(call_data, now, BERLIN)
|
||||||
|
# -120 min from 14:30 = 12:30, floored to 12:30
|
||||||
|
assert start.hour == 12
|
||||||
|
assert start.minute == 30
|
||||||
|
|
||||||
|
def test_negative_start_offset_floors_to_quarter(self) -> None:
|
||||||
|
"""Negative offset gets floored to quarter-hour boundary."""
|
||||||
|
now = datetime(2026, 4, 11, 14, 37, tzinfo=BERLIN)
|
||||||
|
call_data = {
|
||||||
|
"search_start_offset_minutes": -60,
|
||||||
|
"include_current_interval": True,
|
||||||
|
}
|
||||||
|
start, _end = resolve_search_range(call_data, now, BERLIN)
|
||||||
|
# -60 min from 14:37 = 13:37, floored to 13:30
|
||||||
|
assert start.hour == 13
|
||||||
|
assert start.minute == 30
|
||||||
|
|
||||||
|
def test_negative_end_offset(self) -> None:
|
||||||
|
"""End 1 hour ago (fully historical range)."""
|
||||||
|
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
||||||
|
call_data = {
|
||||||
|
"search_start_offset_minutes": -180,
|
||||||
|
"search_end_offset_minutes": -60,
|
||||||
|
"include_current_interval": True,
|
||||||
|
}
|
||||||
|
start, end = resolve_search_range(call_data, now, BERLIN)
|
||||||
|
# Start: -180 min → 11:30, End: -60 min → 13:30
|
||||||
|
assert start.hour == 11
|
||||||
|
assert start.minute == 30
|
||||||
|
assert end.hour == 13
|
||||||
|
assert end.minute == 30
|
||||||
|
|
||||||
|
def test_large_negative_offset_crosses_day(self) -> None:
|
||||||
|
"""Large negative offset crosses day boundary."""
|
||||||
|
now = datetime(2026, 4, 11, 2, 0, tzinfo=BERLIN)
|
||||||
|
call_data = {
|
||||||
|
"search_start_offset_minutes": -180,
|
||||||
|
"include_current_interval": True,
|
||||||
|
}
|
||||||
|
start, _end = resolve_search_range(call_data, now, BERLIN)
|
||||||
|
# -180 min from 02:00 = 23:00 yesterday
|
||||||
|
assert start.day == 10
|
||||||
|
assert start.hour == 23
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Schema validation: day_offset boundaries
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaValidation:
|
||||||
|
"""Verify that schemas accept negative offsets within bounds."""
|
||||||
|
|
||||||
|
def _validate_block_schema(self, data: dict) -> dict:
|
||||||
|
"""Validate data through block schema."""
|
||||||
|
schema = vol.Schema(_COMMON_BLOCK_SCHEMA)
|
||||||
|
return schema(data)
|
||||||
|
|
||||||
|
def _validate_hours_schema(self, data: dict) -> dict:
|
||||||
|
"""Validate data through hours schema."""
|
||||||
|
schema = vol.Schema(_COMMON_HOURS_SCHEMA)
|
||||||
|
return schema(data)
|
||||||
|
|
||||||
|
def test_block_schema_accepts_negative_day_offset(self) -> None:
|
||||||
|
"""Block schema allows negative day offsets."""
|
||||||
|
result = self._validate_block_schema(
|
||||||
|
{
|
||||||
|
"entry_id": "test",
|
||||||
|
"duration": timedelta(hours=1),
|
||||||
|
"search_start_day_offset": -3,
|
||||||
|
"search_end_day_offset": -1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result["search_start_day_offset"] == -3
|
||||||
|
assert result["search_end_day_offset"] == -1
|
||||||
|
|
||||||
|
def test_block_schema_accepts_negative_offset_minutes(self) -> None:
|
||||||
|
"""Block schema allows negative offset minutes."""
|
||||||
|
result = self._validate_block_schema(
|
||||||
|
{
|
||||||
|
"entry_id": "test",
|
||||||
|
"duration": timedelta(hours=1),
|
||||||
|
"search_start_offset_minutes": -1440,
|
||||||
|
"search_end_offset_minutes": -60,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result["search_start_offset_minutes"] == -1440
|
||||||
|
assert result["search_end_offset_minutes"] == -60
|
||||||
|
|
||||||
|
def test_block_schema_rejects_out_of_bounds_day_offset(self) -> None:
|
||||||
|
"""Block schema rejects day offset < -7."""
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
self._validate_block_schema(
|
||||||
|
{
|
||||||
|
"entry_id": "test",
|
||||||
|
"duration": timedelta(hours=1),
|
||||||
|
"search_start_day_offset": -8,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_block_schema_max_day_offset_still_2(self) -> None:
|
||||||
|
"""Block schema still limits forward to +2."""
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
self._validate_block_schema(
|
||||||
|
{
|
||||||
|
"entry_id": "test",
|
||||||
|
"duration": timedelta(hours=1),
|
||||||
|
"search_start_day_offset": 3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hours_schema_accepts_negative_day_offset(self) -> None:
|
||||||
|
"""Hours schema allows negative day offsets."""
|
||||||
|
result = self._validate_hours_schema(
|
||||||
|
{
|
||||||
|
"entry_id": "test",
|
||||||
|
"duration": timedelta(hours=2),
|
||||||
|
"search_start_day_offset": -7,
|
||||||
|
"search_end_day_offset": -5,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result["search_start_day_offset"] == -7
|
||||||
|
|
||||||
|
def test_hours_schema_accepts_negative_offset_minutes(self) -> None:
|
||||||
|
"""Hours schema allows negative offset minutes."""
|
||||||
|
result = self._validate_hours_schema(
|
||||||
|
{
|
||||||
|
"entry_id": "test",
|
||||||
|
"duration": timedelta(hours=2),
|
||||||
|
"search_start_offset_minutes": -10080,
|
||||||
|
"search_end_offset_minutes": -60,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result["search_start_offset_minutes"] == -10080
|
||||||
|
|
||||||
|
def test_hours_schema_rejects_out_of_bounds_offset_minutes(self) -> None:
|
||||||
|
"""Hours schema rejects offset minutes outside ±10080."""
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
self._validate_hours_schema(
|
||||||
|
{
|
||||||
|
"entry_id": "test",
|
||||||
|
"duration": timedelta(hours=2),
|
||||||
|
"search_start_offset_minutes": -10081,
|
||||||
|
}
|
||||||
|
)
|
||||||
602
tests/test_price_window.py
Normal file
602
tests/test_price_window.py
Normal file
|
|
@ -0,0 +1,602 @@
|
||||||
|
"""
|
||||||
|
Tests for price window algorithms.
|
||||||
|
|
||||||
|
Tests the pure algorithm functions in utils/price_window.py:
|
||||||
|
- find_cheapest_contiguous_window (sliding window)
|
||||||
|
- find_cheapest_n_intervals (cheapest N picks with optional min-segment)
|
||||||
|
- group_intervals_into_segments
|
||||||
|
- calculate_window_statistics
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from custom_components.tibber_prices.utils.price_window import (
|
||||||
|
calculate_window_statistics,
|
||||||
|
find_cheapest_contiguous_window,
|
||||||
|
find_cheapest_n_intervals,
|
||||||
|
group_intervals_into_segments,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test Helpers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _make_intervals(
|
||||||
|
prices: list[float],
|
||||||
|
start: datetime | None = None,
|
||||||
|
gap_after: set[int] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Create interval dicts from a list of prices.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prices: List of price values (one per 15-min interval).
|
||||||
|
start: Start datetime (defaults to 2026-04-11T00:00:00+02:00).
|
||||||
|
gap_after: Set of indices after which to insert a 15-min gap
|
||||||
|
(making non-contiguous intervals).
|
||||||
|
|
||||||
|
"""
|
||||||
|
if start is None:
|
||||||
|
start = datetime(2026, 4, 11, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
intervals = []
|
||||||
|
current = start
|
||||||
|
for i, price in enumerate(prices):
|
||||||
|
intervals.append(
|
||||||
|
{
|
||||||
|
"startsAt": current.isoformat(),
|
||||||
|
"total": price,
|
||||||
|
"energy": price * 0.8,
|
||||||
|
"tax": price * 0.2,
|
||||||
|
"level": "NORMAL",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
skip = 2 if gap_after and i in gap_after else 1
|
||||||
|
current += timedelta(minutes=15 * skip)
|
||||||
|
|
||||||
|
return intervals
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# find_cheapest_contiguous_window
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindCheapestContiguousWindow:
|
||||||
|
"""Tests for the sliding window algorithm."""
|
||||||
|
|
||||||
|
def test_empty_intervals(self) -> None:
|
||||||
|
"""Return None for empty input."""
|
||||||
|
assert find_cheapest_contiguous_window([], 4) is None
|
||||||
|
|
||||||
|
def test_duration_exceeds_available(self) -> None:
|
||||||
|
"""Return None when not enough intervals."""
|
||||||
|
intervals = _make_intervals([10.0, 20.0, 15.0])
|
||||||
|
assert find_cheapest_contiguous_window(intervals, 4) is None
|
||||||
|
|
||||||
|
def test_zero_duration(self) -> None:
|
||||||
|
"""Return None for zero duration."""
|
||||||
|
intervals = _make_intervals([10.0, 20.0])
|
||||||
|
assert find_cheapest_contiguous_window(intervals, 0) is None
|
||||||
|
|
||||||
|
def test_exact_fit(self) -> None:
|
||||||
|
"""Window equals all available intervals."""
|
||||||
|
prices = [10.0, 20.0, 15.0, 12.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_contiguous_window(intervals, 4)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result["intervals"]) == 4
|
||||||
|
|
||||||
|
def test_single_interval(self) -> None:
|
||||||
|
"""Window of 1 picks the cheapest single interval."""
|
||||||
|
prices = [30.0, 10.0, 20.0, 40.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_contiguous_window(intervals, 1)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result["intervals"]) == 1
|
||||||
|
assert result["intervals"][0]["total"] == 10.0
|
||||||
|
|
||||||
|
def test_u_shaped_curve(self) -> None:
|
||||||
|
"""Finds cheap window in center of U-shaped price curve."""
|
||||||
|
# U-shape: expensive morning, cheap midday, expensive evening
|
||||||
|
prices = [30.0, 25.0, 15.0, 10.0, 8.0, 9.0, 12.0, 20.0, 28.0, 35.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_contiguous_window(intervals, 4)
|
||||||
|
assert result is not None
|
||||||
|
# Should be intervals 3-6: [10.0, 8.0, 9.0, 12.0] = sum 39.0
|
||||||
|
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||||
|
assert selected_prices == [10.0, 8.0, 9.0, 12.0]
|
||||||
|
|
||||||
|
def test_v_shaped_curve(self) -> None:
|
||||||
|
"""Finds cheapest block on V-shaped day (classic Issue #108 scenario)."""
|
||||||
|
# V-shape: expensive → cheap minimum → expensive
|
||||||
|
prices = [25.0, 20.0, 15.0, 10.0, 5.0, 10.0, 15.0, 20.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
# 4-interval window: cheapest is centered on minimum
|
||||||
|
result = find_cheapest_contiguous_window(intervals, 4)
|
||||||
|
assert result is not None
|
||||||
|
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||||
|
# [15.0, 10.0, 5.0, 10.0] = 40.0 or [10.0, 5.0, 10.0, 15.0] = 40.0
|
||||||
|
assert sum(selected_prices) == 40.0
|
||||||
|
|
||||||
|
def test_flat_prices(self) -> None:
|
||||||
|
"""All prices equal: picks first window."""
|
||||||
|
prices = [10.0] * 8
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_contiguous_window(intervals, 4)
|
||||||
|
assert result is not None
|
||||||
|
# First window (index 0)
|
||||||
|
assert result["intervals"][0]["startsAt"] == intervals[0]["startsAt"]
|
||||||
|
|
||||||
|
def test_cheapest_at_end(self) -> None:
|
||||||
|
"""Cheapest window is the last N intervals."""
|
||||||
|
prices = [30.0, 25.0, 20.0, 15.0, 10.0, 5.0, 3.0, 2.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_contiguous_window(intervals, 4)
|
||||||
|
assert result is not None
|
||||||
|
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||||
|
assert selected_prices == [10.0, 5.0, 3.0, 2.0]
|
||||||
|
|
||||||
|
def test_cheapest_at_start(self) -> None:
|
||||||
|
"""Cheapest window is the first N intervals."""
|
||||||
|
prices = [2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_contiguous_window(intervals, 4)
|
||||||
|
assert result is not None
|
||||||
|
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||||
|
assert selected_prices == [2.0, 3.0, 5.0, 10.0]
|
||||||
|
|
||||||
|
def test_negative_prices(self) -> None:
|
||||||
|
"""Handles negative prices (renewable surplus)."""
|
||||||
|
prices = [5.0, 3.0, -1.0, -3.0, -2.0, 1.0, 4.0, 8.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_contiguous_window(intervals, 3)
|
||||||
|
assert result is not None
|
||||||
|
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||||
|
# [-1.0, -3.0, -2.0] = -6.0 is cheapest 3-block
|
||||||
|
assert selected_prices == [-1.0, -3.0, -2.0]
|
||||||
|
|
||||||
|
def test_midnight_crossing(self) -> None:
|
||||||
|
"""Window can span midnight."""
|
||||||
|
# 8 intervals starting at 22:00 → crossing midnight
|
||||||
|
start = datetime(2026, 4, 11, 22, 0, tzinfo=UTC)
|
||||||
|
prices = [20.0, 15.0, 10.0, 5.0, 3.0, 2.0, 8.0, 12.0]
|
||||||
|
intervals = _make_intervals(prices, start=start)
|
||||||
|
result = find_cheapest_contiguous_window(intervals, 4)
|
||||||
|
assert result is not None
|
||||||
|
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||||
|
assert selected_prices == [5.0, 3.0, 2.0, 8.0]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# find_cheapest_n_intervals
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindCheapestNIntervals:
|
||||||
|
"""Tests for the cheapest-N-picks algorithm."""
|
||||||
|
|
||||||
|
def test_empty_intervals(self) -> None:
|
||||||
|
"""Return None for empty input."""
|
||||||
|
assert find_cheapest_n_intervals([], 4) is None
|
||||||
|
|
||||||
|
def test_count_exceeds_available(self) -> None:
|
||||||
|
"""Return None when not enough intervals."""
|
||||||
|
intervals = _make_intervals([10.0, 20.0, 15.0])
|
||||||
|
assert find_cheapest_n_intervals(intervals, 4) is None
|
||||||
|
|
||||||
|
def test_zero_count(self) -> None:
|
||||||
|
"""Return None for zero count."""
|
||||||
|
intervals = _make_intervals([10.0])
|
||||||
|
assert find_cheapest_n_intervals(intervals, 0) is None
|
||||||
|
|
||||||
|
def test_picks_cheapest(self) -> None:
|
||||||
|
"""Picks the N cheapest intervals regardless of position."""
|
||||||
|
prices = [30.0, 10.0, 25.0, 5.0, 20.0, 8.0, 35.0, 15.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_n_intervals(intervals, 3)
|
||||||
|
assert result is not None
|
||||||
|
selected_prices = sorted(iv["total"] for iv in result["intervals"])
|
||||||
|
assert selected_prices == [5.0, 8.0, 10.0]
|
||||||
|
|
||||||
|
def test_chronological_order(self) -> None:
|
||||||
|
"""Result intervals are sorted chronologically."""
|
||||||
|
prices = [30.0, 10.0, 25.0, 5.0, 20.0, 8.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_n_intervals(intervals, 3)
|
||||||
|
assert result is not None
|
||||||
|
starts = [iv["startsAt"] for iv in result["intervals"]]
|
||||||
|
assert starts == sorted(starts)
|
||||||
|
|
||||||
|
def test_segments_grouped(self) -> None:
|
||||||
|
"""Result contains segments grouping contiguous intervals."""
|
||||||
|
# Cheapest 4 from: 30, 10, 8, 5, 20, 3, 2, 35
|
||||||
|
# Picks: 2(idx6), 3(idx5), 5(idx3), 8(idx2)
|
||||||
|
prices = [30.0, 10.0, 8.0, 5.0, 20.0, 3.0, 2.0, 35.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_n_intervals(intervals, 4)
|
||||||
|
assert result is not None
|
||||||
|
assert "segments" in result
|
||||||
|
assert len(result["segments"]) >= 1
|
||||||
|
|
||||||
|
def test_single_contiguous_segment(self) -> None:
|
||||||
|
"""All picked intervals form one segment."""
|
||||||
|
# Cheapest 3: indices 2,3,4 → [5.0, 3.0, 4.0] all adjacent
|
||||||
|
prices = [20.0, 15.0, 5.0, 3.0, 4.0, 25.0, 30.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_n_intervals(intervals, 3)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result["segments"]) == 1
|
||||||
|
assert result["segments"][0]["interval_count"] == 3
|
||||||
|
|
||||||
|
def test_min_segment_basic(self) -> None:
|
||||||
|
"""With min_segment=2, single-interval segments are excluded."""
|
||||||
|
# Prices: 10, 20, 30, 5, 40, 8, 7, 35
|
||||||
|
# Without constraint: picks 5(idx3), 7(idx6), 8(idx5) → 3 isolated singles
|
||||||
|
# With min_segment=2: must form segments ≥2 intervals
|
||||||
|
prices = [10.0, 20.0, 30.0, 5.0, 40.0, 8.0, 7.0, 35.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_n_intervals(intervals, 3, min_segment_intervals=2)
|
||||||
|
assert result is not None
|
||||||
|
# All segments should be ≥ 2 intervals
|
||||||
|
for seg in result["segments"]:
|
||||||
|
assert seg["interval_count"] >= 2
|
||||||
|
|
||||||
|
def test_min_segment_forces_different_selection(self) -> None:
|
||||||
|
"""Min segment constraint changes the selection vs. no constraint."""
|
||||||
|
prices = [10.0, 50.0, 50.0, 5.0, 50.0, 50.0, 8.0, 50.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
|
||||||
|
# Without constraint: picks indices 0(10), 3(5), 6(8)
|
||||||
|
result_no_constraint = find_cheapest_n_intervals(intervals, 3, min_segment_intervals=1)
|
||||||
|
assert result_no_constraint is not None
|
||||||
|
prices_no = sorted(iv["total"] for iv in result_no_constraint["intervals"])
|
||||||
|
assert prices_no == [5.0, 8.0, 10.0]
|
||||||
|
|
||||||
|
# With constraint (min 2): those are all isolated → must find alternatives
|
||||||
|
result_constrained = find_cheapest_n_intervals(intervals, 3, min_segment_intervals=2)
|
||||||
|
assert result_constrained is not None
|
||||||
|
# Selection will be different
|
||||||
|
prices_constrained = sorted(iv["total"] for iv in result_constrained["intervals"])
|
||||||
|
assert prices_constrained != prices_no
|
||||||
|
|
||||||
|
def test_negative_prices(self) -> None:
|
||||||
|
"""Handles negative prices correctly."""
|
||||||
|
prices = [5.0, -3.0, 10.0, -5.0, 8.0, -1.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_n_intervals(intervals, 3)
|
||||||
|
assert result is not None
|
||||||
|
selected_prices = sorted(iv["total"] for iv in result["intervals"])
|
||||||
|
assert selected_prices == [-5.0, -3.0, -1.0]
|
||||||
|
|
||||||
|
def test_exact_fit(self) -> None:
|
||||||
|
"""Count equals available intervals."""
|
||||||
|
prices = [10.0, 20.0, 15.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_n_intervals(intervals, 3)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result["intervals"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# group_intervals_into_segments
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestGroupIntervalsIntoSegments:
|
||||||
|
"""Tests for segment grouping."""
|
||||||
|
|
||||||
|
def test_empty(self) -> None:
|
||||||
|
"""Empty input returns empty list."""
|
||||||
|
assert group_intervals_into_segments([]) == []
|
||||||
|
|
||||||
|
def test_single_interval(self) -> None:
|
||||||
|
"""Single interval becomes one segment."""
|
||||||
|
intervals = _make_intervals([10.0])
|
||||||
|
segments = group_intervals_into_segments(intervals)
|
||||||
|
assert len(segments) == 1
|
||||||
|
assert segments[0]["interval_count"] == 1
|
||||||
|
assert segments[0]["duration_minutes"] == 15
|
||||||
|
|
||||||
|
def test_all_contiguous(self) -> None:
|
||||||
|
"""All contiguous intervals form one segment."""
|
||||||
|
intervals = _make_intervals([10.0, 20.0, 15.0, 12.0])
|
||||||
|
segments = group_intervals_into_segments(intervals)
|
||||||
|
assert len(segments) == 1
|
||||||
|
assert segments[0]["interval_count"] == 4
|
||||||
|
assert segments[0]["duration_minutes"] == 60
|
||||||
|
|
||||||
|
def test_gap_creates_segments(self) -> None:
|
||||||
|
"""A gap creates separate segments."""
|
||||||
|
# Gap after index 1 (30-min gap instead of 15-min)
|
||||||
|
intervals = _make_intervals([10.0, 20.0, 15.0, 12.0], gap_after={1})
|
||||||
|
segments = group_intervals_into_segments(intervals)
|
||||||
|
assert len(segments) == 2
|
||||||
|
assert segments[0]["interval_count"] == 2
|
||||||
|
assert segments[1]["interval_count"] == 2
|
||||||
|
|
||||||
|
def test_multiple_gaps(self) -> None:
|
||||||
|
"""Multiple gaps create multiple segments."""
|
||||||
|
intervals = _make_intervals(
|
||||||
|
[10.0, 20.0, 15.0, 12.0, 8.0],
|
||||||
|
gap_after={0, 2},
|
||||||
|
)
|
||||||
|
segments = group_intervals_into_segments(intervals)
|
||||||
|
assert len(segments) == 3
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# calculate_window_statistics
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateWindowStatistics:
|
||||||
|
"""Tests for price statistics calculation."""
|
||||||
|
|
||||||
|
def test_empty(self) -> None:
|
||||||
|
"""Empty input returns all None."""
|
||||||
|
stats = calculate_window_statistics([])
|
||||||
|
assert stats["price_mean"] is None
|
||||||
|
assert stats["price_median"] is None
|
||||||
|
assert stats["estimated_total_cost"] is None
|
||||||
|
|
||||||
|
def test_basic_stats(self) -> None:
|
||||||
|
"""Correct mean, median, min, max, spread."""
|
||||||
|
intervals = _make_intervals([10.0, 20.0, 30.0, 40.0])
|
||||||
|
stats = calculate_window_statistics(intervals)
|
||||||
|
assert stats["price_mean"] == 25.0
|
||||||
|
assert stats["price_median"] == 25.0
|
||||||
|
assert stats["price_min"] == 10.0
|
||||||
|
assert stats["price_max"] == 40.0
|
||||||
|
assert stats["price_spread"] == 30.0
|
||||||
|
# 4 intervals x 15min = 1h, cost = sum(price x 0.25h) = (10+20+30+40) x 0.25 = 25.0
|
||||||
|
assert stats["estimated_total_cost"] == 25.0
|
||||||
|
|
||||||
|
def test_unit_factor(self) -> None:
|
||||||
|
"""Unit factor multiplies all values."""
|
||||||
|
intervals = _make_intervals([0.10, 0.20, 0.30])
|
||||||
|
stats = calculate_window_statistics(intervals, unit_factor=100)
|
||||||
|
assert stats["price_mean"] == 20.0
|
||||||
|
assert stats["price_min"] == 10.0
|
||||||
|
assert stats["price_max"] == 30.0
|
||||||
|
# 3 intervals x 15min, prices in subunit: (10+20+30) x 0.25 = 15.0
|
||||||
|
assert stats["estimated_total_cost"] == 15.0
|
||||||
|
|
||||||
|
def test_single_interval(self) -> None:
|
||||||
|
"""Single interval: mean=median=min=max, spread=0."""
|
||||||
|
intervals = _make_intervals([15.0])
|
||||||
|
stats = calculate_window_statistics(intervals)
|
||||||
|
assert stats["price_mean"] == 15.0
|
||||||
|
assert stats["price_median"] == 15.0
|
||||||
|
assert stats["price_min"] == 15.0
|
||||||
|
assert stats["price_max"] == 15.0
|
||||||
|
assert stats["price_spread"] == 0.0
|
||||||
|
# 1 interval x 0.25h x 15.0 = 3.75
|
||||||
|
assert stats["estimated_total_cost"] == 3.75
|
||||||
|
|
||||||
|
def test_negative_prices(self) -> None:
|
||||||
|
"""Handles negative prices."""
|
||||||
|
intervals = _make_intervals([-10.0, -5.0, -20.0])
|
||||||
|
stats = calculate_window_statistics(intervals)
|
||||||
|
assert stats["price_min"] == -20.0
|
||||||
|
assert stats["price_max"] == -5.0
|
||||||
|
assert stats["price_spread"] == 15.0
|
||||||
|
# 3 intervals x 0.25h: (-10+-5+-20) x 0.25 = -8.75
|
||||||
|
assert stats["estimated_total_cost"] == -8.75
|
||||||
|
|
||||||
|
def test_rounding(self) -> None:
|
||||||
|
"""Results are rounded to specified decimals."""
|
||||||
|
intervals = _make_intervals([1.0 / 3.0, 2.0 / 3.0])
|
||||||
|
stats = calculate_window_statistics(intervals, round_decimals=2)
|
||||||
|
assert stats["price_mean"] == 0.5
|
||||||
|
assert stats["price_min"] == 0.33
|
||||||
|
assert stats["price_max"] == 0.67
|
||||||
|
# (0.333...+0.666...) x 0.25 = 0.25
|
||||||
|
assert stats["estimated_total_cost"] == 0.25
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Reverse mode (find most expensive)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindMostExpensiveContiguousWindow:
|
||||||
|
"""Tests for the sliding window algorithm with reverse=True."""
|
||||||
|
|
||||||
|
def test_finds_most_expensive_block(self) -> None:
|
||||||
|
"""Reverse mode finds the most expensive contiguous window."""
|
||||||
|
prices = [10.0, 20.0, 30.0, 40.0, 5.0, 3.0, 2.0, 1.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_contiguous_window(intervals, 4, reverse=True)
|
||||||
|
assert result is not None
|
||||||
|
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||||
|
assert selected_prices == [10.0, 20.0, 30.0, 40.0]
|
||||||
|
|
||||||
|
def test_most_expensive_at_end(self) -> None:
|
||||||
|
"""Most expensive window at the end."""
|
||||||
|
prices = [1.0, 2.0, 3.0, 4.0, 10.0, 20.0, 30.0, 40.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_contiguous_window(intervals, 4, reverse=True)
|
||||||
|
assert result is not None
|
||||||
|
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||||
|
assert selected_prices == [10.0, 20.0, 30.0, 40.0]
|
||||||
|
|
||||||
|
def test_reverse_single_interval(self) -> None:
|
||||||
|
"""Reverse picks the most expensive single interval."""
|
||||||
|
prices = [5.0, 40.0, 10.0, 30.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_contiguous_window(intervals, 1, reverse=True)
|
||||||
|
assert result is not None
|
||||||
|
assert result["intervals"][0]["total"] == 40.0
|
||||||
|
|
||||||
|
def test_reverse_empty_returns_none(self) -> None:
|
||||||
|
"""Edge case: empty input."""
|
||||||
|
assert find_cheapest_contiguous_window([], 4, reverse=True) is None
|
||||||
|
|
||||||
|
def test_reverse_vs_forward_different(self) -> None:
|
||||||
|
"""Reverse and forward give different results on asymmetric data."""
|
||||||
|
prices = [5.0, 10.0, 30.0, 25.0, 3.0, 2.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
cheapest = find_cheapest_contiguous_window(intervals, 2)
|
||||||
|
most_expensive = find_cheapest_contiguous_window(intervals, 2, reverse=True)
|
||||||
|
assert cheapest is not None
|
||||||
|
assert most_expensive is not None
|
||||||
|
cheap_sum = sum(iv["total"] for iv in cheapest["intervals"])
|
||||||
|
exp_sum = sum(iv["total"] for iv in most_expensive["intervals"])
|
||||||
|
assert exp_sum > cheap_sum
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindMostExpensiveNIntervals:
|
||||||
|
"""Tests for the cheapest-N-picks algorithm with reverse=True."""
|
||||||
|
|
||||||
|
def test_picks_most_expensive(self) -> None:
|
||||||
|
"""Reverse picks the N most expensive intervals."""
|
||||||
|
prices = [30.0, 10.0, 25.0, 5.0, 20.0, 8.0, 35.0, 15.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_n_intervals(intervals, 3, reverse=True)
|
||||||
|
assert result is not None
|
||||||
|
selected_prices = sorted((iv["total"] for iv in result["intervals"]), reverse=True)
|
||||||
|
assert selected_prices == [35.0, 30.0, 25.0]
|
||||||
|
|
||||||
|
def test_reverse_chronological_order(self) -> None:
|
||||||
|
"""Reverse result intervals are still sorted chronologically."""
|
||||||
|
prices = [30.0, 10.0, 25.0, 5.0, 20.0, 8.0, 35.0, 15.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_n_intervals(intervals, 3, reverse=True)
|
||||||
|
assert result is not None
|
||||||
|
starts = [iv["startsAt"] for iv in result["intervals"]]
|
||||||
|
assert starts == sorted(starts)
|
||||||
|
|
||||||
|
def test_reverse_min_segment(self) -> None:
|
||||||
|
"""Reverse with min_segment constraint picks expensive segments."""
|
||||||
|
prices = [5.0, 30.0, 35.0, 3.0, 2.0, 40.0, 38.0, 1.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
result = find_cheapest_n_intervals(intervals, 4, min_segment_intervals=2, reverse=True)
|
||||||
|
assert result is not None
|
||||||
|
for seg in result["segments"]:
|
||||||
|
assert seg["interval_count"] >= 2
|
||||||
|
|
||||||
|
def test_reverse_empty_returns_none(self) -> None:
|
||||||
|
"""Edge case: empty input."""
|
||||||
|
assert find_cheapest_n_intervals([], 4, reverse=True) is None
|
||||||
|
|
||||||
|
def test_reverse_vs_forward_different(self) -> None:
|
||||||
|
"""Reverse and forward produce different sets."""
|
||||||
|
prices = [5.0, 10.0, 30.0, 25.0, 3.0, 2.0, 40.0, 15.0]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
cheapest = find_cheapest_n_intervals(intervals, 3)
|
||||||
|
most_expensive = find_cheapest_n_intervals(intervals, 3, reverse=True)
|
||||||
|
assert cheapest is not None
|
||||||
|
assert most_expensive is not None
|
||||||
|
cheap_prices = sorted(iv["total"] for iv in cheapest["intervals"])
|
||||||
|
exp_prices = sorted(iv["total"] for iv in most_expensive["intervals"])
|
||||||
|
assert cheap_prices != exp_prices
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Price Comparison (Cheapest vs Most Expensive)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestPriceComparison:
|
||||||
|
"""Tests for price comparison between cheapest and most expensive windows."""
|
||||||
|
|
||||||
|
def test_contiguous_window_spread(self) -> None:
|
||||||
|
"""Price difference between cheapest and most expensive contiguous windows."""
|
||||||
|
# Prices: clear cheap period (0.05) and clear expensive period (0.30)
|
||||||
|
prices = [0.05, 0.05, 0.05, 0.05, 0.20, 0.20, 0.30, 0.30, 0.30, 0.30]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
|
||||||
|
cheapest = find_cheapest_contiguous_window(intervals, 4, reverse=False)
|
||||||
|
most_expensive = find_cheapest_contiguous_window(intervals, 4, reverse=True)
|
||||||
|
|
||||||
|
assert cheapest is not None
|
||||||
|
assert most_expensive is not None
|
||||||
|
|
||||||
|
cheap_stats = calculate_window_statistics(cheapest["intervals"])
|
||||||
|
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
|
||||||
|
|
||||||
|
assert cheap_stats["price_mean"] is not None
|
||||||
|
assert expensive_stats["price_mean"] is not None
|
||||||
|
|
||||||
|
spread = round(expensive_stats["price_mean"] - cheap_stats["price_mean"], 4)
|
||||||
|
# Mean of [0.30, 0.30, 0.30, 0.30] - mean of [0.05, 0.05, 0.05, 0.05] = 0.25
|
||||||
|
assert spread > 0
|
||||||
|
assert abs(spread - 0.25) < 0.001
|
||||||
|
|
||||||
|
def test_spread_symmetric(self) -> None:
|
||||||
|
"""Price difference is the same regardless of which direction we compute from."""
|
||||||
|
prices = [0.10, 0.10, 0.40, 0.40, 0.15, 0.15, 0.35, 0.35]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
|
||||||
|
cheapest = find_cheapest_contiguous_window(intervals, 2, reverse=False)
|
||||||
|
most_expensive = find_cheapest_contiguous_window(intervals, 2, reverse=True)
|
||||||
|
|
||||||
|
assert cheapest is not None
|
||||||
|
assert most_expensive is not None
|
||||||
|
|
||||||
|
cheap_stats = calculate_window_statistics(cheapest["intervals"])
|
||||||
|
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
|
||||||
|
|
||||||
|
spread_cheap_to_exp = expensive_stats["price_mean"] - cheap_stats["price_mean"]
|
||||||
|
spread_exp_to_cheap = cheap_stats["price_mean"] - expensive_stats["price_mean"]
|
||||||
|
|
||||||
|
assert abs(spread_cheap_to_exp + spread_exp_to_cheap) < 0.0001
|
||||||
|
|
||||||
|
def test_n_intervals_spread(self) -> None:
|
||||||
|
"""Price difference between cheapest and most expensive N picks."""
|
||||||
|
prices = [0.02, 0.50, 0.03, 0.45, 0.01, 0.48, 0.04, 0.42]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
|
||||||
|
cheapest = find_cheapest_n_intervals(intervals, 3, reverse=False)
|
||||||
|
most_expensive = find_cheapest_n_intervals(intervals, 3, reverse=True)
|
||||||
|
|
||||||
|
assert cheapest is not None
|
||||||
|
assert most_expensive is not None
|
||||||
|
|
||||||
|
cheap_stats = calculate_window_statistics(cheapest["intervals"])
|
||||||
|
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
|
||||||
|
|
||||||
|
assert cheap_stats["price_mean"] is not None
|
||||||
|
assert expensive_stats["price_mean"] is not None
|
||||||
|
|
||||||
|
# Cheapest 3: [0.01, 0.02, 0.03] → mean 0.02
|
||||||
|
# Most expensive 3: [0.50, 0.48, 0.45] → mean ~0.4767
|
||||||
|
spread = expensive_stats["price_mean"] - cheap_stats["price_mean"]
|
||||||
|
assert spread > 0.4
|
||||||
|
|
||||||
|
def test_flat_prices_zero_spread(self) -> None:
|
||||||
|
"""Flat prices produce zero price difference."""
|
||||||
|
prices = [0.25, 0.25, 0.25, 0.25, 0.25, 0.25]
|
||||||
|
intervals = _make_intervals(prices)
|
||||||
|
|
||||||
|
cheapest = find_cheapest_contiguous_window(intervals, 3, reverse=False)
|
||||||
|
most_expensive = find_cheapest_contiguous_window(intervals, 3, reverse=True)
|
||||||
|
|
||||||
|
assert cheapest is not None
|
||||||
|
assert most_expensive is not None
|
||||||
|
|
||||||
|
cheap_stats = calculate_window_statistics(cheapest["intervals"])
|
||||||
|
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
|
||||||
|
|
||||||
|
spread = expensive_stats["price_mean"] - cheap_stats["price_mean"]
|
||||||
|
assert abs(spread) < 0.0001
|
||||||
|
|
||||||
|
def test_single_interval_no_spread(self) -> None:
|
||||||
|
"""With only 1 interval and duration=1, cheapest==most expensive (no difference)."""
|
||||||
|
intervals = _make_intervals([0.30])
|
||||||
|
|
||||||
|
cheapest = find_cheapest_contiguous_window(intervals, 1, reverse=False)
|
||||||
|
most_expensive = find_cheapest_contiguous_window(intervals, 1, reverse=True)
|
||||||
|
|
||||||
|
assert cheapest is not None
|
||||||
|
assert most_expensive is not None
|
||||||
|
|
||||||
|
cheap_stats = calculate_window_statistics(cheapest["intervals"])
|
||||||
|
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
|
||||||
|
|
||||||
|
spread = expensive_stats["price_mean"] - cheap_stats["price_mean"]
|
||||||
|
assert abs(spread) < 0.0001
|
||||||
Loading…
Reference in a new issue