Compare commits

...

8 commits

Author SHA1 Message Date
Julian Pawlowski
0df089cc11 chore(release): bump version to 0.31.0b4
Some checks failed
Auto-Tag on Version Bump / Check and create version tag (push) Has been cancelled
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Has been cancelled
Lint / Ruff (push) Has been cancelled
Validate / Hassfest validation (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
2026-05-03 22:19:02 +00:00
Julian Pawlowski
1f74451adf chore(blueprints): disable automatic blueprint installation
Blueprints are kept in the repository for development but are not yet
ready for distribution. Commented out the install and remove calls in
async_setup and async_remove_entry so they are not copied to the user's
HA config directory on integration setup.

Release-Notes: skip
User-Impact: none
2026-05-03 22:17:02 +00:00
Julian Pawlowski
c2ff9cd2f2 fix(blueprints): fix home_connect_alt service call and correct sensor description
Two fixes in all 4 home_connect_alt blueprints:

1. home_connect_alt.start_program uses its own `device_id` field (not HA's
   standard target mechanism) and `program_key` (not `program`). The
   previous `target: entity_id:` was silently ignored, causing the service
   call to fail due to missing required `device_id`. Fixed by:
   - Removing `target: entity_id:` block
   - Adding `data.device_id: "{{ device_id(program_entity) }}"`
   - Renaming `program:` to `program_key:`
   - Adding `| int` filter to numeric option values

2. Same remote_start_sensor description fix as the non-alt variants
   (RemoteControlActive → RemoteControlStartAllowed).

Also reset blueprint version from v2.0.0 to v1.0.0.

Release-Notes: skip
Released-Bug: no
2026-05-03 22:16:56 +00:00
Julian Pawlowski
95d0278241 fix(blueprints): correct remote_start_sensor description in home_connect blueprints
The input field described the wrong binary sensor entity. The automation
correctly triggers on `RemoteControlStartAllowed`, but the label and
description still referenced `RemoteControlActive`.

Updated all 4 home_connect (non-alt) blueprints:
- dishwasher_home_connect.yaml
- washing_machine_home_connect.yaml
- dryer_home_connect.yaml
- laundry_day_pipeline_home_connect.yaml

Also reset blueprint version from v2.0.0 to v1.0.0 (version was bumped
prematurely, blueprints not yet released).

Release-Notes: skip
Released-Bug: no
2026-05-03 22:16:46 +00:00
Julian Pawlowski
b93eedf00e 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.
2026-05-03 22:16:08 +00:00
Julian Pawlowski
ba08bd34c6 chore(periods): house keeping 2026-05-03 19:43:31 +00:00
Julian Pawlowski
9cb5b35184 fix(periods): separate smoothed levels from period detection
Keep raw Tibber API levels for best/peak period filtering while leaving smoothed levels in priceInfo for display-oriented sensors. Also make relaxation retry each flex step with the configured level filter before falling back to level_filter="any", and add regression tests for both paths.

Impact: Best-price periods no longer extend into expensive intervals because of level smoothing, and adjacent best/peak windows stay separated as expected.
2026-05-03 19:40:34 +00:00
Julian Pawlowski
dc4933ec5c feat(services): add price_source parameter to get_chartdata and get_apexcharts_yaml
Add a `price_source` field (total | energy | tax, default: total) to both
services, allowing users to choose which price component is used as the
primary chart series.

- get_chartdata: all 9 interval.get("total") calls now use price_source
- get_apexcharts_yaml: price_source forwarded through all 4 JS
  data_generator calls; yaxis template variables resolve to
  yaxis_min_energy / yaxis_min_tax when price_source != "total"
- Metadata-only path: always computes yaxis_suggested_energy and
  yaxis_suggested_tax alongside the main yaxis bounds so the
  chart_metadata sensor can expose the correct axis scale for any source
- chart_metadata sensor: exposes yaxis_min_energy, yaxis_max_energy,
  yaxis_min_tax, yaxis_max_tax as new attributes
- services.yaml + all 5 language files (en, de, nb, nl, sv): price_source
  field and selector options added

Impact: Users can now chart the raw energy (spot) price or the tax component separately, with correct Y-axis scaling in ApexCharts.

Co-authored-by: Copilot <copilot@github.com>
2026-05-03 18:48:36 +00:00
34 changed files with 990 additions and 378 deletions

View file

@ -169,8 +169,8 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
LOGGER.debug("No chart_metadata configuration found in configuration.yaml") LOGGER.debug("No chart_metadata configuration found in configuration.yaml")
hass.data[DOMAIN][DATA_CHART_METADATA_CONFIG] = {} hass.data[DOMAIN][DATA_CHART_METADATA_CONFIG] = {}
# Install/update bundled blueprints # Blueprints are kept in the repo but not distributed yet.
await hass.async_add_executor_job(_install_blueprints, hass.config.config_dir) # await hass.async_add_executor_job(_install_blueprints, hass.config.config_dir)
return True return True
@ -418,10 +418,10 @@ async def async_remove_entry(
await async_remove_pool_storage(hass, entry.entry_id) await async_remove_pool_storage(hass, entry.entry_id)
LOGGER.debug(f"[tibber_prices] async_remove_entry removed interval pool storage for entry_id={entry.entry_id}") LOGGER.debug(f"[tibber_prices] async_remove_entry removed interval pool storage for entry_id={entry.entry_id}")
# Remove bundled blueprints if this was the last config entry # Blueprints are kept in the repo but not distributed yet.
remaining = [e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id != entry.entry_id] # remaining = [e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id != entry.entry_id]
if not remaining: # if not remaining:
await hass.async_add_executor_job(_remove_blueprints, hass.config.config_dir) # await hass.async_add_executor_job(_remove_blueprints, hass.config.config_dir)
async def async_reload_entry( async def async_reload_entry(

View file

@ -3,7 +3,7 @@ blueprint:
description: > description: >
**Companion blueprint for **Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) [Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0 (HACS integration)** · Blueprint v1.0.0
**Device-driven** dishwasher automation with electricity price **Device-driven** dishwasher automation with electricity price
optimization using the **Home Connect** integration (HA Core). optimization using the **Home Connect** integration (HA Core).
@ -71,10 +71,10 @@ blueprint:
domain: binary_sensor domain: binary_sensor
device_class: door device_class: door
remote_start_sensor: remote_start_sensor:
name: Remote Control Sensor name: Remote Start Sensor
description: > description: >
The "Remote Control Active" binary sensor The "Remote Control Start Allowed" binary sensor
(e.g., `binary_sensor.dishwasher_remote_control`). (e.g., `binary_sensor.dishwasher_remote_start`).
Must be **on** for the automation to proceed. Must be **on** for the automation to proceed.
selector: selector:
entity: entity:

View file

@ -3,7 +3,7 @@ blueprint:
description: > description: >
**Companion blueprint for **Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) [Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0 (HACS integration)** · Blueprint v1.0.0
**Device-driven** dishwasher automation with electricity price **Device-driven** dishwasher automation with electricity price
optimization using **Home Connect Alt** optimization using **Home Connect Alt**
@ -76,10 +76,10 @@ blueprint:
domain: binary_sensor domain: binary_sensor
device_class: door device_class: door
remote_start_sensor: remote_start_sensor:
name: Remote Control Sensor name: Remote Start Sensor
description: > description: >
The "Remote Control Active" binary sensor The "Remote Control Start Allowed" binary sensor
(e.g., `binary_sensor.dishwasher_remote_control_active`). (e.g., `binary_sensor.dishwasher_bsh_common_status_remotecontrolstartallowed`).
Must be **on** for the automation to proceed. Must be **on** for the automation to proceed.
selector: selector:
entity: entity:
@ -448,15 +448,15 @@ actions:
# Dishwashers use StartInRelative (seconds until program starts) # Dishwashers use StartInRelative (seconds until program starts)
start_in_relative: > start_in_relative: >
{{ [0, ((_window_start - now()).total_seconds()) | int] | max }} {{ [0, ((_window_start - now()).total_seconds()) | int] | max }}
_device_id: "{{ device_id(program_entity) }}"
- action: home_connect_alt.start_program - action: home_connect_alt.start_program
target:
entity_id: "{{ program_entity }}"
data: data:
program: "{{ selected_program }}" device_id: "{{ _device_id }}"
program_key: "{{ selected_program }}"
options: options:
- key: BSH.Common.Option.StartInRelative - key: BSH.Common.Option.StartInRelative
value: "{{ start_in_relative }}" value: "{{ start_in_relative | int }}"
- variables: - variables:
_n_title: "{{ title_planned }}" _n_title: "{{ title_planned }}"

View file

@ -3,7 +3,7 @@ blueprint:
description: > description: >
**Companion blueprint for **Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) [Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0 (HACS integration)** · Blueprint v1.0.0
**Device-driven** dryer automation with electricity price **Device-driven** dryer automation with electricity price
optimization using the **Home Connect** integration (HA Core). optimization using the **Home Connect** integration (HA Core).
@ -79,9 +79,9 @@ blueprint:
domain: binary_sensor domain: binary_sensor
device_class: door device_class: door
remote_start_sensor: remote_start_sensor:
name: Remote Control Sensor name: Remote Start Sensor
description: > description: >
The "Remote Control Active" binary sensor. The "Remote Control Start Allowed" binary sensor.
Must be **on** for the automation to proceed. Must be **on** for the automation to proceed.
selector: selector:
entity: entity:

View file

@ -3,7 +3,7 @@ blueprint:
description: > description: >
**Companion blueprint for **Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) [Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0 (HACS integration)** · Blueprint v1.0.0
**Device-driven** dryer automation with electricity price **Device-driven** dryer automation with electricity price
optimization using **Home Connect Alt** optimization using **Home Connect Alt**
@ -85,10 +85,10 @@ blueprint:
domain: binary_sensor domain: binary_sensor
device_class: door device_class: door
remote_start_sensor: remote_start_sensor:
name: Remote Control Sensor name: Remote Start Sensor
description: > description: >
The "Remote Control Active" binary sensor The "Remote Control Start Allowed" binary sensor
(e.g., `binary_sensor.dryer_remote_control_active`). (e.g., `binary_sensor.dryer_bsh_common_status_remotecontrolstartallowed`).
Must be **on** for the automation to proceed. Must be **on** for the automation to proceed.
selector: selector:
entity: entity:
@ -451,15 +451,15 @@ actions:
{% set window_end = _window_start + timedelta(minutes=duration | int) %} {% set window_end = _window_start + timedelta(minutes=duration | int) %}
{% set seconds_until_end = ((window_end - now()).total_seconds()) | int %} {% set seconds_until_end = ((window_end - now()).total_seconds()) | int %}
{{ [duration | int * 60, seconds_until_end] | max }} {{ [duration | int * 60, seconds_until_end] | max }}
_device_id: "{{ device_id(program_entity) }}"
- action: home_connect_alt.start_program - action: home_connect_alt.start_program
target:
entity_id: "{{ program_entity }}"
data: data:
program: "{{ selected_program }}" device_id: "{{ _device_id }}"
program_key: "{{ selected_program }}"
options: options:
- key: BSH.Common.Option.FinishInRelative - key: BSH.Common.Option.FinishInRelative
value: "{{ finish_in_relative }}" value: "{{ finish_in_relative | int }}"
- variables: - variables:
_n_title: "{{ title_planned }}" _n_title: "{{ title_planned }}"

View file

@ -3,7 +3,7 @@ blueprint:
description: > description: >
**Companion blueprint for **Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) [Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0 (HACS integration)** · Blueprint v1.0.0
**Device-driven** laundry pipeline — schedule multiple wash + dry **Device-driven** laundry pipeline — schedule multiple wash + dry
cycles at the cheapest electricity prices using the **Home Connect** cycles at the cheapest electricity prices using the **Home Connect**
@ -89,9 +89,9 @@ blueprint:
domain: binary_sensor domain: binary_sensor
device_class: door device_class: door
washer_remote_start_sensor: washer_remote_start_sensor:
name: Remote Control Sensor name: Remote Start Sensor
description: > description: >
The "Remote Control Active" binary sensor. The "Remote Control Start Allowed" binary sensor.
selector: selector:
entity: entity:
filter: filter:
@ -156,9 +156,9 @@ blueprint:
domain: binary_sensor domain: binary_sensor
device_class: door device_class: door
dryer_remote_start_sensor: dryer_remote_start_sensor:
name: Remote Control Sensor name: Remote Start Sensor
description: > description: >
The "Remote Control Active" binary sensor of the dryer. The "Remote Control Start Allowed" binary sensor of the dryer.
Only used when "Include Dryer" is enabled. Only used when "Include Dryer" is enabled.
default: "" default: ""
selector: selector:

View file

@ -3,7 +3,7 @@ blueprint:
description: > description: >
**Companion blueprint for **Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) [Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0 (HACS integration)** · Blueprint v1.0.0
**Device-driven** laundry pipeline — schedule multiple wash + dry **Device-driven** laundry pipeline — schedule multiple wash + dry
cycles at the cheapest electricity prices using **Home Connect Alt** cycles at the cheapest electricity prices using **Home Connect Alt**
@ -93,9 +93,9 @@ blueprint:
domain: binary_sensor domain: binary_sensor
device_class: door device_class: door
washer_remote_start_sensor: washer_remote_start_sensor:
name: Remote Control Sensor name: Remote Start Sensor
description: > description: >
The "Remote Control Active" binary sensor. The "Remote Control Start Allowed" binary sensor.
selector: selector:
entity: entity:
filter: filter:
@ -162,9 +162,9 @@ blueprint:
domain: binary_sensor domain: binary_sensor
device_class: door device_class: door
dryer_remote_start_sensor: dryer_remote_start_sensor:
name: Remote Control Sensor name: Remote Start Sensor
description: > description: >
The "Remote Control Active" binary sensor of the dryer. The "Remote Control Start Allowed" binary sensor of the dryer.
Only used when "Include Dryer" is enabled. Only used when "Include Dryer" is enabled.
default: "" default: ""
selector: selector:
@ -730,13 +730,12 @@ actions:
{{ [washer_duration | int * 60, seconds_until_end] | max }} {{ [washer_duration | int * 60, seconds_until_end] | max }}
- action: home_connect_alt.start_program - action: home_connect_alt.start_program
target:
entity_id: "{{ washer_program_entity }}"
data: data:
program: "{{ washer_program }}" device_id: "{{ device_id(washer_program_entity) }}"
program_key: "{{ washer_program }}"
options: options:
- key: BSH.Common.Option.FinishInRelative - key: BSH.Common.Option.FinishInRelative
value: "{{ wash_finish_in_relative }}" value: "{{ wash_finish_in_relative | int }}"
- variables: - variables:
_n_title: > _n_title: >
@ -1055,13 +1054,12 @@ actions:
{{ [dryer_duration | int * 60, seconds_until_end] | max }} {{ [dryer_duration | int * 60, seconds_until_end] | max }}
- action: home_connect_alt.start_program - action: home_connect_alt.start_program
target:
entity_id: "{{ dryer_program_entity }}"
data: data:
program: "{{ dryer_program }}" device_id: "{{ device_id(dryer_program_entity) }}"
program_key: "{{ dryer_program }}"
options: options:
- key: BSH.Common.Option.FinishInRelative - key: BSH.Common.Option.FinishInRelative
value: "{{ dry_finish_in_relative }}" value: "{{ dry_finish_in_relative | int }}"
- variables: - variables:
_n_title: > _n_title: >

View file

@ -3,7 +3,7 @@ blueprint:
description: > description: >
**Companion blueprint for **Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) [Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0 (HACS integration)** · Blueprint v1.0.0
**Device-driven** washing machine automation with electricity price **Device-driven** washing machine automation with electricity price
optimization using the **Home Connect** integration (HA Core). optimization using the **Home Connect** integration (HA Core).
@ -79,9 +79,9 @@ blueprint:
domain: binary_sensor domain: binary_sensor
device_class: door device_class: door
remote_start_sensor: remote_start_sensor:
name: Remote Control Sensor name: Remote Start Sensor
description: > description: >
The "Remote Control Active" binary sensor. The "Remote Control Start Allowed" binary sensor.
Must be **on** for the automation to proceed. Must be **on** for the automation to proceed.
selector: selector:
entity: entity:

View file

@ -3,7 +3,7 @@ blueprint:
description: > description: >
**Companion blueprint for **Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) [Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0 (HACS integration)** · Blueprint v1.0.0
**Device-driven** washing machine automation with electricity price **Device-driven** washing machine automation with electricity price
optimization using **Home Connect Alt** optimization using **Home Connect Alt**
@ -85,10 +85,10 @@ blueprint:
domain: binary_sensor domain: binary_sensor
device_class: door device_class: door
remote_start_sensor: remote_start_sensor:
name: Remote Control Sensor name: Remote Start Sensor
description: > description: >
The "Remote Control Active" binary sensor The "Remote Control Start Allowed" binary sensor
(e.g., `binary_sensor.washer_remote_control_active`). (e.g., `binary_sensor.washer_bsh_common_status_remotecontrolstartallowed`).
Must be **on** for the automation to proceed. Must be **on** for the automation to proceed.
selector: selector:
entity: entity:
@ -453,15 +453,15 @@ actions:
{% set window_end = _window_start + timedelta(minutes=duration | int) %} {% set window_end = _window_start + timedelta(minutes=duration | int) %}
{% set seconds_until_end = ((window_end - now()).total_seconds()) | int %} {% set seconds_until_end = ((window_end - now()).total_seconds()) | int %}
{{ [duration | int * 60, seconds_until_end] | max }} {{ [duration | int * 60, seconds_until_end] | max }}
_device_id: "{{ device_id(program_entity) }}"
- action: home_connect_alt.start_program - action: home_connect_alt.start_program
target:
entity_id: "{{ program_entity }}"
data: data:
program: "{{ selected_program }}" device_id: "{{ _device_id }}"
program_key: "{{ selected_program }}"
options: options:
- key: BSH.Common.Option.FinishInRelative - key: BSH.Common.Option.FinishInRelative
value: "{{ finish_in_relative }}" value: "{{ finish_in_relative | int }}"
- variables: - variables:
_n_title: "{{ title_planned }}" _n_title: "{{ title_planned }}"

View file

@ -3,7 +3,7 @@ blueprint:
description: > description: >
**Companion script blueprint for **Companion script blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) [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 Advanced notification dispatcher that replaces the simple

View file

@ -21,6 +21,24 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def _build_period_calculation_intervals(enriched_intervals: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Return enriched intervals with raw Tibber levels restored for period logic."""
period_intervals = copy.deepcopy(enriched_intervals)
for interval in period_intervals:
original_level = interval.pop("_original_level", None)
if original_level is not None:
interval["level"] = original_level
return period_intervals
def _strip_internal_enrichment_fields(enriched_intervals: list[dict[str, Any]]) -> None:
"""Remove internal enrichment helpers before exposing priceInfo."""
for interval in enriched_intervals:
interval.pop("_original_level", None)
class TibberPricesDataTransformer: class TibberPricesDataTransformer:
"""Handles data transformation, enrichment, and period calculations.""" """Handles data transformation, enrichment, and period calculations."""
@ -264,6 +282,9 @@ class TibberPricesDataTransformer:
time=self.time, time=self.time,
) )
period_intervals = _build_period_calculation_intervals(enriched_intervals)
_strip_internal_enrichment_fields(enriched_intervals)
# Store enriched intervals directly as priceInfo (flat list) # Store enriched intervals directly as priceInfo (flat list)
transformed_data = { transformed_data = {
"home_id": home_id, "home_id": home_id,
@ -281,7 +302,7 @@ class TibberPricesDataTransformer:
# Calculate periods (best price and peak price) # Calculate periods (best price and peak price)
if "priceInfo" in transformed_data: if "priceInfo" in transformed_data:
transformed_data["pricePeriods"] = self._calculate_periods_fn( transformed_data["pricePeriods"] = self._calculate_periods_fn(
transformed_data["priceInfo"], transformed_data.get("dayPatterns") period_intervals, transformed_data.get("dayPatterns")
) )
# Cache the transformed data # Cache the transformed data

View file

@ -570,8 +570,10 @@ def calculate_periods_with_relaxation(
Calculate periods with optional global filter relaxation and per-day target tracking. Calculate periods with optional global filter relaxation and per-day target tracking.
Strategy: a single global relaxation loop iterates flex levels (3% steps from Strategy: a single global relaxation loop iterates flex levels (3% steps from
the configured base flex up to MAX_FLEX_HARD_LIMIT). After every step we re-run the configured base flex up to MAX_FLEX_HARD_LIMIT). At each flex level we
period detection across all available days and check, per day, how many quality first re-run period detection with the configured level filter still intact.
Only if that is still insufficient do we retry the same flex with
`level_filter="any"`. After every attempt we check, per day, how many quality
periods (CV PERIOD_MAX_CV) have accumulated. Days that already meet the target periods (CV PERIOD_MAX_CV) have accumulated. Days that already meet the target
(`min_periods`) are not re-processed; the loop exits as soon as **all** days meet (`min_periods`) are not re-processed; the loop exits as soon as **all** days meet
their target. Days with very flat prices automatically need only 1 period their target. Days with very flat prices automatically need only 1 period
@ -580,8 +582,10 @@ def calculate_periods_with_relaxation(
If after all flex levels some days still have ZERO periods, a last-resort If after all flex levels some days still have ZERO periods, a last-resort
`min_period_length` fallback is attempted (see `_try_min_duration_fallback`). `min_period_length` fallback is attempted (see `_try_min_duration_fallback`).
Phase 1: Increase flex threshold step-by-step (up to max_relaxation_attempts) Phase 1: Increase flex threshold step-by-step while preserving the configured
Phase 2: Disable level filter (set to "any") in combination with each flex step level filter.
Phase 2: Retry the same flex with `level_filter="any"` when a concrete level
filter is configured.
Args: Args:
all_prices: All price data points all_prices: All price data points
@ -861,10 +865,12 @@ def calculate_periods_with_relaxation(
days_meeting_requirement += 1 days_meeting_requirement += 1
elif enable_relaxation: elif enable_relaxation:
filter_combination_count = 2 if config.level_filter not in (None, "any") else 1
_LOGGER_DETAILS.debug( _LOGGER_DETAILS.debug(
"%sAll %d days met target with baseline - no relaxation needed", "%sRelaxation strategy: 3%% fixed flex increment per step (%d flex levels x %d filter combinations)",
INDENT_L1, INDENT_L1,
total_days, total_days,
filter_combination_count,
) )
# Sort periods by start time # Sort periods by start time
@ -917,10 +923,11 @@ def relax_all_prices(
""" """
Relax filters for all prices until min_periods per day is reached. Relax filters for all prices until min_periods per day is reached.
Strategy: Try increasing flex by 3% increments, then relax level filter. Strategy: Try increasing flex by 3% increments while keeping the configured
Processes all prices together (yesterday+today+tomorrow), allowing periods level filter. For each flex level, optionally retry with `level_filter="any"`
to cross midnight boundaries. Returns when ALL days have min_periods when a concrete level filter is configured. Processes all prices together
(or max attempts exhausted). (yesterday+today+tomorrow), allowing periods to cross midnight boundaries.
Returns when ALL days have min_periods (or max attempts exhausted).
Args: Args:
all_prices: All price intervals (yesterday+today+tomorrow). all_prices: All price intervals (yesterday+today+tomorrow).
@ -947,6 +954,10 @@ def relax_all_prices(
existing_periods = list(baseline_periods) # Start with baseline existing_periods = list(baseline_periods) # Start with baseline
phases_used = [] phases_used = []
filter_variants: list[tuple[str | None, str | None]] = [(None, original_level_filter)]
if original_level_filter not in (None, "any"):
filter_variants.append(("any", "any"))
# Get available days from prices for checking # Get available days from prices for checking
prices_by_day = group_prices_by_day(all_prices, time=time) prices_by_day = group_prices_by_day(all_prices, time=time)
total_days = len(prices_by_day) total_days = len(prices_by_day)
@ -964,98 +975,103 @@ def relax_all_prices(
) )
break break
phase_label = f"flex={current_flex * 100:.1f}%" for level_override, applied_level_filter in filter_variants:
phase_label = f"flex={current_flex * 100:.1f}%"
phase_label_full = phase_label
if applied_level_filter is not None:
phase_label_full = f"{phase_label} +level_{applied_level_filter}"
# Skip this flex level if callback says not to show it # The callback expects a level override (e.g. None or "any"), not a flex label.
if not should_show_callback(phase_label): if not should_show_callback(level_override):
continue continue
if level_override == "any" and original_level_filter not in (None, "any"):
_LOGGER_DETAILS.debug(
"%s Flex=%.1f%%: OVERRIDING level_filter: %s → ANY",
INDENT_L2,
current_flex * 100,
original_level_filter,
)
# NOTE: config.flex is already normalized to positive by get_period_config()
relaxed_config = config._replace(
flex=current_flex, # Already positive from normalization
level_filter=applied_level_filter,
)
# Try current flex with level="any" (in relaxation mode)
if original_level_filter != "any":
_LOGGER_DETAILS.debug( _LOGGER_DETAILS.debug(
"%s Flex=%.1f%%: OVERRIDING level_filter: %s → ANY", "%s Trying %s: config has %d intervals (all days together), level_filter=%s",
INDENT_L2, INDENT_L2,
current_flex * 100,
original_level_filter,
)
# NOTE: config.flex is already normalized to positive by get_period_config()
relaxed_config = config._replace(
flex=current_flex, # Already positive from normalization
level_filter="any",
)
phase_label_full = f"flex={current_flex * 100:.1f}% +level_any"
_LOGGER_DETAILS.debug(
"%s Trying %s: config has %d intervals (all days together), level_filter=%s",
INDENT_L2,
phase_label_full,
len(all_prices),
relaxed_config.level_filter,
)
# Process ALL prices together (allows midnight crossing)
result = calculate_periods(
all_prices,
config=relaxed_config,
time=time,
day_patterns_by_date=day_patterns_by_date,
)
new_periods = result["periods"]
_LOGGER_DETAILS.debug(
"%s %s: calculate_periods returned %d periods",
INDENT_L2,
phase_label_full,
len(new_periods),
)
# Mark newly found periods with relaxation metadata BEFORE merging
mark_periods_with_relaxation(
new_periods,
relaxation_level=phase_label_full,
original_threshold=base_flex,
applied_threshold=current_flex,
reverse_sort=config.reverse_sort,
)
# Resolve overlaps between existing and new periods
combined, standalone_count = resolve_period_overlaps(
existing_periods=existing_periods,
new_relaxed_periods=new_periods,
all_prices=all_prices,
config=config,
time=time,
)
# Count periods per day with QUALITY GATE check
# Only periods with CV <= PERIOD_MAX_CV count towards min_periods requirement
days_meeting_requirement, quality_period_count = _count_quality_periods(
combined, all_prices, prices_by_day, min_periods, time=time
)
total_periods = len(combined)
_LOGGER_DETAILS.debug(
"%s %s: found %d periods total, %d/%d days meet requirement",
INDENT_L2,
phase_label_full,
total_periods,
days_meeting_requirement,
total_days,
)
existing_periods = combined
phases_used.append(phase_label_full)
# Check if ALL days reached target
if days_meeting_requirement >= total_days:
_LOGGER.info(
"Success with %s - all %d days have %d+ periods (%d total)",
phase_label_full, phase_label_full,
total_days, len(all_prices),
min_periods, relaxed_config.level_filter,
total_periods,
) )
# Process ALL prices together (allows midnight crossing)
result = calculate_periods(
all_prices,
config=relaxed_config,
time=time,
day_patterns_by_date=day_patterns_by_date,
)
new_periods = result["periods"]
_LOGGER_DETAILS.debug(
"%s %s: calculate_periods returned %d periods",
INDENT_L2,
phase_label_full,
len(new_periods),
)
# Mark newly found periods with relaxation metadata BEFORE merging
mark_periods_with_relaxation(
new_periods,
relaxation_level=phase_label_full,
original_threshold=base_flex,
applied_threshold=current_flex,
reverse_sort=config.reverse_sort,
)
# Resolve overlaps between existing and new periods
combined, standalone_count = resolve_period_overlaps(
existing_periods=existing_periods,
new_relaxed_periods=new_periods,
all_prices=all_prices,
config=config,
time=time,
)
# Count periods per day with QUALITY GATE check
# Only periods with CV <= PERIOD_MAX_CV count towards min_periods requirement
days_meeting_requirement, quality_period_count = _count_quality_periods(
combined, all_prices, prices_by_day, min_periods, time=time
)
total_periods = len(combined)
_LOGGER_DETAILS.debug(
"%s %s: found %d periods total, %d/%d days meet requirement",
INDENT_L2,
phase_label_full,
total_periods,
days_meeting_requirement,
total_days,
)
existing_periods = combined
phases_used.append(phase_label_full)
# Check if ALL days reached target
if days_meeting_requirement >= total_days:
_LOGGER.info(
"Success with %s - all %d days have %d+ periods (%d total)",
phase_label_full,
total_days,
min_periods,
total_periods,
)
break
if days_meeting_requirement >= total_days:
break break
# Build final result # Build final result

View file

@ -907,8 +907,9 @@ class TibberPricesPeriodCalculator:
) )
# Check if best price periods should be shown # Check if best price periods should be shown
# If relaxation is enabled, always calculate (relaxation will try "any" filter) # If relaxation is enabled, always calculate (relaxation tries configured level filter
# If relaxation is disabled, apply level filter check # first, then falls back to "any" per flex step if still insufficient)
# If relaxation is disabled, apply level filter check upfront
if enable_relaxation_best: if enable_relaxation_best:
show_best_price = bool(all_prices) show_best_price = bool(all_prices)
else: else:
@ -1009,8 +1010,9 @@ class TibberPricesPeriodCalculator:
) )
# Check if peak price periods should be shown # Check if peak price periods should be shown
# If relaxation is enabled, always calculate (relaxation will try "any" filter) # If relaxation is enabled, always calculate (relaxation tries configured level filter
# If relaxation is disabled, apply level filter check # first, then falls back to "any" per flex step if still insufficient)
# If relaxation is disabled, apply level filter check upfront
if enable_relaxation_peak: if enable_relaxation_peak:
show_peak_price = bool(all_prices) show_peak_price = bool(all_prices)
else: else:

View file

@ -1,11 +1,15 @@
{ {
"domain": "tibber_prices", "domain": "tibber_prices",
"name": "Tibber Price Information & Ratings", "name": "Tibber Price Information & Ratings",
"codeowners": ["@jpawlowski"], "codeowners": [
"@jpawlowski"
],
"config_flow": true, "config_flow": true,
"documentation": "https://github.com/jpawlowski/hass.tibber_prices", "documentation": "https://github.com/jpawlowski/hass.tibber_prices",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues", "issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues",
"requirements": ["aiofiles>=23.2.1"], "requirements": [
"version": "0.31.0b3" "aiofiles>=23.2.1"
],
"version": "0.31.0b4"
} }

View file

@ -138,6 +138,14 @@ def build_chart_metadata_attributes(
if "max" in yaxis_suggested: if "max" in yaxis_suggested:
attributes["yaxis_max"] = yaxis_suggested["max"] attributes["yaxis_max"] = yaxis_suggested["max"]
# Add per-source yaxis bounds (for energy/tax price_source in charts)
for source in ("energy", "tax"):
yaxis_extra = metadata.get(f"yaxis_suggested_{source}", {})
if "min" in yaxis_extra:
attributes[f"yaxis_min_{source}"] = yaxis_extra["min"]
if "max" in yaxis_extra:
attributes[f"yaxis_max_{source}"] = yaxis_extra["max"]
# Add currency info (useful for labeling) # Add currency info (useful for labeling)
if "currency" in metadata: if "currency" in metadata:
attributes["currency"] = metadata["currency"] attributes["currency"] = metadata["currency"]

View file

@ -56,6 +56,17 @@ get_apexcharts_yaml:
- interval - interval
- hourly - hourly
translation_key: resolution translation_key: resolution
price_source:
required: false
default: total
example: energy
selector:
select:
options:
- total
- energy
- tax
translation_key: price_source
highlight_best_price: highlight_best_price:
required: false required: false
default: true default: true
@ -100,6 +111,17 @@ get_chartdata:
- interval - interval
- hourly - hourly
translation_key: resolution translation_key: resolution
price_source:
required: false
default: total
example: energy
selector:
select:
options:
- total
- energy
- tax
translation_key: price_source
filters: filters:
collapsed: true collapsed: true
fields: fields:
@ -989,6 +1011,11 @@ find_cheapest_schedule:
- next_24h - next_24h
- next_48h - next_48h
translation_key: search_scope translation_key: search_scope
include_current_interval:
required: false
default: true
selector:
boolean:
search_range: search_range:
collapsed: true collapsed: true
fields: fields:

View file

@ -191,6 +191,7 @@ def _attempt_find_block(
duration_intervals: int, duration_intervals: int,
smooth_outliers: bool, smooth_outliers: bool,
min_distance_from_avg: float | None, min_distance_from_avg: float | None,
power_profile: list[int] | None,
reverse: bool, reverse: bool,
) -> tuple[dict | None, str]: ) -> tuple[dict | None, str]:
"""Attempt to find a block with specific filter parameters. """Attempt to find a block with specific filter parameters.
@ -207,7 +208,9 @@ def _attempt_find_block(
else: else:
search_data = filtered 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: if result is None:
return None, _determine_no_window_reason( return None, _determine_no_window_reason(
@ -335,6 +338,7 @@ async def _handle_find_block(
duration_intervals=effective_duration, duration_intervals=effective_duration,
smooth_outliers=smooth_outliers, smooth_outliers=smooth_outliers,
min_distance_from_avg=min_distance_from_avg, min_distance_from_avg=min_distance_from_avg,
power_profile=power_profile,
reverse=reverse, reverse=reverse,
) )
@ -362,6 +366,7 @@ async def _handle_find_block(
duration_intervals=effective_duration, duration_intervals=effective_duration,
smooth_outliers=smooth_outliers, smooth_outliers=smooth_outliers,
min_distance_from_avg=step.min_distance_from_avg, min_distance_from_avg=step.min_distance_from_avg,
power_profile=power_profile,
reverse=reverse, reverse=reverse,
) )
if result is not None: if result is not None:
@ -411,7 +416,9 @@ async def _handle_find_block(
effective_duration_minutes = effective_duration * INTERVAL_MINUTES 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, 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 # Calculate statistics and build response
stats = calculate_window_statistics( stats = calculate_window_statistics(

View file

@ -113,6 +113,7 @@ FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
), ),
vol.Optional("must_finish_by"): or_entity_ref(cv.datetime), vol.Optional("must_finish_by"): or_entity_ref(cv.datetime),
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES), 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("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,
@ -133,10 +134,13 @@ def _compute_task_price_comparison(
unit_factor: int, unit_factor: int,
*, *,
include_details: bool, include_details: bool,
power_profile: list[int] | None = None,
) -> dict[str, float | str | None] | None: ) -> dict[str, float | str | None] | None:
"""Compute per-task comparison against most expensive window of same duration.""" """Compute per-task comparison against most expensive window of same duration."""
duration_intervals = len(task_intervals) 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: if comparison_result is None:
return None return None
@ -196,6 +200,8 @@ def _find_cheapest_window_in_pool(
pool: list[dict[str, Any]], pool: list[dict[str, Any]],
duration_intervals: int, duration_intervals: int,
available: list[bool], available: list[bool],
*,
power_profile: list[int] | None = None,
) -> tuple[int, int] | None: ) -> tuple[int, int] | None:
""" """
Find the cheapest contiguous window of `duration_intervals` in available pool slots. 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. pool: Full sorted interval list.
duration_intervals: Required contiguous count. duration_intervals: Required contiguous count.
available: Boolean mask, same length as pool. True = still available. 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: Returns:
(start_index, end_index_exclusive) of the best window, or None if not found. (start_index, end_index_exclusive) of the best window, or None if not found.
@ -235,7 +244,10 @@ def _find_cheapest_window_in_pool(
j += 1 j += 1
if len(block) == duration_intervals: if len(block) == duration_intervals:
window_sum = sum(iv["total"] for iv in block) 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: if best_sum is None or window_sum < best_sum:
best_sum = window_sum best_sum = window_sum
best_start = i best_start = i
@ -306,7 +318,9 @@ def _attempt_schedule(
for k in range(min(sequential_min_idx, len(search_data))): for k in range(min(sequential_min_idx, len(search_data))):
available[k] = False 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: if window is None:
unscheduled.append(task["name"]) unscheduled.append(task["name"])
@ -622,6 +636,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
price_info, price_info,
unit_factor, unit_factor,
include_details=include_comparison_details, include_details=include_comparison_details,
power_profile=task.get("power_profile"),
), ),
} }
) )

View file

@ -57,6 +57,7 @@ APEXCHARTS_SERVICE_SCHEMA = vol.Schema(
vol.Optional("day"): vol.In(["yesterday", "today", "tomorrow", "rolling_window", "rolling_window_autozoom"]), vol.Optional("day"): vol.In(["yesterday", "today", "tomorrow", "rolling_window", "rolling_window_autozoom"]),
vol.Optional("level_type", default="rating_level"): vol.In(["rating_level", "level"]), vol.Optional("level_type", default="rating_level"): vol.In(["rating_level", "level"]),
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]), vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
vol.Optional("price_source", default="total"): vol.In(["total", "energy", "tax"]),
vol.Optional("highlight_best_price", default=True): cv.boolean, vol.Optional("highlight_best_price", default=True): cv.boolean,
vol.Optional("highlight_peak_price", default=False): cv.boolean, vol.Optional("highlight_peak_price", default=False): cv.boolean,
} }
@ -288,6 +289,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
day = call.data.get("day") # Can be None (rolling window mode) day = call.data.get("day") # Can be None (rolling window mode)
level_type = call.data.get("level_type", "rating_level") level_type = call.data.get("level_type", "rating_level")
resolution = call.data.get("resolution", "interval") resolution = call.data.get("resolution", "interval")
price_source = call.data.get("price_source", "total")
highlight_best_price = call.data.get("highlight_best_price", True) highlight_best_price = call.data.get("highlight_best_price", True)
highlight_peak_price = call.data.get("highlight_peak_price", False) highlight_peak_price = call.data.get("highlight_peak_price", False)
@ -366,7 +368,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
f"return_response: true, " f"return_response: true, "
f"service_data: {{ entry_id: '{entry_id}', {day_param}" f"service_data: {{ entry_id: '{entry_id}', {day_param}"
f"period_filter: 'best_price', resolution: '{resolution}', " f"period_filter: 'best_price', resolution: '{resolution}', "
f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param} }} }}); " f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param}, price_source: '{price_source}' }} }}); "
f"const originalData = response.response.data; " f"const originalData = response.response.data; "
f"return originalData.map((point, i) => {{ " f"return originalData.map((point, i) => {{ "
f"const result = [point[0], point[1] === null ? null : 1]; " f"const result = [point[0], point[1] === null ? null : 1]; "
@ -410,7 +412,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
f"return_response: true, " f"return_response: true, "
f"service_data: {{ entry_id: '{entry_id}', {day_param}" f"service_data: {{ entry_id: '{entry_id}', {day_param}"
f"period_filter: 'peak_price', resolution: '{resolution}', " f"period_filter: 'peak_price', resolution: '{resolution}', "
f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param} }} }}); " f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param}, price_source: '{price_source}' }} }}); "
f"const originalData = response.response.data; " f"const originalData = response.response.data; "
f"return originalData.map((point, i) => {{ " f"return originalData.map((point, i) => {{ "
f"const result = [point[0], point[1] === null ? null : 1]; " f"const result = [point[0], point[1] === null ? null : 1]; "
@ -472,7 +474,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
f"return_response: true, " f"return_response: true, "
f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, resolution: '{resolution}', " f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, resolution: '{resolution}', "
f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param}, " f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param}, "
f"connect_segments: true }} }}); " f"price_source: '{price_source}', connect_segments: true }} }}); "
f"return response.response.data;" f"return response.response.data;"
) )
else: else:
@ -485,7 +487,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
f"return_response: true, " f"return_response: true, "
f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, resolution: '{resolution}', " f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, resolution: '{resolution}', "
f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param}, " f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param}, "
f"connect_segments: true }} }}); " f"price_source: '{price_source}', connect_segments: true }} }}); "
f"return response.response.data;" f"return response.response.data;"
) )
# Configure show options based on level_type and level_key # Configure show options based on level_type and level_key
@ -820,10 +822,12 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
variables_dict = {"v_graph_span": template_graph_span} variables_dict = {"v_graph_span": template_graph_span}
if use_sensor_metadata: if use_sensor_metadata:
# Add dynamic metadata variables from sensor # Add dynamic metadata variables from sensor
# Use price-source-specific yaxis attrs when not using 'total'
yaxis_attr_suffix = f"_{price_source}" if price_source != "total" else ""
variables_dict.update( variables_dict.update(
{ {
"v_yaxis_min": f"states['{chart_metadata_sensor}'].attributes.yaxis_min", "v_yaxis_min": f"states['{chart_metadata_sensor}'].attributes.yaxis_min{yaxis_attr_suffix}",
"v_yaxis_max": f"states['{chart_metadata_sensor}'].attributes.yaxis_max", "v_yaxis_max": f"states['{chart_metadata_sensor}'].attributes.yaxis_max{yaxis_attr_suffix}",
} }
) )
@ -985,10 +989,12 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
variables_dict = {"v_offset": template_value} variables_dict = {"v_offset": template_value}
if use_sensor_metadata: if use_sensor_metadata:
# Add dynamic metadata variables from sensor # Add dynamic metadata variables from sensor
# Use price-source-specific yaxis attrs when not using 'total'
yaxis_attr_suffix = f"_{price_source}" if price_source != "total" else ""
variables_dict.update( variables_dict.update(
{ {
"v_yaxis_min": f"states['{chart_metadata_sensor}'].attributes.yaxis_min", "v_yaxis_min": f"states['{chart_metadata_sensor}'].attributes.yaxis_min{yaxis_attr_suffix}",
"v_yaxis_max": f"states['{chart_metadata_sensor}'].attributes.yaxis_max", "v_yaxis_max": f"states['{chart_metadata_sensor}'].attributes.yaxis_max{yaxis_attr_suffix}",
} }
) )

View file

@ -279,6 +279,7 @@ CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
vol.Optional("include_level", default=False): bool, vol.Optional("include_level", default=False): bool,
vol.Optional("include_rating_level", default=False): bool, vol.Optional("include_rating_level", default=False): bool,
vol.Optional("include_average", default=False): bool, vol.Optional("include_average", default=False): bool,
vol.Optional("price_source", default="total"): vol.In(["total", "energy", "tax"]),
vol.Optional("include_energy", default=False): bool, vol.Optional("include_energy", default=False): bool,
vol.Optional("include_tax", default=False): bool, vol.Optional("include_tax", default=False): bool,
vol.Optional("level_filter"): vol.All( vol.Optional("level_filter"): vol.All(
@ -380,6 +381,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
subunit_currency = data.get("subunit_currency", False) subunit_currency = data.get("subunit_currency", False)
metadata = data.get("metadata", "include") metadata = data.get("metadata", "include")
round_decimals = data.get("round_decimals") round_decimals = data.get("round_decimals")
price_source = data.get("price_source", "total")
include_level = data.get("include_level", False) include_level = data.get("include_level", False)
include_rating_level = data.get("include_rating_level", False) include_rating_level = data.get("include_rating_level", False)
include_average = data.get("include_average", False) include_average = data.get("include_average", False)
@ -453,7 +455,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
chart_data_for_meta = [] chart_data_for_meta = []
for interval in all_intervals: for interval in all_intervals:
start_time = interval.get("startsAt") start_time = interval.get("startsAt")
price = interval.get("total") price = interval.get(price_source)
if start_time is not None and price is not None: if start_time is not None and price is not None:
# Convert price to requested currency # Convert price to requested currency
converted_price = round(price * 100, 2) if subunit_currency else round(price, 4) converted_price = round(price * 100, 2) if subunit_currency else round(price, 4)
@ -474,6 +476,33 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
subunit_currency=subunit_currency, subunit_currency=subunit_currency,
) )
# Always add yaxis bounds for energy and tax sources so the chart_metadata
# sensor can expose them regardless of which price_source was requested.
# This lets get_apexcharts_yaml pick the right axis when price_source != "total".
def _yaxis_for_source(source: str) -> dict[str, float] | None:
prices = [
(round(float(iv[source]) * 100, 2) if subunit_currency else round(float(iv[source]), 4))
for iv in all_intervals
if iv.get(source) is not None
]
if not prices:
return None
data_range = max(prices) - min(prices)
padding_below = data_range * 0.08
padding_above = data_range * 0.15
if data_range == 0:
padding_below = abs(prices[0]) * 0.08 or (0.8 if subunit_currency else 0.008)
padding_above = abs(prices[0]) * 0.15 or (1.5 if subunit_currency else 0.015)
if subunit_currency:
return {"min": round(min(prices) - padding_below, 1), "max": round(max(prices) + padding_above, 1)}
return {"min": round(min(prices) - padding_below, 2), "max": round(max(prices) + padding_above, 2)}
for extra_source in ("energy", "tax"):
if extra_source != price_source:
yaxis_extra = _yaxis_for_source(extra_source)
if yaxis_extra:
metadata[f"yaxis_suggested_{extra_source}"] = yaxis_extra
result_meta: dict[str, Any] = {"metadata": metadata} result_meta: dict[str, Any] = {"metadata": metadata}
if resolved_refs: if resolved_refs:
result_meta["_resolved"] = resolved_refs result_meta["_resolved"] = resolved_refs
@ -601,7 +630,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
# Calculate average if requested # Calculate average if requested
if include_average: if include_average:
prices = [p["total"] for p in day_intervals if p.get("total") is not None] prices = [p[price_source] for p in day_intervals if p.get(price_source) is not None]
if prices: if prices:
avg = sum(prices) / len(prices) avg = sum(prices) / len(prices)
# Apply same transformations as to regular prices # Apply same transformations as to regular prices
@ -652,7 +681,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
# No data for this timestamp - skip entirely # No data for this timestamp - skip entirely
continue continue
price = interval.get("total") price = interval.get(price_source)
if price is None: if price is None:
continue continue
@ -704,7 +733,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
for interval in all_prices: for interval in all_prices:
start_time = interval.get("startsAt") start_time = interval.get("startsAt")
price = interval.get("total") price = interval.get(price_source)
if start_time is None or price is None: if start_time is None or price is None:
continue continue
@ -760,8 +789,8 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
next_interval = all_prices[i + 1] next_interval = all_prices[i + 1]
start_time = interval.get("startsAt") start_time = interval.get("startsAt")
price = interval.get("total") price = interval.get(price_source)
next_price = next_interval.get("total") next_price = next_interval.get(price_source)
next_start_time = next_interval.get("startsAt") next_start_time = next_interval.get("startsAt")
if start_time is None or price is None: if start_time is None or price is None:
@ -770,7 +799,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
interval_value = interval.get(filter_field) interval_value = interval.get(filter_field)
next_value = next_interval.get(filter_field) next_value = next_interval.get(filter_field)
prev_value = all_prices[i - 1].get(filter_field) if i > 0 else None prev_value = all_prices[i - 1].get(filter_field) if i > 0 else None
prev_price = all_prices[i - 1].get("total") if i > 0 else None prev_price = all_prices[i - 1].get(price_source) if i > 0 else None
# Check if current interval matches filter # Check if current interval matches filter
if interval_value in filter_values: # type: ignore[operator] if interval_value in filter_values: # type: ignore[operator]
@ -921,7 +950,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
if all_prices: if all_prices:
last_interval = all_prices[-1] last_interval = all_prices[-1]
last_start_time = last_interval.get("startsAt") last_start_time = last_interval.get("startsAt")
last_price = last_interval.get("total") last_price = last_interval.get(price_source)
last_value = last_interval.get(filter_field) last_value = last_interval.get(filter_field)
if last_start_time and last_price is not None and last_value in filter_values: # type: ignore[operator] if last_start_time and last_price is not None and last_value in filter_values: # type: ignore[operator]
@ -975,7 +1004,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
# Mode 'none' (default): Only return matching intervals, no NULL insertion # Mode 'none' (default): Only return matching intervals, no NULL insertion
for interval in all_prices: for interval in all_prices:
start_time = interval.get("startsAt") start_time = interval.get("startsAt")
price = interval.get("total") price = interval.get(price_source)
if start_time is not None and price is not None: if start_time is not None and price is not None:
# Apply period filter if specified # Apply period filter if specified

View file

@ -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. 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) tomorrow_start = today_start + timedelta(days=1)
day_after_start = today_start + timedelta(days=2) day_after_start = today_start + timedelta(days=2)
rolling_start = floor_to_quarter_hour(now) if include_current else now
if scope == "today": if scope == "today":
return today_start, tomorrow_start return today_start, tomorrow_start
if scope == "tomorrow": if scope == "tomorrow":
return tomorrow_start, day_after_start return tomorrow_start, day_after_start
if scope == "remaining_today": if scope == "remaining_today":
return floor_to_quarter_hour(now), tomorrow_start return rolling_start, tomorrow_start
if scope == "next_24h": 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": if scope == "next_48h":
return floor_to_quarter_hour(now), now + timedelta(hours=48) return rolling_start, now + timedelta(hours=48)
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
@ -517,7 +525,7 @@ def resolve_search_range(
# Priority 0: search_scope shorthand # Priority 0: search_scope shorthand
if "search_scope" in call_data: 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 --- # --- Resolve start ---
if "search_start" in call_data: if "search_start" in call_data:

View file

@ -195,6 +195,17 @@
}, },
"submit": "↩ Speichern & Zurück" "submit": "↩ Speichern & Zurück"
}, },
"price_level": {
"title": "🏷️ Preisniveau-Einstellungen (von Tibber API)",
"description": "**Konfiguriere die Stabilisierung für Tibbers Preisniveau-Klassifizierung (sehr günstig/günstig/normal/teuer/sehr teuer).**\n\nTibbers API liefert ein Preisniveau-Feld für jedes Intervall. Diese Einstellung glättet kurze Schwankungen, um Instabilität in Automatisierungen zu verhindern.{entity_warning}",
"data": {
"price_level_gap_tolerance": "Gap-Toleranz"
},
"data_description": {
"price_level_gap_tolerance": "Maximale Anzahl aufeinanderfolgender Intervalle, die 'geglättet' werden können, wenn sie von umgebenden Preisniveaus abweichen. Kleine isolierte Niveauänderungen werden mit dem dominanten Nachbarblock zusammengeführt. Beispiel: 1 bedeutet, dass ein einzelnes 'normal'-Intervall, umgeben von 'günstig'-Intervallen, zu 'günstig' korrigiert wird. Auf 0 setzen zum Deaktivieren. Standard: 1"
},
"submit": "↩ Speichern & Zurück"
},
"best_price": { "best_price": {
"title": "💚 Bestpreis-Zeitraum Einstellungen", "title": "💚 Bestpreis-Zeitraum Einstellungen",
"description": "**Konfiguration für den Bestpreis-Zeitraum mit den niedrigsten Strompreisen.**{entity_warning}{override_warning}\n\n---", "description": "**Konfiguration für den Bestpreis-Zeitraum mit den niedrigsten Strompreisen.**{entity_warning}{override_warning}\n\n---",
@ -375,17 +386,6 @@
"confirm_reset": "Ja, alles auf Werkseinstellungen zurücksetzen" "confirm_reset": "Ja, alles auf Werkseinstellungen zurücksetzen"
}, },
"submit": "Jetzt zurücksetzen" "submit": "Jetzt zurücksetzen"
},
"price_level": {
"title": "🏷️ Preisniveau-Einstellungen (von Tibber API)",
"description": "**Konfiguriere die Stabilisierung für Tibbers Preisniveau-Klassifizierung (sehr günstig/günstig/normal/teuer/sehr teuer).**\n\nTibbers API liefert ein Preisniveau-Feld für jedes Intervall. Diese Einstellung glättet kurze Schwankungen, um Instabilität in Automatisierungen zu verhindern.{entity_warning}",
"data": {
"price_level_gap_tolerance": "Gap-Toleranz"
},
"data_description": {
"price_level_gap_tolerance": "Maximale Anzahl aufeinanderfolgender Intervalle, die 'geglättet' werden können, wenn sie von umgebenden Preisniveaus abweichen. Kleine isolierte Niveauänderungen werden mit dem dominanten Nachbarblock zusammengeführt. Beispiel: 1 bedeutet, dass ein einzelnes 'normal'-Intervall, umgeben von 'günstig'-Intervallen, zu 'günstig' korrigiert wird. Auf 0 setzen zum Deaktivieren. Standard: 1"
},
"submit": "↩ Speichern & Zurück"
} }
}, },
"error": { "error": {
@ -395,11 +395,11 @@
"cannot_connect": "Verbindung fehlgeschlagen", "cannot_connect": "Verbindung fehlgeschlagen",
"invalid_access_token": "Ungültiges Zugriffstoken", "invalid_access_token": "Ungültiges Zugriffstoken",
"different_home": "Der Zugriffstoken ist nicht gültig für die Home ID, für die diese Integration konfiguriert ist.", "different_home": "Der Zugriffstoken ist nicht gültig für die Home ID, für die diese Integration konfiguriert ist.",
"invalid_period_length": "Die Periodenlänge muss mindestens 15 Minuten betragen (Vielfache von 15).",
"invalid_flex": "Flexibilitätsprozentsatz muss zwischen -50% und +50% liegen", "invalid_flex": "Flexibilitätsprozentsatz muss zwischen -50% und +50% liegen",
"invalid_best_price_distance": "Distanzprozentsatz muss zwischen -50% und 0% liegen (negativ = unter Durchschnitt)", "invalid_best_price_distance": "Distanzprozentsatz muss zwischen -50% und 0% liegen (negativ = unter Durchschnitt)",
"invalid_peak_price_distance": "Distanzprozentsatz muss zwischen 0% und 50% liegen (positiv = über Durchschnitt)", "invalid_peak_price_distance": "Distanzprozentsatz muss zwischen 0% und 50% liegen (positiv = über Durchschnitt)",
"invalid_min_periods": "Mindestanzahl der Zeiträume muss zwischen 1 und 10 liegen", "invalid_min_periods": "Mindestanzahl der Zeiträume muss zwischen 1 und 10 liegen",
"invalid_period_length": "Die Periodenlänge muss mindestens 15 Minuten betragen (Vielfache von 15).",
"invalid_gap_count": "Lückentoleranz muss zwischen 0 und 8 liegen", "invalid_gap_count": "Lückentoleranz muss zwischen 0 und 8 liegen",
"invalid_relaxation_attempts": "Lockerungsversuche müssen zwischen 1 und 12 liegen", "invalid_relaxation_attempts": "Lockerungsversuche müssen zwischen 1 und 12 liegen",
"invalid_price_rating_low": "Untere Preis-Bewertungsschwelle muss zwischen -50% und -5% liegen", "invalid_price_rating_low": "Untere Preis-Bewertungsschwelle muss zwischen -50% und -5% liegen",
@ -1389,6 +1389,14 @@
"name": "Stufen-Typ", "name": "Stufen-Typ",
"description": "Wähle, welche Preisstufen-Klassifizierung visualisiert werden soll: 'rating_level' (niedrig/normal/hoch basierend auf deinen konfigurierten Schwellenwerten) oder 'level' (Tibber-API-Stufen: sehr günstig/günstig/normal/teuer/sehr teuer)." "description": "Wähle, welche Preisstufen-Klassifizierung visualisiert werden soll: 'rating_level' (niedrig/normal/hoch basierend auf deinen konfigurierten Schwellenwerten) oder 'level' (Tibber-API-Stufen: sehr günstig/günstig/normal/teuer/sehr teuer)."
}, },
"resolution": {
"name": "Auflösung",
"description": "Zeitauflösung für die Diagrammdaten. 'interval' (Standard): Originale 15-Minuten-Intervalle (96 Punkte pro Tag). 'hourly': Aggregierte Stundenwerte mit einem rollierenden 60-Minuten-Fenster (24 Punkte pro Tag) für ein übersichtlicheres Diagramm."
},
"price_source": {
"name": "Preisquelle",
"description": "Welche Preiskomponente als Hauptpreis verwendet werden soll. 'total' (Standard): Gesamtpreis inkl. Energie, Steuern und Gebühren. 'energy': Nur der reine Spot-/Energiepreis (ohne Steuern und Gebühren). 'tax': Nur Steuer- und Gebührenanteil."
},
"highlight_best_price": { "highlight_best_price": {
"name": "Bestpreis-Zeiträume hervorheben", "name": "Bestpreis-Zeiträume hervorheben",
"description": "Füge eine halbtransparente grüne Überlagerung hinzu, um die Bestpreis-Zeiträume im Diagramm hervorzuheben. Dies erleichtert die visuelle Identifizierung der optimalen Zeiten für den Energieverbrauch." "description": "Füge eine halbtransparente grüne Überlagerung hinzu, um die Bestpreis-Zeiträume im Diagramm hervorzuheben. Dies erleichtert die visuelle Identifizierung der optimalen Zeiten für den Energieverbrauch."
@ -1396,10 +1404,6 @@
"highlight_peak_price": { "highlight_peak_price": {
"name": "Spitzenpreis-Zeiträume hervorheben", "name": "Spitzenpreis-Zeiträume hervorheben",
"description": "Füge eine halbtransparente rote Überlagerung hinzu, um die Spitzenpreis-Zeiträume im Diagramm hervorzuheben. Dies erleichtert die visuelle Identifizierung der Zeiten, in denen Energie am teuersten ist." "description": "Füge eine halbtransparente rote Überlagerung hinzu, um die Spitzenpreis-Zeiträume im Diagramm hervorzuheben. Dies erleichtert die visuelle Identifizierung der Zeiten, in denen Energie am teuersten ist."
},
"resolution": {
"name": "Auflösung",
"description": "Zeitauflösung für die Diagrammdaten. 'interval' (Standard): Originale 15-Minuten-Intervalle (96 Punkte pro Tag). 'hourly': Aggregierte Stundenwerte mit einem rollierenden 60-Minuten-Fenster (24 Punkte pro Tag) für ein übersichtlicheres Diagramm."
} }
} }
}, },
@ -1449,6 +1453,10 @@
"name": "Auflösung", "name": "Auflösung",
"description": "Zeitauflösung für die zurückgegebenen Daten. Optionen: 'interval' (Standard, 15-Minuten-Intervalle, 96 Datenpunkte pro Tag), 'hourly' (stündliche Durchschnitte, 24 Datenpunkte pro Tag)." "description": "Zeitauflösung für die zurückgegebenen Daten. Optionen: 'interval' (Standard, 15-Minuten-Intervalle, 96 Datenpunkte pro Tag), 'hourly' (stündliche Durchschnitte, 24 Datenpunkte pro Tag)."
}, },
"price_source": {
"name": "Preisquelle",
"description": "Welche Preiskomponente als Hauptpreis verwendet werden soll. 'total' (Standard): Gesamtpreis inkl. Energie, Steuern und Gebühren. 'energy': Nur der reine Spot-/Energiepreis (ohne Steuern und Gebühren). 'tax': Nur Steuer- und Gebührenanteil."
},
"output_format": { "output_format": {
"name": "Ausgabeformat", "name": "Ausgabeformat",
"description": "Ausgabeformat für die zurückgegebenen Daten. Optionen: 'array_of_objects' (Standard, Array von Objekten mit anpassbaren Feldnamen), 'array_of_arrays' (Array von [Zeitstempel, Preis]-Arrays mit abschließendem Null-Punkt für Stepline-Charts)." "description": "Ausgabeformat für die zurückgegebenen Daten. Optionen: 'array_of_objects' (Standard, Array von Objekten mit anpassbaren Feldnamen), 'array_of_arrays' (Array von [Zeitstempel, Preis]-Arrays mit abschließendem Null-Punkt für Stepline-Charts)."
@ -1465,6 +1473,10 @@
"name": "Dezimalstellen runden", "name": "Dezimalstellen runden",
"description": "Anzahl der Dezimalstellen, auf die Preise gerundet werden sollen (0-10). Falls nicht angegeben, wird die Standardgenauigkeit verwendet (4 Dezimalstellen für Basiswährung, 2 für Unterwährungseinheit)." "description": "Anzahl der Dezimalstellen, auf die Preise gerundet werden sollen (0-10). Falls nicht angegeben, wird die Standardgenauigkeit verwendet (4 Dezimalstellen für Basiswährung, 2 für Unterwährungseinheit)."
}, },
"data_key": {
"name": "Daten-Schlüssel",
"description": "Benutzerdefinierter Name für den obersten Datenschlüssel in der Antwort. Standard ist 'data', falls nicht angegeben."
},
"include_level": { "include_level": {
"name": "Preisniveau einschließen", "name": "Preisniveau einschließen",
"description": "Fügt das Tibber-Preisniveau (sehr günstig/günstig/normal/teuer/sehr teuer) zu jedem Datenpunkt hinzu." "description": "Fügt das Tibber-Preisniveau (sehr günstig/günstig/normal/teuer/sehr teuer) zu jedem Datenpunkt hinzu."
@ -1544,10 +1556,6 @@
"metadata": { "metadata": {
"name": "Metadaten", "name": "Metadaten",
"description": "Steuerung der Metadaten-Einbindung in der Antwort. 'include' (Standard): Gibt Chart-Daten und Metadaten mit Preisstatistiken, Währungsinformationen, Y-Achsen-Vorschlägen und Zeitbereich zurück. 'only': Gibt nur Metadaten zurück ohne Chart-Daten zu verarbeiten (schnell, nützlich für dynamische Y-Achsen-Konfiguration). 'none': Gibt nur Chart-Daten ohne Metadaten zurück." "description": "Steuerung der Metadaten-Einbindung in der Antwort. 'include' (Standard): Gibt Chart-Daten und Metadaten mit Preisstatistiken, Währungsinformationen, Y-Achsen-Vorschlägen und Zeitbereich zurück. 'only': Gibt nur Metadaten zurück ohne Chart-Daten zu verarbeiten (schnell, nützlich für dynamische Y-Achsen-Konfiguration). 'none': Gibt nur Chart-Daten ohne Metadaten zurück."
},
"data_key": {
"name": "Daten-Schlüssel",
"description": "Benutzerdefinierter Name für den obersten Datenschlüssel in der Antwort. Standard ist 'data', falls nicht angegeben."
} }
} }
}, },
@ -1661,7 +1669,7 @@
}, },
"power_profile": { "power_profile": {
"name": "Leistungsprofil", "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": { "smooth_outliers": {
"name": "Ausreißer glätten", "name": "Ausreißer glätten",
@ -1781,7 +1789,7 @@
}, },
"power_profile": { "power_profile": {
"name": "Leistungsprofil", "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": { "smooth_outliers": {
"name": "Ausreißer glätten", "name": "Ausreißer glätten",
@ -1905,7 +1913,7 @@
}, },
"power_profile": { "power_profile": {
"name": "Leistungsprofil", "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": { "smooth_outliers": {
"name": "Ausreißer glätten", "name": "Ausreißer glätten",
@ -2029,7 +2037,7 @@
}, },
"power_profile": { "power_profile": {
"name": "Leistungsprofil", "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": { "smooth_outliers": {
"name": "Ausreißer glätten", "name": "Ausreißer glätten",
@ -2131,6 +2139,10 @@
"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, starten rollierende Suchbereiche wie remaining_today und next_24h am Beginn des aktuellen Intervalls, sodass es ausgewählt werden kann."
},
"max_price_level": { "max_price_level": {
"name": "Maximale Preisstufe", "name": "Maximale Preisstufe",
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe berücksichtigen. very_cheap = restriktivste, very_expensive = keine Einschränkung." "description": "Nur Intervalle bis zu dieser Tibber-Preisstufe berücksichtigen. very_cheap = restriktivste, very_expensive = keine Einschränkung."
@ -2364,6 +2376,16 @@
"description": "Limit how many separate charging segments may be used per day. The planner keeps the cheapest segments within this limit." "description": "Limit how many separate charging segments may be used per day. The planner keeps the cheapest segments within this limit."
} }
} }
},
"debug_clear_tomorrow": {
"name": "Debug: Morgendaten löschen",
"description": "DEBUG/TESTING: Entfernt die Preisdaten für morgen aus dem Interval-Pool-Cache. Verwende dies, um den Aktualisierungszyklus für Morgendaten zu testen, ohne auf den nächsten Tag zu warten. Nach dem Aufruf dieses Dienstes zeigt der Lifecycle-Sensor 'searching_tomorrow' (nach 13:00 Uhr) an und der nächste Timer-#1-Zyklus lädt neue Daten von der API.",
"fields": {
"entry_id": {
"name": "Eintrag-ID",
"description": "Optionale Konfigurationseintrag-ID. Wenn sie nicht angegeben ist, wird der erste verfügbare Eintrag verwendet."
}
}
} }
}, },
"selector": { "selector": {
@ -2428,6 +2450,13 @@
"peak_price": "Spitzenpreis-Zeiträume" "peak_price": "Spitzenpreis-Zeiträume"
} }
}, },
"price_source": {
"options": {
"total": "Gesamt (inkl. Steuern & Gebühren)",
"energy": "Nur Energiepreis",
"tax": "Nur Steuern & Gebühren"
}
},
"metadata": { "metadata": {
"options": { "options": {
"include": "Einbeziehen (Daten + Metadaten)", "include": "Einbeziehen (Daten + Metadaten)",

View file

@ -1389,6 +1389,14 @@
"name": "Level Type", "name": "Level Type",
"description": "Select which price level classification to visualize: 'rating_level' (low/normal/high based on your configured thresholds) or 'level' (Tibber API levels: very cheap/cheap/normal/expensive/very expensive)." "description": "Select which price level classification to visualize: 'rating_level' (low/normal/high based on your configured thresholds) or 'level' (Tibber API levels: very cheap/cheap/normal/expensive/very expensive)."
}, },
"resolution": {
"name": "Resolution",
"description": "Time resolution for the chart data. 'interval' (default): Original 15-minute intervals (96 points per day). 'hourly': Aggregated hourly values using a rolling 60-minute window (24 points per day) for a cleaner, less cluttered chart."
},
"price_source": {
"name": "Price Source",
"description": "Which price component to use as the main price series. 'total' (default): Total price incl. energy, taxes, and fees. 'energy': Raw spot/energy price only (excluding taxes and fees). 'tax': Taxes and fees only."
},
"highlight_best_price": { "highlight_best_price": {
"name": "Highlight Best Price Periods", "name": "Highlight Best Price Periods",
"description": "Add a semi-transparent green overlay to highlight the best price periods on the chart. This makes it easy to visually identify the optimal times for energy consumption." "description": "Add a semi-transparent green overlay to highlight the best price periods on the chart. This makes it easy to visually identify the optimal times for energy consumption."
@ -1396,10 +1404,6 @@
"highlight_peak_price": { "highlight_peak_price": {
"name": "Highlight Peak Price Periods", "name": "Highlight Peak Price Periods",
"description": "Add a semi-transparent red overlay to highlight the peak price periods on the chart. This makes it easy to visually identify times when energy is most expensive." "description": "Add a semi-transparent red overlay to highlight the peak price periods on the chart. This makes it easy to visually identify times when energy is most expensive."
},
"resolution": {
"name": "Resolution",
"description": "Time resolution for the chart data. 'interval' (default): Original 15-minute intervals (96 points per day). 'hourly': Aggregated hourly values using a rolling 60-minute window (24 points per day) for a cleaner, less cluttered chart."
} }
} }
}, },
@ -1449,6 +1453,10 @@
"name": "Resolution", "name": "Resolution",
"description": "Time resolution for the returned data. Options: 'interval' (default, 15-minute intervals, 96 points per day), 'hourly' (hourly averages, 24 points per day)." "description": "Time resolution for the returned data. Options: 'interval' (default, 15-minute intervals, 96 points per day), 'hourly' (hourly averages, 24 points per day)."
}, },
"price_source": {
"name": "Price Source",
"description": "Which price component to use as the primary price. 'total' (default): Full price including energy, taxes and fees. 'energy': Raw spot/energy price only (excluding taxes and fees). 'tax': Tax and fee component only."
},
"output_format": { "output_format": {
"name": "Output Format", "name": "Output Format",
"description": "Output format for the returned data. Options: 'array_of_objects' (default, array of objects with customizable field names), 'array_of_arrays' (array of [timestamp, price] arrays with trailing null point for stepline charts)." "description": "Output format for the returned data. Options: 'array_of_objects' (default, array of objects with customizable field names), 'array_of_arrays' (array of [timestamp, price] arrays with trailing null point for stepline charts)."
@ -1661,7 +1669,7 @@
}, },
"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. 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": { "smooth_outliers": {
"name": "Smooth Outliers", "name": "Smooth Outliers",
@ -1781,7 +1789,7 @@
}, },
"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. 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": { "smooth_outliers": {
"name": "Smooth Outliers", "name": "Smooth Outliers",
@ -1905,7 +1913,7 @@
}, },
"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. 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": { "smooth_outliers": {
"name": "Smooth Outliers", "name": "Smooth Outliers",
@ -2029,7 +2037,7 @@
}, },
"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. 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": { "smooth_outliers": {
"name": "Smooth Outliers", "name": "Smooth Outliers",
@ -2051,7 +2059,7 @@
}, },
"find_cheapest_schedule": { "find_cheapest_schedule": {
"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 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": { "sections": {
"search_range": { "search_range": {
"name": "Custom Search Range", "name": "Custom Search Range",
@ -2131,6 +2139,10 @@
"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. 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": { "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."
@ -2438,6 +2450,13 @@
"peak_price": "Peak Price Periods" "peak_price": "Peak Price Periods"
} }
}, },
"price_source": {
"options": {
"total": "Total (incl. taxes & fees)",
"energy": "Energy price only",
"tax": "Tax & fees only"
}
},
"metadata": { "metadata": {
"options": { "options": {
"include": "Include (data + metadata)", "include": "Include (data + metadata)",

View file

@ -195,6 +195,17 @@
}, },
"submit": "↩ Lagre & tilbake" "submit": "↩ Lagre & tilbake"
}, },
"price_level": {
"title": "🏷️ Prisnivå-innstillinger",
"description": "**Konfigurer stabilisering for Tibbers prisnivå-klassifisering (veldig billig/billig/normal/dyr/veldig dyr).**\n\nTibbers API gir et prisnivå-felt for hvert intervall. Denne innstillingen jevner ut korte svingninger for å forhindre ustabilitet i automatiseringer.{entity_warning}",
"data": {
"price_level_gap_tolerance": "Gap-toleranse"
},
"data_description": {
"price_level_gap_tolerance": "Maksimalt antall påfølgende intervaller som kan 'jevnes ut' hvis de avviker fra omkringliggende prisnivåer. Små isolerte nivåendringer slås sammen med den dominerende nabogruppen. Eksempel: 1 betyr at et enkelt 'normal'-intervall omgitt av 'billig'-intervaller korrigeres til 'billig'. Sett til 0 for å deaktivere. Standard: 1"
},
"submit": "↩ Lagre & tilbake"
},
"best_price": { "best_price": {
"title": "💚 Beste Prisperiode Innstillinger", "title": "💚 Beste Prisperiode Innstillinger",
"description": "**Konfigurer innstillinger for Beste Prisperiode binærsensor. Denne sensoren er aktiv i perioder med de laveste strømprisene.**{entity_warning}{override_warning}\n\n---", "description": "**Konfigurer innstillinger for Beste Prisperiode binærsensor. Denne sensoren er aktiv i perioder med de laveste strømprisene.**{entity_warning}{override_warning}\n\n---",
@ -375,17 +386,6 @@
"confirm_reset": "Ja, tilbakestill alt til standard" "confirm_reset": "Ja, tilbakestill alt til standard"
}, },
"submit": "Tilbakestill nå" "submit": "Tilbakestill nå"
},
"price_level": {
"title": "🏷️ Prisnivå-innstillinger",
"description": "**Konfigurer stabilisering for Tibbers prisnivå-klassifisering (veldig billig/billig/normal/dyr/veldig dyr).**\n\nTibbers API gir et prisnivå-felt for hvert intervall. Denne innstillingen jevner ut korte svingninger for å forhindre ustabilitet i automatiseringer.{entity_warning}",
"data": {
"price_level_gap_tolerance": "Gap-toleranse"
},
"data_description": {
"price_level_gap_tolerance": "Maksimalt antall påfølgende intervaller som kan 'jevnes ut' hvis de avviker fra omkringliggende prisnivåer. Små isolerte nivåendringer slås sammen med den dominerende nabogruppen. Eksempel: 1 betyr at et enkelt 'normal'-intervall omgitt av 'billig'-intervaller korrigeres til 'billig'. Sett til 0 for å deaktivere. Standard: 1"
},
"submit": "↩ Lagre & tilbake"
} }
}, },
"error": { "error": {
@ -395,11 +395,11 @@
"cannot_connect": "Kunne ikke koble til", "cannot_connect": "Kunne ikke koble til",
"invalid_access_token": "Ugyldig tilgangstoken", "invalid_access_token": "Ugyldig tilgangstoken",
"different_home": "Tilgangstokenet er ikke gyldig for hjem-ID-en denne integrasjonen er konfigurert for.", "different_home": "Tilgangstokenet er ikke gyldig for hjem-ID-en denne integrasjonen er konfigurert for.",
"invalid_period_length": "Periodelengden må være minst 15 minutter (multipler av 15).",
"invalid_flex": "Fleksibilitetsprosent må være mellom -50% og +50%", "invalid_flex": "Fleksibilitetsprosent må være mellom -50% og +50%",
"invalid_best_price_distance": "Avstandsprosent må være mellom -50% og 0% (negativ = under gjennomsnitt)", "invalid_best_price_distance": "Avstandsprosent må være mellom -50% og 0% (negativ = under gjennomsnitt)",
"invalid_peak_price_distance": "Avstandsprosent må være mellom 0% og 50% (positiv = over gjennomsnitt)", "invalid_peak_price_distance": "Avstandsprosent må være mellom 0% og 50% (positiv = over gjennomsnitt)",
"invalid_min_periods": "Minimumsantall perioder må være mellom 1 og 10", "invalid_min_periods": "Minimumsantall perioder må være mellom 1 og 10",
"invalid_period_length": "Periodelengden må være minst 15 minutter (multipler av 15).",
"invalid_gap_count": "Gaptoleranse må være mellom 0 og 8", "invalid_gap_count": "Gaptoleranse må være mellom 0 og 8",
"invalid_relaxation_attempts": "Lempingsforsøk må være mellom 1 og 12", "invalid_relaxation_attempts": "Lempingsforsøk må være mellom 1 og 12",
"invalid_price_rating_low": "Lav prisvurderingsgrense må være mellom -50% og -5%", "invalid_price_rating_low": "Lav prisvurderingsgrense må være mellom -50% og -5%",
@ -1389,6 +1389,14 @@
"name": "Nivåtype", "name": "Nivåtype",
"description": "Velg hvilken prisnivåklassifisering som skal visualiseres: 'rating_level' (lav/normal/høy basert på dine konfigurerte terskelverdier) eller 'level' (Tibber API-nivåer: veldig billig/billig/normal/dyr/veldig dyr)." "description": "Velg hvilken prisnivåklassifisering som skal visualiseres: 'rating_level' (lav/normal/høy basert på dine konfigurerte terskelverdier) eller 'level' (Tibber API-nivåer: veldig billig/billig/normal/dyr/veldig dyr)."
}, },
"resolution": {
"name": "Oppløsning",
"description": "Tidsoppløsning for diagramdata. 'interval' (standard): Opprinnelige 15-minutters intervaller (96 punkter per dag). 'hourly': Aggregerte timeverdier med et rullende 60-minutters vindu (24 punkter per dag) for et ryddigere og mindre rotete diagram."
},
"price_source": {
"name": "Priskilde",
"description": "Hvilken priskomponent som skal brukes som hovedpris. 'total' (standard): Totalpris inkl. energi, skatter og avgifter. 'energy': Kun rå spot-/energipris (uten skatter og avgifter). 'tax': Kun skatter og avgifter."
},
"highlight_best_price": { "highlight_best_price": {
"name": "Fremhev beste prisperioder", "name": "Fremhev beste prisperioder",
"description": "Legg til et halvgjennomsiktig grønt overlegg for å fremheve de beste prisperiodene i diagrammet. Dette gjør det enkelt å visuelt identifisere de optimale tidene for energiforbruk." "description": "Legg til et halvgjennomsiktig grønt overlegg for å fremheve de beste prisperiodene i diagrammet. Dette gjør det enkelt å visuelt identifisere de optimale tidene for energiforbruk."
@ -1396,10 +1404,6 @@
"highlight_peak_price": { "highlight_peak_price": {
"name": "Fremhev høyeste prisperioder", "name": "Fremhev høyeste prisperioder",
"description": "Legg til et halvgjennomsiktig rødt overlegg for å fremheve de høyeste prisperiodene i diagrammet. Dette gjør det enkelt å visuelt identifisere tidene når energi er dyrest." "description": "Legg til et halvgjennomsiktig rødt overlegg for å fremheve de høyeste prisperiodene i diagrammet. Dette gjør det enkelt å visuelt identifisere tidene når energi er dyrest."
},
"resolution": {
"name": "Oppløsning",
"description": "Tidsoppløsning for diagramdata. 'interval' (standard): Opprinnelige 15-minutters intervaller (96 punkter per dag). 'hourly': Aggregerte timeverdier med et rullende 60-minutters vindu (24 punkter per dag) for et ryddigere og mindre rotete diagram."
} }
} }
}, },
@ -1449,6 +1453,10 @@
"name": "Oppløsning", "name": "Oppløsning",
"description": "Tidsoppløsning for de returnerte dataene. Alternativer: 'interval' (standard, 15-minutters intervaller, 96 datapunkter per dag), 'hourly' (timegjennomsnitt, 24 datapunkter per dag)." "description": "Tidsoppløsning for de returnerte dataene. Alternativer: 'interval' (standard, 15-minutters intervaller, 96 datapunkter per dag), 'hourly' (timegjennomsnitt, 24 datapunkter per dag)."
}, },
"price_source": {
"name": "Priskilde",
"description": "Hvilken priskomponent som skal brukes som hovedpris. 'total' (standard): Totalpris inkl. energi, skatter og avgifter. 'energy': Kun rå spot-/energipris (uten skatter og avgifter). 'tax': Kun skatter og avgifter."
},
"output_format": { "output_format": {
"name": "Utdataformat", "name": "Utdataformat",
"description": "Utdataformat for de returnerte dataene. Alternativer: 'array_of_objects' (standard, array av objekter med tilpassbare feltnavn), 'array_of_arrays' (array av [tidsstempel, pris]-arrays med avsluttende null-punkt for stepline-diagrammer)." "description": "Utdataformat for de returnerte dataene. Alternativer: 'array_of_objects' (standard, array av objekter med tilpassbare feltnavn), 'array_of_arrays' (array av [tidsstempel, pris]-arrays med avsluttende null-punkt for stepline-diagrammer)."
@ -1465,6 +1473,10 @@
"name": "Rund desimaler", "name": "Rund desimaler",
"description": "Antall desimalplasser å runde priser til (0-10). Hvis ikke angitt, brukes standard presisjon (4 desimaler for basisvaluta, 2 for underenhet valuta)." "description": "Antall desimalplasser å runde priser til (0-10). Hvis ikke angitt, brukes standard presisjon (4 desimaler for basisvaluta, 2 for underenhet valuta)."
}, },
"data_key": {
"name": "Datanøkkel",
"description": "Tilpasset navn for datanøkkelen på toppnivå i svaret. Standard er 'data' hvis ikke angitt."
},
"include_level": { "include_level": {
"name": "Inkluder prisnivå", "name": "Inkluder prisnivå",
"description": "Inkluder Tibber-prisnivåfeltet (veldig billig/billig/normal/dyr/veldig dyr) i hvert datapunkt." "description": "Inkluder Tibber-prisnivåfeltet (veldig billig/billig/normal/dyr/veldig dyr) i hvert datapunkt."
@ -1544,10 +1556,6 @@
"metadata": { "metadata": {
"name": "Metadata", "name": "Metadata",
"description": "Kontroller metadata-inkludering i svaret. 'include' (standard): Returnerer både diagramdata og metadata med prisstatistikk, valutainformasjon, Y-akse forslag og tidsperiode. 'only': Returnerer bare metadata uten å behandle diagramdata (raskt, nyttig for dynamisk Y-akse konfigurasjon). 'none': Returnerer bare diagramdata uten metadata." "description": "Kontroller metadata-inkludering i svaret. 'include' (standard): Returnerer både diagramdata og metadata med prisstatistikk, valutainformasjon, Y-akse forslag og tidsperiode. 'only': Returnerer bare metadata uten å behandle diagramdata (raskt, nyttig for dynamisk Y-akse konfigurasjon). 'none': Returnerer bare diagramdata uten metadata."
},
"data_key": {
"name": "Datanøkkel",
"description": "Tilpasset navn for datanøkkelen på toppnivå i svaret. Standard er 'data' hvis ikke angitt."
} }
} }
}, },
@ -1661,7 +1669,7 @@
}, },
"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. 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": { "smooth_outliers": {
"name": "Glatt utliggere", "name": "Glatt utliggere",
@ -1781,7 +1789,7 @@
}, },
"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. 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": { "smooth_outliers": {
"name": "Glatt utliggere", "name": "Glatt utliggere",
@ -1905,7 +1913,7 @@
}, },
"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. 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": { "smooth_outliers": {
"name": "Glatt utliggere", "name": "Glatt utliggere",
@ -2029,7 +2037,7 @@
}, },
"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. 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": { "smooth_outliers": {
"name": "Glatt utliggere", "name": "Glatt utliggere",
@ -2131,6 +2139,10 @@
"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": "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": { "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."
@ -2364,6 +2376,16 @@
"description": "Limit how many separate charging segments may be used per day. The planner keeps the cheapest segments within this limit." "description": "Limit how many separate charging segments may be used per day. The planner keeps the cheapest segments within this limit."
} }
} }
},
"debug_clear_tomorrow": {
"name": "Debug: Tøm morgendata",
"description": "DEBUG/TESTING: Fjerner morgendagens prisdata fra interval pool-cachen. Bruk dette for å teste oppdateringssyklusen for morgendata uten å vente til neste dag. Etter at denne tjenesten er kalt, vil lifecycle-sensoren vise 'searching_tomorrow' (etter kl. 13:00), og neste Timer #1-syklus vil hente nye data fra API-et.",
"fields": {
"entry_id": {
"name": "Oppførings-ID",
"description": "Valgfri konfigurasjonsoppførings-ID. Hvis den ikke er angitt, brukes den første tilgjengelige oppføringen."
}
}
} }
}, },
"selector": { "selector": {
@ -2428,6 +2450,13 @@
"peak_price": "Topp prisperioder" "peak_price": "Topp prisperioder"
} }
}, },
"price_source": {
"options": {
"total": "Totalt (inkl. skatter og avgifter)",
"energy": "Kun energipris",
"tax": "Kun skatter og avgifter"
}
},
"metadata": { "metadata": {
"options": { "options": {
"include": "Inkluder (data + metadata)", "include": "Inkluder (data + metadata)",

View file

@ -29,7 +29,7 @@
"data": { "data": {
"home_id": "Huis" "home_id": "Huis"
}, },
"title": "Kies een Huis", "title": "Kies een huis",
"submit": "Huis selecteren" "submit": "Huis selecteren"
}, },
"finish": { "finish": {
@ -195,6 +195,17 @@
}, },
"submit": "↩ Opslaan & Terug" "submit": "↩ Opslaan & Terug"
}, },
"price_level": {
"title": "🏷️ Prijsniveau-instellingen",
"description": "**Configureer stabilisatie voor Tibbers prijsniveau-classificatie (zeer goedkoop/goedkoop/normaal/duur/zeer duur).**\n\nTibbers API levert een prijsniveau-veld voor elk interval. Deze instelling egaliseer korte fluctuaties om instabiliteit in automatiseringen te voorkomen.{entity_warning}",
"data": {
"price_level_gap_tolerance": "Gap-tolerantie"
},
"data_description": {
"price_level_gap_tolerance": "Maximaal aantal opeenvolgende intervallen dat 'afgevlakt' kan worden als ze afwijken van omringende prijsniveaus. Kleine geïsoleerde niveauwijzigingen worden samengevoegd met het dominante aangrenzende blok. Voorbeeld: 1 betekent dat een enkel 'normaal'-interval omringd door 'goedkoop'-intervallen wordt gecorrigeerd naar 'goedkoop'. Stel in op 0 om uit te schakelen. Standaard: 1"
},
"submit": "↩ Opslaan & terug"
},
"best_price": { "best_price": {
"title": "💚 Beste Prijs Periode Instellingen", "title": "💚 Beste Prijs Periode Instellingen",
"description": "**Configureer instellingen voor de Beste Prijs Periode binaire sensor. Deze sensor is actief tijdens periodes met de laagste elektriciteitsprijzen.**{entity_warning}{override_warning}\n\n---", "description": "**Configureer instellingen voor de Beste Prijs Periode binaire sensor. Deze sensor is actief tijdens periodes met de laagste elektriciteitsprijzen.**{entity_warning}{override_warning}\n\n---",
@ -375,17 +386,6 @@
"confirm_reset": "Ja, reset alles naar standaardwaarden" "confirm_reset": "Ja, reset alles naar standaardwaarden"
}, },
"submit": "Nu Resetten" "submit": "Nu Resetten"
},
"price_level": {
"title": "🏷️ Prijsniveau-instellingen",
"description": "**Configureer stabilisatie voor Tibbers prijsniveau-classificatie (zeer goedkoop/goedkoop/normaal/duur/zeer duur).**\n\nTibbers API levert een prijsniveau-veld voor elk interval. Deze instelling egaliseer korte fluctuaties om instabiliteit in automatiseringen te voorkomen.{entity_warning}",
"data": {
"price_level_gap_tolerance": "Gap-tolerantie"
},
"data_description": {
"price_level_gap_tolerance": "Maximaal aantal opeenvolgende intervallen dat 'afgevlakt' kan worden als ze afwijken van omringende prijsniveaus. Kleine geïsoleerde niveauwijzigingen worden samengevoegd met het dominante aangrenzende blok. Voorbeeld: 1 betekent dat een enkel 'normaal'-interval omringd door 'goedkoop'-intervallen wordt gecorrigeerd naar 'goedkoop'. Stel in op 0 om uit te schakelen. Standaard: 1"
},
"submit": "↩ Opslaan & terug"
} }
}, },
"error": { "error": {
@ -1389,17 +1389,21 @@
"name": "Niveautype", "name": "Niveautype",
"description": "Selecteer welke prijsniveauclassificatie gevisualiseerd moet worden: 'rating_level' (laag/normaal/hoog op basis van jouw geconfigureerde drempelwaarden) of 'level' (Tibber API-niveaus: zeer goedkoop/goedkoop/normaal/duur/zeer duur)." "description": "Selecteer welke prijsniveauclassificatie gevisualiseerd moet worden: 'rating_level' (laag/normaal/hoog op basis van jouw geconfigureerde drempelwaarden) of 'level' (Tibber API-niveaus: zeer goedkoop/goedkoop/normaal/duur/zeer duur)."
}, },
"resolution": {
"name": "Resolutie",
"description": "Tijdresolutie voor de grafiekdata. 'interval' (standaard): Originele 15-minutenintervallen (96 punten per dag). 'hourly': Geaggregeerde uurwaarden met een rollend 60-minutenvenster (24 punten per dag) voor een overzichtelijkere grafiek."
},
"price_source": {
"name": "Prijsbron",
"description": "Welke prijscomponent als hoofdprijs gebruikt wordt. 'total' (standaard): Totaalprijs incl. energie, belastingen en kosten. 'energy': Alleen de ruwe spot-/energieprijs (exclusief belastingen en kosten). 'tax': Alleen belastingen en kosten."
},
"highlight_best_price": { "highlight_best_price": {
"name": "Beste prijsperiodes markeren", "name": "Beste prijsperiodes markeren",
"description": "Voeg een halfdo0rzichtige groene overlay toe om de beste prijsperiodes in de grafiek te markeren. Dit maakt het gemakkelijk om visueel de optimale tijden voor energieverbruik te identificeren." "description": "Voeg een halfdoorzichtige groene overlay toe om de beste prijsperiodes in de grafiek te markeren. Dit maakt het gemakkelijk om visueel de optimale tijden voor energieverbruik te identificeren."
}, },
"highlight_peak_price": { "highlight_peak_price": {
"name": "Piekprijsperiodes markeren", "name": "Piekprijsperiodes markeren",
"description": "Voeg een halfdoorzichtige rode overlay toe om de piekprijsperiodes in de grafiek te markeren. Dit maakt het gemakkelijk om visueel de tijden te identificeren wanneer energie het duurst is." "description": "Voeg een halfdoorzichtige rode overlay toe om de piekprijsperiodes in de grafiek te markeren. Dit maakt het gemakkelijk om visueel de tijden te identificeren wanneer energie het duurst is."
},
"resolution": {
"name": "Resolutie",
"description": "Tijdresolutie voor de grafiekdata. 'interval' (standaard): Originele 15-minutenintervallen (96 punten per dag). 'hourly': Geaggregeerde uurwaarden met een rollend 60-minutenvenster (24 punten per dag) voor een overzichtelijkere grafiek."
} }
} }
}, },
@ -1449,6 +1453,10 @@
"name": "Resolutie", "name": "Resolutie",
"description": "Tijdsresolutie voor de geretourneerde gegevens. Opties: 'interval' (standaard, 15-minuten intervallen, 96 datapunten per dag), 'hourly' (uurgemiddelden, 24 datapunten per dag)." "description": "Tijdsresolutie voor de geretourneerde gegevens. Opties: 'interval' (standaard, 15-minuten intervallen, 96 datapunten per dag), 'hourly' (uurgemiddelden, 24 datapunten per dag)."
}, },
"price_source": {
"name": "Prijsbron",
"description": "Welke prijscomponent als hoofdprijs gebruikt wordt. 'total' (standaard): Totaalprijs incl. energie, belastingen en kosten. 'energy': Alleen de ruwe spot-/energieprijs (exclusief belastingen en kosten). 'tax': Alleen belastingen en kosten."
},
"output_format": { "output_format": {
"name": "Uitvoerformaat", "name": "Uitvoerformaat",
"description": "Uitvoerformaat voor de geretourneerde gegevens. Opties: 'array_of_objects' (standaard, array van objecten met aanpasbare veldnamen), 'array_of_arrays' (array van [tijdstempel, prijs]-arrays met afsluitend null-punt voor stepline-grafieken)." "description": "Uitvoerformaat voor de geretourneerde gegevens. Opties: 'array_of_objects' (standaard, array van objecten met aanpasbare veldnamen), 'array_of_arrays' (array van [tijdstempel, prijs]-arrays met afsluitend null-punt voor stepline-grafieken)."
@ -1465,6 +1473,10 @@
"name": "Decimalen afronden", "name": "Decimalen afronden",
"description": "Aantal decimalen om prijzen op af te ronden (0-10). Indien niet opgegeven, wordt de standaardprecisie gebruikt (4 decimalen voor basisvaluta, 2 voor subeenheid valuta)." "description": "Aantal decimalen om prijzen op af te ronden (0-10). Indien niet opgegeven, wordt de standaardprecisie gebruikt (4 decimalen voor basisvaluta, 2 voor subeenheid valuta)."
}, },
"data_key": {
"name": "Gegevenssleutel",
"description": "Aangepaste naam voor de gegevenssleutel op het hoogste niveau in het antwoord. Standaard is 'data' als niet opgegeven."
},
"include_level": { "include_level": {
"name": "Prijsniveau opnemen", "name": "Prijsniveau opnemen",
"description": "Voeg het Tibber-prijsniveauveld (zeer goedkoop/goedkoop/normaal/duur/zeer duur) toe aan elk gegevenspunt." "description": "Voeg het Tibber-prijsniveauveld (zeer goedkoop/goedkoop/normaal/duur/zeer duur) toe aan elk gegevenspunt."
@ -1544,10 +1556,6 @@
"metadata": { "metadata": {
"name": "Metadata", "name": "Metadata",
"description": "Beheer metadata-opname in het antwoord. 'include' (standaard): Retourneert zowel grafiekdata als metadata met prijsstatistieken, valuta-info, Y-as suggesties en tijdsbereik. 'only': Retourneert alleen metadata zonder grafiekdata te verwerken (snel, handig voor dynamische Y-as configuratie). 'none': Retourneert alleen grafiekdata zonder metadata." "description": "Beheer metadata-opname in het antwoord. 'include' (standaard): Retourneert zowel grafiekdata als metadata met prijsstatistieken, valuta-info, Y-as suggesties en tijdsbereik. 'only': Retourneert alleen metadata zonder grafiekdata te verwerken (snel, handig voor dynamische Y-as configuratie). 'none': Retourneert alleen grafiekdata zonder metadata."
},
"data_key": {
"name": "Gegevenssleutel",
"description": "Aangepaste naam voor de gegevenssleutel op het hoogste niveau in het antwoord. Standaard is 'data' als niet opgegeven."
} }
} }
}, },
@ -1661,7 +1669,7 @@
}, },
"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. 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": { "smooth_outliers": {
"name": "Uitschieters gladstrijken", "name": "Uitschieters gladstrijken",
@ -1781,7 +1789,7 @@
}, },
"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. 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": { "smooth_outliers": {
"name": "Uitschieters gladstrijken", "name": "Uitschieters gladstrijken",
@ -1905,7 +1913,7 @@
}, },
"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. 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": { "smooth_outliers": {
"name": "Uitschieters gladstrijken", "name": "Uitschieters gladstrijken",
@ -2029,7 +2037,7 @@
}, },
"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. 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": { "smooth_outliers": {
"name": "Uitschieters gladstrijken", "name": "Uitschieters gladstrijken",
@ -2131,6 +2139,10 @@
"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": "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": { "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."
@ -2364,6 +2376,16 @@
"description": "Limit how many separate charging segments may be used per day. The planner keeps the cheapest segments within this limit." "description": "Limit how many separate charging segments may be used per day. The planner keeps the cheapest segments within this limit."
} }
} }
},
"debug_clear_tomorrow": {
"name": "Debug: Morgengegevens wissen",
"description": "DEBUG/TESTEN: Verwijdert de prijsgegevens voor morgen uit de interval-poolcache. Gebruik dit om de vernieuwingscyclus voor morgengegevens te testen zonder op de volgende dag te wachten. Na het aanroepen van deze service toont de lifecycle-sensor 'searching_tomorrow' (na 13:00) en haalt de volgende Timer #1-cyclus nieuwe gegevens op via de API.",
"fields": {
"entry_id": {
"name": "Item-ID",
"description": "Optionele config-item-ID. Als die niet is opgegeven, wordt het eerste beschikbare item gebruikt."
}
}
} }
}, },
"selector": { "selector": {
@ -2428,6 +2450,13 @@
"peak_price": "Piekprijs Periodes" "peak_price": "Piekprijs Periodes"
} }
}, },
"price_source": {
"options": {
"total": "Totaal (incl. belastingen & kosten)",
"energy": "Alleen energieprijs",
"tax": "Alleen belastingen & kosten"
}
},
"metadata": { "metadata": {
"options": { "options": {
"include": "Inbegrepen (data + metadata)", "include": "Inbegrepen (data + metadata)",

View file

@ -195,6 +195,17 @@
}, },
"submit": "↩ Spara & tillbaka" "submit": "↩ Spara & tillbaka"
}, },
"price_level": {
"title": "🏷️ Prisnivå-inställningar",
"description": "**Konfigurera stabilisering för Tibbers prisnivå-klassificering (mycket billig/billig/normal/dyr/mycket dyr).**\n\nTibbers API tillhandahåller ett prisnivå-fält för varje intervall. Denna inställning jämnar ut korta fluktuationer för att förhindra instabilitet i automatiseringar.{entity_warning}",
"data": {
"price_level_gap_tolerance": "Gap-tolerans"
},
"data_description": {
"price_level_gap_tolerance": "Maximalt antal på varandra följande intervaller som kan 'jämnas ut' om de avviker från omgivande prisnivåer. Små isolerade nivåförändringar sammanfogas med det dominerande grannblocket. Exempel: 1 betyder att ett enstaka 'normal'-intervall omgivet av 'billig'-intervaller korrigeras till 'billig'. Sätt till 0 för att inaktivera. Standard: 1"
},
"submit": "↩ Spara & tillbaka"
},
"best_price": { "best_price": {
"title": "💚 Bästa Prisperiod-inställningar", "title": "💚 Bästa Prisperiod-inställningar",
"description": "**Konfigurera inställningar för binärsensorn Bästa Prisperiod. Denna sensor är aktiv under perioder med lägsta elpriserna.**{entity_warning}{override_warning}\n\n---", "description": "**Konfigurera inställningar för binärsensorn Bästa Prisperiod. Denna sensor är aktiv under perioder med lägsta elpriserna.**{entity_warning}{override_warning}\n\n---",
@ -375,17 +386,6 @@
"confirm_reset": "Ja, återställ allt till standard" "confirm_reset": "Ja, återställ allt till standard"
}, },
"submit": "Återställ nu" "submit": "Återställ nu"
},
"price_level": {
"title": "🏷️ Prisnivå-inställningar",
"description": "**Konfigurera stabilisering för Tibbers prisnivå-klassificering (mycket billig/billig/normal/dyr/mycket dyr).**\n\nTibbers API tillhandahåller ett prisnivå-fält för varje intervall. Denna inställning jämnar ut korta fluktuationer för att förhindra instabilitet i automatiseringar.{entity_warning}",
"data": {
"price_level_gap_tolerance": "Gap-tolerans"
},
"data_description": {
"price_level_gap_tolerance": "Maximalt antal på varandra följande intervaller som kan 'jämnas ut' om de avviker från omgivande prisnivåer. Små isolerade nivåförändringar sammanfogas med det dominerande grannblocket. Exempel: 1 betyder att ett enstaka 'normal'-intervall omgivet av 'billig'-intervaller korrigeras till 'billig'. Sätt till 0 för att inaktivera. Standard: 1"
},
"submit": "↩ Spara & tillbaka"
} }
}, },
"error": { "error": {
@ -1389,6 +1389,14 @@
"name": "Nivåtyp", "name": "Nivåtyp",
"description": "Välj vilken prisnivåklassificering som ska visualiseras: 'rating_level' (låg/normal/hög baserat på dina konfigurerade tröskelvärden) eller 'level' (Tibber API-nivåer: mycket billigt/billigt/normalt/dyrt/mycket dyrt)." "description": "Välj vilken prisnivåklassificering som ska visualiseras: 'rating_level' (låg/normal/hög baserat på dina konfigurerade tröskelvärden) eller 'level' (Tibber API-nivåer: mycket billigt/billigt/normalt/dyrt/mycket dyrt)."
}, },
"resolution": {
"name": "Upplösning",
"description": "Tidsupplösning för diagramdata. 'interval' (standard): Ursprungliga 15-minutersintervall (96 punkter per dag). 'hourly': Aggregerade timvärden med ett rullande 60-minutersfönster (24 punkter per dag) för ett renare och mindre rörigt diagram."
},
"price_source": {
"name": "Priskälla",
"description": "Vilken priskomponent som används som huvudpris. 'total' (standard): Totalpris inkl. energi, skatter och avgifter. 'energy': Enbart rå spot-/energipris (exkl. skatter och avgifter). 'tax': Enbart skatter och avgifter."
},
"highlight_best_price": { "highlight_best_price": {
"name": "Markera bästa prisperioder", "name": "Markera bästa prisperioder",
"description": "Lägg till ett halvtransparent grönt överlag för att markera de bästa prisperioderna i diagrammet. Detta gör det enkelt att visuellt identifiera de optimala tiderna för energiförbrukning." "description": "Lägg till ett halvtransparent grönt överlag för att markera de bästa prisperioderna i diagrammet. Detta gör det enkelt att visuellt identifiera de optimala tiderna för energiförbrukning."
@ -1396,10 +1404,6 @@
"highlight_peak_price": { "highlight_peak_price": {
"name": "Markera högsta prisperioder", "name": "Markera högsta prisperioder",
"description": "Lägg till ett halvtransparent rött överlag för att markera de högsta prisperioderna i diagrammet. Detta gör det enkelt att visuellt identifiera tiderna när energi är som dyrast." "description": "Lägg till ett halvtransparent rött överlag för att markera de högsta prisperioderna i diagrammet. Detta gör det enkelt att visuellt identifiera tiderna när energi är som dyrast."
},
"resolution": {
"name": "Upplösning",
"description": "Tidsupplösning för diagramdata. 'interval' (standard): Ursprungliga 15-minutersintervall (96 punkter per dag). 'hourly': Aggregerade timvärden med ett rullande 60-minutersfönster (24 punkter per dag) för ett renare och mindre rörigt diagram."
} }
} }
}, },
@ -1449,6 +1453,10 @@
"name": "Upplösning", "name": "Upplösning",
"description": "Tidsupplösning för returnerad data. Alternativ: 'interval' (standard, 15-minutersintervall, 96 punkter per dag), 'hourly' (timmedelvärden, 24 punkter per dag)." "description": "Tidsupplösning för returnerad data. Alternativ: 'interval' (standard, 15-minutersintervall, 96 punkter per dag), 'hourly' (timmedelvärden, 24 punkter per dag)."
}, },
"price_source": {
"name": "Priskälla",
"description": "Vilken priskomponent som används som huvudpris. 'total' (standard): Totalpris inkl. energi, skatter och avgifter. 'energy': Enbart rå spot-/energipris (exkl. skatter och avgifter). 'tax': Enbart skatter och avgifter."
},
"output_format": { "output_format": {
"name": "Utdataformat", "name": "Utdataformat",
"description": "Utdataformat för returnerad data. Alternativ: 'array_of_objects' (standard, array av objekt med anpassningsbara fältnamn), 'array_of_arrays' (array av [tidstämpel, pris]-arrays med avslutande null-punkt för stegdiagram)." "description": "Utdataformat för returnerad data. Alternativ: 'array_of_objects' (standard, array av objekt med anpassningsbara fältnamn), 'array_of_arrays' (array av [tidstämpel, pris]-arrays med avslutande null-punkt för stegdiagram)."
@ -1661,7 +1669,7 @@
}, },
"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 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": { "smooth_outliers": {
"name": "Jämna utliggare", "name": "Jämna utliggare",
@ -1781,7 +1789,7 @@
}, },
"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 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": { "smooth_outliers": {
"name": "Jämna utliggare", "name": "Jämna utliggare",
@ -1905,7 +1913,7 @@
}, },
"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 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": { "smooth_outliers": {
"name": "Jämna utliggare", "name": "Jämna utliggare",
@ -2029,7 +2037,7 @@
}, },
"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 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": { "smooth_outliers": {
"name": "Jämna utliggare", "name": "Jämna utliggare",
@ -2131,6 +2139,10 @@
"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 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": { "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."
@ -2364,6 +2376,16 @@
"description": "Limit how many separate charging segments may be used per day. The planner keeps the cheapest segments within this limit." "description": "Limit how many separate charging segments may be used per day. The planner keeps the cheapest segments within this limit."
} }
} }
},
"debug_clear_tomorrow": {
"name": "Debug: Rensa morgondagens data",
"description": "DEBUG/TEST: Tar bort morgondagens prisdata från interval pool-cachen. Använd detta för att testa uppdateringscykeln för morgondagens data utan att vänta till nästa dag. Efter att tjänsten har anropats visar livscykelsensorn 'searching_tomorrow' (efter 13:00) och nästa Timer #1-cykel hämtar nya data från API:et.",
"fields": {
"entry_id": {
"name": "Entry-ID",
"description": "Valfritt config entry-ID. Om det inte anges används den första tillgängliga posten."
}
}
} }
}, },
"selector": { "selector": {
@ -2428,6 +2450,13 @@
"peak_price": "Topprisperioder" "peak_price": "Topprisperioder"
} }
}, },
"price_source": {
"options": {
"total": "Totalt (inkl. skatter & avgifter)",
"energy": "Enbart energipris",
"tax": "Enbart skatter & avgifter"
}
},
"metadata": { "metadata": {
"options": { "options": {
"include": "Inkludera (data + metadata)", "include": "Inkludera (data + metadata)",

View file

@ -979,6 +979,10 @@ def enrich_price_info_with_differences(
# Apply level gap tolerance as post-processing step # Apply level gap tolerance as post-processing step
# This smooths out isolated price level changes from Tibber's API # This smooths out isolated price level changes from Tibber's API
if level_gap_tolerance > 0: if level_gap_tolerance > 0:
for interval in all_intervals:
level = interval.get("level")
if level is not None:
interval.setdefault("_original_level", level)
_apply_level_gap_tolerance(all_intervals, level_gap_tolerance) _apply_level_gap_tolerance(all_intervals, level_gap_tolerance)
return all_intervals return all_intervals

View file

@ -22,18 +22,24 @@ def find_cheapest_contiguous_window(
duration_intervals: int, duration_intervals: int,
*, *,
reverse: bool = False, reverse: bool = False,
power_profile: list[int] | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
""" """
Find the cheapest (or most expensive) contiguous window of exactly N intervals. 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 Uses a sliding window algorithm (O(n)) when no power profile is given.
lowest (or highest) average price. 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: Args:
intervals: Sorted list of price interval dicts with 'startsAt' and 'total' keys. intervals: Sorted list of price interval dicts with 'startsAt' and 'total' keys.
Must be pre-sorted by startsAt in ascending order. Must be pre-sorted by startsAt in ascending order.
duration_intervals: Number of consecutive intervals required. duration_intervals: Number of consecutive intervals required.
reverse: If True, find the most expensive window instead of cheapest. 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: Returns:
Dict with window details (start, end, intervals, statistics), 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: if n == 0 or duration_intervals <= 0 or n < duration_intervals:
return None return None
# Calculate initial window sum best_intervals: list[dict[str, Any]] | None = None
window_sum = sum(intervals[i]["total"] for i in range(duration_intervals)) best_sum: float | None = None
best_sum = window_sum
best_start = 0
# Slide the window # Price-level filtering can create gaps in time. Search each truly contiguous
for i in range(1, n - duration_intervals + 1): # run independently so the returned window always matches real timestamps.
window_sum += intervals[i + duration_intervals - 1]["total"] for segment in group_intervals_into_segments(intervals):
window_sum -= intervals[i - 1]["total"] segment_intervals = segment["intervals"]
if (window_sum > best_sum) if reverse else (window_sum < best_sum): if len(segment_intervals) < duration_intervals:
best_sum = window_sum continue
best_start = i
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 { return {
"start": best_intervals[0]["startsAt"], "start": best_intervals[0]["startsAt"],
"end_interval_start": best_intervals[-1]["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. Find cheapest/most expensive N intervals with minimum segment length constraint.
Iteratively picks intervals, discards segments that are too Uses dynamic programming to find an exact selection of `count` intervals
short, and replaces them with next-best alternatives. where every contiguous run has at least `min_segment` intervals. Real time
gaps break segments even if the filtered list remains index-contiguous.
Converges in at most `count` iterations (worst case: every replacement
creates a new short segment that gets discarded).
""" """
n = len(intervals) n = len(intervals)
# Build index lookup: interval original index → position contiguous_with_prev = [False] * n
# Price-sorted indices for picking cheapest/most expensive available for i in range(1, n):
price_order = sorted(range(n), key=lambda i: intervals[i]["total"], reverse=reverse) 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() def is_better(new_cost: float, old_cost: float | None) -> bool:
excluded: set[int] = set() if old_cost is None:
return True
return new_cost > old_cost if reverse else new_cost < old_cost
# Initial pick: cheapest 'count' intervals current_states: dict[tuple[int, int], float] = {(0, 0): 0.0}
picked = 0 backpointers: list[dict[tuple[int, int], tuple[tuple[int, int], bool]]] = [{} for _ in range(n + 1)]
for idx in price_order:
if picked >= count:
break
if idx not in excluded:
selected.add(idx)
picked += 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 return None
# Iterative refinement: discard short segments, replace with next-cheapest selected_indices: list[int] = []
max_iterations = count + 1 # Safety bound state = best_state
for _ in range(max_iterations): for idx in range(n, 0, -1):
sorted_selected = sorted(selected) prev_state, took_interval = backpointers[idx][state]
segments = _group_indices_into_segments(sorted_selected) if took_interval:
selected_indices.append(idx - 1)
state = prev_state
short_segments = [seg for seg in segments if len(seg) < min_segment] selected_indices.reverse()
if not short_segments: result_intervals = [intervals[i] for i in selected_indices]
break # All segments meet minimum length
# Exclude all indices in short segments
for seg in short_segments:
for idx in seg:
selected.discard(idx)
excluded.add(idx)
# Refill from price order
needed = count - len(selected)
for idx in price_order:
if needed <= 0:
break
if idx not in selected and idx not in excluded:
selected.add(idx)
needed -= 1
if len(selected) < count:
# Not enough intervals available after exclusions
# Return best effort with what we have
break
sorted_selected = sorted(selected)
result_intervals = [intervals[i] for i in sorted_selected]
segments = group_intervals_into_segments(result_intervals) 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 { return {
"intervals": result_intervals, "intervals": result_intervals,
"segments": segments, "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( def group_intervals_into_segments(
intervals: list[dict[str, Any]], intervals: list[dict[str, Any]],
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:

View file

@ -135,20 +135,26 @@ These parameters are available across all scheduling actions:
| Parameter | Description | Default | | Parameter | Description | Default |
|-----------|-------------|---------| |-----------|-------------|---------|
| `entry_id` | Config entry ID. Auto-selects if you only have one home. | Auto | | `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 | — | | `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` | | `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)) | — | | `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` | | `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 | | `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` | | `use_base_unit` | Use base currency (EUR, NOK) instead of subunit (ct, øre) | `false` |
:::note `min_distance_from_avg` availability :::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). `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 ### 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`.
@ -169,7 +175,12 @@ data:
### Power Profile ### 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> <details>
<summary>Show YAML: Power Profile</summary> <summary>Show YAML: Power Profile</summary>
@ -190,8 +201,6 @@ data:
</details> </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 :::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. 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) | | `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[]` | 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 |
| `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 | | `total_estimated_cost` | Combined cost across all tasks |
| `relaxation_applied` | `true` if [relaxation](#relaxation) was needed to schedule 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`) | | `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. 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 :::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 ### Gap Minutes
@ -1005,7 +1015,7 @@ All durations are rounded **up** to the nearest 15 minutes because Tibber price
### Comparison Details ### 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> <details>
<summary>Show YAML: Comparison Details</summary> <summary>Show YAML: Comparison Details</summary>
@ -1019,7 +1029,7 @@ data:
</details> </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 ### 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 | | `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_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 | | `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`) | | `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. Always check the failure fields in your automations before using the results.

View file

@ -12,6 +12,7 @@ Also validates schema boundaries for all 4 services.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, time as dt_time, timedelta from datetime import datetime, time as dt_time, timedelta
from typing import Any, cast
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import pytest import pytest
@ -148,6 +149,29 @@ class TestResolveSearchRangeNegativeOffsetMinutes:
assert start.day == 10 assert start.day == 10
assert start.hour == 23 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 # Schema validation: day_offset boundaries
@ -160,12 +184,12 @@ class TestSchemaValidation:
def _validate_block_schema(self, data: dict) -> dict: def _validate_block_schema(self, data: dict) -> dict:
"""Validate data through block schema.""" """Validate data through block schema."""
schema = vol.Schema(_COMMON_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: def _validate_hours_schema(self, data: dict) -> dict:
"""Validate data through hours schema.""" """Validate data through hours schema."""
schema = vol.Schema(_COMMON_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: def test_block_schema_accepts_negative_day_offset(self) -> None:
"""Block schema allows negative day offsets.""" """Block schema allows negative day offsets."""

View file

@ -71,6 +71,18 @@ class TestSequentialSchema:
) )
assert result["sequential"] is False 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: class TestSequentialOrdering:
"""Sequential mode preserves declaration order and chains search windows.""" """Sequential mode preserves declaration order and chains search windows."""

View file

@ -170,6 +170,87 @@ class TestFindCheapestContiguousWindow:
selected_prices = [iv["total"] for iv in result["intervals"]] selected_prices = [iv["total"] for iv in result["intervals"]]
assert selected_prices == [5.0, 3.0, 2.0, 8.0] 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 # find_cheapest_n_intervals
@ -280,6 +361,11 @@ class TestFindCheapestNIntervals:
assert result is not None assert result is not None
assert len(result["intervals"]) == 3 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 # group_intervals_into_segments

86
tests/test_relaxation.py Normal file
View file

@ -0,0 +1,86 @@
"""Focused regression tests for relaxation phase sequencing."""
from __future__ import annotations
from datetime import timedelta
from unittest.mock import Mock
import pytest
from custom_components.tibber_prices.coordinator.period_handlers import core as core_module
from custom_components.tibber_prices.coordinator.period_handlers.relaxation import relax_all_prices
from custom_components.tibber_prices.coordinator.period_handlers.types import TibberPricesPeriodConfig
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from homeassistant.util import dt as dt_util
def _create_interval(base_time, offset: int, price: float, level: str) -> dict:
"""Create one quarter-hour interval for relaxation tests."""
return {
"startsAt": base_time + timedelta(minutes=offset * 15),
"total": price,
"level": level,
}
@pytest.mark.unit
@pytest.mark.freeze_time("2025-11-22 12:00:00+01:00")
def test_relaxation_preserves_level_filter_before_trying_any(monkeypatch: pytest.MonkeyPatch) -> None:
"""Relaxation should try flex-only phases before dropping the configured level filter."""
base_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
assert base_time is not None
mock_coordinator = Mock()
mock_coordinator.config_entry = Mock()
time_service = TibberPricesTimeService(mock_coordinator)
time_service.now = Mock(return_value=base_time)
all_prices = [
_create_interval(base_time, 0, 0.18, "CHEAP"),
_create_interval(base_time, 1, 0.19, "CHEAP"),
_create_interval(base_time, 2, 0.22, "NORMAL"),
_create_interval(base_time, 3, 0.31, "EXPENSIVE"),
]
config = TibberPricesPeriodConfig(
reverse_sort=False,
flex=0.15,
min_distance_from_avg=5.0,
min_period_length=60,
level_filter="cheap",
gap_count=1,
)
calculate_periods_calls: list[tuple[float, str | None]] = []
callback_args: list[str | None] = []
def fake_calculate_periods(
_all_prices: list[dict],
*,
config: TibberPricesPeriodConfig,
time: TibberPricesTimeService,
day_patterns_by_date: dict | None = None,
time_range=None,
) -> dict:
calculate_periods_calls.append((round(config.flex, 2), config.level_filter))
return {"periods": [], "metadata": {}, "reference_data": {}}
monkeypatch.setattr(core_module, "calculate_periods", fake_calculate_periods)
relax_all_prices(
all_prices=all_prices,
config=config,
min_periods=2,
max_relaxation_attempts=2,
should_show_callback=lambda level_override: callback_args.append(level_override) or True,
baseline_periods=[],
time=time_service,
config_entry=mock_coordinator.config_entry,
)
assert callback_args == [None, "any", None, "any"]
assert calculate_periods_calls == [
(0.18, "cheap"),
(0.18, "any"),
(0.21, "cheap"),
(0.21, "any"),
]

View file

@ -40,6 +40,28 @@ def create_price_intervals(day_offset: int = 0) -> list[dict]:
return intervals return intervals
def create_level_gap_intervals() -> list[dict]:
"""Create a small interval sequence where level smoothing changes the display level."""
base_time = dt_util.now().replace(hour=12, minute=0, second=0, microsecond=0)
levels = ["CHEAP", "CHEAP", "CHEAP", "NORMAL", "CHEAP", "CHEAP"]
totals = [0.10, 0.101, 0.102, 0.18, 0.103, 0.104]
intervals: list[dict] = []
for index, (level, total) in enumerate(zip(levels, totals, strict=True)):
interval_time = base_time + timedelta(minutes=index * 15)
intervals.append(
{
"startsAt": interval_time,
"total": total,
"energy": round(total - 0.02, 4),
"tax": 0.02,
"level": level,
}
)
return intervals
@pytest.mark.unit @pytest.mark.unit
def test_transformation_cache_invalidation_on_new_timestamp() -> None: def test_transformation_cache_invalidation_on_new_timestamp() -> None:
""" """
@ -222,3 +244,46 @@ def test_cache_preserved_when_neither_timestamp_nor_config_changed() -> None:
# Verify period calculation was only called ONCE (during first transform) # Verify period calculation was only called ONCE (during first transform)
assert mock_period_calc.calculate_periods_for_price_info.call_count == 1 assert mock_period_calc.calculate_periods_for_price_info.call_count == 1
@pytest.mark.unit
def test_transform_data_uses_raw_levels_for_period_calculation() -> None:
"""Period calculation must see raw Tibber levels even when priceInfo is smoothed."""
config_entry = Mock()
config_entry.entry_id = "test_entry"
config_entry.data = {"home_id": "home_123"}
config_entry.options = {
"price_level_gap_tolerance": 1,
"price_rating_gap_tolerance": 0,
}
time_service = TibberPricesTimeService()
current_time = datetime(2025, 11, 22, 13, 15, 0, tzinfo=ZoneInfo("Europe/Oslo"))
captured_levels: list[str] = []
def _capture_period_levels(price_info: list[dict], _day_patterns: dict | None = None) -> dict[str, list]:
captured_levels.extend(interval["level"] for interval in price_info)
assert all("_original_level" not in interval for interval in price_info)
return {"best_price": [], "peak_price": []}
transformer = TibberPricesDataTransformer(
config_entry=config_entry,
log_prefix="[Test]",
calculate_periods_fn=_capture_period_levels,
time=time_service,
)
result = transformer.transform_data(
{
"timestamp": current_time,
"home_id": "home_123",
"price_info": create_level_gap_intervals(),
"currency": "EUR",
}
)
smoothed_levels = [interval["level"] for interval in result["priceInfo"]]
assert smoothed_levels == ["CHEAP", "CHEAP", "CHEAP", "CHEAP", "CHEAP", "CHEAP"]
assert captured_levels == ["CHEAP", "CHEAP", "CHEAP", "NORMAL", "CHEAP", "CHEAP"]
assert all("_original_level" not in interval for interval in result["priceInfo"])