mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Compare commits
8 commits
75d7e20a22
...
0df089cc11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0df089cc11 | ||
|
|
1f74451adf | ||
|
|
c2ff9cd2f2 | ||
|
|
95d0278241 | ||
|
|
b93eedf00e | ||
|
|
ba08bd34c6 | ||
|
|
9cb5b35184 | ||
|
|
dc4933ec5c |
34 changed files with 990 additions and 378 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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: >
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]]:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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
86
tests/test_relaxation.py
Normal 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"),
|
||||||
|
]
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue