feat(pricing): add relaxation logic for progressive filter loosening
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run

Implement a new service that progressively relaxes user-defined filters to ensure a result is always returned when price data is available. This includes three phases: halving the minimum distance from average, expanding level filters, and reducing duration.

Impact: Users will receive results even when strict filters would otherwise yield no matches, improving the reliability of scheduling actions.

feat(pricing): enhance scheduling actions with new parameters

Introduce new parameters `smooth_outliers`, `min_distance_from_avg`, and `allow_relaxation` to scheduling actions, allowing for better control over price selection and ensuring results are meaningfully different from average prices.

Impact: Users can now fine-tune their scheduling actions to avoid marginal savings and ensure more uniform pricing within selected windows.

docs(scheduling): update documentation for new features

Revise the scheduling actions documentation to include new parameters and their effects, such as outlier smoothing and minimum distance from average, along with examples for better user understanding.

Impact: Users will have clearer guidance on how to utilize new features effectively in their automations.

test(scheduling): add tests for new relaxation logic

Implement unit tests to verify the behavior of the new relaxation logic in scheduling actions, ensuring that filters are correctly relaxed and results are returned as expected.

Impact: Increased test coverage and reliability of the scheduling features.
This commit is contained in:
Julian Pawlowski 2026-04-18 21:27:05 +00:00
parent 63c3404fbd
commit 303a7c7835
14 changed files with 2189 additions and 422 deletions

View file

@ -292,19 +292,25 @@ find_cheapest_block:
required: true required: true
selector: selector:
duration: duration:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
include_current_interval:
required: false
default: true
selector:
boolean:
search_range: search_range:
collapsed: true
fields: fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start: search_start:
required: false required: false
example: "2026-04-11T06:00:00+02:00" example: "2026-04-11T06:00:00+02:00"
@ -315,6 +321,11 @@ find_cheapest_block:
example: "2026-04-12T00:00:00+02:00" example: "2026-04-12T00:00:00+02:00"
selector: selector:
datetime: datetime:
must_finish_by:
required: false
example: "2026-04-12T07:00:00+02:00"
selector:
datetime:
time_alternatives: time_alternatives:
collapsed: true collapsed: true
fields: fields:
@ -362,11 +373,6 @@ find_cheapest_block:
max: 10080 max: 10080
unit_of_measurement: min unit_of_measurement: min
mode: box mode: box
include_current_interval:
required: false
default: true
selector:
boolean:
price_filter: price_filter:
collapsed: true collapsed: true
fields: fields:
@ -392,13 +398,47 @@ find_cheapest_block:
- expensive - expensive
- very_expensive - very_expensive
translation_key: level_filter translation_key: level_filter
output: search_tuning:
collapsed: true
fields:
smooth_outliers:
required: false
default: true
selector:
boolean:
min_distance_from_avg:
required: false
selector:
number:
min: 0.1
max: 50.0
step: 0.1
unit_of_measurement: "%"
mode: box
allow_relaxation:
required: false
default: true
selector:
boolean:
duration_flexibility_minutes:
required: false
selector:
number:
min: 0
max: 120
step: 15
unit_of_measurement: "min"
mode: box
cost_estimation:
collapsed: true collapsed: true
fields: fields:
power_profile: power_profile:
required: false required: false
selector: selector:
object: object:
output:
collapsed: true
fields:
include_comparison_details: include_comparison_details:
required: false required: false
default: false default: false
@ -422,19 +462,25 @@ find_most_expensive_block:
required: true required: true
selector: selector:
duration: duration:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
include_current_interval:
required: false
default: true
selector:
boolean:
search_range: search_range:
collapsed: true
fields: fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start: search_start:
required: false required: false
example: "2026-04-11T06:00:00+02:00" example: "2026-04-11T06:00:00+02:00"
@ -445,6 +491,11 @@ find_most_expensive_block:
example: "2026-04-12T00:00:00+02:00" example: "2026-04-12T00:00:00+02:00"
selector: selector:
datetime: datetime:
must_finish_by:
required: false
example: "2026-04-12T07:00:00+02:00"
selector:
datetime:
time_alternatives: time_alternatives:
collapsed: true collapsed: true
fields: fields:
@ -492,11 +543,6 @@ find_most_expensive_block:
max: 10080 max: 10080
unit_of_measurement: min unit_of_measurement: min
mode: box mode: box
include_current_interval:
required: false
default: true
selector:
boolean:
price_filter: price_filter:
collapsed: true collapsed: true
fields: fields:
@ -522,13 +568,47 @@ find_most_expensive_block:
- expensive - expensive
- very_expensive - very_expensive
translation_key: level_filter translation_key: level_filter
output: search_tuning:
collapsed: true
fields:
smooth_outliers:
required: false
default: true
selector:
boolean:
min_distance_from_avg:
required: false
selector:
number:
min: 0.1
max: 50.0
step: 0.1
unit_of_measurement: "%"
mode: box
allow_relaxation:
required: false
default: true
selector:
boolean:
duration_flexibility_minutes:
required: false
selector:
number:
min: 0
max: 120
step: 15
unit_of_measurement: "min"
mode: box
cost_estimation:
collapsed: true collapsed: true
fields: fields:
power_profile: power_profile:
required: false required: false
selector: selector:
object: object:
output:
collapsed: true
fields:
include_comparison_details: include_comparison_details:
required: false required: false
default: false default: false
@ -556,19 +636,25 @@ find_cheapest_hours:
required: false required: false
selector: selector:
duration: duration:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
include_current_interval:
required: false
default: true
selector:
boolean:
search_range: search_range:
collapsed: true
fields: fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start: search_start:
required: false required: false
example: "2026-04-11T06:00:00+02:00" example: "2026-04-11T06:00:00+02:00"
@ -579,6 +665,11 @@ find_cheapest_hours:
example: "2026-04-12T00:00:00+02:00" example: "2026-04-12T00:00:00+02:00"
selector: selector:
datetime: datetime:
must_finish_by:
required: false
example: "2026-04-12T07:00:00+02:00"
selector:
datetime:
time_alternatives: time_alternatives:
collapsed: true collapsed: true
fields: fields:
@ -626,11 +717,6 @@ find_cheapest_hours:
max: 10080 max: 10080
unit_of_measurement: min unit_of_measurement: min
mode: box mode: box
include_current_interval:
required: false
default: true
selector:
boolean:
price_filter: price_filter:
collapsed: true collapsed: true
fields: fields:
@ -656,13 +742,47 @@ find_cheapest_hours:
- expensive - expensive
- very_expensive - very_expensive
translation_key: level_filter translation_key: level_filter
output: search_tuning:
collapsed: true
fields:
smooth_outliers:
required: false
default: true
selector:
boolean:
min_distance_from_avg:
required: false
selector:
number:
min: 0.1
max: 50.0
step: 0.1
unit_of_measurement: "%"
mode: box
allow_relaxation:
required: false
default: true
selector:
boolean:
duration_flexibility_minutes:
required: false
selector:
number:
min: 0
max: 120
step: 15
unit_of_measurement: "min"
mode: box
cost_estimation:
collapsed: true collapsed: true
fields: fields:
power_profile: power_profile:
required: false required: false
selector: selector:
object: object:
output:
collapsed: true
fields:
include_comparison_details: include_comparison_details:
required: false required: false
default: false default: false
@ -690,19 +810,25 @@ find_most_expensive_hours:
required: false required: false
selector: selector:
duration: duration:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
include_current_interval:
required: false
default: true
selector:
boolean:
search_range: search_range:
collapsed: true
fields: fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start: search_start:
required: false required: false
example: "2026-04-11T06:00:00+02:00" example: "2026-04-11T06:00:00+02:00"
@ -713,6 +839,11 @@ find_most_expensive_hours:
example: "2026-04-12T00:00:00+02:00" example: "2026-04-12T00:00:00+02:00"
selector: selector:
datetime: datetime:
must_finish_by:
required: false
example: "2026-04-12T07:00:00+02:00"
selector:
datetime:
time_alternatives: time_alternatives:
collapsed: true collapsed: true
fields: fields:
@ -760,11 +891,6 @@ find_most_expensive_hours:
max: 10080 max: 10080
unit_of_measurement: min unit_of_measurement: min
mode: box mode: box
include_current_interval:
required: false
default: true
selector:
boolean:
price_filter: price_filter:
collapsed: true collapsed: true
fields: fields:
@ -790,13 +916,47 @@ find_most_expensive_hours:
- expensive - expensive
- very_expensive - very_expensive
translation_key: level_filter translation_key: level_filter
output: search_tuning:
collapsed: true
fields:
smooth_outliers:
required: false
default: true
selector:
boolean:
min_distance_from_avg:
required: false
selector:
number:
min: 0.1
max: 50.0
step: 0.1
unit_of_measurement: "%"
mode: box
allow_relaxation:
required: false
default: true
selector:
boolean:
duration_flexibility_minutes:
required: false
selector:
number:
min: 0
max: 120
step: 15
unit_of_measurement: "min"
mode: box
cost_estimation:
collapsed: true collapsed: true
fields: fields:
power_profile: power_profile:
required: false required: false
selector: selector:
object: object:
output:
collapsed: true
fields:
include_comparison_details: include_comparison_details:
required: false required: false
default: false default: false
@ -821,30 +981,29 @@ find_cheapest_schedule:
example: '[{"name": "dishwasher", "duration": "02:00:00"}, {"name": "washing_machine", "duration": "01:30:00"}]' example: '[{"name": "dishwasher", "duration": "02:00:00"}, {"name": "washing_machine", "duration": "01:30:00"}]'
selector: selector:
object: object:
scheduling_options: gap_minutes:
fields: required: false
gap_minutes: default: 0
required: false selector:
default: 0 number:
selector: min: 0
number: max: 120
min: 0 unit_of_measurement: min
max: 120 mode: box
unit_of_measurement: min search_scope:
mode: box required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_range: search_range:
collapsed: true
fields: fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start: search_start:
required: false required: false
example: "2026-04-11T06:00:00+02:00" example: "2026-04-11T06:00:00+02:00"
@ -855,6 +1014,11 @@ find_cheapest_schedule:
example: "2026-04-12T00:00:00+02:00" example: "2026-04-12T00:00:00+02:00"
selector: selector:
datetime: datetime:
must_finish_by:
required: false
example: "2026-04-12T07:00:00+02:00"
selector:
datetime:
time_alternatives: time_alternatives:
collapsed: true collapsed: true
fields: fields:
@ -927,6 +1091,28 @@ find_cheapest_schedule:
- expensive - expensive
- very_expensive - very_expensive
translation_key: level_filter translation_key: level_filter
search_tuning:
collapsed: true
fields:
smooth_outliers:
required: false
default: true
selector:
boolean:
allow_relaxation:
required: false
default: true
selector:
boolean:
duration_flexibility_minutes:
required: false
selector:
number:
min: 0
max: 120
step: 15
unit_of_measurement: "min"
mode: box
output: output:
collapsed: true collapsed: true
fields: fields:

View file

@ -11,7 +11,7 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import math import math
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
import voluptuous as vol import voluptuous as vol
@ -28,14 +28,25 @@ from .helpers import (
INTERVAL_MINUTES, INTERVAL_MINUTES,
PRICE_LEVEL_ORDER, PRICE_LEVEL_ORDER,
VALID_SEARCH_SCOPES, VALID_SEARCH_SCOPES,
apply_must_finish_by,
build_rating_lookup, build_rating_lookup,
build_response_interval, build_response_interval,
calculate_search_range_avg,
check_min_distance_from_avg,
filter_intervals_by_price_level, filter_intervals_by_price_level,
get_entry_and_data, get_entry_and_data,
resolve_home_timezone, resolve_home_timezone,
resolve_search_range, resolve_search_range,
restore_original_prices,
smooth_service_intervals,
validate_power_profile_length, validate_power_profile_length,
validate_price_level_range, validate_price_level_range,
validate_search_params,
)
from .relaxation import (
MIN_RELAXED_DURATION_INTERVALS,
calculate_max_duration_reduction_intervals,
generate_relaxation_steps,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -71,6 +82,11 @@ _COMMON_BLOCK_SCHEMA = {
), ),
vol.Optional("include_current_interval", default=True): cv.boolean, vol.Optional("include_current_interval", default=True): cv.boolean,
vol.Optional("use_base_unit", default=False): cv.boolean, vol.Optional("use_base_unit", default=False): cv.boolean,
vol.Optional("smooth_outliers", default=True): cv.boolean,
vol.Optional("min_distance_from_avg"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=50.0)),
vol.Optional("allow_relaxation", default=True): cv.boolean,
vol.Optional("duration_flexibility_minutes"): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
vol.Optional("must_finish_by"): cv.datetime,
} }
FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA = vol.Schema(_COMMON_BLOCK_SCHEMA) FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA = vol.Schema(_COMMON_BLOCK_SCHEMA)
@ -139,6 +155,53 @@ def _determine_no_window_reason(
return "insufficient_contiguous_window" return "insufficient_contiguous_window"
def _attempt_find_block(
price_info: list[dict],
*,
max_price_level: str | None,
min_price_level: str | None,
duration_intervals: int,
smooth_outliers: bool,
min_distance_from_avg: float | None,
reverse: bool,
) -> tuple[dict | None, str]:
"""Attempt to find a block with specific filter parameters.
Returns:
(result_dict, "") on success or (None, reason_code) on failure.
"""
level_filter_active = min_price_level is not None or max_price_level is not None
filtered = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
if smooth_outliers and filtered:
search_data = smooth_service_intervals(filtered)
else:
search_data = filtered
result = find_cheapest_contiguous_window(search_data, duration_intervals, reverse=reverse)
if result is None:
return None, _determine_no_window_reason(
price_info, filtered, duration_intervals, level_filter_active=level_filter_active
)
# Restore original prices (smoothing only affects window selection)
if smooth_outliers:
result["intervals"] = restore_original_prices(result["intervals"])
# Check distance constraint
if min_distance_from_avg is not None:
range_avg = calculate_search_range_avg(price_info)
window_mean = sum(iv["total"] for iv in result["intervals"]) / len(result["intervals"])
if range_avg is not None and not check_min_distance_from_avg(
window_mean, range_avg, min_distance_from_avg, reverse=reverse
):
return None, "window_above_distance_threshold" if not reverse else "window_below_distance_threshold"
return result, ""
async def _handle_find_block( async def _handle_find_block(
call: ServiceCall, call: ServiceCall,
*, *,
@ -159,7 +222,10 @@ async def _handle_find_block(
min_price_level: str | None = call.data.get("min_price_level") min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False) include_comparison_details: bool = call.data.get("include_comparison_details", False)
power_profile: list[int] | None = call.data.get("power_profile") power_profile: list[int] | None = call.data.get("power_profile")
level_filter_active = min_price_level is not None or max_price_level is not None smooth_outliers: bool = call.data.get("smooth_outliers", True)
min_distance_from_avg: float | None = call.data.get("min_distance_from_avg")
allow_relaxation: bool = call.data.get("allow_relaxation", True)
duration_flexibility_minutes: int | None = call.data.get("duration_flexibility_minutes")
duration_minutes_requested = int(duration_td.total_seconds() / 60) duration_minutes_requested = int(duration_td.total_seconds() / 60)
# Round up to nearest quarter-hour interval # Round up to nearest quarter-hour interval
@ -182,9 +248,13 @@ async def _handle_find_block(
home_tz = ZoneInfo(home_timezone) home_tz = ZoneInfo(home_timezone)
# Handle must_finish_by: convert deadline to search_end
validate_search_params(call.data)
effective_data, must_finish_by_dt = apply_must_finish_by(call.data, home_tz)
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default) # Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
now = dt_util.now().astimezone(home_tz) now = dt_util.now().astimezone(home_tz)
search_start, search_end = resolve_search_range(call.data, now, home_tz) search_start, search_end = resolve_search_range(effective_data, now, home_tz)
duration_intervals = duration_minutes // INTERVAL_MINUTES duration_intervals = duration_minutes // INTERVAL_MINUTES
@ -224,41 +294,90 @@ async def _handle_find_block(
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry) 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) price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
# Apply optional price level filter (P5) # --- Attempt with original parameters ---
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level) effective_duration = duration_intervals
result, reason = _attempt_find_block(
price_info,
max_price_level=max_price_level,
min_price_level=min_price_level,
duration_intervals=effective_duration,
smooth_outliers=smooth_outliers,
min_distance_from_avg=min_distance_from_avg,
reverse=reverse,
)
# Find cheapest/most expensive window relaxation_applied = False
result = find_cheapest_contiguous_window(filtered_price_info, duration_intervals, reverse=reverse) relaxation_steps = 0
# --- Relaxation loop ---
if result is None and allow_relaxation:
max_reduction = calculate_max_duration_reduction_intervals(duration_intervals, duration_flexibility_minutes)
steps = generate_relaxation_steps(
min_distance_from_avg=min_distance_from_avg,
max_price_level=max_price_level,
min_price_level=min_price_level,
total_intervals=duration_intervals,
min_duration_intervals=MIN_RELAXED_DURATION_INTERVALS,
max_duration_reduction_intervals=max_reduction,
reverse=reverse,
)
for step in steps:
effective_duration = duration_intervals - step.duration_reduction
result, reason = _attempt_find_block(
price_info,
max_price_level=step.max_price_level,
min_price_level=step.min_price_level,
duration_intervals=effective_duration,
smooth_outliers=smooth_outliers,
min_distance_from_avg=step.min_distance_from_avg,
reverse=reverse,
)
if result is not None:
relaxation_applied = True
relaxation_steps = step.step_number
effective_duration_minutes = effective_duration * INTERVAL_MINUTES
_LOGGER.info(
"%s: relaxation succeeded at step %d (phase=%s, duration=%dmin)",
service_label,
step.step_number,
step.phase,
effective_duration_minutes,
)
break
else:
reason = "relaxation_exhausted"
if result is None: if result is None:
reason = _determine_no_window_reason(
price_info,
filtered_price_info,
duration_intervals,
level_filter_active=level_filter_active,
)
_LOGGER.info( _LOGGER.info(
"%s: no window found (reason=%s, need %d intervals, have %d after level filter)", "%s: no window found (reason=%s, need %d intervals, have %d in range)",
service_label, service_label,
reason, reason,
duration_intervals, effective_duration,
len(filtered_price_info), len(price_info),
) )
return { response: dict[str, Any] = {
"home_id": home_id, "home_id": home_id,
"search_start": search_start.isoformat(), "search_start": search_start.isoformat(),
"search_end": search_end.isoformat(), "search_end": search_end.isoformat(),
"must_finish_by": must_finish_by_dt.isoformat() if must_finish_by_dt else None,
"duration_minutes_requested": duration_minutes_requested, "duration_minutes_requested": duration_minutes_requested,
"duration_minutes": duration_minutes, "duration_minutes": effective_duration * INTERVAL_MINUTES,
"currency": currency, "currency": currency,
"price_unit": price_unit, "price_unit": price_unit,
"window_found": False, "window_found": False,
"reason": reason, "reason": reason,
"relaxation_applied": relaxation_applied,
"window": None, "window": None,
} }
if relaxation_applied:
response["relaxation_steps"] = relaxation_steps
return response
# Effective duration may differ from original if relaxation reduced it
effective_duration_minutes = effective_duration * INTERVAL_MINUTES
# Find the opposite-direction window for price comparison (from full unfiltered list) # 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) comparison_result = find_cheapest_contiguous_window(price_info, effective_duration, reverse=not reverse)
# Calculate statistics and build response # Calculate statistics and build response
stats = calculate_window_statistics( stats = calculate_window_statistics(
@ -280,27 +399,42 @@ async def _handle_find_block(
else: else:
end_time = last_start + timedelta(minutes=INTERVAL_MINUTES) end_time = last_start + timedelta(minutes=INTERVAL_MINUTES)
# Calculate seconds until window start for scheduling convenience
window_start_str = (
result["intervals"][0]["startsAt"]
if isinstance(result["intervals"][0]["startsAt"], str)
else result["intervals"][0]["startsAt"].isoformat()
)
window_start_dt = datetime.fromisoformat(window_start_str)
seconds_until_start = max(0, int((window_start_dt - now).total_seconds()))
end_time_dt = end_time if isinstance(end_time, datetime) else datetime.fromisoformat(end_time)
seconds_until_end = max(0, int((end_time_dt - now).total_seconds()))
response = { response = {
"home_id": home_id, "home_id": home_id,
"search_start": search_start.isoformat(), "search_start": search_start.isoformat(),
"search_end": search_end.isoformat(), "search_end": search_end.isoformat(),
"must_finish_by": must_finish_by_dt.isoformat() if must_finish_by_dt else None,
"duration_minutes_requested": duration_minutes_requested, "duration_minutes_requested": duration_minutes_requested,
"duration_minutes": duration_minutes, "duration_minutes": effective_duration_minutes,
"currency": currency, "currency": currency,
"price_unit": price_unit, "price_unit": price_unit,
"window_found": True, "window_found": True,
"relaxation_applied": relaxation_applied,
"window": { "window": {
"start": result["intervals"][0]["startsAt"] "start": window_start_str,
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, "end": end_time.isoformat() if hasattr(end_time, "isoformat") else end_time,
"duration_minutes": duration_minutes, "seconds_until_start": seconds_until_start,
"seconds_until_end": seconds_until_end,
"duration_minutes": effective_duration_minutes,
"interval_count": len(result["intervals"]), "interval_count": len(result["intervals"]),
**stats, **stats,
"intervals": response_intervals, "intervals": response_intervals,
}, },
"price_comparison": price_comparison or None, "price_comparison": price_comparison or None,
} }
if relaxation_applied:
response["relaxation_steps"] = relaxation_steps
_LOGGER.info( _LOGGER.info(
"%s: found window at %s, mean=%.4f %s", "%s: found window at %s, mean=%.4f %s",

View file

@ -11,7 +11,7 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import math import math
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
import voluptuous as vol import voluptuous as vol
@ -25,14 +25,25 @@ from .helpers import (
INTERVAL_MINUTES, INTERVAL_MINUTES,
PRICE_LEVEL_ORDER, PRICE_LEVEL_ORDER,
VALID_SEARCH_SCOPES, VALID_SEARCH_SCOPES,
apply_must_finish_by,
build_rating_lookup, build_rating_lookup,
build_response_interval, build_response_interval,
calculate_search_range_avg,
check_min_distance_from_avg,
filter_intervals_by_price_level, filter_intervals_by_price_level,
get_entry_and_data, get_entry_and_data,
resolve_home_timezone, resolve_home_timezone,
resolve_search_range, resolve_search_range,
restore_original_prices,
smooth_service_intervals,
validate_power_profile_length, validate_power_profile_length,
validate_price_level_range, validate_price_level_range,
validate_search_params,
)
from .relaxation import (
MIN_RELAXED_DURATION_INTERVALS,
calculate_max_duration_reduction_intervals,
generate_relaxation_steps,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -72,6 +83,11 @@ _COMMON_HOURS_SCHEMA = {
), ),
vol.Optional("include_current_interval", default=True): cv.boolean, vol.Optional("include_current_interval", default=True): cv.boolean,
vol.Optional("use_base_unit", default=False): cv.boolean, vol.Optional("use_base_unit", default=False): cv.boolean,
vol.Optional("smooth_outliers", default=True): cv.boolean,
vol.Optional("min_distance_from_avg"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=50.0)),
vol.Optional("allow_relaxation", default=True): cv.boolean,
vol.Optional("duration_flexibility_minutes"): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
vol.Optional("must_finish_by"): cv.datetime,
} }
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA) FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA)
@ -94,6 +110,56 @@ def _determine_no_intervals_reason(
return "insufficient_intervals_for_constraints" return "insufficient_intervals_for_constraints"
def _attempt_find_hours(
price_info: list[dict],
*,
max_price_level: str | None,
min_price_level: str | None,
total_intervals: int,
min_segment_intervals: int,
smooth_outliers: bool,
min_distance_from_avg: float | None,
reverse: bool,
) -> tuple[dict | None, str]:
"""Attempt to find hours with specific filter parameters.
Returns:
(result_dict, "") on success or (None, reason_code) on failure.
"""
level_filter_active = min_price_level is not None or max_price_level is not None
filtered = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
if smooth_outliers and filtered:
search_data = smooth_service_intervals(filtered)
else:
search_data = filtered
result = find_cheapest_n_intervals(search_data, total_intervals, min_segment_intervals, reverse=reverse)
if result is None:
return None, _determine_no_intervals_reason(
price_info, filtered, total_intervals, level_filter_active=level_filter_active
)
# Restore original prices (smoothing only affects scoring)
if smooth_outliers:
result["intervals"] = restore_original_prices(result["intervals"])
for seg in result["segments"]:
seg["intervals"] = restore_original_prices(seg["intervals"])
# Check distance constraint
if min_distance_from_avg is not None:
range_avg = calculate_search_range_avg(price_info)
window_mean = sum(iv["total"] for iv in result["intervals"]) / len(result["intervals"])
if range_avg is not None and not check_min_distance_from_avg(
window_mean, range_avg, min_distance_from_avg, reverse=reverse
):
return None, "selection_above_distance_threshold" if not reverse else "selection_below_distance_threshold"
return result, ""
def _build_found_response( def _build_found_response(
*, *,
result: dict, result: dict,
@ -217,7 +283,10 @@ async def _handle_find_hours(
min_price_level: str | None = call.data.get("min_price_level") min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False) include_comparison_details: bool = call.data.get("include_comparison_details", False)
power_profile: list[int] | None = call.data.get("power_profile") power_profile: list[int] | None = call.data.get("power_profile")
level_filter_active = min_price_level is not None or max_price_level is not None smooth_outliers: bool = call.data.get("smooth_outliers", True)
min_distance_from_avg: float | None = call.data.get("min_distance_from_avg")
allow_relaxation: bool = call.data.get("allow_relaxation", True)
duration_flexibility_minutes: int | None = call.data.get("duration_flexibility_minutes")
total_minutes_requested = int(duration_td.total_seconds() / 60) 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 min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES
@ -243,9 +312,13 @@ async def _handle_find_hours(
home_tz = ZoneInfo(home_timezone) home_tz = ZoneInfo(home_timezone)
# Handle must_finish_by: convert deadline to search_end
validate_search_params(call.data)
effective_data, must_finish_by_dt = apply_must_finish_by(call.data, home_tz)
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default) # Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
now = dt_util.now().astimezone(home_tz) now = dt_util.now().astimezone(home_tz)
search_start, search_end = resolve_search_range(call.data, now, home_tz) search_start, search_end = resolve_search_range(effective_data, now, home_tz)
total_intervals = total_minutes // INTERVAL_MINUTES total_intervals = total_minutes // INTERVAL_MINUTES
min_segment_intervals = min_segment_minutes // INTERVAL_MINUTES min_segment_intervals = min_segment_minutes // INTERVAL_MINUTES
@ -296,47 +369,97 @@ async def _handle_find_hours(
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry) 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) price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
# Apply optional price level filter (P5) # --- Attempt with original parameters ---
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level) effective_total = total_intervals
result, reason = _attempt_find_hours(
price_info,
max_price_level=max_price_level,
min_price_level=min_price_level,
total_intervals=effective_total,
min_segment_intervals=min_segment_intervals,
smooth_outliers=smooth_outliers,
min_distance_from_avg=min_distance_from_avg,
reverse=reverse,
)
# Find cheapest/most expensive intervals relaxation_applied = False
result = find_cheapest_n_intervals(filtered_price_info, total_intervals, min_segment_intervals, reverse=reverse) relaxation_steps = 0
# --- Relaxation loop ---
if result is None and allow_relaxation:
max_reduction = calculate_max_duration_reduction_intervals(total_intervals, duration_flexibility_minutes)
min_dur = max(MIN_RELAXED_DURATION_INTERVALS, min_segment_intervals)
steps = generate_relaxation_steps(
min_distance_from_avg=min_distance_from_avg,
max_price_level=max_price_level,
min_price_level=min_price_level,
total_intervals=total_intervals,
min_duration_intervals=min_dur,
max_duration_reduction_intervals=max_reduction,
reverse=reverse,
)
for step in steps:
effective_total = total_intervals - step.duration_reduction
result, reason = _attempt_find_hours(
price_info,
max_price_level=step.max_price_level,
min_price_level=step.min_price_level,
total_intervals=effective_total,
min_segment_intervals=min_segment_intervals,
smooth_outliers=smooth_outliers,
min_distance_from_avg=step.min_distance_from_avg,
reverse=reverse,
)
if result is not None:
relaxation_applied = True
relaxation_steps = step.step_number
_LOGGER.info(
"%s: relaxation succeeded at step %d (phase=%s, intervals=%d)",
service_label,
step.step_number,
step.phase,
effective_total,
)
break
else:
reason = "relaxation_exhausted"
effective_total_minutes = effective_total * INTERVAL_MINUTES
if result is None: if result is None:
reason = _determine_no_intervals_reason(
price_info,
filtered_price_info,
total_intervals,
level_filter_active=level_filter_active,
)
_LOGGER.info( _LOGGER.info(
"%s: no interval selection found (reason=%s, need %d, have %d after level filter)", "%s: no interval selection found (reason=%s, need %d, have %d in range)",
service_label, service_label,
reason, reason,
total_intervals, effective_total,
len(filtered_price_info), len(price_info),
) )
return { response: dict[str, Any] = {
"home_id": home_id, "home_id": home_id,
"search_start": search_start.isoformat(), "search_start": search_start.isoformat(),
"search_end": search_end.isoformat(), "search_end": search_end.isoformat(),
"must_finish_by": must_finish_by_dt.isoformat() if must_finish_by_dt else None,
"total_minutes_requested": total_minutes_requested, "total_minutes_requested": total_minutes_requested,
"total_minutes": total_minutes, "total_minutes": effective_total_minutes,
"min_segment_minutes_requested": min_segment_minutes_requested, "min_segment_minutes_requested": min_segment_minutes_requested,
"min_segment_minutes": min_segment_minutes, "min_segment_minutes": min_segment_minutes,
"currency": currency, "currency": currency,
"price_unit": price_unit, "price_unit": price_unit,
"intervals_found": False, "intervals_found": False,
"reason": reason, "reason": reason,
"relaxation_applied": relaxation_applied,
"schedule": None, "schedule": None,
} }
if relaxation_applied:
response["relaxation_steps"] = relaxation_steps
return response
# Find opposite-direction selection for price comparison (from full unfiltered list) # Find opposite-direction selection for price comparison (from full unfiltered list)
comparison_result = find_cheapest_n_intervals( comparison_result = find_cheapest_n_intervals(
price_info, total_intervals, min_segment_intervals, reverse=not reverse price_info, effective_total, min_segment_intervals, reverse=not reverse
) )
return _build_found_response( found_response = _build_found_response(
result=result, result=result,
comparison_result=comparison_result, comparison_result=comparison_result,
reverse=reverse, reverse=reverse,
@ -344,7 +467,7 @@ async def _handle_find_hours(
search_start=search_start, search_start=search_start,
search_end=search_end, search_end=search_end,
total_minutes_requested=total_minutes_requested, total_minutes_requested=total_minutes_requested,
total_minutes=total_minutes, total_minutes=effective_total_minutes,
min_segment_minutes_requested=min_segment_minutes_requested, min_segment_minutes_requested=min_segment_minutes_requested,
min_segment_minutes=min_segment_minutes, min_segment_minutes=min_segment_minutes,
currency=currency, currency=currency,
@ -355,6 +478,22 @@ async def _handle_find_hours(
include_comparison_details=include_comparison_details, include_comparison_details=include_comparison_details,
power_profile=power_profile, power_profile=power_profile,
) )
found_response["relaxation_applied"] = relaxation_applied
found_response["must_finish_by"] = must_finish_by_dt.isoformat() if must_finish_by_dt else None
if relaxation_applied:
found_response["relaxation_steps"] = relaxation_steps
# Add seconds_until_start (time until first segment starts)
schedule = found_response.get("schedule")
if schedule and schedule.get("segments"):
first_seg_start = schedule["segments"][0]["start"]
first_seg_dt = datetime.fromisoformat(first_seg_start)
schedule["seconds_until_start"] = max(0, int((first_seg_dt - now).total_seconds()))
last_seg_end = schedule["segments"][-1]["end"]
last_seg_end_dt = datetime.fromisoformat(last_seg_end)
schedule["seconds_until_end"] = max(0, int((last_seg_end_dt - now).total_seconds()))
return found_response
async def handle_find_cheapest_hours(call: ServiceCall) -> ServiceResponse: async def handle_find_cheapest_hours(call: ServiceCall) -> ServiceResponse:

View file

@ -28,14 +28,23 @@ from .helpers import (
INTERVAL_MINUTES, INTERVAL_MINUTES,
PRICE_LEVEL_ORDER, PRICE_LEVEL_ORDER,
VALID_SEARCH_SCOPES, VALID_SEARCH_SCOPES,
apply_must_finish_by,
build_rating_lookup, build_rating_lookup,
build_response_interval, build_response_interval,
filter_intervals_by_price_level, filter_intervals_by_price_level,
get_entry_and_data, get_entry_and_data,
resolve_home_timezone, resolve_home_timezone,
resolve_search_range, resolve_search_range,
restore_original_prices,
smooth_service_intervals,
validate_power_profile_length, validate_power_profile_length,
validate_price_level_range, validate_price_level_range,
validate_search_params,
)
from .relaxation import (
MIN_RELAXED_DURATION_INTERVALS,
build_level_filter_steps,
calculate_max_duration_reduction_intervals,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -77,11 +86,15 @@ FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)), 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_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_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("must_finish_by"): cv.datetime,
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES), 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("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("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("include_comparison_details", default=False): cv.boolean, vol.Optional("include_comparison_details", default=False): cv.boolean,
vol.Optional("use_base_unit", default=False): cv.boolean, vol.Optional("use_base_unit", default=False): cv.boolean,
vol.Optional("smooth_outliers", default=True): cv.boolean,
vol.Optional("allow_relaxation", default=True): cv.boolean,
vol.Optional("duration_flexibility_minutes"): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
} }
) )
@ -209,6 +222,68 @@ def _find_cheapest_window_in_pool(
return (best_start, best_start + duration_intervals) return (best_start, best_start + duration_intervals)
def _attempt_schedule(
price_info: list[dict[str, Any]],
*,
max_price_level: str | None,
min_price_level: str | None,
tasks: list[dict[str, Any]],
gap_intervals: int,
smooth_outliers: bool,
) -> tuple[list[dict[str, Any]], list[str], list[dict[str, Any]]]:
"""Attempt to schedule tasks with specific filter parameters.
Returns:
(assignments, unscheduled_names, filtered_price_info)
"""
filtered = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
if smooth_outliers and filtered:
search_data = smooth_service_intervals(filtered)
else:
search_data = filtered
if not search_data:
return [], [t["name"] for t in tasks], filtered
# Greedy assignment: longest task first
tasks_sorted = sorted(tasks, key=lambda t: t["duration_intervals"], reverse=True)
available = [True] * len(search_data)
assignments: list[dict[str, Any]] = []
unscheduled: list[str] = []
for task in tasks_sorted:
dur_intervals = task["duration_intervals"]
window = _find_cheapest_window_in_pool(search_data, dur_intervals, available)
if window is None:
unscheduled.append(task["name"])
continue
start_idx, end_idx = window
task_intervals = search_data[start_idx:end_idx]
# Restore original prices for response
if smooth_outliers:
task_intervals = restore_original_prices(task_intervals)
# Mark task intervals + trailing gap as unavailable
gap_end = min(end_idx + gap_intervals, len(search_data))
for k in range(start_idx, gap_end):
available[k] = False
assignments.append(
{
"name": task["name"],
"task": task,
"intervals": task_intervals,
}
)
return assignments, unscheduled, filtered
async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
"""Handle find_cheapest_schedule service call.""" """Handle find_cheapest_schedule service call."""
service_label = "find_cheapest_schedule" service_label = "find_cheapest_schedule"
@ -220,7 +295,9 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
max_price_level: str | None = call.data.get("max_price_level") max_price_level: str | None = call.data.get("max_price_level")
min_price_level: str | None = call.data.get("min_price_level") min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False) include_comparison_details: bool = call.data.get("include_comparison_details", False)
level_filter_active = min_price_level is not None or max_price_level is not None smooth_outliers: bool = call.data.get("smooth_outliers", True)
allow_relaxation: bool = call.data.get("allow_relaxation", True)
duration_flexibility_minutes: int | None = call.data.get("duration_flexibility_minutes")
# Validate task names are unique (before any expensive operations) # Validate task names are unique (before any expensive operations)
task_names = [t["name"] for t in tasks_raw] task_names = [t["name"] for t in tasks_raw]
@ -251,8 +328,12 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
home_tz = ZoneInfo(home_timezone) home_tz = ZoneInfo(home_timezone)
# Validate and handle must_finish_by
validate_search_params(call.data)
effective_data, must_finish_by_dt = apply_must_finish_by(call.data, home_tz)
now = dt_util.now().astimezone(home_tz) now = dt_util.now().astimezone(home_tz)
search_start, search_end = resolve_search_range(call.data, now, home_tz) search_start, search_end = resolve_search_range(effective_data, now, home_tz)
# Resolve task durations (round up to intervals) # Resolve task durations (round up to intervals)
tasks: list[dict[str, Any]] = [] tasks: list[dict[str, Any]] = []
@ -322,51 +403,112 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry) 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) price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
# Apply optional level filter # --- Attempt with original parameters ---
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level) raw_assignments, unscheduled, filtered = _attempt_schedule(
price_info,
max_price_level=max_price_level,
min_price_level=min_price_level,
tasks=tasks,
gap_intervals=gap_intervals,
smooth_outliers=smooth_outliers,
)
all_scheduled = len(unscheduled) == 0
level_filter_active = min_price_level is not None or max_price_level is not None
if not filtered_price_info: relaxation_applied = False
reason = _determine_schedule_reason( relaxation_steps = 0
all_tasks_scheduled=False,
assignments_count=0,
price_info=price_info,
filtered_price_info=filtered_price_info,
level_filter_active=level_filter_active,
)
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,
"reason": reason,
"tasks": [],
"total_estimated_cost": None,
}
# Greedy assignment: longest task first # --- Relaxation loop ---
tasks_sorted = sorted(tasks, key=lambda t: t["duration_intervals"], reverse=True) if not all_scheduled and allow_relaxation:
available = [True] * len(filtered_price_info) step_num = 0
best_assignments = raw_assignments
best_unscheduled = unscheduled
best_filtered = filtered
best_step = 0
# Phase 1: Level filter relaxation
level_steps = build_level_filter_steps(max_price_level, min_price_level, reverse=False)
for lvl_max, lvl_min in level_steps:
step_num += 1
a, u, f = _attempt_schedule(
price_info,
max_price_level=lvl_max,
min_price_level=lvl_min,
tasks=tasks,
gap_intervals=gap_intervals,
smooth_outliers=smooth_outliers,
)
if len(a) > len(best_assignments):
best_assignments, best_unscheduled, best_filtered = a, u, f
best_step = step_num
if not u:
break
# Phase 2: Duration reduction (uniform across all tasks)
if best_unscheduled:
shortest_task = min(t["duration_intervals"] for t in tasks)
max_reduction = calculate_max_duration_reduction_intervals(shortest_task, duration_flexibility_minutes)
for reduction in range(1, max_reduction + 1):
# Build reduced-duration tasks
reduced = []
can_reduce = True
for t in tasks:
new_dur = t["duration_intervals"] - reduction
if new_dur < MIN_RELAXED_DURATION_INTERVALS:
can_reduce = False
break
reduced.append(
{
**t,
"duration_intervals": new_dur,
"duration_minutes": new_dur * INTERVAL_MINUTES,
}
)
if not can_reduce:
break
step_num += 1
a, u, f = _attempt_schedule(
price_info,
max_price_level=None,
min_price_level=None,
tasks=reduced,
gap_intervals=gap_intervals,
smooth_outliers=smooth_outliers,
)
if len(a) > len(best_assignments):
best_assignments, best_unscheduled, best_filtered = a, u, f
best_step = step_num
if not u:
break
if best_step > 0 and len(best_assignments) > len(raw_assignments):
raw_assignments = best_assignments
unscheduled = best_unscheduled
filtered = best_filtered
relaxation_applied = True
relaxation_steps = best_step
all_scheduled = len(unscheduled) == 0
_LOGGER.info(
"%s: relaxation improved result at step %d (%d/%d tasks)",
service_label,
best_step,
len(raw_assignments),
len(tasks),
)
elif not unscheduled:
# All scheduled via relaxation at best_step
raw_assignments = best_assignments
unscheduled = best_unscheduled
filtered = best_filtered
relaxation_applied = True
relaxation_steps = best_step
all_scheduled = True
# --- Build full response assignments from raw ---
assignments: list[dict[str, Any]] = [] assignments: list[dict[str, Any]] = []
unscheduled: list[str] = [] for raw in raw_assignments:
task_intervals = raw["intervals"]
for task in tasks_sorted: task = raw["task"]
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( stats = calculate_window_statistics(
task_intervals, task_intervals,
@ -381,7 +523,6 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
last_dt = datetime.fromisoformat(last_start) if isinstance(last_start, str) else last_start last_dt = datetime.fromisoformat(last_start) if isinstance(last_start, str) else last_start
end_dt = last_dt + timedelta(minutes=INTERVAL_MINUTES) 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] task_response_intervals = [build_response_interval(iv, unit_factor, rating_lookup) for iv in task_intervals]
assignments.append( assignments.append(
@ -411,14 +552,15 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
] ]
total_estimated_cost = round(sum(total_cost_values), 4) if total_cost_values else None total_estimated_cost = round(sum(total_cost_values), 4) if total_cost_values else None
all_scheduled = len(unscheduled) == 0
reason = _determine_schedule_reason( reason = _determine_schedule_reason(
all_tasks_scheduled=all_scheduled, all_tasks_scheduled=all_scheduled,
assignments_count=len(assignments), assignments_count=len(assignments),
price_info=price_info, price_info=price_info,
filtered_price_info=filtered_price_info, filtered_price_info=filtered,
level_filter_active=level_filter_active, level_filter_active=level_filter_active,
) )
if not all_scheduled and relaxation_applied and reason is not None:
reason = "relaxation_exhausted"
_LOGGER.info( _LOGGER.info(
"%s: scheduled %d/%d tasks, total_cost=%s", "%s: scheduled %d/%d tasks, total_cost=%s",
@ -428,16 +570,27 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
total_estimated_cost, total_estimated_cost,
) )
# Compute seconds_until_start and seconds_until_end for each scheduled task
for task_entry in assignments:
task_start_dt = datetime.fromisoformat(task_entry["start"])
task_entry["seconds_until_start"] = max(0, int((task_start_dt - now).total_seconds()))
task_end_dt = datetime.fromisoformat(task_entry["end"])
task_entry["seconds_until_end"] = max(0, int((task_end_dt - now).total_seconds()))
result: dict[str, Any] = { result: dict[str, Any] = {
"home_id": home_id, "home_id": home_id,
"search_start": search_start.isoformat(), "search_start": search_start.isoformat(),
"search_end": search_end.isoformat(), "search_end": search_end.isoformat(),
"must_finish_by": must_finish_by_dt.isoformat() if must_finish_by_dt else None,
"currency": currency, "currency": currency,
"price_unit": price_unit, "price_unit": price_unit,
"all_tasks_scheduled": all_scheduled, "all_tasks_scheduled": all_scheduled,
"reason": reason, "reason": reason,
"relaxation_applied": relaxation_applied,
"unscheduled_tasks": unscheduled or None, "unscheduled_tasks": unscheduled or None,
"tasks": assignments, "tasks": assignments,
"total_estimated_cost": total_estimated_cost, "total_estimated_cost": total_estimated_cost,
} }
if relaxation_applied:
result["relaxation_steps"] = relaxation_steps
return result return result

View file

@ -11,6 +11,7 @@ Functions:
localize_to_home_tz: Localize datetime to Tibber home timezone localize_to_home_tz: Localize datetime to Tibber home timezone
calculate_end_of_tomorrow: Calculate end of tomorrow in home timezone calculate_end_of_tomorrow: Calculate end of tomorrow in home timezone
floor_to_quarter_hour: Floor datetime to quarter-hour boundary floor_to_quarter_hour: Floor datetime to quarter-hour boundary
apply_must_finish_by: Convert must_finish_by deadline to search_end
resolve_search_range: Resolve search start/end from various input formats resolve_search_range: Resolve search start/end from various input formats
filter_intervals_by_price_level: Filter intervals by Tibber price level filter_intervals_by_price_level: Filter intervals by Tibber price level
VALID_SEARCH_SCOPES: Set of valid search_scope shorthand values VALID_SEARCH_SCOPES: Set of valid search_scope shorthand values
@ -46,6 +47,10 @@ if TYPE_CHECKING:
# Interval duration in minutes (quarter-hourly resolution) # Interval duration in minutes (quarter-hourly resolution)
INTERVAL_MINUTES = 15 INTERVAL_MINUTES = 15
# Default flexibility percentage for outlier smoothing in services
# Matches the period system default (15%) for consistency
DEFAULT_SERVICE_SMOOTHING_FLEX = 15.0
# Valid scopes for the search_scope shorthand parameter # Valid scopes for the search_scope shorthand parameter
VALID_SEARCH_SCOPES = frozenset({"today", "tomorrow", "remaining_today", "next_24h", "next_48h"}) VALID_SEARCH_SCOPES = frozenset({"today", "tomorrow", "remaining_today", "next_24h", "next_48h"})
@ -65,6 +70,7 @@ _EXPLICIT_RANGE_PARAMS = frozenset(
"search_end_offset_minutes", "search_end_offset_minutes",
"search_start_day_offset", "search_start_day_offset",
"search_end_day_offset", "search_end_day_offset",
"must_finish_by",
} }
) )
@ -94,6 +100,17 @@ def validate_search_params(call_data: dict[str, Any]) -> None:
translation_placeholders={"params": ", ".join(sorted(conflicting))}, translation_placeholders={"params": ", ".join(sorted(conflicting))},
) )
# must_finish_by conflicts with all end-boundary parameters
if "must_finish_by" in call_data:
end_conflicts = {"search_end", "search_end_time", "search_end_offset_minutes"}
conflicting_end = [p for p in end_conflicts if p in call_data]
if conflicting_end:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="must_finish_by_conflicts_with_end",
translation_placeholders={"params": ", ".join(sorted(conflicting_end))},
)
# search_start and search_start_time are mutually exclusive start-time specifications # search_start and search_start_time are mutually exclusive start-time specifications
if "search_start" in call_data and "search_start_time" in call_data: if "search_start" in call_data and "search_start_time" in call_data:
raise ServiceValidationError( raise ServiceValidationError(
@ -123,6 +140,36 @@ def validate_search_params(call_data: dict[str, Any]) -> None:
) )
def apply_must_finish_by(
call_data: dict[str, Any],
home_tz: ZoneInfo,
) -> tuple[dict[str, Any], datetime | None]:
"""Convert must_finish_by deadline to search_end.
When must_finish_by is set, search_end is set to must_finish_by directly.
The interval pool uses exclusive end_time (intervals with startsAt < end_time),
so the latest found window/schedule naturally ends at search_end.
Args:
call_data: Service call data dict.
home_tz: Home timezone for datetime localization.
Returns:
Tuple of (possibly modified call_data, localized must_finish_by datetime or None).
If must_finish_by is absent, returns the original call_data unchanged.
"""
if "must_finish_by" not in call_data:
return call_data, None
must_finish_by = localize_to_home_tz(call_data["must_finish_by"], home_tz)
modified = dict(call_data)
modified["search_end"] = must_finish_by
del modified["must_finish_by"]
return modified, must_finish_by
def validate_price_level_range( def validate_price_level_range(
min_price_level: str | None, min_price_level: str | None,
max_price_level: str | None, max_price_level: str | None,
@ -507,3 +554,108 @@ def resolve_search_range(
) )
return search_start, search_end return search_start, search_end
def smooth_service_intervals(
intervals: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""
Apply outlier smoothing to price intervals for service window finding.
Reuses the period system's outlier filtering with a fixed flexibility
percentage. Smoothed intervals are used for window FINDING only
original prices should be restored for response reporting.
Args:
intervals: Price intervals to smooth.
Returns:
New list of intervals with spike prices smoothed.
Smoothed intervals have '_original_total' preserving the real price.
"""
from custom_components.tibber_prices.coordinator.period_handlers.outlier_filtering import ( # noqa: PLC0415
filter_price_outliers,
)
return filter_price_outliers(intervals, DEFAULT_SERVICE_SMOOTHING_FLEX, 0)
def restore_original_prices(intervals: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Restore original prices on intervals that were smoothed.
After using smoothed intervals for window finding, call this to get
intervals with true prices for response reporting.
Args:
intervals: Intervals possibly containing '_original_total' from smoothing.
Returns:
New list of intervals with original prices restored and smoothing
metadata removed.
"""
result = []
for iv in intervals:
if "_original_total" in iv:
restored = {k: v for k, v in iv.items() if k not in ("_smoothed", "_original_total")}
restored["total"] = iv["_original_total"]
result.append(restored)
else:
result.append(iv)
return result
def calculate_search_range_avg(intervals: list[dict[str, Any]]) -> float | None:
"""
Calculate average price across all intervals in the search range.
Used as reference for min_distance_from_avg validation.
Args:
intervals: All price intervals in the search range (unfiltered).
Returns:
Average price in base currency unit, or None if no intervals.
"""
if not intervals:
return None
return sum(iv["total"] for iv in intervals) / len(intervals)
def check_min_distance_from_avg(
window_mean_base: float,
range_avg: float,
min_distance_pct: float,
*,
reverse: bool,
) -> bool:
"""
Check if window mean price meets the minimum distance from range average.
For cheapest searches: window mean must be at least X% BELOW range average.
For most expensive searches: window mean must be at least X% ABOVE range average.
Args:
window_mean_base: Window mean price in BASE currency (not display unit).
range_avg: Search range average price in BASE currency.
min_distance_pct: Required distance as percentage (e.g. 5.0 = 5%).
reverse: True for most-expensive searches.
Returns:
True if the window passes the distance check.
"""
if range_avg == 0:
return True # Cannot calculate percentage difference from zero
distance_ratio = min_distance_pct / 100
if reverse:
# Most expensive: window mean must be >= avg * (1 + distance)
threshold = range_avg * (1 + distance_ratio)
return window_mean_base >= threshold
# Cheapest: window mean must be <= avg * (1 - distance)
threshold = range_avg * (1 - distance_ratio)
return window_mean_base <= threshold

View file

@ -0,0 +1,202 @@
"""Service relaxation logic for progressive filter loosening.
When allow_relaxation is enabled (default), services progressively loosen
user-provided filters to guarantee a result whenever price data is available.
Relaxation phases (in order):
1. Halve min_distance_from_avg remove it entirely
2. Expand level filters one step at a time remove all level filters
3. Reduce duration by 1 interval per step (up to dynamic or user-specified cap)
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
from .helpers import INTERVAL_MINUTES
_LOGGER = logging.getLogger(__name__)
PRICE_LEVEL_ORDER = ("very_cheap", "cheap", "normal", "expensive", "very_expensive")
#: Never reduce duration below this many intervals (30 min)
MIN_RELAXED_DURATION_INTERVALS = 2
@dataclass(frozen=True)
class RelaxationStep:
"""Parameters for a single relaxation attempt."""
step_number: int
min_distance_from_avg: float | None
max_price_level: str | None
min_price_level: str | None
duration_reduction: int # intervals subtracted from original duration
phase: str # "distance" | "level_filter" | "duration"
def calculate_max_duration_reduction_intervals(
total_intervals: int,
explicit_flexibility_minutes: int | None = None,
) -> int:
"""Calculate maximum number of intervals that duration can be reduced by.
Args:
total_intervals: Original requested duration in intervals.
explicit_flexibility_minutes: User-specified cap in minutes (None = auto).
Returns:
Maximum number of intervals to reduce (0 = no reduction allowed).
"""
if explicit_flexibility_minutes is not None:
return max(0, explicit_flexibility_minutes // INTERVAL_MINUTES)
# Dynamic: ~20% of duration, min 0 for short tasks, max 4 intervals (60 min)
if total_intervals <= 3:
return 0
return min(4, max(1, total_intervals // 5))
def build_level_filter_steps(
max_price_level: str | None,
min_price_level: str | None,
*,
reverse: bool,
) -> list[tuple[str | None, str | None]]:
"""Build progressive level filter relaxation tuples (max_level, min_level).
For cheapest (reverse=False): expand max_price_level upward.
For most expensive (reverse=True): expand min_price_level downward.
Then remove remaining filters.
"""
if max_price_level is None and min_price_level is None:
return []
steps: list[tuple[str | None, str | None]] = []
cur_max = max_price_level
cur_min = min_price_level
# Primary direction: widen the dominant constraint
if not reverse and cur_max is not None:
idx = PRICE_LEVEL_ORDER.index(cur_max)
for next_idx in range(idx + 1, len(PRICE_LEVEL_ORDER)):
cur_max = PRICE_LEVEL_ORDER[next_idx]
steps.append((cur_max, cur_min))
cur_max = None
# If min still set, add intermediate step (max removed, min stays)
if cur_min is not None:
steps.append((None, cur_min))
if reverse and cur_min is not None:
idx = PRICE_LEVEL_ORDER.index(cur_min)
for next_idx in range(idx - 1, -1, -1):
cur_min = PRICE_LEVEL_ORDER[next_idx]
steps.append((cur_max, cur_min))
cur_min = None
if cur_max is not None:
steps.append((cur_max, None))
# Final: remove all level filters
if not steps or steps[-1] != (None, None):
steps.append((None, None))
return steps
def generate_relaxation_steps(
*,
min_distance_from_avg: float | None,
max_price_level: str | None,
min_price_level: str | None,
total_intervals: int,
min_duration_intervals: int,
max_duration_reduction_intervals: int,
reverse: bool,
) -> list[RelaxationStep]:
"""Generate progressive relaxation steps for service retry logic.
Each step represents a complete set of filter parameters to try.
Steps are ordered from least to most relaxed.
Args:
min_distance_from_avg: Original distance threshold (None = not set).
max_price_level: Original max level filter (None = not set).
min_price_level: Original min level filter (None = not set).
total_intervals: Original requested duration in intervals.
min_duration_intervals: Absolute minimum duration (never go below this).
max_duration_reduction_intervals: Maximum intervals to reduce duration by.
reverse: True for most_expensive services.
Returns:
List of RelaxationStep objects to try in order.
"""
steps: list[RelaxationStep] = []
step_num = 0
# Track cumulative state — each phase inherits from previous
cur_distance = min_distance_from_avg
cur_max = max_price_level
cur_min = min_price_level
# Phase 1: Relax min_distance_from_avg
if cur_distance is not None:
halved = round(cur_distance / 2, 1)
if halved >= 0.1:
step_num += 1
steps.append(
RelaxationStep(
step_number=step_num,
min_distance_from_avg=halved,
max_price_level=cur_max,
min_price_level=cur_min,
duration_reduction=0,
phase="distance",
)
)
step_num += 1
steps.append(
RelaxationStep(
step_number=step_num,
min_distance_from_avg=None,
max_price_level=cur_max,
min_price_level=cur_min,
duration_reduction=0,
phase="distance",
)
)
cur_distance = None
# Phase 2: Relax level filters
level_steps = build_level_filter_steps(cur_max, cur_min, reverse=reverse)
for lvl_max, lvl_min in level_steps:
step_num += 1
steps.append(
RelaxationStep(
step_number=step_num,
min_distance_from_avg=None,
max_price_level=lvl_max,
min_price_level=lvl_min,
duration_reduction=0,
phase="level_filter",
)
)
# Phase 3: Reduce duration (1 interval per step)
for reduction in range(1, max_duration_reduction_intervals + 1):
new_dur = total_intervals - reduction
if new_dur >= min_duration_intervals:
step_num += 1
steps.append(
RelaxationStep(
step_number=step_num,
min_distance_from_avg=None,
max_price_level=None,
min_price_level=None,
duration_reduction=reduction,
phase="duration",
)
)
return steps

View file

@ -1289,6 +1289,9 @@
}, },
"tasks_exceed_search_window": { "tasks_exceed_search_window": {
"message": "Die Gesamtdauer aller Aufgaben inklusive Pausen ({total_minutes} Min.) überschreitet das Suchfenster ({window_minutes} Min.). Reduziere die Aufgabendauern, verringere gap_minutes oder erweitere den Suchzeitraum." "message": "Die Gesamtdauer aller Aufgaben inklusive Pausen ({total_minutes} Min.) überschreitet das Suchfenster ({window_minutes} Min.). Reduziere die Aufgabendauern, verringere gap_minutes oder erweitere den Suchzeitraum."
},
"must_finish_by_conflicts_with_end": {
"message": "must_finish_by kann nicht mit Endgrenz-Parametern ({params}) kombiniert werden. Verwende must_finish_by allein — es setzt das Suchende automatisch auf die Deadline."
} }
}, },
"services": { "services": {
@ -1503,20 +1506,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Suchbereich", "name": "Benutzerdefinierter Suchbereich",
"description": "Zeitfenster fuer die Suche festlegen." "description": "Exakte Start- und Endzeiten für die Suche festlegen. Überschreibt den Suchbereich (Shortcut), wenn gesetzt."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative Zeitbereich-Optionen", "name": "Erweiterte Zeitoptionen",
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets." "description": "Alternative Möglichkeiten zum Festlegen des Suchbereichs über Tageszeit und Minuten-Offsets."
}, },
"price_filter": { "price_filter": {
"name": "Preisstufen-Filter", "name": "Preisstufen-Filter",
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken." "description": "Suche auf Intervalle innerhalb des angegebenen Tibber-Preisstufen-Bereichs einschränken."
},
"search_tuning": {
"name": "Suchalgorithmus-Feinabstimmung",
"description": "Feinabstimmung wie die Suche Ausreißer, Mindestqualitätsschwellen und Fallback-Verhalten behandelt."
},
"cost_estimation": {
"name": "Kostenabschätzung",
"description": "Leistungsprofil angeben, um genaue Energiekostenschätzungen basierend auf dem tatsächlichen Verbrauch zu erhalten."
}, },
"output": { "output": {
"name": "Ausgabeoptionen", "name": "Ausgabeoptionen",
"description": "Kostenabschaetzung und Vergleichsausgabe steuern." "description": "Ausgabeformat steuern: Vergleichsdetails und Währungseinheit."
} }
}, },
"fields": { "fields": {
@ -1536,6 +1547,10 @@
"name": "Suchende", "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." "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."
}, },
"must_finish_by": {
"name": "Muss fertig sein bis",
"description": "Deadline: das Gerät muss bis zu diesem Zeitpunkt fertig sein. Der Suchbereich endet an dieser Deadline — der Service findet das günstigste Zeitfenster, das vorher endet. Kann nicht mit Suchende, Suchende-Uhrzeit oder Suchende-Offset kombiniert werden."
},
"search_start_time": { "search_start_time": {
"name": "Suchbeginn-Uhrzeit", "name": "Suchbeginn-Uhrzeit",
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist." "description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
@ -1570,23 +1585,39 @@
}, },
"search_scope": { "search_scope": {
"name": "Suchbereich (Shortcut)", "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." "description": "Kurzwahl für häufige Suchbereiche. Überschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
}, },
"max_price_level": { "max_price_level": {
"name": "Maximale Preisstufe", "name": "Maximale Preisstufe",
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung." "description": "Nur Intervalle bis zu dieser Tibber-Preisstufe berücksichtigen. very_cheap = restriktivste, very_expensive = keine Einschränkung."
}, },
"min_price_level": { "min_price_level": {
"name": "Minimale Preisstufe", "name": "Minimale Preisstufe",
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren." "description": "Nur Intervalle ab dieser Tibber-Preisstufe berücksichtigen. Nützlich für find_most_expensive, um wirklich teure Intervalle zu fokussieren."
}, },
"include_comparison_details": { "include_comparison_details": {
"name": "Vergleichsdetails einschliessen", "name": "Vergleichsdetails einschliessen",
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end." "description": "Das price_comparison-Ergebnis um zusätzliche Felder ergänzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
}, },
"power_profile": { "power_profile": {
"name": "Leistungsprofil", "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." "description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsächlichen Verbrauch statt einer festen 1-kW-Last an."
},
"smooth_outliers": {
"name": "Ausreißer glätten",
"description": "Preisausreißer vor der Suche glätten. Ausreißer-Intervalle werden vorübergehend durch den Durchschnitt ihrer Nachbarn ersetzt, sodass ein einzelner Ausschlag das Ergebnis nicht dominiert. Die Antwort zeigt immer die Original-Preise (ungeglättet). Standard: aktiviert."
},
"min_distance_from_avg": {
"name": "Min. Abstand vom Durchschnitt",
"description": "Ergebnis muss mindestens um diesen Prozentsatz vom Suchbereichs-Durchschnitt abweichen. Für günstigste: Ergebnis muss mindestens X% unter dem Durchschnitt liegen. Für teuerste: mindestens X% darüber. Wird die Bedingung nicht erfüllt, wird kein Ergebnis zurückgegeben (reason: selection_above/below_distance_threshold). Leer lassen zum Deaktivieren."
},
"allow_relaxation": {
"name": "Lockerung erlauben",
"description": "Filter schrittweise lockern, um ein Ergebnis zu garantieren. Phasen: 1) Abstandsschwelle reduzieren/entfernen 2) Preislevel-Filter erweitern 3) Dauer reduzieren. Standard: aktiviert."
},
"duration_flexibility_minutes": {
"name": "Dauer-Flexibilität",
"description": "Maximale Minuten, um die die Dauer bei der Lockerung verkürzt werden darf (0120, Schritt 15). Leer lassen für automatische Berechnung (~20% der Dauer, max. 60 Min.)."
} }
} }
}, },
@ -1595,20 +1626,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Suchbereich", "name": "Benutzerdefinierter Suchbereich",
"description": "Zeitfenster fuer die Suche festlegen." "description": "Exakte Start- und Endzeiten für die Suche festlegen. Überschreibt den Suchbereich (Shortcut), wenn gesetzt."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative Zeitbereich-Optionen", "name": "Erweiterte Zeitoptionen",
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets." "description": "Alternative Möglichkeiten zum Festlegen des Suchbereichs über Tageszeit und Minuten-Offsets."
}, },
"price_filter": { "price_filter": {
"name": "Preisstufen-Filter", "name": "Preisstufen-Filter",
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken." "description": "Suche auf Intervalle innerhalb des angegebenen Tibber-Preisstufen-Bereichs einschränken."
},
"search_tuning": {
"name": "Suchalgorithmus-Feinabstimmung",
"description": "Feinabstimmung wie die Suche Ausreißer, Mindestqualitätsschwellen und Fallback-Verhalten behandelt."
},
"cost_estimation": {
"name": "Kostenabschätzung",
"description": "Leistungsprofil angeben, um genaue Energiekostenschätzungen basierend auf dem tatsächlichen Verbrauch zu erhalten."
}, },
"output": { "output": {
"name": "Ausgabeoptionen", "name": "Ausgabeoptionen",
"description": "Kostenabschaetzung und Vergleichsausgabe steuern." "description": "Ausgabeformat steuern: Vergleichsdetails und Währungseinheit."
} }
}, },
"fields": { "fields": {
@ -1628,6 +1667,10 @@
"name": "Suchende", "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." "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."
}, },
"must_finish_by": {
"name": "Muss fertig sein bis",
"description": "Deadline: das Gerät muss bis zu diesem Zeitpunkt fertig sein. Der Suchbereich endet an dieser Deadline — der Service findet das teuerste Zeitfenster, das vorher endet. Kann nicht mit Suchende, Suchende-Uhrzeit, Suchende-Versatz oder Suchbereich (Shortcut) kombiniert werden."
},
"search_start_time": { "search_start_time": {
"name": "Suchbeginn-Uhrzeit", "name": "Suchbeginn-Uhrzeit",
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist." "description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
@ -1662,23 +1705,39 @@
}, },
"search_scope": { "search_scope": {
"name": "Suchbereich (Shortcut)", "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." "description": "Kurzwahl für häufige Suchbereiche. Überschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
}, },
"max_price_level": { "max_price_level": {
"name": "Maximale Preisstufe", "name": "Maximale Preisstufe",
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung." "description": "Nur Intervalle bis zu dieser Tibber-Preisstufe berücksichtigen. very_cheap = restriktivste, very_expensive = keine Einschränkung."
}, },
"min_price_level": { "min_price_level": {
"name": "Minimale Preisstufe", "name": "Minimale Preisstufe",
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren." "description": "Nur Intervalle ab dieser Tibber-Preisstufe berücksichtigen. Nützlich für find_most_expensive, um wirklich teure Intervalle zu fokussieren."
}, },
"include_comparison_details": { "include_comparison_details": {
"name": "Vergleichsdetails einschliessen", "name": "Vergleichsdetails einschliessen",
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end." "description": "Das price_comparison-Ergebnis um zusätzliche Felder ergänzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
}, },
"power_profile": { "power_profile": {
"name": "Leistungsprofil", "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." "description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsächlichen Verbrauch statt einer festen 1-kW-Last an."
},
"smooth_outliers": {
"name": "Ausreißer glätten",
"description": "Preisausreißer vor der Suche glätten. Ausreißer-Intervalle werden vorübergehend durch den Durchschnitt ihrer Nachbarn ersetzt, sodass ein einzelner Ausschlag das Ergebnis nicht dominiert. Die Antwort zeigt immer die Original-Preise (ungeglättet). Standard: aktiviert."
},
"min_distance_from_avg": {
"name": "Min. Abstand vom Durchschnitt",
"description": "Ergebnis muss mindestens um diesen Prozentsatz vom Suchbereichs-Durchschnitt abweichen. Für günstigste: Ergebnis muss mindestens X% unter dem Durchschnitt liegen. Für teuerste: mindestens X% darüber. Wird die Bedingung nicht erfüllt, wird kein Ergebnis zurückgegeben (reason: selection_above/below_distance_threshold). Leer lassen zum Deaktivieren."
},
"allow_relaxation": {
"name": "Lockerung erlauben",
"description": "Filter schrittweise lockern, um ein Ergebnis zu garantieren. Phasen: 1) Abstandsschwelle reduzieren/entfernen 2) Preislevel-Filter erweitern 3) Dauer reduzieren. Standard: aktiviert."
},
"duration_flexibility_minutes": {
"name": "Dauer-Flexibilität",
"description": "Maximale Minuten, um die die Dauer bei der Lockerung verkürzt werden darf (0120, Schritt 15). Leer lassen für automatische Berechnung (~20% der Dauer, max. 60 Min.)."
} }
} }
}, },
@ -1687,20 +1746,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Suchbereich", "name": "Benutzerdefinierter Suchbereich",
"description": "Zeitfenster fuer die Suche festlegen." "description": "Exakte Start- und Endzeiten für die Suche festlegen. Überschreibt den Suchbereich (Shortcut), wenn gesetzt."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative Zeitbereich-Optionen", "name": "Erweiterte Zeitoptionen",
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets." "description": "Alternative Möglichkeiten zum Festlegen des Suchbereichs über Tageszeit und Minuten-Offsets."
}, },
"price_filter": { "price_filter": {
"name": "Preisstufen-Filter", "name": "Preisstufen-Filter",
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken." "description": "Suche auf Intervalle innerhalb des angegebenen Tibber-Preisstufen-Bereichs einschränken."
},
"search_tuning": {
"name": "Suchalgorithmus-Feinabstimmung",
"description": "Feinabstimmung wie die Suche Ausreißer, Mindestqualitätsschwellen und Fallback-Verhalten behandelt."
},
"cost_estimation": {
"name": "Kostenabschätzung",
"description": "Leistungsprofil angeben, um genaue Energiekostenschätzungen basierend auf dem tatsächlichen Verbrauch zu erhalten."
}, },
"output": { "output": {
"name": "Ausgabeoptionen", "name": "Ausgabeoptionen",
"description": "Kostenabschaetzung und Vergleichsausgabe steuern." "description": "Ausgabeformat steuern: Vergleichsdetails und Währungseinheit."
} }
}, },
"fields": { "fields": {
@ -1720,6 +1787,10 @@
"name": "Suchende", "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." "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."
}, },
"must_finish_by": {
"name": "Muss fertig sein bis",
"description": "Deadline: das Gerät muss bis zu diesem Zeitpunkt fertig sein. Der Suchbereich endet an dieser Deadline — der Service findet das günstigste Zeitfenster, das vorher endet. Kann nicht mit Suchende, Suchende-Uhrzeit oder Suchende-Offset kombiniert werden."
},
"search_start_time": { "search_start_time": {
"name": "Suchbeginn-Uhrzeit", "name": "Suchbeginn-Uhrzeit",
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist." "description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
@ -1758,23 +1829,39 @@
}, },
"search_scope": { "search_scope": {
"name": "Suchbereich (Shortcut)", "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." "description": "Kurzwahl für häufige Suchbereiche. Überschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
}, },
"max_price_level": { "max_price_level": {
"name": "Maximale Preisstufe", "name": "Maximale Preisstufe",
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung." "description": "Nur Intervalle bis zu dieser Tibber-Preisstufe berücksichtigen. very_cheap = restriktivste, very_expensive = keine Einschränkung."
}, },
"min_price_level": { "min_price_level": {
"name": "Minimale Preisstufe", "name": "Minimale Preisstufe",
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren." "description": "Nur Intervalle ab dieser Tibber-Preisstufe berücksichtigen. Nützlich für find_most_expensive, um wirklich teure Intervalle zu fokussieren."
}, },
"include_comparison_details": { "include_comparison_details": {
"name": "Vergleichsdetails einschliessen", "name": "Vergleichsdetails einschliessen",
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end." "description": "Das price_comparison-Ergebnis um zusätzliche Felder ergänzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
}, },
"power_profile": { "power_profile": {
"name": "Leistungsprofil", "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." "description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsächlichen Verbrauch statt einer festen 1-kW-Last an."
},
"smooth_outliers": {
"name": "Ausreißer glätten",
"description": "Preisausreißer vor der Suche glätten. Ausreißer-Intervalle werden vorübergehend durch den Durchschnitt ihrer Nachbarn ersetzt, sodass ein einzelner Ausschlag das Ergebnis nicht dominiert. Die Antwort zeigt immer die Original-Preise (ungeglättet). Standard: aktiviert."
},
"min_distance_from_avg": {
"name": "Min. Abstand vom Durchschnitt",
"description": "Ergebnis muss mindestens um diesen Prozentsatz vom Suchbereichs-Durchschnitt abweichen. Für günstigste: Ergebnis muss mindestens X% unter dem Durchschnitt liegen. Für teuerste: mindestens X% darüber. Wird die Bedingung nicht erfüllt, wird kein Ergebnis zurückgegeben (reason: selection_above/below_distance_threshold). Leer lassen zum Deaktivieren."
},
"allow_relaxation": {
"name": "Lockerung erlauben",
"description": "Filter schrittweise lockern, um ein Ergebnis zu garantieren. Phasen: 1) Abstandsschwelle reduzieren/entfernen 2) Preislevel-Filter erweitern 3) Dauer reduzieren. Standard: aktiviert."
},
"duration_flexibility_minutes": {
"name": "Dauer-Flexibilität",
"description": "Maximale Minuten, um die die Dauer bei der Lockerung verkürzt werden darf (0120, Schritt 15). Leer lassen für automatische Berechnung (~20% der Dauer, max. 60 Min.)."
} }
} }
}, },
@ -1783,20 +1870,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Suchbereich", "name": "Benutzerdefinierter Suchbereich",
"description": "Zeitfenster fuer die Suche festlegen." "description": "Exakte Start- und Endzeiten für die Suche festlegen. Überschreibt den Suchbereich (Shortcut), wenn gesetzt."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative Zeitbereich-Optionen", "name": "Erweiterte Zeitoptionen",
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets." "description": "Alternative Möglichkeiten zum Festlegen des Suchbereichs über Tageszeit und Minuten-Offsets."
}, },
"price_filter": { "price_filter": {
"name": "Preisstufen-Filter", "name": "Preisstufen-Filter",
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken." "description": "Suche auf Intervalle innerhalb des angegebenen Tibber-Preisstufen-Bereichs einschränken."
},
"search_tuning": {
"name": "Suchalgorithmus-Feinabstimmung",
"description": "Feinabstimmung wie die Suche Ausreißer, Mindestqualitätsschwellen und Fallback-Verhalten behandelt."
},
"cost_estimation": {
"name": "Kostenabschätzung",
"description": "Leistungsprofil angeben, um genaue Energiekostenschätzungen basierend auf dem tatsächlichen Verbrauch zu erhalten."
}, },
"output": { "output": {
"name": "Ausgabeoptionen", "name": "Ausgabeoptionen",
"description": "Kostenabschaetzung und Vergleichsausgabe steuern." "description": "Ausgabeformat steuern: Vergleichsdetails und Währungseinheit."
} }
}, },
"fields": { "fields": {
@ -1816,6 +1911,10 @@
"name": "Suchende", "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." "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."
}, },
"must_finish_by": {
"name": "Muss fertig sein bis",
"description": "Deadline: das Gerät muss bis zu diesem Zeitpunkt fertig sein. Der Suchbereich endet an dieser Deadline — der Service findet das teuerste Zeitfenster, das vorher endet. Kann nicht mit Suchende, Suchende-Uhrzeit, Suchende-Versatz oder Suchbereich (Shortcut) kombiniert werden."
},
"search_start_time": { "search_start_time": {
"name": "Suchbeginn-Uhrzeit", "name": "Suchbeginn-Uhrzeit",
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist." "description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
@ -1854,49 +1953,65 @@
}, },
"search_scope": { "search_scope": {
"name": "Suchbereich (Shortcut)", "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." "description": "Kurzwahl für häufige Suchbereiche. Überschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
}, },
"max_price_level": { "max_price_level": {
"name": "Maximale Preisstufe", "name": "Maximale Preisstufe",
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung." "description": "Nur Intervalle bis zu dieser Tibber-Preisstufe berücksichtigen. very_cheap = restriktivste, very_expensive = keine Einschränkung."
}, },
"min_price_level": { "min_price_level": {
"name": "Minimale Preisstufe", "name": "Minimale Preisstufe",
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren." "description": "Nur Intervalle ab dieser Tibber-Preisstufe berücksichtigen. Nützlich für find_most_expensive, um wirklich teure Intervalle zu fokussieren."
}, },
"include_comparison_details": { "include_comparison_details": {
"name": "Vergleichsdetails einschliessen", "name": "Vergleichsdetails einschliessen",
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end." "description": "Das price_comparison-Ergebnis um zusätzliche Felder ergänzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
}, },
"power_profile": { "power_profile": {
"name": "Leistungsprofil", "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." "description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsächlichen Verbrauch statt einer festen 1-kW-Last an."
},
"smooth_outliers": {
"name": "Ausreißer glätten",
"description": "Preisausreißer vor der Suche glätten. Ausreißer-Intervalle werden vorübergehend durch den Durchschnitt ihrer Nachbarn ersetzt, sodass ein einzelner Ausschlag das Ergebnis nicht dominiert. Die Antwort zeigt immer die Original-Preise (ungeglättet). Standard: aktiviert."
},
"min_distance_from_avg": {
"name": "Min. Abstand vom Durchschnitt",
"description": "Ergebnis muss mindestens um diesen Prozentsatz vom Suchbereichs-Durchschnitt abweichen. Für günstigste: Ergebnis muss mindestens X% unter dem Durchschnitt liegen. Für teuerste: mindestens X% darüber. Wird die Bedingung nicht erfüllt, wird kein Ergebnis zurückgegeben (reason: selection_above/below_distance_threshold). Leer lassen zum Deaktivieren."
},
"allow_relaxation": {
"name": "Lockerung erlauben",
"description": "Filter schrittweise lockern, um ein Ergebnis zu garantieren. Phasen: 1) Abstandsschwelle reduzieren/entfernen 2) Preislevel-Filter erweitern 3) Dauer reduzieren. Standard: aktiviert."
},
"duration_flexibility_minutes": {
"name": "Dauer-Flexibilität",
"description": "Maximale Minuten, um die die Dauer bei der Lockerung verkürzt werden darf (0120, Schritt 15). Leer lassen für automatische Berechnung (~20% der Dauer, max. 60 Min.)."
} }
} }
}, },
"find_cheapest_schedule": { "find_cheapest_schedule": {
"name": "Guenstigstes Programm planen", "name": "Günstigstes Programm planen",
"description": "Plant mehrere Geraete optimal ohne Zeitueberschneidung. Jede Aufgabe erhaelt das guenstigste verfuegbare zusammenhaengende Zeitfenster.", "description": "Plant mehrere Geräte optimal ohne Zeitüberschneidung. Jede Aufgabe erhält das günstigste verfügbare zusammenhängende Zeitfenster.",
"sections": { "sections": {
"scheduling_options": {
"name": "Planungsoptionen",
"description": "Aufgaben und Puffer zwischen den Geraeteaufzeiten konfigurieren."
},
"search_range": { "search_range": {
"name": "Suchbereich", "name": "Benutzerdefinierter Suchbereich",
"description": "Zeitfenster fuer die Suche festlegen." "description": "Exakte Start- und Endzeiten für die Suche festlegen. Überschreibt den Suchbereich (Shortcut), wenn gesetzt."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative Zeitbereich-Optionen", "name": "Erweiterte Zeitoptionen",
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets." "description": "Alternative Möglichkeiten zum Festlegen des Suchbereichs über Tageszeit und Minuten-Offsets."
}, },
"price_filter": { "price_filter": {
"name": "Preisstufen-Filter", "name": "Preisstufen-Filter",
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken." "description": "Suche auf Intervalle innerhalb des angegebenen Tibber-Preisstufen-Bereichs einschränken."
},
"search_tuning": {
"name": "Suchalgorithmus-Feinabstimmung",
"description": "Feinabstimmung wie die Suche Ausreißer, Mindestqualitätsschwellen und Fallback-Verhalten behandelt."
}, },
"output": { "output": {
"name": "Ausgabeoptionen", "name": "Ausgabeoptionen",
"description": "Kostenabschaetzung und Vergleichsausgabe steuern." "description": "Ausgabeformat steuern: Vergleichsdetails und Währungseinheit."
} }
}, },
"fields": { "fields": {
@ -1906,7 +2021,7 @@
}, },
"tasks": { "tasks": {
"name": "Aufgaben", "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." "description": "Liste der zu planenden Aufgaben. Jede Aufgabe benötigt name (Text) und duration (hh:mm:ss). Optional: power_profile (Watt pro 15-min-Intervall). Maximal 4 Aufgaben."
}, },
"gap_minutes": { "gap_minutes": {
"name": "Pause zwischen Aufgaben (Minuten)", "name": "Pause zwischen Aufgaben (Minuten)",
@ -1914,7 +2029,7 @@
}, },
"search_scope": { "search_scope": {
"name": "Suchbereich (Shortcut)", "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." "description": "Kurzwahl für häufige Suchbereiche. Überschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
}, },
"search_start": { "search_start": {
"name": "Suchbeginn", "name": "Suchbeginn",
@ -1924,6 +2039,10 @@
"name": "Suchende", "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." "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."
}, },
"must_finish_by": {
"name": "Muss fertig sein bis",
"description": "Deadline: das Gerät muss bis zu diesem Zeitpunkt fertig sein. Der Suchbereich endet an dieser Deadline — der Service findet das günstigste Zeitfenster, das vorher endet. Kann nicht mit Suchende, Suchende-Uhrzeit oder Suchende-Offset kombiniert werden."
},
"search_start_time": { "search_start_time": {
"name": "Suchbeginn-Uhrzeit", "name": "Suchbeginn-Uhrzeit",
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist." "description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
@ -1948,25 +2067,33 @@
"name": "Suchende-Versatz (Minuten)", "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." "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": { "max_price_level": {
"name": "Maximale Preisstufe", "name": "Maximale Preisstufe",
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung." "description": "Nur Intervalle bis zu dieser Tibber-Preisstufe berücksichtigen. very_cheap = restriktivste, very_expensive = keine Einschränkung."
}, },
"min_price_level": { "min_price_level": {
"name": "Minimale Preisstufe", "name": "Minimale Preisstufe",
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren." "description": "Nur Intervalle ab dieser Tibber-Preisstufe berücksichtigen. Nützlich für find_most_expensive, um wirklich teure Intervalle zu fokussieren."
}, },
"include_comparison_details": { "include_comparison_details": {
"name": "Vergleichsdetails einbeziehen", "name": "Vergleichsdetails einbeziehen",
"description": "Fuegt pro Aufgabe zusaetzliche price_comparison-Details hinzu (comparison_price_min, comparison_price_max, comparison_window_end), um das gefundene Zeitfenster mit dem gegenteiligen Extremfenster gleicher Dauer zu vergleichen." "description": "Fügt pro Aufgabe zusätzliche price_comparison-Details hinzu (comparison_price_min, comparison_price_max, comparison_window_end), um das gefundene Zeitfenster mit dem gegenteiligen Extremfenster gleicher Dauer zu vergleichen."
}, },
"use_base_unit": { "use_base_unit": {
"name": "Basiswährung verwenden", "name": "Basiswährung verwenden",
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen." "description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
},
"smooth_outliers": {
"name": "Ausreißer glätten",
"description": "Preisausreißer vor der Suche glätten. Ausreißer-Intervalle werden vorübergehend durch den Durchschnitt ihrer Nachbarn ersetzt, sodass ein einzelner Ausschlag das Ergebnis nicht dominiert. Die Antwort zeigt immer die Original-Preise (ungeglättet). Standard: aktiviert."
},
"allow_relaxation": {
"name": "Lockerung erlauben",
"description": "Filter schrittweise lockern, um ein Ergebnis zu garantieren. Phasen: 1) Abstandsschwelle reduzieren/entfernen 2) Preislevel-Filter erweitern 3) Dauer reduzieren. Standard: aktiviert."
},
"duration_flexibility_minutes": {
"name": "Dauer-Flexibilität",
"description": "Maximale Minuten, um die die Dauer bei der Lockerung verkürzt werden darf (0120, Schritt 15). Leer lassen für automatische Berechnung (~20% der Dauer, max. 60 Min.)."
} }
} }
} }
@ -2075,8 +2202,8 @@
"today": "Heute", "today": "Heute",
"tomorrow": "Morgen", "tomorrow": "Morgen",
"remaining_today": "Rest heute", "remaining_today": "Rest heute",
"next_24h": "Naechste 24 Stunden", "next_24h": "Nächste 24 Stunden",
"next_48h": "Naechste 48 Stunden" "next_48h": "Nächste 48 Stunden"
} }
} }
} }

View file

@ -1289,6 +1289,9 @@
}, },
"tasks_exceed_search_window": { "tasks_exceed_search_window": {
"message": "Total task time including gaps ({total_minutes} min) exceeds the search window ({window_minutes} min). Reduce task durations, lower gap_minutes, or extend the search range." "message": "Total task time including gaps ({total_minutes} min) exceeds the search window ({window_minutes} min). Reduce task durations, lower gap_minutes, or extend the search range."
},
"must_finish_by_conflicts_with_end": {
"message": "must_finish_by cannot be combined with end-boundary parameters ({params}). Use must_finish_by alone — it sets the search end to the deadline automatically."
} }
}, },
"services": { "services": {
@ -1503,20 +1506,28 @@
"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. If no window is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_contiguous_window).", "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. If no window is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_contiguous_window).",
"sections": { "sections": {
"search_range": { "search_range": {
"name": "Search Range", "name": "Custom Search Range",
"description": "Define the time window to search within." "description": "Define precise start and end times for the search. Overrides Search Scope when set."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative Time Range Options", "name": "Advanced Time Options",
"description": "Alternative ways to define the search range using time-of-day and offsets." "description": "Alternative ways to define the search range using time-of-day and minute offsets."
}, },
"price_filter": { "price_filter": {
"name": "Price Level Filter", "name": "Price Level Filter",
"description": "Restrict search to intervals within the specified price level range." "description": "Restrict search to intervals within the specified Tibber price level range."
},
"search_tuning": {
"name": "Search Algorithm Tuning",
"description": "Fine-tune how the search handles outliers, minimum quality thresholds, and fallback behavior."
},
"cost_estimation": {
"name": "Cost Estimation",
"description": "Provide a power profile to get accurate energy cost estimates based on actual consumption."
}, },
"output": { "output": {
"name": "Output Options", "name": "Output Options",
"description": "Control cost estimation and comparison output." "description": "Control output format: comparison details and currency unit."
} }
}, },
"fields": { "fields": {
@ -1536,6 +1547,10 @@
"name": "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." "description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
}, },
"must_finish_by": {
"name": "Must Finish By",
"description": "Deadline: the appliance must be finished by this time. The search range ends at this deadline — the service finds the cheapest window that completes before it. Cannot be combined with Search End, Search End Time, or Search End Offset."
},
"search_start_time": { "search_start_time": {
"name": "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." "description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
@ -1587,6 +1602,22 @@
"power_profile": { "power_profile": {
"name": "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." "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."
},
"smooth_outliers": {
"name": "Smooth Outliers",
"description": "Smooth price outliers before searching. Outlier intervals are temporarily replaced by the average of their neighbors, so a single spike or dip does not dominate the result. The response always shows the original (unsmoothed) prices. Default: enabled."
},
"min_distance_from_avg": {
"name": "Min. Distance from Average",
"description": "Require the found result to differ from the search-range average by at least this percentage. For cheapest: result must be at least X% below average. For most expensive: at least X% above. If the condition is not met, no result is returned (reason: selection_above/below_distance_threshold). Leave empty to disable."
},
"allow_relaxation": {
"name": "Allow relaxation",
"description": "Progressively relax filters to guarantee a result when possible. Phases: 1) Reduce/remove distance threshold 2) Expand level filters 3) Reduce duration. Default: enabled."
},
"duration_flexibility_minutes": {
"name": "Duration flexibility",
"description": "Maximum minutes the duration may be shortened during relaxation (0120, step 15). Leave empty for automatic calculation (~20% of duration, max 60 min)."
} }
} }
}, },
@ -1595,20 +1626,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Search Range", "name": "Custom Search Range",
"description": "Define the time window to search within." "description": "Define precise start and end times for the search. Overrides Search Scope when set."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative Time Range Options", "name": "Advanced Time Options",
"description": "Alternative ways to define the search range using time-of-day and offsets." "description": "Alternative ways to define the search range using time-of-day and minute offsets."
}, },
"price_filter": { "price_filter": {
"name": "Price Level Filter", "name": "Price Level Filter",
"description": "Restrict search to intervals within the specified price level range." "description": "Restrict search to intervals within the specified Tibber price level range."
},
"search_tuning": {
"name": "Search Algorithm Tuning",
"description": "Fine-tune how the search handles outliers, minimum quality thresholds, and fallback behavior."
},
"cost_estimation": {
"name": "Cost Estimation",
"description": "Provide a power profile to get accurate energy cost estimates based on actual consumption."
}, },
"output": { "output": {
"name": "Output Options", "name": "Output Options",
"description": "Control cost estimation and comparison output." "description": "Control output format: comparison details and currency unit."
} }
}, },
"fields": { "fields": {
@ -1628,6 +1667,10 @@
"name": "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." "description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
}, },
"must_finish_by": {
"name": "Must Finish By",
"description": "Deadline: the appliance must be finished by this time. The search range ends at this deadline — the service finds the most expensive window that completes before it. Cannot be combined with Search End, Search End Time, Search End Offset, or Search Scope."
},
"search_start_time": { "search_start_time": {
"name": "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." "description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
@ -1679,6 +1722,22 @@
"power_profile": { "power_profile": {
"name": "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." "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."
},
"smooth_outliers": {
"name": "Smooth Outliers",
"description": "Smooth price outliers before searching. Outlier intervals are temporarily replaced by the average of their neighbors, so a single spike or dip does not dominate the result. The response always shows the original (unsmoothed) prices. Default: enabled."
},
"min_distance_from_avg": {
"name": "Min. Distance from Average",
"description": "Require the found result to differ from the search-range average by at least this percentage. For cheapest: result must be at least X% below average. For most expensive: at least X% above. If the condition is not met, no result is returned (reason: selection_above/below_distance_threshold). Leave empty to disable."
},
"allow_relaxation": {
"name": "Allow relaxation",
"description": "Progressively relax filters to guarantee a result when possible. Phases: 1) Reduce/remove distance threshold 2) Expand level filters 3) Reduce duration. Default: enabled."
},
"duration_flexibility_minutes": {
"name": "Duration flexibility",
"description": "Maximum minutes the duration may be shortened during relaxation (0120, step 15). Leave empty for automatic calculation (~20% of duration, max 60 min)."
} }
} }
}, },
@ -1687,20 +1746,28 @@
"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. If no schedule is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_intervals_for_constraints).", "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. If no schedule is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_intervals_for_constraints).",
"sections": { "sections": {
"search_range": { "search_range": {
"name": "Search Range", "name": "Custom Search Range",
"description": "Define the time window to search within." "description": "Define precise start and end times for the search. Overrides Search Scope when set."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative Time Range Options", "name": "Advanced Time Options",
"description": "Alternative ways to define the search range using time-of-day and offsets." "description": "Alternative ways to define the search range using time-of-day and minute offsets."
}, },
"price_filter": { "price_filter": {
"name": "Price Level Filter", "name": "Price Level Filter",
"description": "Restrict search to intervals within the specified price level range." "description": "Restrict search to intervals within the specified Tibber price level range."
},
"search_tuning": {
"name": "Search Algorithm Tuning",
"description": "Fine-tune how the search handles outliers, minimum quality thresholds, and fallback behavior."
},
"cost_estimation": {
"name": "Cost Estimation",
"description": "Provide a power profile to get accurate energy cost estimates based on actual consumption."
}, },
"output": { "output": {
"name": "Output Options", "name": "Output Options",
"description": "Control cost estimation and comparison output." "description": "Control output format: comparison details and currency unit."
} }
}, },
"fields": { "fields": {
@ -1720,6 +1787,10 @@
"name": "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." "description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
}, },
"must_finish_by": {
"name": "Must Finish By",
"description": "Deadline: the appliance must be finished by this time. The search range ends at this deadline — the service finds the cheapest window that completes before it. Cannot be combined with Search End, Search End Time, or Search End Offset."
},
"search_start_time": { "search_start_time": {
"name": "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." "description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
@ -1775,6 +1846,22 @@
"power_profile": { "power_profile": {
"name": "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." "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."
},
"smooth_outliers": {
"name": "Smooth Outliers",
"description": "Smooth price outliers before searching. Outlier intervals are temporarily replaced by the average of their neighbors, so a single spike or dip does not dominate the result. The response always shows the original (unsmoothed) prices. Default: enabled."
},
"min_distance_from_avg": {
"name": "Min. Distance from Average",
"description": "Require the found result to differ from the search-range average by at least this percentage. For cheapest: result must be at least X% below average. For most expensive: at least X% above. If the condition is not met, no result is returned (reason: selection_above/below_distance_threshold). Leave empty to disable."
},
"allow_relaxation": {
"name": "Allow relaxation",
"description": "Progressively relax filters to guarantee a result when possible. Phases: 1) Reduce/remove distance threshold 2) Expand level filters 3) Reduce duration. Default: enabled."
},
"duration_flexibility_minutes": {
"name": "Duration flexibility",
"description": "Maximum minutes the duration may be shortened during relaxation (0120, step 15). Leave empty for automatic calculation (~20% of duration, max 60 min)."
} }
} }
}, },
@ -1783,20 +1870,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Search Range", "name": "Custom Search Range",
"description": "Define the time window to search within." "description": "Define precise start and end times for the search. Overrides Search Scope when set."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative Time Range Options", "name": "Advanced Time Options",
"description": "Alternative ways to define the search range using time-of-day and offsets." "description": "Alternative ways to define the search range using time-of-day and minute offsets."
}, },
"price_filter": { "price_filter": {
"name": "Price Level Filter", "name": "Price Level Filter",
"description": "Restrict search to intervals within the specified price level range." "description": "Restrict search to intervals within the specified Tibber price level range."
},
"search_tuning": {
"name": "Search Algorithm Tuning",
"description": "Fine-tune how the search handles outliers, minimum quality thresholds, and fallback behavior."
},
"cost_estimation": {
"name": "Cost Estimation",
"description": "Provide a power profile to get accurate energy cost estimates based on actual consumption."
}, },
"output": { "output": {
"name": "Output Options", "name": "Output Options",
"description": "Control cost estimation and comparison output." "description": "Control output format: comparison details and currency unit."
} }
}, },
"fields": { "fields": {
@ -1816,6 +1911,10 @@
"name": "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." "description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
}, },
"must_finish_by": {
"name": "Must Finish By",
"description": "Deadline: the appliance must be finished by this time. The search range ends at this deadline — the service finds the most expensive window that completes before it. Cannot be combined with Search End, Search End Time, Search End Offset, or Search Scope."
},
"search_start_time": { "search_start_time": {
"name": "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." "description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
@ -1871,6 +1970,22 @@
"power_profile": { "power_profile": {
"name": "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." "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."
},
"smooth_outliers": {
"name": "Smooth Outliers",
"description": "Smooth price outliers before searching. Outlier intervals are temporarily replaced by the average of their neighbors, so a single spike or dip does not dominate the result. The response always shows the original (unsmoothed) prices. Default: enabled."
},
"min_distance_from_avg": {
"name": "Min. Distance from Average",
"description": "Require the found result to differ from the search-range average by at least this percentage. For cheapest: result must be at least X% below average. For most expensive: at least X% above. If the condition is not met, no result is returned (reason: selection_above/below_distance_threshold). Leave empty to disable."
},
"allow_relaxation": {
"name": "Allow relaxation",
"description": "Progressively relax filters to guarantee a result when possible. Phases: 1) Reduce/remove distance threshold 2) Expand level filters 3) Reduce duration. Default: enabled."
},
"duration_flexibility_minutes": {
"name": "Duration flexibility",
"description": "Maximum minutes the duration may be shortened during relaxation (0120, step 15). Leave empty for automatic calculation (~20% of duration, max 60 min)."
} }
} }
}, },
@ -1878,25 +1993,25 @@
"name": "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. If scheduling is incomplete, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_contiguous_window, insufficient_contiguous_window_for_some_tasks).", "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. If scheduling is incomplete, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_contiguous_window, insufficient_contiguous_window_for_some_tasks).",
"sections": { "sections": {
"scheduling_options": {
"name": "Scheduling Options",
"description": "Configure tasks and gap constraints between them."
},
"search_range": { "search_range": {
"name": "Search Range", "name": "Custom Search Range",
"description": "Define the time window to search within." "description": "Define precise start and end times for the search. Overrides Search Scope when set."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative Time Range Options", "name": "Advanced Time Options",
"description": "Alternative ways to define the search range using time-of-day and offsets." "description": "Alternative ways to define the search range using time-of-day and minute offsets."
}, },
"price_filter": { "price_filter": {
"name": "Price Level Filter", "name": "Price Level Filter",
"description": "Restrict search to intervals within the specified price level range." "description": "Restrict search to intervals within the specified Tibber price level range."
},
"search_tuning": {
"name": "Search Algorithm Tuning",
"description": "Fine-tune how the search handles outliers, minimum quality thresholds, and fallback behavior."
}, },
"output": { "output": {
"name": "Output Options", "name": "Output Options",
"description": "Control output currency unit." "description": "Control output format: comparison details and currency unit."
} }
}, },
"fields": { "fields": {
@ -1924,6 +2039,10 @@
"name": "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." "description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
}, },
"must_finish_by": {
"name": "Must Finish By",
"description": "Deadline: the appliance must be finished by this time. The search range ends at this deadline — the service finds the cheapest window that completes before it. Cannot be combined with Search End, Search End Time, or Search End Offset."
},
"search_start_time": { "search_start_time": {
"name": "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." "description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
@ -1948,10 +2067,6 @@
"name": "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." "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": { "max_price_level": {
"name": "Maximum 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." "description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
@ -1967,6 +2082,18 @@
"use_base_unit": { "use_base_unit": {
"name": "Use Base Currency 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." "description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
},
"smooth_outliers": {
"name": "Smooth Outliers",
"description": "Smooth price outliers before searching. Outlier intervals are temporarily replaced by the average of their neighbors, so a single spike or dip does not dominate the result. The response always shows the original (unsmoothed) prices. Default: enabled."
},
"allow_relaxation": {
"name": "Allow relaxation",
"description": "Progressively relax filters to guarantee a result when possible. Phases: 1) Reduce/remove distance threshold 2) Expand level filters 3) Reduce duration. Default: enabled."
},
"duration_flexibility_minutes": {
"name": "Duration flexibility",
"description": "Maximum minutes the duration may be shortened during relaxation (0120, step 15). Leave empty for automatic calculation (~20% of duration, max 60 min)."
} }
} }
}, },

View file

@ -1289,6 +1289,9 @@
}, },
"tasks_exceed_search_window": { "tasks_exceed_search_window": {
"message": "Total oppgavetid inkludert pauser ({total_minutes} min) overstiger søkevinduet ({window_minutes} min). Reduser oppgavevarighetene, senk gap_minutes, eller utvid søkeperioden." "message": "Total oppgavetid inkludert pauser ({total_minutes} min) overstiger søkevinduet ({window_minutes} min). Reduser oppgavevarighetene, senk gap_minutes, eller utvid søkeperioden."
},
"must_finish_by_conflicts_with_end": {
"message": "must_finish_by kan ikke kombineres med sluttgrenseparametere ({params}). Bruk must_finish_by alene — det setter søkeslutt til fristen automatisk."
} }
}, },
"services": { "services": {
@ -1503,20 +1506,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Soekeomraade", "name": "Egendefinert søkeområde",
"description": "Definer tidsvinduet for soeket." "description": "Definer presise start- og sluttider for søket. Overstyrer søkeomfanget når satt."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative tidsalternativer", "name": "Avanserte tidsalternativer",
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets." "description": "Alternative måter å definere søkeområdet med klokkeslett og minuttforskyvninger."
}, },
"price_filter": { "price_filter": {
"name": "Prisnivaae-filter", "name": "Prisnivåfilter",
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet." "description": "Begrens søket til intervaller innenfor det angitte Tibber-prisnivåområdet."
},
"search_tuning": {
"name": "Finjustering av søkealgoritme",
"description": "Finjuster hvordan søket håndterer uteliggere, minimumskvalitetsgrenser og reserveoppførsel."
},
"cost_estimation": {
"name": "Kostnadsberegning",
"description": "Oppgi en effektprofil for å få nøyaktige energikostnadsestimater basert på faktisk forbruk."
}, },
"output": { "output": {
"name": "Utdata-alternativer", "name": "Utdataalternativer",
"description": "Styr kostnadsestimat og sammenligningsutdata." "description": "Kontroller utdataformat: sammenligningsdetaljer og valutaenhet."
} }
}, },
"fields": { "fields": {
@ -1536,6 +1547,10 @@
"name": "Søkeslutt", "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." "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."
}, },
"must_finish_by": {
"name": "Må være ferdig innen",
"description": "Frist: apparatet må være ferdig innen dette tidspunktet. Søkeområdet slutter ved denne fristen — tjenesten finner det billigste vinduet som fullføres før det. Kan ikke kombineres med Søkeslutt, Søkeslutt-tid eller Søkeslutt-offset."
},
"search_start_time": { "search_start_time": {
"name": "Søkestart-klokkeslett", "name": "Søkestart-klokkeslett",
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt." "description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
@ -1587,6 +1602,22 @@
"power_profile": { "power_profile": {
"name": "Effektprofil", "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." "description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
},
"smooth_outliers": {
"name": "Glatt utliggere",
"description": "Glatt prisutliggere før søk. Utligger-intervaller erstattes midlertidig med gjennomsnittet av naboene, slik at en enkelt topp eller bunn ikke dominerer resultatet. Svaret viser alltid de originale (uglattede) prisene. Standard: aktivert."
},
"min_distance_from_avg": {
"name": "Min. avstand fra gjennomsnitt",
"description": "Krev at resultatet avviker fra søkeområdets gjennomsnitt med minst denne prosenten. For billigst: resultatet må være minst X% under gjennomsnittet. For dyrest: minst X% over. Hvis betingelsen ikke oppfylles, returneres intet resultat (reason: selection_above/below_distance_threshold). La stå tom for å deaktivere."
},
"allow_relaxation": {
"name": "Tillat slakking",
"description": "Slakk filtre gradvis for å garantere et resultat. Faser: 1) Reduser/fjern avstandsterskel 2) Utvid prisnivåfiltre 3) Reduser varighet. Standard: aktivert."
},
"duration_flexibility_minutes": {
"name": "Varighetsfleksibilitet",
"description": "Maks minutter varigheten kan forkortes under slakking (0120, steg 15). La stå tom for automatisk beregning (~20% av varigheten, maks 60 min)."
} }
} }
}, },
@ -1595,20 +1626,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Soekeomraade", "name": "Egendefinert søkeområde",
"description": "Definer tidsvinduet for soeket." "description": "Definer presise start- og sluttider for søket. Overstyrer søkeomfanget når satt."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative tidsalternativer", "name": "Avanserte tidsalternativer",
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets." "description": "Alternative måter å definere søkeområdet med klokkeslett og minuttforskyvninger."
}, },
"price_filter": { "price_filter": {
"name": "Prisnivaae-filter", "name": "Prisnivåfilter",
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet." "description": "Begrens søket til intervaller innenfor det angitte Tibber-prisnivåområdet."
},
"search_tuning": {
"name": "Finjustering av søkealgoritme",
"description": "Finjuster hvordan søket håndterer uteliggere, minimumskvalitetsgrenser og reserveoppførsel."
},
"cost_estimation": {
"name": "Kostnadsberegning",
"description": "Oppgi en effektprofil for å få nøyaktige energikostnadsestimater basert på faktisk forbruk."
}, },
"output": { "output": {
"name": "Utdata-alternativer", "name": "Utdataalternativer",
"description": "Styr kostnadsestimat og sammenligningsutdata." "description": "Kontroller utdataformat: sammenligningsdetaljer og valutaenhet."
} }
}, },
"fields": { "fields": {
@ -1628,6 +1667,10 @@
"name": "Søkeslutt", "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." "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."
}, },
"must_finish_by": {
"name": "Må være ferdig innen",
"description": "Frist: apparatet må være ferdig innen dette tidspunktet. Søkeområdet slutter ved denne fristen — tjenesten finner det dyreste vinduet som fullføres før den. Kan ikke kombineres med Søkeslutt, Søkeslutt-klokkeslett, Søkeslutt-forskyvning eller Søkeomfang."
},
"search_start_time": { "search_start_time": {
"name": "Søkestart-klokkeslett", "name": "Søkestart-klokkeslett",
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt." "description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
@ -1679,6 +1722,22 @@
"power_profile": { "power_profile": {
"name": "Effektprofil", "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." "description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
},
"smooth_outliers": {
"name": "Glatt utliggere",
"description": "Glatt prisutliggere før søk. Utligger-intervaller erstattes midlertidig med gjennomsnittet av naboene, slik at en enkelt topp eller bunn ikke dominerer resultatet. Svaret viser alltid de originale (uglattede) prisene. Standard: aktivert."
},
"min_distance_from_avg": {
"name": "Min. avstand fra gjennomsnitt",
"description": "Krev at resultatet avviker fra søkeområdets gjennomsnitt med minst denne prosenten. For billigst: resultatet må være minst X% under gjennomsnittet. For dyrest: minst X% over. Hvis betingelsen ikke oppfylles, returneres intet resultat (reason: selection_above/below_distance_threshold). La stå tom for å deaktivere."
},
"allow_relaxation": {
"name": "Tillat slakking",
"description": "Slakk filtre gradvis for å garantere et resultat. Faser: 1) Reduser/fjern avstandsterskel 2) Utvid prisnivåfiltre 3) Reduser varighet. Standard: aktivert."
},
"duration_flexibility_minutes": {
"name": "Varighetsfleksibilitet",
"description": "Maks minutter varigheten kan forkortes under slakking (0120, steg 15). La stå tom for automatisk beregning (~20% av varigheten, maks 60 min)."
} }
} }
}, },
@ -1687,20 +1746,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Soekeomraade", "name": "Egendefinert søkeområde",
"description": "Definer tidsvinduet for soeket." "description": "Definer presise start- og sluttider for søket. Overstyrer søkeomfanget når satt."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative tidsalternativer", "name": "Avanserte tidsalternativer",
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets." "description": "Alternative måter å definere søkeområdet med klokkeslett og minuttforskyvninger."
}, },
"price_filter": { "price_filter": {
"name": "Prisnivaae-filter", "name": "Prisnivåfilter",
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet." "description": "Begrens søket til intervaller innenfor det angitte Tibber-prisnivåområdet."
},
"search_tuning": {
"name": "Finjustering av søkealgoritme",
"description": "Finjuster hvordan søket håndterer uteliggere, minimumskvalitetsgrenser og reserveoppførsel."
},
"cost_estimation": {
"name": "Kostnadsberegning",
"description": "Oppgi en effektprofil for å få nøyaktige energikostnadsestimater basert på faktisk forbruk."
}, },
"output": { "output": {
"name": "Utdata-alternativer", "name": "Utdataalternativer",
"description": "Styr kostnadsestimat og sammenligningsutdata." "description": "Kontroller utdataformat: sammenligningsdetaljer og valutaenhet."
} }
}, },
"fields": { "fields": {
@ -1720,6 +1787,10 @@
"name": "Søkeslutt", "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." "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."
}, },
"must_finish_by": {
"name": "Må være ferdig innen",
"description": "Frist: apparatet må være ferdig innen dette tidspunktet. Søkeområdet slutter ved denne fristen — tjenesten finner det billigste vinduet som fullføres før det. Kan ikke kombineres med Søkeslutt, Søkeslutt-tid eller Søkeslutt-offset."
},
"search_start_time": { "search_start_time": {
"name": "Søkestart-klokkeslett", "name": "Søkestart-klokkeslett",
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt." "description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
@ -1775,6 +1846,22 @@
"power_profile": { "power_profile": {
"name": "Effektprofil", "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." "description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
},
"smooth_outliers": {
"name": "Glatt utliggere",
"description": "Glatt prisutliggere før søk. Utligger-intervaller erstattes midlertidig med gjennomsnittet av naboene, slik at en enkelt topp eller bunn ikke dominerer resultatet. Svaret viser alltid de originale (uglattede) prisene. Standard: aktivert."
},
"min_distance_from_avg": {
"name": "Min. avstand fra gjennomsnitt",
"description": "Krev at resultatet avviker fra søkeområdets gjennomsnitt med minst denne prosenten. For billigst: resultatet må være minst X% under gjennomsnittet. For dyrest: minst X% over. Hvis betingelsen ikke oppfylles, returneres intet resultat (reason: selection_above/below_distance_threshold). La stå tom for å deaktivere."
},
"allow_relaxation": {
"name": "Tillat slakking",
"description": "Slakk filtre gradvis for å garantere et resultat. Faser: 1) Reduser/fjern avstandsterskel 2) Utvid prisnivåfiltre 3) Reduser varighet. Standard: aktivert."
},
"duration_flexibility_minutes": {
"name": "Varighetsfleksibilitet",
"description": "Maks minutter varigheten kan forkortes under slakking (0120, steg 15). La stå tom for automatisk beregning (~20% av varigheten, maks 60 min)."
} }
} }
}, },
@ -1783,20 +1870,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Soekeomraade", "name": "Egendefinert søkeområde",
"description": "Definer tidsvinduet for soeket." "description": "Definer presise start- og sluttider for søket. Overstyrer søkeomfanget når satt."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative tidsalternativer", "name": "Avanserte tidsalternativer",
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets." "description": "Alternative måter å definere søkeområdet med klokkeslett og minuttforskyvninger."
}, },
"price_filter": { "price_filter": {
"name": "Prisnivaae-filter", "name": "Prisnivåfilter",
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet." "description": "Begrens søket til intervaller innenfor det angitte Tibber-prisnivåområdet."
},
"search_tuning": {
"name": "Finjustering av søkealgoritme",
"description": "Finjuster hvordan søket håndterer uteliggere, minimumskvalitetsgrenser og reserveoppførsel."
},
"cost_estimation": {
"name": "Kostnadsberegning",
"description": "Oppgi en effektprofil for å få nøyaktige energikostnadsestimater basert på faktisk forbruk."
}, },
"output": { "output": {
"name": "Utdata-alternativer", "name": "Utdataalternativer",
"description": "Styr kostnadsestimat og sammenligningsutdata." "description": "Kontroller utdataformat: sammenligningsdetaljer og valutaenhet."
} }
}, },
"fields": { "fields": {
@ -1816,6 +1911,10 @@
"name": "Søkeslutt", "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." "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."
}, },
"must_finish_by": {
"name": "Må være ferdig innen",
"description": "Frist: apparatet må være ferdig innen dette tidspunktet. Søkeområdet slutter ved denne fristen — tjenesten finner det dyreste vinduet som fullføres før den. Kan ikke kombineres med Søkeslutt, Søkeslutt-klokkeslett, Søkeslutt-forskyvning eller Søkeomfang."
},
"search_start_time": { "search_start_time": {
"name": "Søkestart-klokkeslett", "name": "Søkestart-klokkeslett",
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt." "description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
@ -1871,6 +1970,22 @@
"power_profile": { "power_profile": {
"name": "Effektprofil", "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." "description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
},
"smooth_outliers": {
"name": "Glatt utliggere",
"description": "Glatt prisutliggere før søk. Utligger-intervaller erstattes midlertidig med gjennomsnittet av naboene, slik at en enkelt topp eller bunn ikke dominerer resultatet. Svaret viser alltid de originale (uglattede) prisene. Standard: aktivert."
},
"min_distance_from_avg": {
"name": "Min. avstand fra gjennomsnitt",
"description": "Krev at resultatet avviker fra søkeområdets gjennomsnitt med minst denne prosenten. For billigst: resultatet må være minst X% under gjennomsnittet. For dyrest: minst X% over. Hvis betingelsen ikke oppfylles, returneres intet resultat (reason: selection_above/below_distance_threshold). La stå tom for å deaktivere."
},
"allow_relaxation": {
"name": "Tillat slakking",
"description": "Slakk filtre gradvis for å garantere et resultat. Faser: 1) Reduser/fjern avstandsterskel 2) Utvid prisnivåfiltre 3) Reduser varighet. Standard: aktivert."
},
"duration_flexibility_minutes": {
"name": "Varighetsfleksibilitet",
"description": "Maks minutter varigheten kan forkortes under slakking (0120, steg 15). La stå tom for automatisk beregning (~20% av varigheten, maks 60 min)."
} }
} }
}, },
@ -1878,25 +1993,25 @@
"name": "Finn billigste tidsplan", "name": "Finn billigste tidsplan",
"description": "Planlegger flere apparater optimalt uten tidsoverlapp. Hver oppgave tildeles det billigste tilgjengelige sammenhengende tidsvinduet.", "description": "Planlegger flere apparater optimalt uten tidsoverlapp. Hver oppgave tildeles det billigste tilgjengelige sammenhengende tidsvinduet.",
"sections": { "sections": {
"scheduling_options": {
"name": "Planleggingsalternativer",
"description": "Konfigurer oppgaver og pause mellom dem."
},
"search_range": { "search_range": {
"name": "Soekeomraade", "name": "Egendefinert søkeområde",
"description": "Definer tidsvinduet for soeket." "description": "Definer presise start- og sluttider for søket. Overstyrer søkeomfanget når satt."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternative tidsalternativer", "name": "Avanserte tidsalternativer",
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets." "description": "Alternative måter å definere søkeområdet med klokkeslett og minuttforskyvninger."
}, },
"price_filter": { "price_filter": {
"name": "Prisnivaae-filter", "name": "Prisnivåfilter",
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet." "description": "Begrens søket til intervaller innenfor det angitte Tibber-prisnivåområdet."
},
"search_tuning": {
"name": "Finjustering av søkealgoritme",
"description": "Finjuster hvordan søket håndterer uteliggere, minimumskvalitetsgrenser og reserveoppførsel."
}, },
"output": { "output": {
"name": "Utdata-alternativer", "name": "Utdataalternativer",
"description": "Styr kostnadsestimat og sammenligningsutdata." "description": "Kontroller utdataformat: sammenligningsdetaljer og valutaenhet."
} }
}, },
"fields": { "fields": {
@ -1924,6 +2039,10 @@
"name": "Søkeslutt", "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." "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."
}, },
"must_finish_by": {
"name": "Må være ferdig innen",
"description": "Frist: apparatet må være ferdig innen dette tidspunktet. Søkeområdet slutter ved denne fristen — tjenesten finner det billigste vinduet som fullføres før det. Kan ikke kombineres med Søkeslutt, Søkeslutt-tid eller Søkeslutt-offset."
},
"search_start_time": { "search_start_time": {
"name": "Søkestart-klokkeslett", "name": "Søkestart-klokkeslett",
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt." "description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
@ -1948,10 +2067,6 @@
"name": "Søkeslutt-forskyvning (minutter)", "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." "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": { "max_price_level": {
"name": "Maksimalt prisnivaae", "name": "Maksimalt prisnivaae",
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning." "description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
@ -1967,6 +2082,18 @@
"use_base_unit": { "use_base_unit": {
"name": "Bruk basisvaluta", "name": "Bruk basisvaluta",
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger." "description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
},
"smooth_outliers": {
"name": "Glatt utliggere",
"description": "Glatt prisutliggere før søk. Utligger-intervaller erstattes midlertidig med gjennomsnittet av naboene, slik at en enkelt topp eller bunn ikke dominerer resultatet. Svaret viser alltid de originale (uglattede) prisene. Standard: aktivert."
},
"allow_relaxation": {
"name": "Tillat slakking",
"description": "Slakk filtre gradvis for å garantere et resultat. Faser: 1) Reduser/fjern avstandsterskel 2) Utvid prisnivåfiltre 3) Reduser varighet. Standard: aktivert."
},
"duration_flexibility_minutes": {
"name": "Varighetsfleksibilitet",
"description": "Maks minutter varigheten kan forkortes under slakking (0120, steg 15). La stå tom for automatisk beregning (~20% av varigheten, maks 60 min)."
} }
} }
} }

View file

@ -1289,6 +1289,9 @@
}, },
"tasks_exceed_search_window": { "tasks_exceed_search_window": {
"message": "De totale taaktijd inclusief pauzes ({total_minutes} min) overschrijdt het zoekvenster ({window_minutes} min). Verklein de taakduur, verlaag gap_minutes of vergroot het zoekbereik." "message": "De totale taaktijd inclusief pauzes ({total_minutes} min) overschrijdt het zoekvenster ({window_minutes} min). Verklein de taakduur, verlaag gap_minutes of vergroot het zoekbereik."
},
"must_finish_by_conflicts_with_end": {
"message": "must_finish_by kan niet worden gecombineerd met eindgrensparameters ({params}). Gebruik must_finish_by alleen — het stelt het zoekeinde automatisch in op de deadline."
} }
}, },
"services": { "services": {
@ -1503,20 +1506,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Zoekbereik", "name": "Aangepast zoekbereik",
"description": "Definieer het tijdvenster voor het zoeken." "description": "Definieer precieze start- en eindtijden voor het zoeken. Overschrijft het zoekbereik wanneer ingesteld."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternatieve tijdbereiksopties", "name": "Geavanceerde tijdopties",
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets." "description": "Alternatieve manieren om het zoekbereik te definiëren met tijdstip en minuutverschuivingen."
}, },
"price_filter": { "price_filter": {
"name": "Prijsniveau-filter", "name": "Prijsniveaufilter",
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik." "description": "Beperk het zoeken tot intervallen binnen het opgegeven Tibber-prijsniveaubereik."
},
"search_tuning": {
"name": "Zoekalgoritme-fijnafstelling",
"description": "Verfijn hoe het zoeken omgaat met uitschieters, minimale kwaliteitsdrempels en terugvalgedrag."
},
"cost_estimation": {
"name": "Kostenraming",
"description": "Geef een vermogensprofiel op voor nauwkeurige energiekostenramingen op basis van werkelijk verbruik."
}, },
"output": { "output": {
"name": "Uitvoeropties", "name": "Uitvoeropties",
"description": "Stuur kostnadsraming en vergelijkingsuitvoer." "description": "Uitvoerformaat regelen: vergelijkingsdetails en valuta-eenheid."
} }
}, },
"fields": { "fields": {
@ -1536,6 +1547,10 @@
"name": "Zoekeinde", "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." "description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
}, },
"must_finish_by": {
"name": "Moet klaar zijn voor",
"description": "Deadline: het apparaat moet voor dit tijdstip klaar zijn. Het zoekbereik eindigt bij deze deadline — de service vindt het goedkoopste venster dat ervoor eindigt. Kan niet worden gecombineerd met Zoek einde, Zoek eindtijd of Zoek einde offset."
},
"search_start_time": { "search_start_time": {
"name": "Zoekstart-tijd", "name": "Zoekstart-tijd",
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld." "description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
@ -1587,6 +1602,22 @@
"power_profile": { "power_profile": {
"name": "Vermogensprofiel", "name": "Vermogensprofiel",
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik." "description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
},
"smooth_outliers": {
"name": "Uitschieters gladstrijken",
"description": "Prijsuitschieters gladstrijken vóór het zoeken. Uitschieters worden tijdelijk vervangen door het gemiddelde van hun buren, zodat een enkele piek of dip het resultaat niet domineert. Het antwoord toont altijd de originele (onafgevlakte) prijzen. Standaard: ingeschakeld."
},
"min_distance_from_avg": {
"name": "Min. afstand van gemiddelde",
"description": "Vereis dat het gevonden resultaat minstens dit percentage afwijkt van het gemiddelde van het zoekbereik. Voor goedkoopst: resultaat moet minstens X% onder het gemiddelde liggen. Voor duurste: minstens X% erboven. Als niet voldaan, wordt geen resultaat teruggegeven (reason: selection_above/below_distance_threshold). Leeg laten om te deactiveren."
},
"allow_relaxation": {
"name": "Versoepeling toestaan",
"description": "Filters geleidelijk versoepelen om een resultaat te garanderen. Fasen: 1) Afstandsdrempel verlagen/verwijderen 2) Prijsniveaufilters uitbreiden 3) Duur verkorten. Standaard: ingeschakeld."
},
"duration_flexibility_minutes": {
"name": "Duurflexibiliteit",
"description": "Maximale minuten waarmee de duur mag worden verkort bij versoepeling (0120, stap 15). Leeg laten voor automatische berekening (~20% van de duur, max 60 min)."
} }
} }
}, },
@ -1595,20 +1626,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Zoekbereik", "name": "Aangepast zoekbereik",
"description": "Definieer het tijdvenster voor het zoeken." "description": "Definieer precieze start- en eindtijden voor het zoeken. Overschrijft het zoekbereik wanneer ingesteld."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternatieve tijdbereiksopties", "name": "Geavanceerde tijdopties",
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets." "description": "Alternatieve manieren om het zoekbereik te definiëren met tijdstip en minuutverschuivingen."
}, },
"price_filter": { "price_filter": {
"name": "Prijsniveau-filter", "name": "Prijsniveaufilter",
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik." "description": "Beperk het zoeken tot intervallen binnen het opgegeven Tibber-prijsniveaubereik."
},
"search_tuning": {
"name": "Zoekalgoritme-fijnafstelling",
"description": "Verfijn hoe het zoeken omgaat met uitschieters, minimale kwaliteitsdrempels en terugvalgedrag."
},
"cost_estimation": {
"name": "Kostenraming",
"description": "Geef een vermogensprofiel op voor nauwkeurige energiekostenramingen op basis van werkelijk verbruik."
}, },
"output": { "output": {
"name": "Uitvoeropties", "name": "Uitvoeropties",
"description": "Stuur kostnadsraming en vergelijkingsuitvoer." "description": "Uitvoerformaat regelen: vergelijkingsdetails en valuta-eenheid."
} }
}, },
"fields": { "fields": {
@ -1628,6 +1667,10 @@
"name": "Zoekeinde", "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." "description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
}, },
"must_finish_by": {
"name": "Moet klaar zijn voor",
"description": "Deadline: het apparaat moet voor dit tijdstip klaar zijn. Het zoekbereik eindigt bij deze deadline — de service vindt het duurste venster dat ervoor eindigt. Kan niet gecombineerd worden met Zoekeinde, Zoekeinde-tijdstip, Zoekeinde-verschuiving of Zoekbereik."
},
"search_start_time": { "search_start_time": {
"name": "Zoekstart-tijd", "name": "Zoekstart-tijd",
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld." "description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
@ -1679,6 +1722,22 @@
"power_profile": { "power_profile": {
"name": "Vermogensprofiel", "name": "Vermogensprofiel",
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik." "description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
},
"smooth_outliers": {
"name": "Uitschieters gladstrijken",
"description": "Prijsuitschieters gladstrijken vóór het zoeken. Uitschieters worden tijdelijk vervangen door het gemiddelde van hun buren, zodat een enkele piek of dip het resultaat niet domineert. Het antwoord toont altijd de originele (onafgevlakte) prijzen. Standaard: ingeschakeld."
},
"min_distance_from_avg": {
"name": "Min. afstand van gemiddelde",
"description": "Vereis dat het gevonden resultaat minstens dit percentage afwijkt van het gemiddelde van het zoekbereik. Voor goedkoopst: resultaat moet minstens X% onder het gemiddelde liggen. Voor duurste: minstens X% erboven. Als niet voldaan, wordt geen resultaat teruggegeven (reason: selection_above/below_distance_threshold). Leeg laten om te deactiveren."
},
"allow_relaxation": {
"name": "Versoepeling toestaan",
"description": "Filters geleidelijk versoepelen om een resultaat te garanderen. Fasen: 1) Afstandsdrempel verlagen/verwijderen 2) Prijsniveaufilters uitbreiden 3) Duur verkorten. Standaard: ingeschakeld."
},
"duration_flexibility_minutes": {
"name": "Duurflexibiliteit",
"description": "Maximale minuten waarmee de duur mag worden verkort bij versoepeling (0120, stap 15). Leeg laten voor automatische berekening (~20% van de duur, max 60 min)."
} }
} }
}, },
@ -1687,20 +1746,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Zoekbereik", "name": "Aangepast zoekbereik",
"description": "Definieer het tijdvenster voor het zoeken." "description": "Definieer precieze start- en eindtijden voor het zoeken. Overschrijft het zoekbereik wanneer ingesteld."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternatieve tijdbereiksopties", "name": "Geavanceerde tijdopties",
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets." "description": "Alternatieve manieren om het zoekbereik te definiëren met tijdstip en minuutverschuivingen."
}, },
"price_filter": { "price_filter": {
"name": "Prijsniveau-filter", "name": "Prijsniveaufilter",
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik." "description": "Beperk het zoeken tot intervallen binnen het opgegeven Tibber-prijsniveaubereik."
},
"search_tuning": {
"name": "Zoekalgoritme-fijnafstelling",
"description": "Verfijn hoe het zoeken omgaat met uitschieters, minimale kwaliteitsdrempels en terugvalgedrag."
},
"cost_estimation": {
"name": "Kostenraming",
"description": "Geef een vermogensprofiel op voor nauwkeurige energiekostenramingen op basis van werkelijk verbruik."
}, },
"output": { "output": {
"name": "Uitvoeropties", "name": "Uitvoeropties",
"description": "Stuur kostnadsraming en vergelijkingsuitvoer." "description": "Uitvoerformaat regelen: vergelijkingsdetails en valuta-eenheid."
} }
}, },
"fields": { "fields": {
@ -1720,6 +1787,10 @@
"name": "Zoekeinde", "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." "description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
}, },
"must_finish_by": {
"name": "Moet klaar zijn voor",
"description": "Deadline: het apparaat moet voor dit tijdstip klaar zijn. Het zoekbereik eindigt bij deze deadline — de service vindt het goedkoopste venster dat ervoor eindigt. Kan niet worden gecombineerd met Zoek einde, Zoek eindtijd of Zoek einde offset."
},
"search_start_time": { "search_start_time": {
"name": "Zoekstart-tijd", "name": "Zoekstart-tijd",
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld." "description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
@ -1775,6 +1846,22 @@
"power_profile": { "power_profile": {
"name": "Vermogensprofiel", "name": "Vermogensprofiel",
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik." "description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
},
"smooth_outliers": {
"name": "Uitschieters gladstrijken",
"description": "Prijsuitschieters gladstrijken vóór het zoeken. Uitschieters worden tijdelijk vervangen door het gemiddelde van hun buren, zodat een enkele piek of dip het resultaat niet domineert. Het antwoord toont altijd de originele (onafgevlakte) prijzen. Standaard: ingeschakeld."
},
"min_distance_from_avg": {
"name": "Min. afstand van gemiddelde",
"description": "Vereis dat het gevonden resultaat minstens dit percentage afwijkt van het gemiddelde van het zoekbereik. Voor goedkoopst: resultaat moet minstens X% onder het gemiddelde liggen. Voor duurste: minstens X% erboven. Als niet voldaan, wordt geen resultaat teruggegeven (reason: selection_above/below_distance_threshold). Leeg laten om te deactiveren."
},
"allow_relaxation": {
"name": "Versoepeling toestaan",
"description": "Filters geleidelijk versoepelen om een resultaat te garanderen. Fasen: 1) Afstandsdrempel verlagen/verwijderen 2) Prijsniveaufilters uitbreiden 3) Duur verkorten. Standaard: ingeschakeld."
},
"duration_flexibility_minutes": {
"name": "Duurflexibiliteit",
"description": "Maximale minuten waarmee de duur mag worden verkort bij versoepeling (0120, stap 15). Leeg laten voor automatische berekening (~20% van de duur, max 60 min)."
} }
} }
}, },
@ -1783,20 +1870,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Zoekbereik", "name": "Aangepast zoekbereik",
"description": "Definieer het tijdvenster voor het zoeken." "description": "Definieer precieze start- en eindtijden voor het zoeken. Overschrijft het zoekbereik wanneer ingesteld."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternatieve tijdbereiksopties", "name": "Geavanceerde tijdopties",
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets." "description": "Alternatieve manieren om het zoekbereik te definiëren met tijdstip en minuutverschuivingen."
}, },
"price_filter": { "price_filter": {
"name": "Prijsniveau-filter", "name": "Prijsniveaufilter",
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik." "description": "Beperk het zoeken tot intervallen binnen het opgegeven Tibber-prijsniveaubereik."
},
"search_tuning": {
"name": "Zoekalgoritme-fijnafstelling",
"description": "Verfijn hoe het zoeken omgaat met uitschieters, minimale kwaliteitsdrempels en terugvalgedrag."
},
"cost_estimation": {
"name": "Kostenraming",
"description": "Geef een vermogensprofiel op voor nauwkeurige energiekostenramingen op basis van werkelijk verbruik."
}, },
"output": { "output": {
"name": "Uitvoeropties", "name": "Uitvoeropties",
"description": "Stuur kostnadsraming en vergelijkingsuitvoer." "description": "Uitvoerformaat regelen: vergelijkingsdetails en valuta-eenheid."
} }
}, },
"fields": { "fields": {
@ -1816,6 +1911,10 @@
"name": "Zoekeinde", "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." "description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
}, },
"must_finish_by": {
"name": "Moet klaar zijn voor",
"description": "Deadline: het apparaat moet voor dit tijdstip klaar zijn. Het zoekbereik eindigt bij deze deadline — de service vindt het duurste venster dat ervoor eindigt. Kan niet gecombineerd worden met Zoekeinde, Zoekeinde-tijdstip, Zoekeinde-verschuiving of Zoekbereik."
},
"search_start_time": { "search_start_time": {
"name": "Zoekstart-tijd", "name": "Zoekstart-tijd",
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld." "description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
@ -1871,6 +1970,22 @@
"power_profile": { "power_profile": {
"name": "Vermogensprofiel", "name": "Vermogensprofiel",
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik." "description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
},
"smooth_outliers": {
"name": "Uitschieters gladstrijken",
"description": "Prijsuitschieters gladstrijken vóór het zoeken. Uitschieters worden tijdelijk vervangen door het gemiddelde van hun buren, zodat een enkele piek of dip het resultaat niet domineert. Het antwoord toont altijd de originele (onafgevlakte) prijzen. Standaard: ingeschakeld."
},
"min_distance_from_avg": {
"name": "Min. afstand van gemiddelde",
"description": "Vereis dat het gevonden resultaat minstens dit percentage afwijkt van het gemiddelde van het zoekbereik. Voor goedkoopst: resultaat moet minstens X% onder het gemiddelde liggen. Voor duurste: minstens X% erboven. Als niet voldaan, wordt geen resultaat teruggegeven (reason: selection_above/below_distance_threshold). Leeg laten om te deactiveren."
},
"allow_relaxation": {
"name": "Versoepeling toestaan",
"description": "Filters geleidelijk versoepelen om een resultaat te garanderen. Fasen: 1) Afstandsdrempel verlagen/verwijderen 2) Prijsniveaufilters uitbreiden 3) Duur verkorten. Standaard: ingeschakeld."
},
"duration_flexibility_minutes": {
"name": "Duurflexibiliteit",
"description": "Maximale minuten waarmee de duur mag worden verkort bij versoepeling (0120, stap 15). Leeg laten voor automatische berekening (~20% van de duur, max 60 min)."
} }
} }
}, },
@ -1878,25 +1993,25 @@
"name": "Goedkoopste schema vinden", "name": "Goedkoopste schema vinden",
"description": "Plant meerdere apparaten optimaal zonder tijdoverlap. Elke taak krijgt het goedkoopste beschikbare aaneengesloten tijdvenster.", "description": "Plant meerdere apparaten optimaal zonder tijdoverlap. Elke taak krijgt het goedkoopste beschikbare aaneengesloten tijdvenster.",
"sections": { "sections": {
"scheduling_options": {
"name": "Planningsopties",
"description": "Configureer taken en tussenpozen."
},
"search_range": { "search_range": {
"name": "Zoekbereik", "name": "Aangepast zoekbereik",
"description": "Definieer het tijdvenster voor het zoeken." "description": "Definieer precieze start- en eindtijden voor het zoeken. Overschrijft het zoekbereik wanneer ingesteld."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternatieve tijdbereiksopties", "name": "Geavanceerde tijdopties",
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets." "description": "Alternatieve manieren om het zoekbereik te definiëren met tijdstip en minuutverschuivingen."
}, },
"price_filter": { "price_filter": {
"name": "Prijsniveau-filter", "name": "Prijsniveaufilter",
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik." "description": "Beperk het zoeken tot intervallen binnen het opgegeven Tibber-prijsniveaubereik."
},
"search_tuning": {
"name": "Zoekalgoritme-fijnafstelling",
"description": "Verfijn hoe het zoeken omgaat met uitschieters, minimale kwaliteitsdrempels en terugvalgedrag."
}, },
"output": { "output": {
"name": "Uitvoeropties", "name": "Uitvoeropties",
"description": "Stuur kostnadsraming en vergelijkingsuitvoer." "description": "Uitvoerformaat regelen: vergelijkingsdetails en valuta-eenheid."
} }
}, },
"fields": { "fields": {
@ -1924,6 +2039,10 @@
"name": "Zoekeinde", "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." "description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
}, },
"must_finish_by": {
"name": "Moet klaar zijn voor",
"description": "Deadline: het apparaat moet voor dit tijdstip klaar zijn. Het zoekbereik eindigt bij deze deadline — de service vindt het goedkoopste venster dat ervoor eindigt. Kan niet worden gecombineerd met Zoek einde, Zoek eindtijd of Zoek einde offset."
},
"search_start_time": { "search_start_time": {
"name": "Zoekstart-tijd", "name": "Zoekstart-tijd",
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld." "description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
@ -1948,10 +2067,6 @@
"name": "Zoekeinde-offset (minuten)", "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." "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": { "max_price_level": {
"name": "Maximaal prijsniveau", "name": "Maximaal prijsniveau",
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking." "description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
@ -1967,6 +2082,18 @@
"use_base_unit": { "use_base_unit": {
"name": "Basisvaluta gebruiken", "name": "Basisvaluta gebruiken",
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen." "description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
},
"smooth_outliers": {
"name": "Uitschieters gladstrijken",
"description": "Prijsuitschieters gladstrijken vóór het zoeken. Uitschieters worden tijdelijk vervangen door het gemiddelde van hun buren, zodat een enkele piek of dip het resultaat niet domineert. Het antwoord toont altijd de originele (onafgevlakte) prijzen. Standaard: ingeschakeld."
},
"allow_relaxation": {
"name": "Versoepeling toestaan",
"description": "Filters geleidelijk versoepelen om een resultaat te garanderen. Fasen: 1) Afstandsdrempel verlagen/verwijderen 2) Prijsniveaufilters uitbreiden 3) Duur verkorten. Standaard: ingeschakeld."
},
"duration_flexibility_minutes": {
"name": "Duurflexibiliteit",
"description": "Maximale minuten waarmee de duur mag worden verkort bij versoepeling (0120, stap 15). Leeg laten voor automatische berekening (~20% van de duur, max 60 min)."
} }
} }
} }

View file

@ -1289,6 +1289,9 @@
}, },
"tasks_exceed_search_window": { "tasks_exceed_search_window": {
"message": "Total uppgiftstid inklusive pauser ({total_minutes} min) överstiger sökfönstret ({window_minutes} min). Minska uppgiftslängderna, sänk gap_minutes eller utöka sökintervallet." "message": "Total uppgiftstid inklusive pauser ({total_minutes} min) överstiger sökfönstret ({window_minutes} min). Minska uppgiftslängderna, sänk gap_minutes eller utöka sökintervallet."
},
"must_finish_by_conflicts_with_end": {
"message": "must_finish_by kan inte kombineras med slutgränsparametrar ({params}). Använd must_finish_by ensamt — det sätter sökslutet till deadline automatiskt."
} }
}, },
"services": { "services": {
@ -1503,20 +1506,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Soekomraade", "name": "Anpassat sökintervall",
"description": "Definiera tidsfoenstret att soeka inom." "description": "Definiera exakta start- och sluttider för sökningen. Åsidosätter sökområdet när det anges."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternativa tidsinstaellningar", "name": "Avancerade tidsalternativ",
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset." "description": "Alternativa sätt att definiera sökintervallet med klockslag och minutförskjutningar."
}, },
"price_filter": { "price_filter": {
"name": "Prisnivaaefilter", "name": "Prisnivåfilter",
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet." "description": "Begränsa sökningen till intervall inom det angivna Tibber-prisnivåintervallet."
},
"search_tuning": {
"name": "Finjustering av sökalgoritm",
"description": "Finjustera hur sökningen hanterar avvikare, minimikvalitetströsklar och reservbeteende."
},
"cost_estimation": {
"name": "Kostnadsuppskattning",
"description": "Ange en effektprofil för att få exakta energikostnadsuppskattningar baserat på faktisk förbrukning."
}, },
"output": { "output": {
"name": "Utdataalternativ", "name": "Utdataalternativ",
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat." "description": "Kontrollera utdataformat: jämförelsedetaljer och valutaenhet."
} }
}, },
"fields": { "fields": {
@ -1536,6 +1547,10 @@
"name": "Sökslut", "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." "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."
}, },
"must_finish_by": {
"name": "Måste vara klar senast",
"description": "Deadline: apparaten måste vara klar vid denna tidpunkt. Sökintervallet slutar vid denna deadline — tjänsten hittar det billigaste fönstret som slutar före det. Kan inte kombineras med Sökslutt, Sökslutt-tid eller Sökslutt-offset."
},
"search_start_time": { "search_start_time": {
"name": "Sökstart-klockslag", "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." "description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
@ -1587,6 +1602,22 @@
"power_profile": { "power_profile": {
"name": "Effektprofil", "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." "description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
},
"smooth_outliers": {
"name": "Jämna utliggare",
"description": "Jämna prisutliggare före sökning. Utliggarintervall ersätts tillfälligt med genomsnittet av sina grannar, så att en enskild topp eller dipp inte dominerar resultatet. Svaret visar alltid de ursprungliga (outjämnade) priserna. Standard: aktiverat."
},
"min_distance_from_avg": {
"name": "Min. avstånd från genomsnitt",
"description": "Kräv att resultatet avviker från sökområdets genomsnitt med minst denna procent. För billigast: resultatet måste vara minst X% under genomsnittet. För dyrast: minst X% över. Om villkoret inte uppfylls returneras inget resultat (reason: selection_above/below_distance_threshold). Lämna tomt för att inaktivera."
},
"allow_relaxation": {
"name": "Tillåt avslappning",
"description": "Slappna av filter gradvis för att garantera ett resultat. Faser: 1) Minska/ta bort avståndströskeln 2) Utöka prisnivåfilter 3) Minska varaktighet. Standard: aktiverat."
},
"duration_flexibility_minutes": {
"name": "Varaktighetsflexibilitet",
"description": "Max minuter varaktigheten kan förkortas under avslappning (0120, steg 15). Lämna tomt för automatisk beräkning (~20% av varaktigheten, max 60 min)."
} }
} }
}, },
@ -1595,20 +1626,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Soekomraade", "name": "Anpassat sökintervall",
"description": "Definiera tidsfoenstret att soeka inom." "description": "Definiera exakta start- och sluttider för sökningen. Åsidosätter sökområdet när det anges."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternativa tidsinstaellningar", "name": "Avancerade tidsalternativ",
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset." "description": "Alternativa sätt att definiera sökintervallet med klockslag och minutförskjutningar."
}, },
"price_filter": { "price_filter": {
"name": "Prisnivaaefilter", "name": "Prisnivåfilter",
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet." "description": "Begränsa sökningen till intervall inom det angivna Tibber-prisnivåintervallet."
},
"search_tuning": {
"name": "Finjustering av sökalgoritm",
"description": "Finjustera hur sökningen hanterar avvikare, minimikvalitetströsklar och reservbeteende."
},
"cost_estimation": {
"name": "Kostnadsuppskattning",
"description": "Ange en effektprofil för att få exakta energikostnadsuppskattningar baserat på faktisk förbrukning."
}, },
"output": { "output": {
"name": "Utdataalternativ", "name": "Utdataalternativ",
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat." "description": "Kontrollera utdataformat: jämförelsedetaljer och valutaenhet."
} }
}, },
"fields": { "fields": {
@ -1628,6 +1667,10 @@
"name": "Sökslut", "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." "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."
}, },
"must_finish_by": {
"name": "Måste vara klar senast",
"description": "Deadline: apparaten måste vara klar vid denna tid. Sökintervallet slutar vid denna deadline — tjänsten hittar det dyraste fönstret som slutar före den. Kan inte kombineras med Sökslutt, Sökslutt-klockslag, Sökslutt-förskjutning eller Sökområde."
},
"search_start_time": { "search_start_time": {
"name": "Sökstart-klockslag", "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." "description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
@ -1679,6 +1722,22 @@
"power_profile": { "power_profile": {
"name": "Effektprofil", "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." "description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
},
"smooth_outliers": {
"name": "Jämna utliggare",
"description": "Jämna prisutliggare före sökning. Utliggarintervall ersätts tillfälligt med genomsnittet av sina grannar, så att en enskild topp eller dipp inte dominerar resultatet. Svaret visar alltid de ursprungliga (outjämnade) priserna. Standard: aktiverat."
},
"min_distance_from_avg": {
"name": "Min. avstånd från genomsnitt",
"description": "Kräv att resultatet avviker från sökområdets genomsnitt med minst denna procent. För billigast: resultatet måste vara minst X% under genomsnittet. För dyrast: minst X% över. Om villkoret inte uppfylls returneras inget resultat (reason: selection_above/below_distance_threshold). Lämna tomt för att inaktivera."
},
"allow_relaxation": {
"name": "Tillåt avslappning",
"description": "Slappna av filter gradvis för att garantera ett resultat. Faser: 1) Minska/ta bort avståndströskeln 2) Utöka prisnivåfilter 3) Minska varaktighet. Standard: aktiverat."
},
"duration_flexibility_minutes": {
"name": "Varaktighetsflexibilitet",
"description": "Max minuter varaktigheten kan förkortas under avslappning (0120, steg 15). Lämna tomt för automatisk beräkning (~20% av varaktigheten, max 60 min)."
} }
} }
}, },
@ -1687,20 +1746,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Soekomraade", "name": "Anpassat sökintervall",
"description": "Definiera tidsfoenstret att soeka inom." "description": "Definiera exakta start- och sluttider för sökningen. Åsidosätter sökområdet när det anges."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternativa tidsinstaellningar", "name": "Avancerade tidsalternativ",
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset." "description": "Alternativa sätt att definiera sökintervallet med klockslag och minutförskjutningar."
}, },
"price_filter": { "price_filter": {
"name": "Prisnivaaefilter", "name": "Prisnivåfilter",
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet." "description": "Begränsa sökningen till intervall inom det angivna Tibber-prisnivåintervallet."
},
"search_tuning": {
"name": "Finjustering av sökalgoritm",
"description": "Finjustera hur sökningen hanterar avvikare, minimikvalitetströsklar och reservbeteende."
},
"cost_estimation": {
"name": "Kostnadsuppskattning",
"description": "Ange en effektprofil för att få exakta energikostnadsuppskattningar baserat på faktisk förbrukning."
}, },
"output": { "output": {
"name": "Utdataalternativ", "name": "Utdataalternativ",
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat." "description": "Kontrollera utdataformat: jämförelsedetaljer och valutaenhet."
} }
}, },
"fields": { "fields": {
@ -1720,6 +1787,10 @@
"name": "Sökslut", "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." "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."
}, },
"must_finish_by": {
"name": "Måste vara klar senast",
"description": "Deadline: apparaten måste vara klar vid denna tidpunkt. Sökintervallet slutar vid denna deadline — tjänsten hittar det billigaste fönstret som slutar före det. Kan inte kombineras med Sökslutt, Sökslutt-tid eller Sökslutt-offset."
},
"search_start_time": { "search_start_time": {
"name": "Sökstart-klockslag", "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." "description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
@ -1775,6 +1846,22 @@
"power_profile": { "power_profile": {
"name": "Effektprofil", "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." "description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
},
"smooth_outliers": {
"name": "Jämna utliggare",
"description": "Jämna prisutliggare före sökning. Utliggarintervall ersätts tillfälligt med genomsnittet av sina grannar, så att en enskild topp eller dipp inte dominerar resultatet. Svaret visar alltid de ursprungliga (outjämnade) priserna. Standard: aktiverat."
},
"min_distance_from_avg": {
"name": "Min. avstånd från genomsnitt",
"description": "Kräv att resultatet avviker från sökområdets genomsnitt med minst denna procent. För billigast: resultatet måste vara minst X% under genomsnittet. För dyrast: minst X% över. Om villkoret inte uppfylls returneras inget resultat (reason: selection_above/below_distance_threshold). Lämna tomt för att inaktivera."
},
"allow_relaxation": {
"name": "Tillåt avslappning",
"description": "Slappna av filter gradvis för att garantera ett resultat. Faser: 1) Minska/ta bort avståndströskeln 2) Utöka prisnivåfilter 3) Minska varaktighet. Standard: aktiverat."
},
"duration_flexibility_minutes": {
"name": "Varaktighetsflexibilitet",
"description": "Max minuter varaktigheten kan förkortas under avslappning (0120, steg 15). Lämna tomt för automatisk beräkning (~20% av varaktigheten, max 60 min)."
} }
} }
}, },
@ -1783,20 +1870,28 @@
"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.", "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": { "sections": {
"search_range": { "search_range": {
"name": "Soekomraade", "name": "Anpassat sökintervall",
"description": "Definiera tidsfoenstret att soeka inom." "description": "Definiera exakta start- och sluttider för sökningen. Åsidosätter sökområdet när det anges."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternativa tidsinstaellningar", "name": "Avancerade tidsalternativ",
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset." "description": "Alternativa sätt att definiera sökintervallet med klockslag och minutförskjutningar."
}, },
"price_filter": { "price_filter": {
"name": "Prisnivaaefilter", "name": "Prisnivåfilter",
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet." "description": "Begränsa sökningen till intervall inom det angivna Tibber-prisnivåintervallet."
},
"search_tuning": {
"name": "Finjustering av sökalgoritm",
"description": "Finjustera hur sökningen hanterar avvikare, minimikvalitetströsklar och reservbeteende."
},
"cost_estimation": {
"name": "Kostnadsuppskattning",
"description": "Ange en effektprofil för att få exakta energikostnadsuppskattningar baserat på faktisk förbrukning."
}, },
"output": { "output": {
"name": "Utdataalternativ", "name": "Utdataalternativ",
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat." "description": "Kontrollera utdataformat: jämförelsedetaljer och valutaenhet."
} }
}, },
"fields": { "fields": {
@ -1816,6 +1911,10 @@
"name": "Sökslut", "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." "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."
}, },
"must_finish_by": {
"name": "Måste vara klar senast",
"description": "Deadline: apparaten måste vara klar vid denna tid. Sökintervallet slutar vid denna deadline — tjänsten hittar det dyraste fönstret som slutar före den. Kan inte kombineras med Sökslutt, Sökslutt-klockslag, Sökslutt-förskjutning eller Sökområde."
},
"search_start_time": { "search_start_time": {
"name": "Sökstart-klockslag", "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." "description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
@ -1871,6 +1970,22 @@
"power_profile": { "power_profile": {
"name": "Effektprofil", "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." "description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
},
"smooth_outliers": {
"name": "Jämna utliggare",
"description": "Jämna prisutliggare före sökning. Utliggarintervall ersätts tillfälligt med genomsnittet av sina grannar, så att en enskild topp eller dipp inte dominerar resultatet. Svaret visar alltid de ursprungliga (outjämnade) priserna. Standard: aktiverat."
},
"min_distance_from_avg": {
"name": "Min. avstånd från genomsnitt",
"description": "Kräv att resultatet avviker från sökområdets genomsnitt med minst denna procent. För billigast: resultatet måste vara minst X% under genomsnittet. För dyrast: minst X% över. Om villkoret inte uppfylls returneras inget resultat (reason: selection_above/below_distance_threshold). Lämna tomt för att inaktivera."
},
"allow_relaxation": {
"name": "Tillåt avslappning",
"description": "Slappna av filter gradvis för att garantera ett resultat. Faser: 1) Minska/ta bort avståndströskeln 2) Utöka prisnivåfilter 3) Minska varaktighet. Standard: aktiverat."
},
"duration_flexibility_minutes": {
"name": "Varaktighetsflexibilitet",
"description": "Max minuter varaktigheten kan förkortas under avslappning (0120, steg 15). Lämna tomt för automatisk beräkning (~20% av varaktigheten, max 60 min)."
} }
} }
}, },
@ -1878,25 +1993,25 @@
"name": "Hitta billigaste schema", "name": "Hitta billigaste schema",
"description": "Schemalaggar flera apparater optimalt utan tidsoeverlapp. Varje uppgift tilldelas det billigaste tillgaengliga sammanhangande tidsfoenster.", "description": "Schemalaggar flera apparater optimalt utan tidsoeverlapp. Varje uppgift tilldelas det billigaste tillgaengliga sammanhangande tidsfoenster.",
"sections": { "sections": {
"scheduling_options": {
"name": "Schemalagningsalternativ",
"description": "Konfigurera uppgifter och pauser mellan dem."
},
"search_range": { "search_range": {
"name": "Soekomraade", "name": "Anpassat sökintervall",
"description": "Definiera tidsfoenstret att soeka inom." "description": "Definiera exakta start- och sluttider för sökningen. Åsidosätter sökområdet när det anges."
}, },
"time_alternatives": { "time_alternatives": {
"name": "Alternativa tidsinstaellningar", "name": "Avancerade tidsalternativ",
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset." "description": "Alternativa sätt att definiera sökintervallet med klockslag och minutförskjutningar."
}, },
"price_filter": { "price_filter": {
"name": "Prisnivaaefilter", "name": "Prisnivåfilter",
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet." "description": "Begränsa sökningen till intervall inom det angivna Tibber-prisnivåintervallet."
},
"search_tuning": {
"name": "Finjustering av sökalgoritm",
"description": "Finjustera hur sökningen hanterar avvikare, minimikvalitetströsklar och reservbeteende."
}, },
"output": { "output": {
"name": "Utdataalternativ", "name": "Utdataalternativ",
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat." "description": "Kontrollera utdataformat: jämförelsedetaljer och valutaenhet."
} }
}, },
"fields": { "fields": {
@ -1924,6 +2039,10 @@
"name": "Sökslut", "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." "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."
}, },
"must_finish_by": {
"name": "Måste vara klar senast",
"description": "Deadline: apparaten måste vara klar vid denna tidpunkt. Sökintervallet slutar vid denna deadline — tjänsten hittar det billigaste fönstret som slutar före det. Kan inte kombineras med Sökslutt, Sökslutt-tid eller Sökslutt-offset."
},
"search_start_time": { "search_start_time": {
"name": "Sökstart-klockslag", "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." "description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
@ -1948,10 +2067,6 @@
"name": "Sökslut-förskjutning (minuter)", "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." "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": { "max_price_level": {
"name": "Maximal prisnivaae", "name": "Maximal prisnivaae",
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning." "description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
@ -1967,6 +2082,18 @@
"use_base_unit": { "use_base_unit": {
"name": "Använd basvaluta", "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." "description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
},
"smooth_outliers": {
"name": "Jämna utliggare",
"description": "Jämna prisutliggare före sökning. Utliggarintervall ersätts tillfälligt med genomsnittet av sina grannar, så att en enskild topp eller dipp inte dominerar resultatet. Svaret visar alltid de ursprungliga (outjämnade) priserna. Standard: aktiverat."
},
"allow_relaxation": {
"name": "Tillåt avslappning",
"description": "Slappna av filter gradvis för att garantera ett resultat. Faser: 1) Minska/ta bort avståndströskeln 2) Utöka prisnivåfilter 3) Minska varaktighet. Standard: aktiverat."
},
"duration_flexibility_minutes": {
"name": "Varaktighetsflexibilitet",
"description": "Max minuter varaktigheten kan förkortas under avslappning (0120, steg 15). Lämna tomt för automatisk beräkning (~20% av varaktigheten, max 60 min)."
} }
} }
} }

View file

@ -14,6 +14,8 @@ from datetime import datetime, timedelta
import statistics import statistics
from typing import Any from typing import Any
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation
def find_cheapest_contiguous_window( def find_cheapest_contiguous_window(
intervals: list[dict[str, Any]], intervals: list[dict[str, Any]],
@ -287,6 +289,7 @@ def calculate_window_statistics(
"price_min": None, "price_min": None,
"price_max": None, "price_max": None,
"price_spread": None, "price_spread": None,
"coefficient_of_variation": None,
"estimated_total_cost": None, "estimated_total_cost": None,
} }
if power_profile is not None: if power_profile is not None:
@ -302,6 +305,10 @@ def calculate_window_statistics(
hours_per_interval = interval_minutes / 60 hours_per_interval = interval_minutes / 60
# Calculate coefficient of variation (CV) as quality indicator
cv = calculate_coefficient_of_variation(prices)
cv_rounded = round(cv, round_decimals) if cv is not None else None
if power_profile is not None: if power_profile is not None:
# Extend profile to cover all intervals by repeating the last value # Extend profile to cover all intervals by repeating the last value
last_watts = power_profile[-1] if power_profile else 1000 last_watts = power_profile[-1] if power_profile else 1000
@ -317,6 +324,7 @@ def calculate_window_statistics(
"price_min": price_min, "price_min": price_min,
"price_max": price_max, "price_max": price_max,
"price_spread": spread, "price_spread": spread,
"coefficient_of_variation": cv_rounded,
"estimated_total_cost": estimated_cost, "estimated_total_cost": estimated_cost,
"estimated_load_kwh": estimated_load_kwh, "estimated_load_kwh": estimated_load_kwh,
} }
@ -331,6 +339,7 @@ def calculate_window_statistics(
"price_min": price_min, "price_min": price_min,
"price_max": price_max, "price_max": price_max,
"price_spread": spread, "price_spread": spread,
"coefficient_of_variation": cv_rounded,
"estimated_total_cost": estimated_cost, "estimated_total_cost": estimated_cost,
} }

View file

@ -137,9 +137,17 @@ These parameters are available across all scheduling actions:
| `include_current_interval` | Include the currently running 15-minute interval in the search? | `true` | | `include_current_interval` | Include the currently running 15-minute interval in the search? | `true` |
| `min_price_level` | Only consider intervals at or above this Tibber level | — | | `min_price_level` | Only consider intervals at or above this Tibber level | — |
| `max_price_level` | Only consider intervals at or below this Tibber level | — | | `max_price_level` | Only consider intervals at or below this Tibber level | — |
| `smooth_outliers` | Smooth price outliers before searching (see [below](#outlier-smoothing)) | `true` |
| `min_distance_from_avg` | Require result to differ from average by X% (see [below](#minimum-distance-from-average)) | — |
| `allow_relaxation` | Progressively loosen filters to guarantee a result (see [below](#relaxation)) | `true` |
| `duration_flexibility_minutes` | Max minutes the duration may be shortened during relaxation (see [below](#relaxation)) | Auto |
| `power_profile` | Watt values per 15-min interval for accurate cost estimates | — | | `power_profile` | Watt values per 15-min interval for accurate cost estimates | — |
| `use_base_unit` | Use base currency (EUR, NOK) instead of subunit (ct, øre) | `false` | | `use_base_unit` | Use base currency (EUR, NOK) instead of subunit (ct, øre) | `false` |
:::note `min_distance_from_avg` availability
`min_distance_from_avg` is available in `find_cheapest_block`, `find_most_expensive_block`, `find_cheapest_hours`, and `find_most_expensive_hours`. It is **not** available in `find_cheapest_schedule` (multi-task semantics make a single threshold ambiguous).
:::
### Price Level Filtering ### Price Level Filtering
Restrict the search to specific Tibber price levels. Levels from lowest to highest: `very_cheap`, `cheap`, `normal`, `expensive`, `very_expensive`. Restrict the search to specific Tibber price levels. Levels from lowest to highest: `very_cheap`, `cheap`, `normal`, `expensive`, `very_expensive`.
@ -187,6 +195,131 @@ The service then calculates `estimated_total_cost` using the actual power draw p
The number of entries in `power_profile` must exactly match the number of 15-minute intervals in `duration`. A 2-hour duration needs 8 entries. The number of entries in `power_profile` must exactly match the number of 15-minute intervals in `duration`. A 2-hour duration needs 8 entries.
::: :::
### Outlier Smoothing
Enabled by default. A single extreme price spike (or dip) can pull the "cheapest" window away from a genuinely good period. With `smooth_outliers: true`, outlier intervals are temporarily replaced by the average of their neighbors before the search runs. **The response always shows original (unsmoothed) prices** — smoothing only affects _which_ window is selected.
Set `smooth_outliers: false` to disable:
```yaml
service: tibber_prices.find_cheapest_block
data:
duration: "02:00:00"
search_scope: next_24h
smooth_outliers: false
```
### Minimum Distance from Average
Opt-in quality gate. Ensures the found result is meaningfully different from the search-range average — not just "the cheapest, but still close to average".
- **For cheapest:** the result must be at least X% _below_ the average.
- **For most expensive:** the result must be at least X% _above_ the average.
If the condition is not met, **no result is returned** and the `reason` field explains why (`window_above_distance_threshold` for blocks, `selection_above_distance_threshold` for hours — or `..._below_...` for most expensive).
```yaml
# Only return a result if it's at least 10% cheaper than average
service: tibber_prices.find_cheapest_block
data:
duration: "02:00:00"
search_scope: today
min_distance_from_avg: 10.0
```
:::tip When to use `min_distance_from_avg`
On days with flat prices (all intervals nearly the same), the "cheapest" window may only be marginally cheaper than any other. Use `min_distance_from_avg` to avoid scheduling an appliance for negligible savings — and instead run it whenever convenient.
:::
### Coefficient of Variation
All scheduling action responses include a `coefficient_of_variation` field in their statistics. This measures the relative price spread within the found window (standard deviation ÷ mean, as a ratio). A low value (e.g., 0.05) means prices are very uniform; a high value (e.g., 0.30) means prices vary significantly within the window.
You can use this in automations to decide whether the found window is "good enough":
```yaml
# Only start if prices within the window are reasonably uniform
condition: template
value_template: >
{{ action_response.window.coefficient_of_variation < 0.15 }}
```
### Relaxation
Enabled by default. Relaxation ensures you **always get a result**, even when your filters are too strict for the available price data. Without relaxation, a tight `max_price_level` or `min_distance_from_avg` could return nothing — leaving your automation without a plan. With relaxation, the service progressively loosens constraints until a window is found.
**How it works:**
Relaxation proceeds in three phases, stopping as soon as a result is found:
1. **Distance relaxation** — Halves `min_distance_from_avg`, then removes it entirely
2. **Level filter relaxation** — Gradually widens the allowed price level range (e.g., `very_cheap``cheap``normal` → any)
3. **Duration reduction** — Shortens the duration by one interval (15 min) per step, down to a minimum of 30 minutes
Each phase tries the least invasive change first. If phase 1 produces a result, phases 2 and 3 are never attempted.
:::tip When does relaxation activate?
Relaxation only activates when the original parameters return no result. If your filters already find a window, relaxation does nothing — there is zero overhead.
:::
**Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `allow_relaxation` | boolean | `true` | Enable progressive filter relaxation. Set to `false` for strict mode (fail if nothing matches). |
| `duration_flexibility_minutes` | number | Auto | Maximum minutes the duration may be shortened (0120, in 15-min steps). If omitted, calculated automatically based on the requested duration. |
```yaml
# Opt out of relaxation (strict mode)
service: tibber_prices.find_cheapest_block
data:
duration: "02:00:00"
search_scope: next_24h
allow_relaxation: false
```
```yaml
# Allow up to 30 min shorter than requested
service: tibber_prices.find_cheapest_block
data:
duration: "02:00:00"
search_scope: next_24h
duration_flexibility_minutes: 30
```
**Response metadata:**
When relaxation is applied, the response includes extra fields:
| Field | Description |
|-------|-------------|
| `relaxation_applied` | `true` if filters were relaxed to find the result, `false` if original parameters succeeded |
| `relaxation_steps` | Number of relaxation steps applied (only present when `relaxation_applied` is `true`) |
| `duration_minutes` | Effective duration (may be shorter than `duration_minutes_requested` if duration was reduced) |
If all relaxation steps are exhausted without finding a result, the response still indicates failure with `reason: "relaxation_exhausted"`.
```yaml
# Check if relaxation was needed in your automation
- if: "{{ result.window_found and not result.relaxation_applied }}"
then:
- service: notify.mobile_app
data:
message: "Found optimal window at original settings"
- if: "{{ result.window_found and result.relaxation_applied }}"
then:
- service: notify.mobile_app
data:
message: >
Found window after {{ result.relaxation_steps }} relaxation steps.
Effective duration: {{ result.duration_minutes }} min
(requested: {{ result.duration_minutes_requested }} min)
```
:::note Schedule service differences
For `find_cheapest_schedule`, relaxation works slightly differently: phase 1 (distance) is skipped because the schedule service does not support `min_distance_from_avg`. Level filter relaxation and duration reduction apply to all tasks uniformly.
:::
--- ---
## Find Cheapest Block ## Find Cheapest Block
@ -285,6 +418,9 @@ response_variable: result
| `window.estimated_total_cost` | Estimated cost (assumes 1 kW unless `power_profile` provided) | | `window.estimated_total_cost` | Estimated cost (assumes 1 kW unless `power_profile` provided) |
| `price_comparison` | How this window compares to the most expensive alternative | | `price_comparison` | How this window compares to the most expensive alternative |
| `price_comparison.price_difference` | How much cheaper this window is vs. the most expensive option | | `price_comparison.price_difference` | How much cheaper this window is vs. the most expensive option |
| `relaxation_applied` | `true` if [relaxation](#relaxation) was needed to find the result |
| `relaxation_steps` | Number of relaxation steps applied (only when `relaxation_applied` is `true`) |
| `duration_minutes` | Effective duration — may differ from `duration_minutes_requested` after relaxation |
### Use in Automations ### Use in Automations
@ -432,6 +568,9 @@ response_variable: result
| `schedule.segment_count` | How many separate contiguous runs the schedule has | | `schedule.segment_count` | How many separate contiguous runs the schedule has |
| `schedule.segments[]` | Each continuous "on" period with its own start/end and price stats | | `schedule.segments[]` | Each continuous "on" period with its own start/end and price stats |
| `schedule.intervals[]` | All selected intervals in chronological order | | `schedule.intervals[]` | All selected intervals in chronological order |
| `relaxation_applied` | `true` if [relaxation](#relaxation) was needed to find the result |
| `relaxation_steps` | Number of relaxation steps applied (only when `relaxation_applied` is `true`) |
| `total_minutes` | Effective total duration — may differ from `total_minutes_requested` after relaxation |
--- ---
@ -568,6 +707,8 @@ response_variable: result
| `tasks[]` | Each task with its assigned time window and price statistics | | `tasks[]` | Each task with its assigned time window and price statistics |
| `tasks[].start` / `tasks[].end` | When to start and stop each appliance | | `tasks[].start` / `tasks[].end` | When to start and stop each appliance |
| `total_estimated_cost` | Combined cost across all tasks | | `total_estimated_cost` | Combined cost across all tasks |
| `relaxation_applied` | `true` if [relaxation](#relaxation) was needed to schedule all tasks |
| `relaxation_steps` | Number of relaxation steps applied (only when `relaxation_applied` is `true`) |
### Why Not Just Call find_cheapest_block Multiple Times? ### Why Not Just Call find_cheapest_block Multiple Times?
@ -785,4 +926,18 @@ If no intervals match your criteria (e.g., the search range is too short, all in
- `find_cheapest_hours`: `"intervals_found": false, "schedule": null` - `find_cheapest_hours`: `"intervals_found": false, "schedule": null`
- `find_cheapest_schedule`: `"all_tasks_scheduled": false, "unscheduled_tasks": ["task_name"]` - `find_cheapest_schedule`: `"all_tasks_scheduled": false, "unscheduled_tasks": ["task_name"]`
Always check these fields in your automations before using the results. The `reason` field contains a stable machine-readable code you can use in automations:
| Reason Code | Meaning |
|-------------|---------|
| `no_data_in_range` | No price data available for the search range |
| `no_intervals_matching_level_filter` | Level filter excluded all intervals |
| `insufficient_intervals_after_filter` | Not enough intervals left after filtering |
| `insufficient_intervals_for_constraints` | Enough intervals, but constraints (min segment) can't be met |
| `window_above_distance_threshold` | Block found, but not far enough below average (`min_distance_from_avg`) |
| `window_below_distance_threshold` | Most expensive block found, but not far enough above average |
| `selection_above_distance_threshold` | Hours found, but not far enough below average (`min_distance_from_avg`) |
| `selection_below_distance_threshold` | Most expensive hours found, but not far enough above average |
| `relaxation_exhausted` | All relaxation steps tried, still no result (only when `allow_relaxation: true`) |
Always check the failure fields in your automations before using the results.

View file

@ -221,6 +221,7 @@ async def test_block_handler_returns_level_filter_reason(monkeypatch: pytest.Mon
"duration": timedelta(hours=1), "duration": timedelta(hours=1),
"max_price_level": "very_cheap", "max_price_level": "very_cheap",
"use_base_unit": True, "use_base_unit": True,
"allow_relaxation": False,
}, },
) )
response = cast("dict[str, Any]", await handle_find_cheapest_block(cast("ServiceCall", call))) response = cast("dict[str, Any]", await handle_find_cheapest_block(cast("ServiceCall", call)))
@ -251,6 +252,7 @@ async def test_hours_handler_returns_insufficient_intervals_reason(monkeypatch:
data={ data={
"duration": timedelta(hours=1), # needs 4 intervals "duration": timedelta(hours=1), # needs 4 intervals
"use_base_unit": True, "use_base_unit": True,
"allow_relaxation": False,
}, },
) )
response = cast("dict[str, Any]", await handle_find_cheapest_hours(cast("ServiceCall", call))) response = cast("dict[str, Any]", await handle_find_cheapest_hours(cast("ServiceCall", call)))