mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
feat(services): add power-profile-weighted window selection
Add `include_current_interval` parameter to `find_cheapest_block` and `find_cheapest_schedule` services, controlling whether the currently active price interval can be the start of the selected window. Add power-profile weighting to `find_cheapest_contiguous_window`: accepts an optional `power_profile` list that weights each interval's price by relative power draw (e.g. heat-up phase heavier than steady state). Without a profile the behaviour is unchanged (uniform weighting). Extend search-range tests and add price-window unit tests covering weighted and unweighted scenarios, edge cases, and sequential scheduling interactions. Update scheduling-actions documentation with parameter and profile examples. Impact: Users can now model appliances with non-uniform power draw (e.g. heat pumps, washing machines) to find truly cheapest windows based on actual energy cost rather than average price.
This commit is contained in:
parent
ba08bd34c6
commit
b93eedf00e
15 changed files with 356 additions and 120 deletions
|
|
@ -3,7 +3,7 @@ blueprint:
|
|||
description: >
|
||||
**Companion script blueprint for
|
||||
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
|
||||
appliance blueprints** · Blueprint v2.0.0
|
||||
appliance blueprints** · Blueprint v1.0.0
|
||||
|
||||
|
||||
Advanced notification dispatcher that replaces the simple
|
||||
|
|
|
|||
|
|
@ -1011,6 +1011,11 @@ find_cheapest_schedule:
|
|||
- next_24h
|
||||
- next_48h
|
||||
translation_key: search_scope
|
||||
include_current_interval:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
search_range:
|
||||
collapsed: true
|
||||
fields:
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ def _attempt_find_block(
|
|||
duration_intervals: int,
|
||||
smooth_outliers: bool,
|
||||
min_distance_from_avg: float | None,
|
||||
power_profile: list[int] | None,
|
||||
reverse: bool,
|
||||
) -> tuple[dict | None, str]:
|
||||
"""Attempt to find a block with specific filter parameters.
|
||||
|
|
@ -207,7 +208,9 @@ def _attempt_find_block(
|
|||
else:
|
||||
search_data = filtered
|
||||
|
||||
result = find_cheapest_contiguous_window(search_data, duration_intervals, reverse=reverse)
|
||||
result = find_cheapest_contiguous_window(
|
||||
search_data, duration_intervals, reverse=reverse, power_profile=power_profile
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return None, _determine_no_window_reason(
|
||||
|
|
@ -335,6 +338,7 @@ async def _handle_find_block(
|
|||
duration_intervals=effective_duration,
|
||||
smooth_outliers=smooth_outliers,
|
||||
min_distance_from_avg=min_distance_from_avg,
|
||||
power_profile=power_profile,
|
||||
reverse=reverse,
|
||||
)
|
||||
|
||||
|
|
@ -362,6 +366,7 @@ async def _handle_find_block(
|
|||
duration_intervals=effective_duration,
|
||||
smooth_outliers=smooth_outliers,
|
||||
min_distance_from_avg=step.min_distance_from_avg,
|
||||
power_profile=power_profile,
|
||||
reverse=reverse,
|
||||
)
|
||||
if result is not None:
|
||||
|
|
@ -411,7 +416,9 @@ async def _handle_find_block(
|
|||
effective_duration_minutes = effective_duration * INTERVAL_MINUTES
|
||||
|
||||
# Find the opposite-direction window for price comparison (from full unfiltered list)
|
||||
comparison_result = find_cheapest_contiguous_window(price_info, effective_duration, reverse=not reverse)
|
||||
comparison_result = find_cheapest_contiguous_window(
|
||||
price_info, effective_duration, reverse=not reverse, power_profile=power_profile
|
||||
)
|
||||
|
||||
# Calculate statistics and build response
|
||||
stats = calculate_window_statistics(
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
|
|||
),
|
||||
vol.Optional("must_finish_by"): or_entity_ref(cv.datetime),
|
||||
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
|
||||
vol.Optional("include_current_interval", default=True): cv.boolean,
|
||||
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||
vol.Optional("include_comparison_details", default=False): cv.boolean,
|
||||
|
|
@ -133,10 +134,13 @@ def _compute_task_price_comparison(
|
|||
unit_factor: int,
|
||||
*,
|
||||
include_details: bool,
|
||||
power_profile: list[int] | None = None,
|
||||
) -> dict[str, float | str | None] | None:
|
||||
"""Compute per-task comparison against most expensive window of same duration."""
|
||||
duration_intervals = len(task_intervals)
|
||||
comparison_result = find_cheapest_contiguous_window(full_price_info, duration_intervals, reverse=True)
|
||||
comparison_result = find_cheapest_contiguous_window(
|
||||
full_price_info, duration_intervals, reverse=True, power_profile=power_profile
|
||||
)
|
||||
if comparison_result is None:
|
||||
return None
|
||||
|
||||
|
|
@ -196,6 +200,8 @@ def _find_cheapest_window_in_pool(
|
|||
pool: list[dict[str, Any]],
|
||||
duration_intervals: int,
|
||||
available: list[bool],
|
||||
*,
|
||||
power_profile: list[int] | None = None,
|
||||
) -> tuple[int, int] | None:
|
||||
"""
|
||||
Find the cheapest contiguous window of `duration_intervals` in available pool slots.
|
||||
|
|
@ -204,6 +210,9 @@ def _find_cheapest_window_in_pool(
|
|||
pool: Full sorted interval list.
|
||||
duration_intervals: Required contiguous count.
|
||||
available: Boolean mask, same length as pool. True = still available.
|
||||
power_profile: Optional watt value per interval for weighted scoring.
|
||||
Only the first duration_intervals values are used. When provided,
|
||||
scoring uses \u03a3 price[i] \u00d7 watt[i] instead of \u03a3 price[i].
|
||||
|
||||
Returns:
|
||||
(start_index, end_index_exclusive) of the best window, or None if not found.
|
||||
|
|
@ -235,6 +244,9 @@ def _find_cheapest_window_in_pool(
|
|||
j += 1
|
||||
|
||||
if len(block) == duration_intervals:
|
||||
if power_profile:
|
||||
window_sum = sum(block[k]["total"] * power_profile[k] for k in range(len(block)))
|
||||
else:
|
||||
window_sum = sum(iv["total"] for iv in block)
|
||||
if best_sum is None or window_sum < best_sum:
|
||||
best_sum = window_sum
|
||||
|
|
@ -306,7 +318,9 @@ def _attempt_schedule(
|
|||
for k in range(min(sequential_min_idx, len(search_data))):
|
||||
available[k] = False
|
||||
|
||||
window = _find_cheapest_window_in_pool(search_data, dur_intervals, available)
|
||||
window = _find_cheapest_window_in_pool(
|
||||
search_data, dur_intervals, available, power_profile=task.get("power_profile")
|
||||
)
|
||||
|
||||
if window is None:
|
||||
unscheduled.append(task["name"])
|
||||
|
|
@ -622,6 +636,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
price_info,
|
||||
unit_factor,
|
||||
include_details=include_comparison_details,
|
||||
power_profile=task.get("power_profile"),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -357,7 +357,13 @@ def _resolve_time_with_day_offset(
|
|||
)
|
||||
|
||||
|
||||
def _resolve_scope(scope: str, now: datetime, _home_tz: ZoneInfo) -> tuple[datetime, datetime]:
|
||||
def _resolve_scope(
|
||||
scope: str,
|
||||
now: datetime,
|
||||
_home_tz: ZoneInfo,
|
||||
*,
|
||||
include_current: bool,
|
||||
) -> tuple[datetime, datetime]:
|
||||
"""
|
||||
Convert a search_scope shorthand into explicit start/end datetimes.
|
||||
|
||||
|
|
@ -374,16 +380,18 @@ def _resolve_scope(scope: str, now: datetime, _home_tz: ZoneInfo) -> tuple[datet
|
|||
tomorrow_start = today_start + timedelta(days=1)
|
||||
day_after_start = today_start + timedelta(days=2)
|
||||
|
||||
rolling_start = floor_to_quarter_hour(now) if include_current else now
|
||||
|
||||
if scope == "today":
|
||||
return today_start, tomorrow_start
|
||||
if scope == "tomorrow":
|
||||
return tomorrow_start, day_after_start
|
||||
if scope == "remaining_today":
|
||||
return floor_to_quarter_hour(now), tomorrow_start
|
||||
return rolling_start, tomorrow_start
|
||||
if scope == "next_24h":
|
||||
return floor_to_quarter_hour(now), now + timedelta(hours=24)
|
||||
return rolling_start, now + timedelta(hours=24)
|
||||
if scope == "next_48h":
|
||||
return floor_to_quarter_hour(now), now + timedelta(hours=48)
|
||||
return rolling_start, now + timedelta(hours=48)
|
||||
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
|
|
@ -517,7 +525,7 @@ def resolve_search_range(
|
|||
|
||||
# Priority 0: search_scope shorthand
|
||||
if "search_scope" in call_data:
|
||||
return _resolve_scope(call_data["search_scope"], now, home_tz)
|
||||
return _resolve_scope(call_data["search_scope"], now, home_tz, include_current=include_current)
|
||||
|
||||
# --- Resolve start ---
|
||||
if "search_start" in call_data:
|
||||
|
|
|
|||
|
|
@ -1669,7 +1669,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"name": "Leistungsprofil",
|
||||
"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."
|
||||
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Beeinflusst die Fensterauswahl (Phasen mit hoher Leistung landen auf den günstigsten bzw. teuersten Intervallen) und die Kostenberechnung (estimated_total_cost nutzt den tatsächlichen Verbrauch statt einer festen 1-kW-Last)."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Ausreißer glätten",
|
||||
|
|
@ -1789,7 +1789,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"name": "Leistungsprofil",
|
||||
"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."
|
||||
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Beeinflusst die Fensterauswahl (Phasen mit hoher Leistung landen auf den günstigsten bzw. teuersten Intervallen) und die Kostenberechnung (estimated_total_cost nutzt den tatsächlichen Verbrauch statt einer festen 1-kW-Last)."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Ausreißer glätten",
|
||||
|
|
@ -1913,7 +1913,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"name": "Leistungsprofil",
|
||||
"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."
|
||||
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Beeinflusst nur die Kostenberechnung (estimated_total_cost nutzt den tatsächlichen Verbrauch statt einer festen 1-kW-Last). Profilgewichtete Auswahl gilt nicht für nicht-zusammenhängende Intervalle."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Ausreißer glätten",
|
||||
|
|
@ -2037,7 +2037,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"name": "Leistungsprofil",
|
||||
"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."
|
||||
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Beeinflusst nur die Kostenberechnung (estimated_total_cost nutzt den tatsächlichen Verbrauch statt einer festen 1-kW-Last). Profilgewichtete Auswahl gilt nicht für nicht-zusammenhängende Intervalle."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Ausreißer glätten",
|
||||
|
|
@ -2139,6 +2139,10 @@
|
|||
"name": "Suchende-Versatz (Minuten)",
|
||||
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Aktuelles Intervall einbeziehen",
|
||||
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert, starten rollierende Suchbereiche wie remaining_today und next_24h am Beginn des aktuellen Intervalls, sodass es ausgewählt werden kann."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximale Preisstufe",
|
||||
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe berücksichtigen. very_cheap = restriktivste, very_expensive = keine Einschränkung."
|
||||
|
|
|
|||
|
|
@ -1669,7 +1669,7 @@
|
|||
},
|
||||
"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. Affects window selection (high-wattage phases land on cheapest/most expensive intervals) and cost reporting (estimated_total_cost uses actual consumption instead of flat 1 kW)."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Smooth Outliers",
|
||||
|
|
@ -1789,7 +1789,7 @@
|
|||
},
|
||||
"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. Affects window selection (high-wattage phases land on cheapest/most expensive intervals) and cost reporting (estimated_total_cost uses actual consumption instead of flat 1 kW)."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Smooth Outliers",
|
||||
|
|
@ -1913,7 +1913,7 @@
|
|||
},
|
||||
"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. Affects cost reporting only (estimated_total_cost uses actual consumption instead of flat 1 kW). Profile-weighted selection is not applied to non-contiguous interval picks."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Smooth Outliers",
|
||||
|
|
@ -2037,7 +2037,7 @@
|
|||
},
|
||||
"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. Affects cost reporting only (estimated_total_cost uses actual consumption instead of flat 1 kW). Profile-weighted selection is not applied to non-contiguous interval picks."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Smooth Outliers",
|
||||
|
|
@ -2059,7 +2059,7 @@
|
|||
},
|
||||
"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 longest-first for efficient packing unless sequential ordering is enabled. 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": {
|
||||
"search_range": {
|
||||
"name": "Custom Search Range",
|
||||
|
|
@ -2139,6 +2139,10 @@
|
|||
"name": "Search End Offset (minutes)",
|
||||
"description": "Alternative: stop searching this many minutes from now. Positive = future, negative = past. Ignored if Search End or Search End Time is set."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Include Current Interval",
|
||||
"description": "Include the currently running 15-minute interval in the search. When enabled, rolling scopes like remaining_today and next_24h start at the beginning of the current interval so it can be selected."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximum Price Level",
|
||||
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
|
||||
|
|
|
|||
|
|
@ -1669,7 +1669,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
|
||||
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Påvirker valg av tidsvindu (faser med høyt forbruk legges til de billigste/dyreste intervallene) og kostnadsrapportering (estimated_total_cost bruker faktisk forbruk i stedet for en fast 1 kW-last)."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Glatt utliggere",
|
||||
|
|
@ -1789,7 +1789,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
|
||||
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Påvirker valg av tidsvindu (faser med høyt forbruk legges til de billigste/dyreste intervallene) og kostnadsrapportering (estimated_total_cost bruker faktisk forbruk i stedet for en fast 1 kW-last)."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Glatt utliggere",
|
||||
|
|
@ -1913,7 +1913,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
|
||||
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Påvirker kun kostnadsrapportering (estimated_total_cost bruker faktisk forbruk i stedet for en fast 1 kW-last). Profilveiet utvalg gjelder ikke for ikke-sammenhengende intervaller."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Glatt utliggere",
|
||||
|
|
@ -2037,7 +2037,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
|
||||
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Påvirker kun kostnadsrapportering (estimated_total_cost bruker faktisk forbruk i stedet for en fast 1 kW-last). Profilveiet utvalg gjelder ikke for ikke-sammenhengende intervaller."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Glatt utliggere",
|
||||
|
|
@ -2139,6 +2139,10 @@
|
|||
"name": "Søkeslutt-forskyvning (minutter)",
|
||||
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Ta med gjeldende intervall",
|
||||
"description": "Ta med det pågående 15-minuttersintervallet i søket. Når dette er aktivert, starter rullerende søkeområder som remaining_today og next_24h ved starten av gjeldende intervall slik at det kan velges."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maksimalt prisnivaae",
|
||||
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
|
||||
|
|
|
|||
|
|
@ -1669,7 +1669,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"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. Beïnvloedt vensterselectie (fasen met hoog vermogen worden op de goedkoopste/duurste intervallen geplaatst) en kostenrapportage (estimated_total_cost gebruikt werkelijk verbruik in plaats van een vaste 1 kW-last)."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Uitschieters gladstrijken",
|
||||
|
|
@ -1789,7 +1789,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"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. Beïnvloedt vensterselectie (fasen met hoog vermogen worden op de goedkoopste/duurste intervallen geplaatst) en kostenrapportage (estimated_total_cost gebruikt werkelijk verbruik in plaats van een vaste 1 kW-last)."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Uitschieters gladstrijken",
|
||||
|
|
@ -1913,7 +1913,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"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. Beïnvloedt alleen de kostenrapportage (estimated_total_cost gebruikt werkelijk verbruik in plaats van een vaste 1 kW-last). Profielgewogen selectie geldt niet voor niet-aaneengesloten intervallen."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Uitschieters gladstrijken",
|
||||
|
|
@ -2037,7 +2037,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"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. Beïnvloedt alleen de kostenrapportage (estimated_total_cost gebruikt werkelijk verbruik in plaats van een vaste 1 kW-last). Profielgewogen selectie geldt niet voor niet-aaneengesloten intervallen."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Uitschieters gladstrijken",
|
||||
|
|
@ -2139,6 +2139,10 @@
|
|||
"name": "Zoekeinde-offset (minuten)",
|
||||
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Huidig interval opnemen",
|
||||
"description": "Neem het momenteel lopende interval van 15 minuten mee in de zoekopdracht. Wanneer ingeschakeld, starten rollende scopes zoals remaining_today en next_24h aan het begin van het huidige interval zodat het gekozen kan worden."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximaal prijsniveau",
|
||||
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
|
||||
|
|
|
|||
|
|
@ -1669,7 +1669,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
|
||||
"description": "Variabel effektförbrukning i watt per 15-minutersintervall. Påverkar fönsterurval (faser med hög effekt placeras på billigaste/dyraste intervallen) och kostnadsrapportering (estimated_total_cost använder faktisk förbrukning istället för en fast 1 kW-last)."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Jämna utliggare",
|
||||
|
|
@ -1789,7 +1789,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
|
||||
"description": "Variabel effektförbrukning i watt per 15-minutersintervall. Påverkar fönsterurval (faser med hög effekt placeras på billigaste/dyraste intervallen) och kostnadsrapportering (estimated_total_cost använder faktisk förbrukning istället för en fast 1 kW-last)."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Jämna utliggare",
|
||||
|
|
@ -1913,7 +1913,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
|
||||
"description": "Variabel effektförbrukning i watt per 15-minutersintervall. Påverkar bara kostnadsrapportering (estimated_total_cost använder faktisk förbrukning istället för en fast 1 kW-last). Profilvägt urval tillämpas inte för icke-sammanhängande intervaller."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Jämna utliggare",
|
||||
|
|
@ -2037,7 +2037,7 @@
|
|||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
|
||||
"description": "Variabel effektförbrukning i watt per 15-minutersintervall. Påverkar bara kostnadsrapportering (estimated_total_cost använder faktisk förbrukning istället för en fast 1 kW-last). Profilvägt urval tillämpas inte för icke-sammanhängande intervaller."
|
||||
},
|
||||
"smooth_outliers": {
|
||||
"name": "Jämna utliggare",
|
||||
|
|
@ -2139,6 +2139,10 @@
|
|||
"name": "Sökslut-förskjutning (minuter)",
|
||||
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Inkludera aktuellt intervall",
|
||||
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När detta är aktiverat börjar rullande sökomfång som remaining_today och next_24h vid början av det aktuella intervallet så att det kan väljas."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximal prisnivaae",
|
||||
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
|
||||
|
|
|
|||
|
|
@ -22,18 +22,24 @@ def find_cheapest_contiguous_window(
|
|||
duration_intervals: int,
|
||||
*,
|
||||
reverse: bool = False,
|
||||
power_profile: list[int] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Find the cheapest (or most expensive) contiguous window of exactly N intervals.
|
||||
|
||||
Uses a sliding window algorithm (O(n)) to find the window with the
|
||||
lowest (or highest) average price.
|
||||
Uses a sliding window algorithm (O(n)) when no power profile is given.
|
||||
With a power profile, uses O(n\u00d7k) direct scoring so that the window with the
|
||||
lowest weighted cost (\u03a3 price[i] \u00d7 watt[i]) is selected instead of lowest
|
||||
average price. This ensures high-wattage phases of the cycle land on cheap intervals.
|
||||
|
||||
Args:
|
||||
intervals: Sorted list of price interval dicts with 'startsAt' and 'total' keys.
|
||||
Must be pre-sorted by startsAt in ascending order.
|
||||
duration_intervals: Number of consecutive intervals required.
|
||||
reverse: If True, find the most expensive window instead of cheapest.
|
||||
power_profile: Optional watt value per interval. Only the first
|
||||
duration_intervals values are used (profile may be longer). When
|
||||
provided, scoring uses \u03a3 price[i] \u00d7 watt[i] instead of \u03a3 price[i].
|
||||
|
||||
Returns:
|
||||
Dict with window details (start, end, intervals, statistics),
|
||||
|
|
@ -44,20 +50,47 @@ def find_cheapest_contiguous_window(
|
|||
if n == 0 or duration_intervals <= 0 or n < duration_intervals:
|
||||
return None
|
||||
|
||||
# Calculate initial window sum
|
||||
window_sum = sum(intervals[i]["total"] for i in range(duration_intervals))
|
||||
best_sum = window_sum
|
||||
best_start = 0
|
||||
best_intervals: list[dict[str, Any]] | None = None
|
||||
best_sum: float | None = None
|
||||
|
||||
# Slide the window
|
||||
for i in range(1, n - duration_intervals + 1):
|
||||
window_sum += intervals[i + duration_intervals - 1]["total"]
|
||||
window_sum -= intervals[i - 1]["total"]
|
||||
if (window_sum > best_sum) if reverse else (window_sum < best_sum):
|
||||
best_sum = window_sum
|
||||
best_start = i
|
||||
# Price-level filtering can create gaps in time. Search each truly contiguous
|
||||
# run independently so the returned window always matches real timestamps.
|
||||
for segment in group_intervals_into_segments(intervals):
|
||||
segment_intervals = segment["intervals"]
|
||||
if len(segment_intervals) < duration_intervals:
|
||||
continue
|
||||
|
||||
if power_profile:
|
||||
# With a power profile the weights rotate with each window position,
|
||||
# so a simple O(1) sliding update is not possible. Recompute each score
|
||||
# directly. Only the first duration_intervals weights are used.
|
||||
segment_best_sum: float = sum(
|
||||
segment_intervals[k]["total"] * power_profile[k] for k in range(duration_intervals)
|
||||
)
|
||||
segment_best_start = 0
|
||||
for i in range(1, len(segment_intervals) - duration_intervals + 1):
|
||||
score = sum(segment_intervals[i + k]["total"] * power_profile[k] for k in range(duration_intervals))
|
||||
if (score > segment_best_sum) if reverse else (score < segment_best_sum):
|
||||
segment_best_sum = score
|
||||
segment_best_start = i
|
||||
else:
|
||||
window_sum = sum(segment_intervals[i]["total"] for i in range(duration_intervals))
|
||||
segment_best_sum = window_sum
|
||||
segment_best_start = 0
|
||||
for i in range(1, len(segment_intervals) - duration_intervals + 1):
|
||||
window_sum += segment_intervals[i + duration_intervals - 1]["total"]
|
||||
window_sum -= segment_intervals[i - 1]["total"]
|
||||
if (window_sum > segment_best_sum) if reverse else (window_sum < segment_best_sum):
|
||||
segment_best_sum = window_sum
|
||||
segment_best_start = i
|
||||
|
||||
if best_sum is None or ((segment_best_sum > best_sum) if reverse else (segment_best_sum < best_sum)):
|
||||
best_sum = segment_best_sum
|
||||
best_intervals = segment_intervals[segment_best_start : segment_best_start + duration_intervals]
|
||||
|
||||
if best_intervals is None:
|
||||
return None
|
||||
|
||||
best_intervals = intervals[best_start : best_start + duration_intervals]
|
||||
return {
|
||||
"start": best_intervals[0]["startsAt"],
|
||||
"end_interval_start": best_intervals[-1]["startsAt"],
|
||||
|
|
@ -123,87 +156,101 @@ def _find_with_min_segment(
|
|||
"""
|
||||
Find cheapest/most expensive N intervals with minimum segment length constraint.
|
||||
|
||||
Iteratively picks intervals, discards segments that are too
|
||||
short, and replaces them with next-best alternatives.
|
||||
|
||||
Converges in at most `count` iterations (worst case: every replacement
|
||||
creates a new short segment that gets discarded).
|
||||
Uses dynamic programming to find an exact selection of `count` intervals
|
||||
where every contiguous run has at least `min_segment` intervals. Real time
|
||||
gaps break segments even if the filtered list remains index-contiguous.
|
||||
"""
|
||||
n = len(intervals)
|
||||
|
||||
# Build index lookup: interval original index → position
|
||||
# Price-sorted indices for picking cheapest/most expensive available
|
||||
price_order = sorted(range(n), key=lambda i: intervals[i]["total"], reverse=reverse)
|
||||
contiguous_with_prev = [False] * n
|
||||
for i in range(1, n):
|
||||
prev_start = _parse_timestamp(intervals[i - 1]["startsAt"])
|
||||
curr_start = _parse_timestamp(intervals[i]["startsAt"])
|
||||
contiguous_with_prev[i] = curr_start - prev_start == timedelta(minutes=15)
|
||||
|
||||
selected: set[int] = set()
|
||||
excluded: set[int] = set()
|
||||
def is_better(new_cost: float, old_cost: float | None) -> bool:
|
||||
if old_cost is None:
|
||||
return True
|
||||
return new_cost > old_cost if reverse else new_cost < old_cost
|
||||
|
||||
# Initial pick: cheapest 'count' intervals
|
||||
picked = 0
|
||||
for idx in price_order:
|
||||
if picked >= count:
|
||||
break
|
||||
if idx not in excluded:
|
||||
selected.add(idx)
|
||||
picked += 1
|
||||
current_states: dict[tuple[int, int], float] = {(0, 0): 0.0}
|
||||
backpointers: list[dict[tuple[int, int], tuple[tuple[int, int], bool]]] = [{} for _ in range(n + 1)]
|
||||
|
||||
if len(selected) < count:
|
||||
for idx, interval in enumerate(intervals, start=1):
|
||||
next_states: dict[tuple[int, int], float] = {}
|
||||
next_back: dict[tuple[int, int], tuple[tuple[int, int], bool]] = {}
|
||||
interval_cost = float(interval["total"])
|
||||
|
||||
for prev_state, prev_cost in current_states.items():
|
||||
selected_count, run_len = prev_state
|
||||
effective_run_len = run_len
|
||||
|
||||
if idx > 1 and not contiguous_with_prev[idx - 1] and run_len != 0:
|
||||
if run_len < min_segment:
|
||||
continue
|
||||
effective_run_len = 0
|
||||
|
||||
if effective_run_len in (0, min_segment):
|
||||
skip_state = (selected_count, 0)
|
||||
if is_better(prev_cost, next_states.get(skip_state)):
|
||||
next_states[skip_state] = prev_cost
|
||||
next_back[skip_state] = (prev_state, False)
|
||||
|
||||
if selected_count >= count:
|
||||
continue
|
||||
|
||||
if effective_run_len == 0:
|
||||
new_run_len = 1
|
||||
elif effective_run_len < min_segment:
|
||||
new_run_len = effective_run_len + 1
|
||||
else:
|
||||
new_run_len = min_segment
|
||||
|
||||
take_state = (selected_count + 1, new_run_len)
|
||||
take_cost = prev_cost + interval_cost
|
||||
if is_better(take_cost, next_states.get(take_state)):
|
||||
next_states[take_state] = take_cost
|
||||
next_back[take_state] = (prev_state, True)
|
||||
|
||||
current_states = next_states
|
||||
backpointers[idx] = next_back
|
||||
|
||||
best_state: tuple[int, int] | None = None
|
||||
best_cost: float | None = None
|
||||
for state, cost in current_states.items():
|
||||
selected_count, run_len = state
|
||||
if selected_count != count or run_len not in (0, min_segment):
|
||||
continue
|
||||
if is_better(cost, best_cost):
|
||||
best_state = state
|
||||
best_cost = cost
|
||||
|
||||
if best_state is None:
|
||||
return None
|
||||
|
||||
# Iterative refinement: discard short segments, replace with next-cheapest
|
||||
max_iterations = count + 1 # Safety bound
|
||||
for _ in range(max_iterations):
|
||||
sorted_selected = sorted(selected)
|
||||
segments = _group_indices_into_segments(sorted_selected)
|
||||
selected_indices: list[int] = []
|
||||
state = best_state
|
||||
for idx in range(n, 0, -1):
|
||||
prev_state, took_interval = backpointers[idx][state]
|
||||
if took_interval:
|
||||
selected_indices.append(idx - 1)
|
||||
state = prev_state
|
||||
|
||||
short_segments = [seg for seg in segments if len(seg) < min_segment]
|
||||
if not short_segments:
|
||||
break # All segments meet minimum length
|
||||
|
||||
# Exclude all indices in short segments
|
||||
for seg in short_segments:
|
||||
for idx in seg:
|
||||
selected.discard(idx)
|
||||
excluded.add(idx)
|
||||
|
||||
# Refill from price order
|
||||
needed = count - len(selected)
|
||||
for idx in price_order:
|
||||
if needed <= 0:
|
||||
break
|
||||
if idx not in selected and idx not in excluded:
|
||||
selected.add(idx)
|
||||
needed -= 1
|
||||
|
||||
if len(selected) < count:
|
||||
# Not enough intervals available after exclusions
|
||||
# Return best effort with what we have
|
||||
break
|
||||
|
||||
sorted_selected = sorted(selected)
|
||||
result_intervals = [intervals[i] for i in sorted_selected]
|
||||
selected_indices.reverse()
|
||||
result_intervals = [intervals[i] for i in selected_indices]
|
||||
segments = group_intervals_into_segments(result_intervals)
|
||||
|
||||
if len(result_intervals) != count:
|
||||
return None
|
||||
if any(seg["interval_count"] < min_segment for seg in segments):
|
||||
return None
|
||||
|
||||
return {
|
||||
"intervals": result_intervals,
|
||||
"segments": segments,
|
||||
}
|
||||
|
||||
|
||||
def _group_indices_into_segments(indices: list[int]) -> list[list[int]]:
|
||||
"""Group sorted integer indices into contiguous runs."""
|
||||
if not indices:
|
||||
return []
|
||||
|
||||
segments: list[list[int]] = [[indices[0]]]
|
||||
for i in range(1, len(indices)):
|
||||
if indices[i] == indices[i - 1] + 1:
|
||||
segments[-1].append(indices[i])
|
||||
else:
|
||||
segments.append([indices[i]])
|
||||
return segments
|
||||
|
||||
|
||||
def group_intervals_into_segments(
|
||||
intervals: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
|
|
|
|||
|
|
@ -135,20 +135,26 @@ These parameters are available across all scheduling actions:
|
|||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `entry_id` | Config entry ID. Auto-selects if you only have one home. | Auto |
|
||||
| `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? Only applies to `remaining_today`, `next_24h`, `next_48h`, and default (no scope) — has no effect for `today` or `tomorrow` (those always cover the full calendar day). | `true` |
|
||||
| `min_price_level` | Only consider intervals at or above 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. Affects **window selection** for block/schedule services and cost reporting for all services (see note below). | — |
|
||||
| `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).
|
||||
:::
|
||||
|
||||
:::note `power_profile` selection impact
|
||||
For `find_cheapest_block`, `find_most_expensive_block`, and `find_cheapest_schedule`, the profile controls **which window is selected**: each candidate is scored by weighted cost (Σ price × watt per interval) so high-wattage phases land on the cheapest (or most expensive) intervals.
|
||||
|
||||
For `find_cheapest_hours` and `find_most_expensive_hours`, the profile only affects cost reporting — non-contiguous interval picks make profile-weighted selection semantically undefined.
|
||||
:::
|
||||
|
||||
### Price Level Filtering
|
||||
|
||||
Restrict the search to specific Tibber price levels. Levels from lowest to highest: `very_cheap`, `cheap`, `normal`, `expensive`, `very_expensive`.
|
||||
|
|
@ -169,7 +175,12 @@ data:
|
|||
|
||||
### Power Profile
|
||||
|
||||
By default, cost estimates assume a constant 1 kW load. If your appliance has variable power draw, provide a power profile — **one watt value per 15-minute interval**:
|
||||
By default, cost estimates assume a constant 1 kW load. If your appliance has variable power draw, provide a power profile — **one watt value per 15-minute interval**.
|
||||
|
||||
When a power profile is present it affects **both selection and reporting**:
|
||||
|
||||
- **Selection** — instead of lowest average price, each candidate window is scored by weighted cost (Σ price × watt per interval). High-wattage phases of the cycle are placed on the cheapest intervals.
|
||||
- **Reporting** — `estimated_total_cost` and `estimated_load_kwh` reflect the actual variable power draw.
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Power Profile</summary>
|
||||
|
|
@ -190,8 +201,6 @@ data:
|
|||
|
||||
</details>
|
||||
|
||||
The service then calculates `estimated_total_cost` using the actual power draw per interval instead of flat 1 kW, and adds `estimated_load_kwh` (total energy consumed) to the response.
|
||||
|
||||
:::info Duration and profile must match
|
||||
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.
|
||||
:::
|
||||
|
|
@ -731,6 +740,7 @@ response_variable: result
|
|||
| `unscheduled_tasks` | List of task names that couldn't be placed (or `null` if all succeeded) |
|
||||
| `tasks[]` | Each task with its assigned time window and price statistics |
|
||||
| `tasks[].start` / `tasks[].end` | When to start and stop each appliance |
|
||||
| `tasks[].price_comparison` | Optional per-task comparison against the opposite extreme window when `include_comparison_details` is `true` |
|
||||
| `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`) |
|
||||
|
|
@ -740,7 +750,7 @@ response_variable: result
|
|||
If you call `find_cheapest_block` separately for each appliance, they might all find the **same** cheap time window. `find_cheapest_schedule` solves this by tracking which intervals are already claimed — each appliance gets its own non-overlapping slot.
|
||||
|
||||
:::tip Sequential ordering
|
||||
By default, `find_cheapest_schedule` optimizes purely for **price** — it does not guarantee task order. The dryer could be scheduled before the washing machine if that's cheaper. For sequential workflows (washing machine → dryer), add `sequential: true` to guarantee declaration-order scheduling. See [Automation Examples — Sequential Scheduling](automation-examples.md#washing-machine--dryer-sequential-scheduling) for a complete example.
|
||||
By default, `find_cheapest_schedule` does not guarantee task order. In non-sequential mode, tasks are packed longest-first and each task then gets the cheapest slot that still fits, so the dryer may be scheduled before the washing machine. For sequential workflows (washing machine → dryer), add `sequential: true` to guarantee declaration-order scheduling. See [Automation Examples — Sequential Scheduling](automation-examples.md#washing-machine--dryer-sequential-scheduling) for a complete example.
|
||||
:::
|
||||
|
||||
### Gap Minutes
|
||||
|
|
@ -1005,7 +1015,7 @@ All durations are rounded **up** to the nearest 15 minutes because Tibber price
|
|||
|
||||
### Comparison Details
|
||||
|
||||
Add `include_comparison_details: true` to `find_cheapest_block` or `find_cheapest_hours` to get extra fields in the comparison:
|
||||
Add `include_comparison_details: true` to `find_cheapest_block`, `find_cheapest_hours`, or `find_cheapest_schedule` to get extra fields in the comparison:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Comparison Details</summary>
|
||||
|
|
@ -1019,7 +1029,7 @@ data:
|
|||
|
||||
</details>
|
||||
|
||||
This adds `comparison_price_min`, `comparison_price_max`, and `comparison_window_end` to the `price_comparison` object.
|
||||
This adds `comparison_price_min`, `comparison_price_max`, and `comparison_window_end` to the `price_comparison` object. For `find_cheapest_schedule`, these details are added to each task's `price_comparison` object.
|
||||
|
||||
### Response When No Window Found
|
||||
|
||||
|
|
@ -1041,6 +1051,8 @@ The `reason` field contains a stable machine-readable code you can use in automa
|
|||
| `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 |
|
||||
| `insufficient_contiguous_window` | No valid contiguous block could be built from the remaining intervals |
|
||||
| `insufficient_contiguous_window_for_some_tasks` | Schedule found slots for some tasks, but not all of them |
|
||||
| `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.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ Also validates schema boundaries for all 4 services.
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time as dt_time, timedelta
|
||||
from typing import Any, cast
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
|
@ -148,6 +149,29 @@ class TestResolveSearchRangeNegativeOffsetMinutes:
|
|||
assert start.day == 10
|
||||
assert start.hour == 23
|
||||
|
||||
def test_search_scope_excludes_current_interval_when_disabled(self) -> None:
|
||||
"""Relative search scopes honor include_current_interval=false."""
|
||||
now = datetime(2026, 4, 11, 14, 37, tzinfo=BERLIN)
|
||||
call_data = {
|
||||
"search_scope": "next_24h",
|
||||
"include_current_interval": False,
|
||||
}
|
||||
start, end = resolve_search_range(call_data, now, BERLIN)
|
||||
assert start == now
|
||||
assert end == now + timedelta(hours=24)
|
||||
|
||||
def test_search_scope_includes_current_interval_when_enabled(self) -> None:
|
||||
"""Relative search scopes include the current quarter when enabled."""
|
||||
now = datetime(2026, 4, 11, 14, 37, tzinfo=BERLIN)
|
||||
call_data = {
|
||||
"search_scope": "next_24h",
|
||||
"include_current_interval": True,
|
||||
}
|
||||
start, end = resolve_search_range(call_data, now, BERLIN)
|
||||
assert start.hour == 14
|
||||
assert start.minute == 30
|
||||
assert end == now + timedelta(hours=24)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema validation: day_offset boundaries
|
||||
|
|
@ -160,12 +184,12 @@ class TestSchemaValidation:
|
|||
def _validate_block_schema(self, data: dict) -> dict:
|
||||
"""Validate data through block schema."""
|
||||
schema = vol.Schema(_COMMON_BLOCK_SCHEMA)
|
||||
return schema(data)
|
||||
return cast("dict[str, Any]", schema(data))
|
||||
|
||||
def _validate_hours_schema(self, data: dict) -> dict:
|
||||
"""Validate data through hours schema."""
|
||||
schema = vol.Schema(_COMMON_HOURS_SCHEMA)
|
||||
return schema(data)
|
||||
return cast("dict[str, Any]", schema(data))
|
||||
|
||||
def test_block_schema_accepts_negative_day_offset(self) -> None:
|
||||
"""Block schema allows negative day offsets."""
|
||||
|
|
|
|||
|
|
@ -71,6 +71,18 @@ class TestSequentialSchema:
|
|||
)
|
||||
assert result["sequential"] is False
|
||||
|
||||
def test_schema_defaults_include_current_interval_true(self) -> None:
|
||||
"""Schedule schema should expose include_current_interval like other actions."""
|
||||
result = cast(
|
||||
"dict[str, Any]",
|
||||
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA(
|
||||
{
|
||||
"tasks": [{"name": "dishwasher", "duration": timedelta(hours=1)}],
|
||||
}
|
||||
),
|
||||
)
|
||||
assert result["include_current_interval"] is True
|
||||
|
||||
|
||||
class TestSequentialOrdering:
|
||||
"""Sequential mode preserves declaration order and chains search windows."""
|
||||
|
|
|
|||
|
|
@ -170,6 +170,87 @@ class TestFindCheapestContiguousWindow:
|
|||
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||
assert selected_prices == [5.0, 3.0, 2.0, 8.0]
|
||||
|
||||
def test_gap_breaks_contiguous_window(self) -> None:
|
||||
"""A real time gap prevents windows from spanning across it."""
|
||||
intervals = _make_intervals([1.0, 2.0, 3.0, 4.0], gap_after={1})
|
||||
assert find_cheapest_contiguous_window(intervals, 3) is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# find_cheapest_contiguous_window — power_profile weighted scoring
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFindCheapestContiguousWindowWithPowerProfile:
|
||||
"""Tests for power-profile-weighted window selection."""
|
||||
|
||||
def test_profile_changes_selection(self) -> None:
|
||||
"""Front-loaded profile prefers placing cheap intervals at high-wattage positions."""
|
||||
# Prices: [10, 10, 5, 5, 10]
|
||||
# Without profile: windows 1 and 2 both sum to 20; first tie wins (index 1, prices [10,5,5])
|
||||
# Profile [3000, 500, 500] — first interval costs 6× more per unit:
|
||||
# Window 0: 10*3000+10*500+5*500 = 37500
|
||||
# Window 1: 10*3000+ 5*500+5*500 = 35000
|
||||
# Window 2: 5*3000+ 5*500+10*500 = 22500 ← cheapest weighted
|
||||
prices = [10.0, 10.0, 5.0, 5.0, 10.0]
|
||||
intervals = _make_intervals(prices)
|
||||
|
||||
result_no_profile = find_cheapest_contiguous_window(intervals, 3)
|
||||
assert result_no_profile is not None
|
||||
assert result_no_profile["intervals"][0]["total"] == 10.0 # index 1
|
||||
|
||||
result_profile = find_cheapest_contiguous_window(intervals, 3, power_profile=[3000, 500, 500])
|
||||
assert result_profile is not None
|
||||
assert result_profile["intervals"][0]["total"] == 5.0 # index 2
|
||||
|
||||
def test_profile_no_effect_with_uniform_weights(self) -> None:
|
||||
"""A uniform profile produces the same selection as no profile."""
|
||||
prices = [20.0, 15.0, 5.0, 3.0, 4.0, 18.0, 25.0]
|
||||
intervals = _make_intervals(prices)
|
||||
|
||||
result_no_profile = find_cheapest_contiguous_window(intervals, 3)
|
||||
result_uniform = find_cheapest_contiguous_window(intervals, 3, power_profile=[1000, 1000, 1000])
|
||||
|
||||
assert result_no_profile is not None
|
||||
assert result_uniform is not None
|
||||
assert result_no_profile["intervals"][0]["startsAt"] == result_uniform["intervals"][0]["startsAt"]
|
||||
|
||||
def test_profile_reverse_most_expensive(self) -> None:
|
||||
"""Profile-weighted most-expensive selection places high-watt phases on peak prices."""
|
||||
# Prices: [5, 10, 20, 10, 5]
|
||||
# Profile [3000, 500]: front-load is 6× heavier
|
||||
# Window 0: 5*3000+10*500 = 20000
|
||||
# Window 1: 10*3000+20*500 = 40000
|
||||
# Window 2: 20*3000+10*500 = 65000 ← most expensive weighted
|
||||
# Window 3: 10*3000+ 5*500 = 32500
|
||||
prices = [5.0, 10.0, 20.0, 10.0, 5.0]
|
||||
intervals = _make_intervals(prices)
|
||||
|
||||
result = find_cheapest_contiguous_window(intervals, 2, reverse=True, power_profile=[3000, 500])
|
||||
assert result is not None
|
||||
assert result["intervals"][0]["total"] == 20.0 # window starts at index 2
|
||||
|
||||
def test_profile_longer_than_duration_uses_first_n(self) -> None:
|
||||
"""A profile longer than duration only uses the first duration_intervals values."""
|
||||
# Profile [3000, 500, 500, 999, 999] — only first 3 used for a 3-interval window
|
||||
# Should be identical to profile [3000, 500, 500]
|
||||
prices = [10.0, 10.0, 5.0, 5.0, 10.0]
|
||||
intervals = _make_intervals(prices)
|
||||
|
||||
result_exact = find_cheapest_contiguous_window(intervals, 3, power_profile=[3000, 500, 500])
|
||||
result_longer = find_cheapest_contiguous_window(intervals, 3, power_profile=[3000, 500, 500, 9999, 9999])
|
||||
|
||||
assert result_exact is not None
|
||||
assert result_longer is not None
|
||||
assert result_exact["intervals"][0]["startsAt"] == result_longer["intervals"][0]["startsAt"]
|
||||
|
||||
def test_profile_gap_still_prevents_spanning(self) -> None:
|
||||
"""Profile weighting does not override the temporal-gap check."""
|
||||
# Very cheap interval at index 2 is separated by a gap — cannot be included
|
||||
intervals = _make_intervals([10.0, 10.0, 1.0, 10.0], gap_after={1})
|
||||
# Only two contiguous segments of 2 intervals each; 3-interval window impossible
|
||||
assert find_cheapest_contiguous_window(intervals, 3, power_profile=[3000, 500, 500]) is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# find_cheapest_n_intervals
|
||||
|
|
@ -280,6 +361,11 @@ class TestFindCheapestNIntervals:
|
|||
assert result is not None
|
||||
assert len(result["intervals"]) == 3
|
||||
|
||||
def test_min_segment_impossible_returns_none(self) -> None:
|
||||
"""Return None instead of partial results when min segment cannot be met."""
|
||||
intervals = _make_intervals([1.0, 2.0, 3.0, 4.0], gap_after={0, 1, 2})
|
||||
assert find_cheapest_n_intervals(intervals, 2, min_segment_intervals=2) is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# group_intervals_into_segments
|
||||
|
|
|
|||
Loading…
Reference in a new issue