hass.tibber_prices/docs/user/versioned_docs/version-v0.31.0/scheduling-actions.md

1062 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Scheduling Actions
Find the cheapest (or most expensive) time windows for your appliances — automatically. These actions analyze real Tibber price data and return optimal scheduling recommendations.
:::warning Experimental
The scheduling actions (`find_cheapest_block`, `find_cheapest_hours`, `find_cheapest_schedule`, `find_most_expensive_block`, `find_most_expensive_hours`) are **experimental** and still undergoing testing. Their parameters, response formats, and behavior may change in future releases. Use them in automations with care, and please [report any issues](https://github.com/jpawlowski/hass.tibber_prices/issues).
:::
:::tip Entity ID tip
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
:::
## Overview
| Action | What It Does | Best For |
|--------|-------------|----------|
| [`find_cheapest_block`](#find-cheapest-block) | Finds the cheapest **contiguous** time window | Dishwasher, washing machine, dryer |
| [`find_cheapest_hours`](#find-cheapest-hours) | Finds the cheapest intervals (can be **non-contiguous**) | EV charging, battery storage, water heater |
| [`find_cheapest_schedule`](#find-cheapest-schedule) | Schedules **multiple appliances** without overlap | Dishwasher + washing machine + dryer overnight |
| [`find_most_expensive_block`](#find-most-expensive-block) | Finds the most expensive contiguous window | Avoid running appliances during peak prices |
| [`find_most_expensive_hours`](#find-most-expensive-hours) | Finds the most expensive intervals | Battery discharge optimization, peak avoidance |
## Choosing the Right Action
```mermaid
flowchart TD
A["How many appliances?"] -->|One| B["Must it run uninterrupted?"]
A -->|Multiple, no overlap| F["find_cheapest_schedule"]
B -->|"Yes (dishwasher, dryer)"| C["find_cheapest_block"]
B -->|"No (EV, battery, water heater)"| D["find_cheapest_hours"]
C --> E["Need the opposite?<br/>→ find_most_expensive_block"]
D --> G["Need the opposite?<br/>→ find_most_expensive_hours"]
```
**Rules of thumb:**
- **Dishwasher, washing machine, dryer** → `find_cheapest_block` (must run X hours straight)
- **EV charging, battery, pool pump** → `find_cheapest_hours` (total runtime matters, not continuity)
- **Multiple independent appliances** → `find_cheapest_schedule` (prevents overlap + manages gaps)
- **Sequential chain (A must finish before B)** → `find_cheapest_schedule` with `sequential: true` (guaranteed order + gap)
- **"When should I NOT run this?"** → `find_most_expensive_block` or `find_most_expensive_hours`
---
## Search Range
All scheduling actions share the same flexible search range options. You can define _when_ to look for cheap prices in several ways:
### Quick Scopes
The simplest approach — use `search_scope` for common time ranges:
| Scope | Start | End |
|-------|-------|-----|
| `today` | 00:00 today | 00:00 tomorrow |
| `tomorrow` | 00:00 tomorrow | 00:00 day after tomorrow |
| `remaining_today` | Now | 00:00 tomorrow |
| `next_24h` | Now | Now + 24 hours |
| `next_48h` | Now | Now + 48 hours |
<details>
<summary>Show YAML: Quick Scopes</summary>
```yaml
service: tibber_prices.find_cheapest_block
data:
duration: "02:00:00"
search_scope: tomorrow
```
</details>
### Explicit Start/End
For full control, specify exact datetime values:
<details>
<summary>Show YAML: Explicit Start and End</summary>
```yaml
service: tibber_prices.find_cheapest_block
data:
duration: "02:00:00"
search_start: "2026-04-11T22:00:00+02:00"
search_end: "2026-04-12T06:00:00+02:00"
```
</details>
### Time-of-Day with Day Offset
Schedule relative to today using time + day offset:
<details>
<summary>Show YAML: Time of Day with Offset</summary>
```yaml
service: tibber_prices.find_cheapest_block
data:
duration: "02:00:00"
search_start_time: "22:00:00" # 22:00 today
search_end_time: "06:00:00"
search_end_day_offset: 1 # 06:00 tomorrow
```
</details>
### Minute Offsets from Now
For relative searches:
<details>
<summary>Show YAML: Relative Minute Offsets</summary>
```yaml
service: tibber_prices.find_cheapest_block
data:
duration: "01:30:00"
search_start_offset_minutes: 0 # Starting now
search_end_offset_minutes: 480 # Next 8 hours
```
</details>
### Default Behavior
If you omit all range parameters, the search covers **now until the end of tomorrow** — the maximum window with available price data.
:::caution Don't mix scopes with explicit ranges
`search_scope` cannot be combined with explicit range parameters (`search_start`, `search_end`, etc.). Use one approach or the other.
:::
---
## Common Parameters
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? 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. 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`.
<details>
<summary>Show YAML: Price Level Filtering</summary>
```yaml
# Only search within cheap or very cheap intervals
service: tibber_prices.find_cheapest_block
data:
duration: "02:00:00"
search_scope: next_24h
max_price_level: cheap # Exclude normal, expensive, very_expensive
```
</details>
### 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**.
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>
```yaml
# Washing machine: high power for heating, then less
service: tibber_prices.find_cheapest_block
data:
duration: "01:30:00" # 6 intervals × 15 min
power_profile:
- 2200 # Interval 1: Heating water (2.2 kW)
- 2200 # Interval 2: Heating continues
- 800 # Interval 3: Washing cycle
- 800 # Interval 4: Washing cycle
- 1500 # Interval 5: Spin cycle
- 500 # Interval 6: Final rinse
```
</details>
:::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.
:::
### 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
Finds the single cheapest **contiguous** time window of a given duration.
**Use when:** Your appliance must run uninterrupted for a fixed time.
### Basic Example
<details>
<summary>Show YAML: Find Cheapest Block</summary>
```yaml
service: tibber_prices.find_cheapest_block
data:
duration: "02:00:00"
search_scope: next_24h
response_variable: result
```
</details>
### Example with All Options
<details>
<summary>Show YAML: Cheapest Block with All Options</summary>
```yaml
service: tibber_prices.find_cheapest_block
data:
duration: "02:00:00"
search_scope: next_24h
max_price_level: normal
power_profile: [2200, 2200, 800, 800, 1500, 500, 400, 200]
include_comparison_details: true
include_current_interval: false
response_variable: result
```
</details>
### Response
<details>
<summary>Show JSON: Cheapest Block Example Response</summary>
```json
{
"home_id": "abc-123",
"search_start": "2026-04-11T14:00:00+02:00",
"search_end": "2026-04-12T14:00:00+02:00",
"duration_minutes_requested": 120,
"duration_minutes": 120,
"currency": "EUR",
"price_unit": "ct/kWh",
"window_found": true,
"window": {
"start": "2026-04-12T02:00:00+02:00",
"end": "2026-04-12T04:00:00+02:00",
"duration_minutes": 120,
"interval_count": 8,
"price_mean": 14.25,
"price_median": 13.90,
"price_min": 12.00,
"price_max": 16.80,
"price_spread": 4.80,
"estimated_total_cost": 28.50,
"intervals": [
{
"starts_at": "2026-04-12T02:00:00+02:00",
"ends_at": "2026-04-12T02:15:00+02:00",
"price": 12.00,
"level": "very_cheap",
"rating_level": "low"
}
]
},
"price_comparison": {
"comparison_price_mean": 32.10,
"price_difference": 17.85,
"comparison_window_start": "2026-04-11T18:00:00+02:00"
}
}
```
</details>
**Key response fields:**
| Field | Description |
|-------|-------------|
| `window_found` | `true` if a window was found, `false` if no intervals match the criteria |
| `window.start` / `window.end` | When to start and stop the appliance |
| `window.price_mean` | Average price during the window |
| `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.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
**Prerequisite:** Create an `input_datetime.dishwasher_start` helper (type: Date and time) in **Settings → Helpers**.
<details>
<summary>Show YAML: Dishwasher Automation (Plan + Execute)</summary>
```yaml
automation:
# Step 1: Plan the cheapest start time every evening
- alias: "Dishwasher - Plan Cheapest Start"
trigger:
- platform: time
at: "20:00:00"
action:
- service: tibber_prices.find_cheapest_block
data:
duration: "02:00:00"
search_start_time: "20:00:00"
search_end_time: "06:00:00"
search_end_day_offset: 1
response_variable: result
- if: "{{ result.window_found }}"
then:
- service: input_datetime.set_datetime
target:
entity_id: input_datetime.dishwasher_start
data:
datetime: "{{ result.window.start }}"
# Step 2: Start the dishwasher at the stored time (survives HA restarts)
- alias: "Dishwasher - Start at Planned Time"
trigger:
- platform: time
at: input_datetime.dishwasher_start
action:
# Option A: Smart appliance via Home Connect (see tip below)
# Option B: Smart plug
- service: switch.turn_on
target:
entity_id: switch.dishwasher_smart_plug
```
</details>
:::tip Home Connect instead of smart plugs
If you have a **Bosch/Siemens appliance with Home Connect**, you can start the program directly instead of using a smart plug. See [Automation Examples — Home Connect tip](automation-examples.md#dishwasher-find-cheapest-2-hour-window-tonight) for exact service call syntax for both the official and alternative integration.
:::
---
## Find Cheapest Hours
Finds the cheapest N minutes of intervals within a search range. Intervals **do not need to be contiguous** — the service picks the cheapest individual 15-minute slots and groups them into segments.
**Use when:** Your device can pause and resume freely (EV charger, battery storage, pool pump).
### Basic Example
<details>
<summary>Show YAML: Find Cheapest Hours</summary>
```yaml
service: tibber_prices.find_cheapest_hours
data:
duration: "04:00:00"
search_scope: next_24h
response_variable: result
```
</details>
### With Minimum Segment Duration
Some devices shouldn't cycle on/off too rapidly. Use `min_segment_duration` to ensure each contiguous run is at least a minimum length:
<details>
<summary>Show YAML: With Minimum Segment Duration</summary>
```yaml
# EV charger: 3 hours total, but each charging session at least 30 min
service: tibber_prices.find_cheapest_hours
data:
duration: "03:00:00"
min_segment_duration: "00:30:00"
search_scope: next_24h
response_variable: result
```
</details>
### Response
<details>
<summary>Show JSON: Cheapest Hours Example Response</summary>
```json
{
"home_id": "abc-123",
"search_start": "2026-04-11T14:00:00+02:00",
"search_end": "2026-04-12T14:00:00+02:00",
"total_minutes_requested": 240,
"total_minutes": 240,
"currency": "EUR",
"price_unit": "ct/kWh",
"intervals_found": true,
"schedule": {
"total_minutes": 240,
"interval_count": 16,
"price_mean": 13.50,
"price_median": 13.20,
"price_min": 10.80,
"price_max": 16.30,
"price_spread": 5.50,
"estimated_total_cost": 54.00,
"segment_count": 3,
"segments": [
{
"start": "2026-04-11T23:00:00+02:00",
"end": "2026-04-12T00:30:00+02:00",
"duration_minutes": 90,
"interval_count": 6,
"price_mean": 11.20,
"intervals": []
},
{
"start": "2026-04-12T02:00:00+02:00",
"end": "2026-04-12T03:15:00+02:00",
"duration_minutes": 75,
"interval_count": 5,
"price_mean": 12.80,
"intervals": []
},
{
"start": "2026-04-12T05:00:00+02:00",
"end": "2026-04-12T06:15:00+02:00",
"duration_minutes": 75,
"interval_count": 5,
"price_mean": 16.00,
"intervals": []
}
],
"intervals": []
},
"price_comparison": {
"comparison_price_mean": 28.50,
"price_difference": 15.00
}
}
```
</details>
**Key response fields:**
| Field | Description |
|-------|-------------|
| `intervals_found` | `true` if enough cheap intervals were found |
| `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.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 |
---
## Find Cheapest Schedule
Schedules **multiple appliances** within the same search range, ensuring they don't overlap. Each appliance gets its own cheapest contiguous time window.
**Use when:** You have multiple appliances sharing a circuit or you want to avoid running them at the same time (e.g., limited main fuse capacity).
### How It Works
**Default mode** (optimizes for price):
1. Tasks are sorted by duration (longest first — harder to place)
2. The longest task claims the cheapest contiguous block
3. Those intervals are marked as **unavailable**
4. The next task finds the cheapest block in the **remaining** intervals
5. Optional gap between tasks ensures a pause (e.g., for shared plumbing or circuit recovery)
**Sequential mode** (`sequential: true` — guarantees order):
1. Tasks are placed in **declaration order** (the order you list them)
2. Each task's search window starts after the previous task ends (+ gap)
3. Price optimization still applies **within** each task's available window
4. If a task can't be placed, all subsequent tasks are also unscheduled (the chain breaks)
### Basic Example
<details>
<summary>Show YAML: Find Cheapest Schedule</summary>
```yaml
service: tibber_prices.find_cheapest_schedule
data:
tasks:
- name: dishwasher
duration: "02:00:00"
- name: washing_machine
duration: "01:30:00"
search_scope: next_24h
response_variable: result
```
</details>
### With Gap and Power Profiles
<details>
<summary>Show YAML: With Gap and Power Profiles</summary>
```yaml
service: tibber_prices.find_cheapest_schedule
data:
tasks:
- name: dishwasher
duration: "02:00:00"
power_profile: [2200, 2200, 800, 800, 1500, 500, 400, 200]
- name: washing_machine
duration: "01:30:00"
power_profile: [2000, 2000, 800, 800, 1200, 500]
- name: dryer
duration: "01:00:00"
power_profile: [2500, 2500, 2000, 1500]
gap_minutes: 15
search_start_time: "22:00:00"
search_end_time: "07:00:00"
search_end_day_offset: 1
response_variable: result
```
</details>
### Response
<details>
<summary>Show JSON: Cheapest Schedule Example Response</summary>
```json
{
"home_id": "abc-123",
"search_start": "2026-04-11T22:00:00+02:00",
"search_end": "2026-04-12T07:00:00+02:00",
"currency": "EUR",
"price_unit": "ct/kWh",
"all_tasks_scheduled": true,
"unscheduled_tasks": null,
"tasks": [
{
"name": "dishwasher",
"start": "2026-04-12T00:00:00+02:00",
"end": "2026-04-12T02:00:00+02:00",
"duration_minutes_requested": 120,
"duration_minutes": 120,
"price_mean": 12.30,
"price_median": 12.10,
"price_min": 10.50,
"price_max": 14.20,
"price_spread": 3.70,
"estimated_total_cost": 24.60,
"intervals": []
},
{
"name": "washing_machine",
"start": "2026-04-12T02:15:00+02:00",
"end": "2026-04-12T03:45:00+02:00",
"duration_minutes_requested": 90,
"duration_minutes": 90,
"price_mean": 13.80,
"price_median": 13.50,
"price_min": 12.00,
"price_max": 16.10,
"price_spread": 4.10,
"estimated_total_cost": 20.70,
"intervals": []
},
{
"name": "dryer",
"start": "2026-04-12T04:00:00+02:00",
"end": "2026-04-12T05:00:00+02:00",
"duration_minutes_requested": 60,
"duration_minutes": 60,
"price_mean": 14.50,
"price_median": 14.30,
"price_min": 13.80,
"price_max": 15.40,
"price_spread": 1.60,
"estimated_total_cost": 14.50,
"intervals": []
}
],
"total_estimated_cost": 59.80
}
```
</details>
**Key response fields:**
| Field | Description |
|-------|-------------|
| `all_tasks_scheduled` | `true` if every task found a slot, `false` if some couldn't fit |
| `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`) |
### Why Not Just Call find_cheapest_block Multiple Times?
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` 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
Use `gap_minutes` to add a mandatory pause between appliances:
- **Shared plumbing**: 15 min gap between dishwasher and washing machine
- **Circuit protection**: 30 min gap to let cables cool down
- **Heat pump compressor**: 1530 min cool-down between cycles
The gap is rounded up to the nearest 15 minutes (quarter-hour granularity).
---
## Find Most Expensive Block
The opposite of `find_cheapest_block` — finds the most expensive contiguous window.
**Parameters:** Identical to `find_cheapest_block`.
**Response:** Same structure. The `price_comparison` compares against the cheapest block.
<details>
<summary>Show YAML: Find Most Expensive Block</summary>
```yaml
service: tibber_prices.find_most_expensive_block
data:
duration: "02:00:00"
search_scope: tomorrow
response_variable: peak
```
</details>
**Use cases:**
- "When should I definitely NOT run my washing machine?"
- Schedule battery discharge during peak prices
- Send notifications before expensive periods start
---
## Find Most Expensive Hours
The opposite of `find_cheapest_hours` — finds the most expensive intervals (non-contiguous).
**Parameters:** Identical to `find_cheapest_hours` (including `min_segment_duration`).
**Response:** Same structure. The `price_comparison` compares against the cheapest hours.
<details>
<summary>Show YAML: Find Most Expensive Hours</summary>
```yaml
service: tibber_prices.find_most_expensive_hours
data:
duration: "04:00:00"
search_scope: tomorrow
response_variable: peak
```
</details>
**Use cases:**
- Battery discharge optimization: sell stored energy during the most expensive 4 hours
- Demand response: reduce consumption during the most expensive periods
- Peak avoidance alerts: notify before expensive intervals start
---
## Practical Examples
:::tip Restart-safe automations
All examples below use `input_datetime` helpers to store planned start times. This ensures your schedule **survives HA restarts** — unlike `delay` or `wait_for_trigger` which are lost on restart.
**Setup:** Create an `input_datetime` helper per appliance in **Settings → Devices & Services → Helpers → Create Helper → Date and/or time** (choose "Date and time").
:::
### Overnight Appliance Scheduling
Schedule dishwasher + washing machine to run overnight at cheapest prices, with a 15-minute gap between them. These appliances are **independent** — either can run first.
:::tip Sequential appliances (e.g., washer → dryer)?
If one appliance **must** finish before another starts, add `sequential: true` to your `find_cheapest_schedule` call — this guarantees tasks run in the order you list them. See [Automation Examples — Sequential Scheduling](automation-examples.md#washing-machine--dryer-sequential-scheduling).
:::
**Prerequisites:** Create `input_datetime.dishwasher_start` and `input_datetime.washing_machine_start` helpers.
<details>
<summary>Show YAML: Overnight Appliance Scheduling (Plan + Execute)</summary>
```yaml
automation:
# Planning automation — runs every evening
- alias: "Laundry - Plan Overnight Schedule"
trigger:
- platform: time
at: "21:00:00"
action:
- service: tibber_prices.find_cheapest_schedule
data:
tasks:
- name: dishwasher
duration: "02:00:00"
- name: washing_machine
duration: "01:30:00"
gap_minutes: 15
search_start_time: "22:00:00"
search_end_time: "06:00:00"
search_end_day_offset: 1
response_variable: schedule
- if: "{{ schedule.all_tasks_scheduled }}"
then:
# Store start times in helpers (survives HA restarts)
- service: input_datetime.set_datetime
target:
entity_id: input_datetime.dishwasher_start
data:
datetime: >
{{ schedule.tasks | selectattr('name', 'eq', 'dishwasher')
| map(attribute='start') | first }}
- service: input_datetime.set_datetime
target:
entity_id: input_datetime.washing_machine_start
data:
datetime: >
{{ schedule.tasks | selectattr('name', 'eq', 'washing_machine')
| map(attribute='start') | first }}
- service: notify.mobile_app
data:
title: "🧺 Laundry Planned"
message: >
Dishwasher: {{ (schedule.tasks | selectattr('name', 'eq', 'dishwasher')
| map(attribute='start') | first) | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
Washing machine: {{ (schedule.tasks | selectattr('name', 'eq', 'washing_machine')
| map(attribute='start') | first) | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
Total cost: ~{{ schedule.total_estimated_cost | round(1) }}
{{ schedule.currency }}
# Execution automations — trigger at stored times
- alias: "Dishwasher - Start at Planned Time"
trigger:
- platform: time
at: input_datetime.dishwasher_start
action:
# Use Home Connect or smart plug — see automation-examples.md
- service: switch.turn_on
target:
entity_id: switch.dishwasher_smart_plug
- alias: "Washing Machine - Start at Planned Time"
trigger:
- platform: time
at: input_datetime.washing_machine_start
action:
- service: switch.turn_on
target:
entity_id: switch.washing_machine_smart_plug
```
</details>
### EV Charging During Cheapest 4 Hours
**Prerequisite:** Create `input_datetime.ev_charge_start` helper.
<details>
<summary>Show YAML: EV Charging in Cheapest 4 Hours</summary>
```yaml
automation:
- alias: "EV - Plan Cheapest Charging"
trigger:
- platform: time
at: "18:00:00"
condition:
- condition: numeric_state
entity_id: sensor.ev_battery_level
below: 80
action:
- service: tibber_prices.find_cheapest_hours
data:
duration: "04:00:00"
min_segment_duration: "00:30:00"
search_start_time: "18:00:00"
search_end_time: "07:00:00"
search_end_day_offset: 1
response_variable: charging
- if: "{{ charging.intervals_found }}"
then:
# Store first segment start time
- service: input_datetime.set_datetime
target:
entity_id: input_datetime.ev_charge_start
data:
datetime: "{{ charging.schedule.segments[0].start }}"
- service: notify.mobile_app
data:
title: "🔌 EV Charging Planned"
message: >
{{ charging.schedule.segment_count }} sessions:
{% for seg in charging.schedule.segments %}
• {{ seg.start | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}{{ seg.end
| as_datetime | as_local | as_timestamp
| timestamp_custom('%H:%M') }}
({{ seg.price_mean | round(1) }} {{ charging.price_unit }})
{% endfor %}
Savings vs. peak: {{ charging.price_comparison.price_difference
| round(1) }} {{ charging.price_unit }}
```
</details>
:::tip Simpler alternative for EV charging
If your charger can't pause/resume, use `find_cheapest_block` instead for one contiguous window. See the [Automation Examples](automation-examples.md#ev-charging-cheapest-4-hours-overnight) for a complete example.
:::
### Peak Price Warning
<details>
<summary>Show YAML: Peak Price Warning</summary>
```yaml
automation:
- alias: "Peak Price - Morning Warning"
trigger:
- platform: time
at: "07:00:00"
action:
- service: tibber_prices.find_most_expensive_block
data:
duration: "02:00:00"
search_scope: today
response_variable: peak
- if: "{{ peak.window_found }}"
then:
- service: notify.mobile_app
data:
title: "⚡ Expensive Period Today"
message: >
Avoid heavy loads between
{{ peak.window.start | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
and {{ peak.window.end | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}.
Average price: {{ peak.window.price_mean | round(1) }}
{{ peak.price_unit }}
```
</details>
---
## Technical Notes
### Duration Rounding
All durations are rounded **up** to the nearest 15 minutes because Tibber price data has quarter-hourly resolution. A 20-minute duration becomes 30 minutes (2 intervals). A 2-hour duration stays at 120 minutes (8 intervals).
### Comparison Details
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>
```yaml
service: tibber_prices.find_cheapest_block
data:
duration: "02:00:00"
include_comparison_details: true
```
</details>
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
If no intervals match your criteria (e.g., the search range is too short, all intervals are filtered out by price level), the response indicates failure:
- `find_cheapest_block`: `"window_found": false, "window": null`
- `find_cheapest_hours`: `"intervals_found": false, "schedule": null`
- `find_cheapest_schedule`: `"all_tasks_scheduled": false, "unscheduled_tasks": ["task_name"]`
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 |
| `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.