Compare commits

..

No commits in common. "2f202dc1869484028412c5907970cc378be4c433" and "e94130953f6617b683b9c019a080ab35704d9805" have entirely different histories.

18 changed files with 166 additions and 1086 deletions

View file

@ -35,9 +35,7 @@
"ms-vscode-remote.remote-containers", "ms-vscode-remote.remote-containers",
"redhat.vscode-yaml", "redhat.vscode-yaml",
"ryanluker.vscode-coverage-gutters", "ryanluker.vscode-coverage-gutters",
"MermaidChart.vscode-mermaid-chart", "MermaidChart.vscode-mermaid-chart"
"Anthropic.claude-code",
"openai.chatgpt"
], ],
"settings": { "settings": {
"editor.tabSize": 4, "editor.tabSize": 4,

View file

@ -9,7 +9,6 @@ on:
permissions: permissions:
contents: write contents: write
actions: write # Needed to explicitly dispatch release.yml (see note below)
concurrency: concurrency:
group: ${{ github.workflow }} group: ${{ github.workflow }}
@ -67,22 +66,6 @@ jobs:
echo "✅ Created and pushed tag: $TAG" echo "✅ Created and pushed tag: $TAG"
- name: Trigger release notes workflow
if: steps.tag_check.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="v${{ steps.manifest.outputs.version }}"
# NOTE: Tag pushes made with the default GITHUB_TOKEN do NOT trigger
# other workflows (GitHub's built-in recursion guard). Since we just
# pushed the tag above using GITHUB_TOKEN, release.yml's
# `push: tags: v*.*.*` trigger will NOT fire on its own.
# workflow_dispatch/repository_dispatch are exempt from that guard,
# so we explicitly dispatch release.yml here instead.
echo "::notice::Explicitly dispatching release.yml for $TAG (tag push via GITHUB_TOKEN does not auto-trigger workflows)"
gh workflow run release.yml --ref "$TAG" -f tag="$TAG"
- name: Summary - name: Summary
run: | run: |
if [ "${{ steps.tag_check.outputs.exists }}" = "true" ]; then if [ "${{ steps.tag_check.outputs.exists }}" = "true" ]; then

View file

@ -11,5 +11,5 @@
"requirements": [ "requirements": [
"aiofiles>=23.2.1" "aiofiles>=23.2.1"
], ],
"version": "0.32.0" "version": "0.32.0b1"
} }

View file

@ -218,245 +218,6 @@ def _add_interval_if_available(
return True return True
def _constraints_satisfied(
intervals: list[dict[str, Any]],
*,
max_cycles_per_day: int | None,
min_charge_duration_minutes: int | None,
interval_minutes: int,
) -> bool:
"""Check whether current interval selection satisfies active constraints."""
grouped_segments = group_intervals_into_segments(intervals)
if max_cycles_per_day and len(grouped_segments) > max_cycles_per_day:
return False
if min_charge_duration_minutes:
required_intervals = max(1, math.ceil(min_charge_duration_minutes / interval_minutes))
if any(segment["interval_count"] < required_intervals for segment in grouped_segments):
return False
return True
def _extend_for_min_duration(
selected_map: dict[str, dict[str, Any]],
*,
candidate_map: dict[str, dict[str, Any]],
candidates_sorted: list[dict[str, Any]],
candidate_index: dict[str, int],
minimum_power_w: int,
charging_efficiency: float,
interval_minutes: int,
min_charge_duration_minutes: int,
warnings: list[str],
) -> None:
"""Extend short segments by adding contiguous neighbor intervals."""
required_intervals = max(1, math.ceil(min_charge_duration_minutes / interval_minutes))
progress = True
while progress:
progress = False
selected_intervals = sorted(selected_map.values(), key=_interval_start)
segments = group_intervals_into_segments(selected_intervals)
for segment in segments:
if segment["interval_count"] >= required_intervals:
continue
while segment["interval_count"] < required_intervals:
first = segment["intervals"][0]["startsAt"]
last = segment["intervals"][-1]["startsAt"]
first_index = candidate_index[first]
last_index = candidate_index[last]
prev_interval = candidates_sorted[first_index - 1] if first_index > 0 else None
next_interval = candidates_sorted[last_index + 1] if last_index + 1 < len(candidates_sorted) else None
options: list[dict[str, Any]] = []
if (
prev_interval is not None
and _interval_start(candidate_map[first]) - _interval_start(prev_interval)
== timedelta(minutes=interval_minutes)
and prev_interval["startsAt"] not in selected_map
):
options.append(prev_interval)
if (
next_interval is not None
and _interval_start(next_interval) - _interval_start(candidate_map[last])
== timedelta(minutes=interval_minutes)
and next_interval["startsAt"] not in selected_map
):
options.append(next_interval)
if not options:
warnings.append("min_charge_duration_unreachable")
break
cheapest = min(options, key=lambda interval: (_sort_price(interval), _interval_start(interval)))
added = _add_interval_if_available(
selected_map,
candidate_map,
cheapest["startsAt"],
power_w=minimum_power_w,
charging_efficiency=charging_efficiency,
interval_minutes=interval_minutes,
)
if not added:
break
progress = True
selected_intervals = sorted(selected_map.values(), key=_interval_start)
segment = next(
seg
for seg in group_intervals_into_segments(selected_intervals)
if first in {iv["startsAt"] for iv in seg["intervals"]}
)
def _merge_for_max_cycles(
selected_map: dict[str, dict[str, Any]],
*,
candidate_map: dict[str, dict[str, Any]],
candidates_sorted: list[dict[str, Any]],
candidate_index: dict[str, int],
minimum_power_w: int,
charging_efficiency: float,
interval_minutes: int,
max_cycles_per_day: int,
warnings: list[str],
) -> None:
"""Bridge cheapest gaps until the cycle limit is satisfied."""
while True:
selected_intervals = sorted(selected_map.values(), key=_interval_start)
segments = group_intervals_into_segments(selected_intervals)
if len(segments) <= max_cycles_per_day:
break
best_gap: tuple[float, list[dict[str, Any]]] | None = None
for left, right in pairwise(segments):
left_end_index = candidate_index[left["intervals"][-1]["startsAt"]]
right_start_index = candidate_index[right["intervals"][0]["startsAt"]]
gap = candidates_sorted[left_end_index + 1 : right_start_index]
if not gap:
continue
if any(interval["startsAt"] in selected_map for interval in gap):
continue
if any(
_interval_start(gap[index + 1]) - _interval_start(gap[index]) != timedelta(minutes=interval_minutes)
for index in range(len(gap) - 1)
):
continue
penalty = sum(_sort_price(interval) for interval in gap)
if best_gap is None or penalty < best_gap[0]:
best_gap = (penalty, gap)
if best_gap is None:
warnings.append("max_cycles_unreachable")
break
for interval in best_gap[1]:
_add_interval_if_available(
selected_map,
candidate_map,
interval["startsAt"],
power_w=minimum_power_w,
charging_efficiency=charging_efficiency,
interval_minutes=interval_minutes,
)
def _collect_removable_edge_indices(
selected_intervals: list[dict[str, Any]],
*,
total_grid_energy: float,
target_grid_energy_kwh: float,
max_cycles_per_day: int | None,
min_charge_duration_minutes: int | None,
interval_minutes: int,
protected_starts: frozenset[str] | None,
) -> list[int]:
"""Return edge interval indices that can be removed while keeping constraints valid."""
removable_indices: list[int] = []
segments = group_intervals_into_segments(selected_intervals)
for segment in segments:
first_start = segment["intervals"][0]["startsAt"]
last_start = segment["intervals"][-1]["startsAt"]
for edge_start in (first_start, last_start):
if protected_starts is not None and edge_start in protected_starts:
continue
edge_index = next(
(index for index, interval in enumerate(selected_intervals) if interval["startsAt"] == edge_start),
None,
)
if edge_index is None or edge_index in removable_indices:
continue
candidate = selected_intervals[edge_index]
new_total = total_grid_energy - float(candidate["grid_energy_kwh"])
if new_total + _INTERVAL_TOLERANCE < target_grid_energy_kwh:
continue
new_selection = selected_intervals[:edge_index] + selected_intervals[edge_index + 1 :]
if not new_selection:
continue
if not _constraints_satisfied(
new_selection,
max_cycles_per_day=max_cycles_per_day,
min_charge_duration_minutes=min_charge_duration_minutes,
interval_minutes=interval_minutes,
):
continue
removable_indices.append(edge_index)
return removable_indices
def _trim_to_target_energy(
selected_map: dict[str, dict[str, Any]],
*,
target_grid_energy_kwh: float,
max_cycles_per_day: int | None,
min_charge_duration_minutes: int | None,
interval_minutes: int,
protected_starts: frozenset[str] | None = None,
) -> dict[str, dict[str, Any]]:
"""Trim excess energy from selection by removing expensive edge intervals first.
Intervals whose ``startsAt`` is listed in ``protected_starts`` (for example, intervals
required to satisfy a ``must_reach_by`` deadline) are never removed, even if that means
the target energy cannot be fully reached through trimming alone.
"""
selected_intervals = sorted(selected_map.values(), key=_interval_start)
total_grid_energy = sum(float(interval["grid_energy_kwh"]) for interval in selected_intervals)
while selected_intervals and total_grid_energy > target_grid_energy_kwh + _INTERVAL_TOLERANCE:
removable_indices = _collect_removable_edge_indices(
selected_intervals,
total_grid_energy=total_grid_energy,
target_grid_energy_kwh=target_grid_energy_kwh,
max_cycles_per_day=max_cycles_per_day,
min_charge_duration_minutes=min_charge_duration_minutes,
interval_minutes=interval_minutes,
protected_starts=protected_starts,
)
if not removable_indices:
break
best_index = max(removable_indices, key=lambda index: _sort_price(selected_intervals[index]))
total_grid_energy -= float(selected_intervals[best_index]["grid_energy_kwh"])
del selected_intervals[best_index]
return {interval["startsAt"]: interval for interval in selected_intervals}
def apply_segment_constraints( def apply_segment_constraints(
schedule: dict[str, Any], schedule: dict[str, Any],
candidate_intervals: list[dict[str, Any]], candidate_intervals: list[dict[str, Any]],
@ -464,15 +225,9 @@ def apply_segment_constraints(
charging_efficiency: float, charging_efficiency: float,
min_charge_duration_minutes: int | None = None, min_charge_duration_minutes: int | None = None,
max_cycles_per_day: int | None = None, max_cycles_per_day: int | None = None,
target_grid_energy_kwh: float | None = None,
protected_starts: frozenset[str] | None = None,
interval_minutes: int = 15, interval_minutes: int = 15,
) -> tuple[dict[str, Any], list[str]]: ) -> tuple[dict[str, Any], list[str]]:
"""Extend/bridge selected intervals to satisfy segment duration and cycle constraints. """Extend/bridge selected intervals to satisfy segment duration and cycle constraints."""
``protected_starts`` marks intervals (by ``startsAt``) that must never be removed while
trimming to ``target_grid_energy_kwh``, e.g. intervals required to meet a deadline.
"""
warnings: list[str] = [] warnings: list[str] = []
selected_map = {interval["startsAt"]: dict(interval) for interval in schedule["intervals"]} selected_map = {interval["startsAt"]: dict(interval) for interval in schedule["intervals"]}
candidate_map = {interval["startsAt"]: interval for interval in candidate_intervals} candidate_map = {interval["startsAt"]: interval for interval in candidate_intervals}
@ -481,40 +236,103 @@ def apply_segment_constraints(
minimum_power_w = int(schedule["minimum_power_w"]) minimum_power_w = int(schedule["minimum_power_w"])
if min_charge_duration_minutes: if min_charge_duration_minutes:
_extend_for_min_duration( required_intervals = max(1, math.ceil(min_charge_duration_minutes / interval_minutes))
selected_map, progress = True
candidate_map=candidate_map, while progress:
candidates_sorted=candidates_sorted, progress = False
candidate_index=candidate_index, selected_intervals = sorted(selected_map.values(), key=_interval_start)
minimum_power_w=minimum_power_w, segments = group_intervals_into_segments(selected_intervals)
charging_efficiency=charging_efficiency, for segment in segments:
interval_minutes=interval_minutes, if segment["interval_count"] >= required_intervals:
min_charge_duration_minutes=min_charge_duration_minutes, continue
warnings=warnings, while segment["interval_count"] < required_intervals:
) first = segment["intervals"][0]["startsAt"]
last = segment["intervals"][-1]["startsAt"]
first_index = candidate_index[first]
last_index = candidate_index[last]
prev_interval = candidates_sorted[first_index - 1] if first_index > 0 else None
next_interval = (
candidates_sorted[last_index + 1] if last_index + 1 < len(candidates_sorted) else None
)
prev_contiguous = False
next_contiguous = False
if prev_interval is not None:
prev_contiguous = _interval_start(candidate_map[first]) - _interval_start(
prev_interval
) == timedelta(minutes=interval_minutes)
if next_interval is not None:
next_contiguous = _interval_start(next_interval) - _interval_start(
candidate_map[last]
) == timedelta(minutes=interval_minutes)
options: list[dict[str, Any]] = []
if prev_interval is not None and prev_contiguous and prev_interval["startsAt"] not in selected_map:
options.append(prev_interval)
if next_interval is not None and next_contiguous and next_interval["startsAt"] not in selected_map:
options.append(next_interval)
if not options:
warnings.append("min_charge_duration_unreachable")
break
cheapest = min(options, key=lambda interval: (_sort_price(interval), _interval_start(interval)))
added = _add_interval_if_available(
selected_map,
candidate_map,
cheapest["startsAt"],
power_w=minimum_power_w,
charging_efficiency=charging_efficiency,
interval_minutes=interval_minutes,
)
if not added:
break
progress = True
selected_intervals = sorted(selected_map.values(), key=_interval_start)
segment = next(
seg
for seg in group_intervals_into_segments(selected_intervals)
if first in {iv["startsAt"] for iv in seg["intervals"]}
)
if max_cycles_per_day: if max_cycles_per_day:
_merge_for_max_cycles( while True:
selected_map, selected_intervals = sorted(selected_map.values(), key=_interval_start)
candidate_map=candidate_map, segments = group_intervals_into_segments(selected_intervals)
candidates_sorted=candidates_sorted, if len(segments) <= max_cycles_per_day:
candidate_index=candidate_index, break
minimum_power_w=minimum_power_w,
charging_efficiency=charging_efficiency,
interval_minutes=interval_minutes,
max_cycles_per_day=max_cycles_per_day,
warnings=warnings,
)
if target_grid_energy_kwh is not None: best_gap: tuple[float, list[dict[str, Any]]] | None = None
selected_map = _trim_to_target_energy( for left, right in pairwise(segments):
selected_map, left_end_index = candidate_index[left["intervals"][-1]["startsAt"]]
target_grid_energy_kwh=target_grid_energy_kwh, right_start_index = candidate_index[right["intervals"][0]["startsAt"]]
max_cycles_per_day=max_cycles_per_day, gap = candidates_sorted[left_end_index + 1 : right_start_index]
min_charge_duration_minutes=min_charge_duration_minutes, if not gap:
interval_minutes=interval_minutes, continue
protected_starts=protected_starts, if any(interval["startsAt"] in selected_map for interval in gap):
) continue
if any(
_interval_start(gap[index + 1]) - _interval_start(gap[index]) != timedelta(minutes=interval_minutes)
for index in range(len(gap) - 1)
):
continue
penalty = sum(_sort_price(interval) for interval in gap)
if best_gap is None or penalty < best_gap[0]:
best_gap = (penalty, gap)
if best_gap is None:
warnings.append("max_cycles_unreachable")
break
for interval in best_gap[1]:
_add_interval_if_available(
selected_map,
candidate_map,
interval["startsAt"],
power_w=minimum_power_w,
charging_efficiency=charging_efficiency,
interval_minutes=interval_minutes,
)
selected_intervals = sorted(selected_map.values(), key=_interval_start) selected_intervals = sorted(selected_map.values(), key=_interval_start)
segments = group_intervals_into_segments(selected_intervals) segments = group_intervals_into_segments(selected_intervals)

View file

@ -368,17 +368,7 @@ async def _handle_find_block(
# --- Relaxation loop --- # --- Relaxation loop ---
if result is None and allow_relaxation: if result is None and allow_relaxation:
# A power_profile is a fixed per-interval watt array matching the original max_reduction = calculate_max_duration_reduction_intervals(duration_intervals, duration_flexibility_minutes)
# requested duration. Reducing duration during relaxation would silently
# truncate it (dropping trailing phases of the appliance cycle) and
# invalidate the weighted window selection, so duration reduction is
# disabled whenever a power_profile is supplied. Distance and level-filter
# relaxation remain available since they don't affect interval count.
max_reduction = (
0
if power_profile
else calculate_max_duration_reduction_intervals(duration_intervals, duration_flexibility_minutes)
)
steps = generate_relaxation_steps( steps = generate_relaxation_steps(
min_distance_from_avg=min_distance_from_avg, min_distance_from_avg=min_distance_from_avg,
max_price_level=max_price_level, max_price_level=max_price_level,

View file

@ -443,18 +443,7 @@ async def _handle_find_hours(
# --- Relaxation loop --- # --- Relaxation loop ---
if result is None and allow_relaxation: if result is None and allow_relaxation:
# A power_profile is a fixed per-interval watt array matching the original max_reduction = calculate_max_duration_reduction_intervals(total_intervals, duration_flexibility_minutes)
# requested duration. Reducing the interval count during relaxation would
# silently truncate it (dropping trailing phases of the appliance cycle)
# when building the weighted cost estimate, so duration reduction is
# disabled whenever a power_profile is supplied. Distance and
# level-filter relaxation remain available since they don't change the
# interval count.
max_reduction = (
0
if power_profile
else calculate_max_duration_reduction_intervals(total_intervals, duration_flexibility_minutes)
)
min_dur = max(MIN_RELAXED_DURATION_INTERVALS, min_segment_intervals) min_dur = max(MIN_RELAXED_DURATION_INTERVALS, min_segment_intervals)
steps = generate_relaxation_steps( steps = generate_relaxation_steps(
min_distance_from_avg=min_distance_from_avg, min_distance_from_avg=min_distance_from_avg,

View file

@ -229,7 +229,6 @@ def _find_cheapest_window_in_pool(
# and all intervals are contiguous in time (no gaps) # and all intervals are contiguous in time (no gaps)
block: list[dict[str, Any]] = [] block: list[dict[str, Any]] = []
j = i j = i
stopped_at_gap = False
while j < n and len(block) < duration_intervals: while j < n and len(block) < duration_intervals:
if not available[j]: if not available[j]:
break break
@ -240,10 +239,7 @@ def _find_cheapest_window_in_pool(
prev_dt = datetime.fromisoformat(prev_start) if isinstance(prev_start, str) else prev_start prev_dt = datetime.fromisoformat(prev_start) if isinstance(prev_start, str) else prev_start
curr_dt = datetime.fromisoformat(curr_start) if isinstance(curr_start, str) else curr_start curr_dt = datetime.fromisoformat(curr_start) if isinstance(curr_start, str) else curr_start
if curr_dt - prev_dt != timedelta(minutes=INTERVAL_MINUTES): if curr_dt - prev_dt != timedelta(minutes=INTERVAL_MINUTES):
# Gap in time — can't extend this block, but slot j itself # Gap in time — can't extend this block, skip to j+1
# (already confirmed available above) is a fresh, untried
# candidate window start and must not be skipped.
stopped_at_gap = True
break break
block.append(pool[j]) block.append(pool[j])
j += 1 j += 1
@ -257,15 +253,8 @@ def _find_cheapest_window_in_pool(
best_sum = window_sum best_sum = window_sum
best_start = i best_start = i
i += 1 i += 1
elif stopped_at_gap:
# Retry starting exactly at the gap position (index j), not j+1 —
# j is available and untested as a window start. Regression guard:
# previously this jumped to j+1, silently skipping a valid
# (sometimes cheaper) candidate window right after a time gap
# caused by price-level filtering or missing data.
i = j
else: else:
# Skip past the blocking unavailable slot (or end of pool) # Skip past the blocking unavailable/non-contiguous slot
i = j + 1 i = j + 1
if best_start == -1: if best_start == -1:
@ -571,13 +560,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
break break
# Phase 2: Duration reduction (uniform across all tasks) # Phase 2: Duration reduction (uniform across all tasks)
# Skipped when any task has a power_profile: it's a fixed per-interval if best_unscheduled:
# watt array matching that task's original duration, and reducing
# duration_intervals here without shrinking the profile would silently
# truncate it (dropping trailing phases of the appliance cycle) when
# used for weighted window selection and cost estimation.
any_task_has_power_profile = any(t.get("power_profile") for t in tasks)
if best_unscheduled and not any_task_has_power_profile:
shortest_task = min(t["duration_intervals"] for t in tasks) shortest_task = min(t["duration_intervals"] for t in tasks)
max_reduction = calculate_max_duration_reduction_intervals(shortest_task, duration_flexibility_minutes) max_reduction = calculate_max_duration_reduction_intervals(shortest_task, duration_flexibility_minutes)
for reduction in range(1, max_reduction + 1): for reduction in range(1, max_reduction + 1):

View file

@ -24,7 +24,6 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
import math import math
import re import re
import statistics
from typing import TYPE_CHECKING, Any, Final from typing import TYPE_CHECKING, Any, Final
import voluptuous as vol import voluptuous as vol
@ -175,12 +174,7 @@ def _calculate_metadata(
min_val = min(data) min_val = min(data)
max_val = max(data) max_val = max(data)
mean_val = sum(data) / len(data) mean_val = sum(data) / len(data)
# CRITICAL: statistics.median() averages the two middle values for median_val = sorted(data)[len(data) // 2]
# even-length data (the common case: a full day is always an even
# number of 15min/hourly intervals). A naive sorted(data)[len//2]
# would instead return the upper-middle value, silently overstating
# the median for every "combined"/per-day stat block.
median_val = statistics.median(data)
# Calculate mean_position and median_position (0-1 scale) # Calculate mean_position and median_position (0-1 scale)
price_range = max_val - min_val price_range = max_val - min_val

View file

@ -717,15 +717,6 @@ def check_min_distance_from_avg(
For cheapest searches: window mean must be at least X% BELOW range average. For cheapest searches: window mean must be at least X% BELOW range average.
For most expensive searches: window mean must be at least X% ABOVE range average. For most expensive searches: window mean must be at least X% ABOVE range average.
CRITICAL: Uses abs(range_avg) to scale the distance so the direction of the
threshold shift is always correct, even when range_avg is negative (Tibber
prices can go negative during grid oversupply). Multiplying a negative
range_avg directly by (1 ± ratio) flips the intended direction (e.g. avg *
1.05 makes a negative average MORE negative, i.e. cheaper, which is the
wrong direction for a "most expensive" threshold). This mirrors the
abs()-based normalization already used for the period system's
min_distance_from_avg handling (see coordinator/period_handlers/level_filtering.py).
Args: Args:
window_mean_base: Window mean price in BASE currency (not display unit). window_mean_base: Window mean price in BASE currency (not display unit).
range_avg: Search range average price in BASE currency. range_avg: Search range average price in BASE currency.
@ -740,11 +731,10 @@ def check_min_distance_from_avg(
return True # Cannot calculate percentage difference from zero return True # Cannot calculate percentage difference from zero
distance_ratio = min_distance_pct / 100 distance_ratio = min_distance_pct / 100
distance_amount = abs(range_avg) * distance_ratio
if reverse: if reverse:
# Most expensive: window mean must be >= avg + distance # Most expensive: window mean must be >= avg * (1 + distance)
threshold = range_avg + distance_amount threshold = range_avg * (1 + distance_ratio)
return window_mean_base >= threshold return window_mean_base >= threshold
# Cheapest: window mean must be <= avg - distance # Cheapest: window mean must be <= avg * (1 - distance)
threshold = range_avg - distance_amount threshold = range_avg * (1 - distance_ratio)
return window_mean_base <= threshold return window_mean_base <= threshold

View file

@ -576,23 +576,12 @@ def _attempt_plan(
if schedule["unallocated_grid_energy_kwh"] > 1e-6: if schedule["unallocated_grid_energy_kwh"] > 1e-6:
return None, "energy_unreachable" return None, "energy_unreachable"
# Deadline-critical intervals (selected to satisfy must_reach_soc by the deadline) must
# never be dropped by segment-constraint trimming, or deadline_met could silently become
# False even though the overall energy target is still satisfied.
protected_starts = (
frozenset(interval["startsAt"] for interval in schedule["pre_deadline"]["intervals"])
if "pre_deadline" in schedule
else None
)
schedule, warnings = apply_segment_constraints( schedule, warnings = apply_segment_constraints(
schedule, schedule,
candidates, candidates,
charging_efficiency=ctx.charging_efficiency, charging_efficiency=ctx.charging_efficiency,
min_charge_duration_minutes=ctx.min_charge_duration_minutes, min_charge_duration_minutes=ctx.min_charge_duration_minutes,
max_cycles_per_day=ctx.max_cycles_per_day, max_cycles_per_day=ctx.max_cycles_per_day,
target_grid_energy_kwh=effective_energy_needed_grid_kwh,
protected_starts=protected_starts,
interval_minutes=INTERVAL_MINUTES, interval_minutes=INTERVAL_MINUTES,
) )
scheduled_intervals = build_soc_progression_from_schedule( scheduled_intervals = build_soc_progression_from_schedule(

View file

@ -12,16 +12,7 @@ This page collects **real-world examples** contributed by the community — temp
## Country-Specific Price Calculations ## Country-Specific Price Calculations
The Tibber API provides the raw spot price (`energy_price` attribute) and tax/fee component (`tax` attribute) on every price sensor. Their unit follows your integration's **Currency Display Mode**: The Tibber API provides the raw spot price (`energy_price` attribute) and tax/fee component (`tax` attribute) on every price sensor. Since the exact composition of `tax` varies by country, you can use these attributes to build **your own** country-specific calculations with Home Assistant templates.
- Subunit mode: `ct/kWh` (default for EUR, including NL)
- Base mode: `€/kWh`
Since the exact composition of `tax` varies by country, you can use these attributes to build **your own** country-specific calculations with Home Assistant templates.
:::tip Keep templates unit-safe
For long-term stable templates, normalize values to `€/kWh` inside your template (recommended below). If you use Subunit mode, you can alternatively use the dedicated **Current Electricity Price (Energy Dashboard)** sensor (`current_interval_price_base`), which provides base-currency values for Energy Dashboard use cases. In Base mode, this extra sensor is not exposed because `current_interval_price` already provides base-currency values.
:::
:::tip Why templates instead of built-in calculations? :::tip Why templates instead of built-in calculations?
Tax rates and energy fees change regularly (often annually). Using `input_number` helpers in Home Assistant keeps your calculations up-to-date with a simple UI adjustment — no integration update needed. Tax rates and energy fees change regularly (often annually). Using `input_number` helpers in Home Assistant keeps your calculations up-to-date with a simple UI adjustment — no integration update needed.
@ -43,11 +34,11 @@ In the Netherlands, the electricity price paid to consumers includes:
| Component | Dutch Name | Typical Value (2025) | | Component | Dutch Name | Typical Value (2025) |
|-----------|-----------|---------------------| |-----------|-----------|---------------------|
| Spot price | Inkoopprijs | Variable (`energy_price` attribute; unit depends on display mode) | | Spot price | Inkoopprijs | Variable (= `energy_price` attribute) |
| Energy tax | Energiebelasting | ~0.0916 €/kWh (excl. VAT) | | Energy tax | Energiebelasting | ~0.0916 €/kWh (excl. VAT) |
| Purchase fee | Inkoopvergoeding | ~0.0205 €/kWh |
| Sales fee | Verkoopvergoeding | ~-0.0205 €/kWh |
| VAT | BTW | 21% | | VAT | BTW | 21% |
| Purchase fee | Inkoopvergoeding | ~0.0205 €/kWh |
| Sales fee | Verkoopvergoeding | ~0.0205 €/kWh |
:::warning Rates change annually :::warning Rates change annually
The values above are examples. Check [Rijksoverheid.nl](https://www.rijksoverheid.nl/onderwerpen/belastingplan/energiebelasting) for current energy tax rates and your energy contract for purchase/sales fees. The values above are examples. Check [Rijksoverheid.nl](https://www.rijksoverheid.nl/onderwerpen/belastingplan/energiebelasting) for current energy tax rates and your energy contract for purchase/sales fees.
@ -66,13 +57,9 @@ Create `input_number` helpers in Home Assistant for each fee component. This way
| Helper | Entity ID | Min | Max | Step | Unit | Example Value | | Helper | Entity ID | Min | Max | Step | Unit | Example Value |
|--------|-----------|-----|-----|------|------|---------------| |--------|-----------|-----|-----|------|------|---------------|
| Energiebelasting | `input_number.energiebelasting` | 0 | 1 | 0.0001 | €/kWh | 0.0916 | | Energiebelasting | `input_number.energiebelasting` | 0 | 1 | 0.0001 | €/kWh | 0.0916 |
| Inkoopvergoeding | `input_number.inkoopvergoeding` | 0 | 1 | 0.0001 | €/kWh | 0.0205 |
| Verkoopvergoeding | `input_number.verkoopvergoeding` | -1 | 1 | 0.0001 | €/kWh | -0.0205 |
| BTW percentage | `input_number.btw_percentage` | 0 | 100 | 0.01 | % | 21 | | BTW percentage | `input_number.btw_percentage` | 0 | 100 | 0.01 | % | 21 |
| Inkoopvergoeding | `input_number.inkoopvergoeding` | 0 | 1 | 0.0001 | €/kWh | 0.0205 |
:::note Signed fee input | Verkoopvergoeding | `input_number.verkoopvergoeding` | 0 | 1 | 0.0001 | €/kWh | 0.0205 |
`input_number.verkoopvergoeding` is a signed value in this example, so negative values are allowed. Enter all fee components excluding VAT.
:::
<details> <details>
<summary>Show YAML: Input Number Helpers</summary> <summary>Show YAML: Input Number Helpers</summary>
@ -88,6 +75,13 @@ input_number:
step: 0.0001 step: 0.0001
unit_of_measurement: "€/kWh" unit_of_measurement: "€/kWh"
icon: mdi:lightning-bolt icon: mdi:lightning-bolt
btw_percentage:
name: BTW Percentage
min: 0
max: 100
step: 0.01
unit_of_measurement: "%"
icon: mdi:percent
inkoopvergoeding: inkoopvergoeding:
name: Inkoopvergoeding name: Inkoopvergoeding
min: 0 min: 0
@ -97,18 +91,11 @@ input_number:
icon: mdi:cash-minus icon: mdi:cash-minus
verkoopvergoeding: verkoopvergoeding:
name: Verkoopvergoeding name: Verkoopvergoeding
min: -1 min: 0
max: 1 max: 1
step: 0.0001 step: 0.0001
unit_of_measurement: "€/kWh" unit_of_measurement: "€/kWh"
icon: mdi:cash-plus icon: mdi:cash-plus
btw_percentage:
name: BTW Percentage
min: 0
max: 100
step: 0.01
unit_of_measurement: "%"
icon: mdi:percent
``` ```
</details> </details>
@ -125,26 +112,19 @@ template:
- sensor: - sensor:
# Feed-in compensation WITH saldering (current rules, until 2027) # Feed-in compensation WITH saldering (current rules, until 2027)
# With saldering, you effectively earn the full consumer price # With saldering, you effectively earn the full consumer price
# plus purchase and sales fee components (use negative verkoopvergoeding # minus the purchase fee, plus the sales fee.
# to offset inkoopvergoeding when your contract defines it that way).
- name: "Solar Feed-In Price (with Saldering)" - name: "Solar Feed-In Price (with Saldering)"
unique_id: solar_feed_in_saldering unique_id: solar_feed_in_saldering
unit_of_measurement: "€/kWh" unit_of_measurement: "€/kWh"
device_class: monetary device_class: monetary
state: > state: >
{# Option A: current display-mode sensor (default) #} {% set energy = state_attr('sensor.<home_name>_current_electricity_price', 'energy_price') %}
{# Option B: in Subunit mode, switch to current_interval_price_base for base-currency workflows #}
{% set price_entity = 'sensor.<home_name>_current_electricity_price' %}
{% set energy_raw = state_attr(price_entity, 'energy_price') %}
{% set price_unit = state_attr(price_entity, 'unit_of_measurement') %}
{% set unit_factor = 100 if price_unit == 'ct/kWh' else 1 %}
{% set eb = states('input_number.energiebelasting') | float %} {% set eb = states('input_number.energiebelasting') | float %}
{% set btw = states('input_number.btw_percentage') | float / 100 %}
{% set inkoop = states('input_number.inkoopvergoeding') | float %} {% set inkoop = states('input_number.inkoopvergoeding') | float %}
{% set verkoop = states('input_number.verkoopvergoeding') | float %} {% set verkoop = states('input_number.verkoopvergoeding') | float %}
{% set btw = states('input_number.btw_percentage') | float / 100 %} {% if energy is not none %}
{% if energy_raw is not none %} {{ ((energy + eb) * (1 + btw) - inkoop + verkoop) | round(4) }}
{% set energy = (energy_raw | float) / unit_factor %}
{{ ((energy + eb + inkoop + verkoop) * (1 + btw)) | round(4) }}
{% else %} {% else %}
unavailable unavailable
{% endif %} {% endif %}
@ -152,24 +132,17 @@ template:
# Feed-in compensation WITHOUT saldering (after 2027) # Feed-in compensation WITHOUT saldering (after 2027)
# Without saldering, you only earn the raw spot price # Without saldering, you only earn the raw spot price
# plus purchase and sales fee components. # minus the purchase fee, plus the sales fee.
- name: "Solar Feed-In Price (without Saldering)" - name: "Solar Feed-In Price (without Saldering)"
unique_id: solar_feed_in_no_saldering unique_id: solar_feed_in_no_saldering
unit_of_measurement: "€/kWh" unit_of_measurement: "€/kWh"
device_class: monetary device_class: monetary
state: > state: >
{# Option A: current display-mode sensor (default) #} {% set energy = state_attr('sensor.<home_name>_current_electricity_price', 'energy_price') %}
{# Option B: in Subunit mode, switch to current_interval_price_base for base-currency workflows #}
{% set price_entity = 'sensor.<home_name>_current_electricity_price' %}
{% set energy_raw = state_attr(price_entity, 'energy_price') %}
{% set price_unit = state_attr(price_entity, 'unit_of_measurement') %}
{% set unit_factor = 100 if price_unit == 'ct/kWh' else 1 %}
{% set inkoop = states('input_number.inkoopvergoeding') | float %} {% set inkoop = states('input_number.inkoopvergoeding') | float %}
{% set verkoop = states('input_number.verkoopvergoeding') | float %} {% set verkoop = states('input_number.verkoopvergoeding') | float %}
{% set btw = states('input_number.btw_percentage') | float / 100 %} {% if energy is not none %}
{% if energy_raw is not none %} {{ (energy - inkoop + verkoop) | round(4) }}
{% set energy = (energy_raw | float) / unit_factor %}
{{ ((energy + inkoop + verkoop) * (1 + btw)) | round(4) }}
{% else %} {% else %}
unavailable unavailable
{% endif %} {% endif %}
@ -214,10 +187,6 @@ automation:
To understand the financial impact of the saldering phase-out, you can create a dashboard comparing both scenarios side by side: To understand the financial impact of the saldering phase-out, you can create a dashboard comparing both scenarios side by side:
:::note Unit label reminder
The label `ct/kWh` below is a manual display label. If your integration uses Base currency mode, update this label to `€/kWh` so it matches your active display mode.
:::
<details> <details>
<summary>Show YAML: Preparing for the End of Saldering</summary> <summary>Show YAML: Preparing for the End of Saldering</summary>
@ -230,7 +199,7 @@ entities:
- type: attribute - type: attribute
entity: sensor.<home_name>_current_electricity_price entity: sensor.<home_name>_current_electricity_price
attribute: energy_price attribute: energy_price
name: "Spot Price (energy, ct/kWh)" name: "Spot Price (energy)"
icon: mdi:transmission-tower icon: mdi:transmission-tower
- entity: sensor.solar_feed_in_price_with_saldering - entity: sensor.solar_feed_in_price_with_saldering
name: "Feed-In with Saldering" name: "Feed-In with Saldering"
@ -244,135 +213,43 @@ entities:
--- ---
## 🇩🇪 Germany: Feed-In Compensation ## 🇩🇪 Germany: Price Composition
### Background ### Background
In Germany, private households usually get a **fixed feed-in compensation** (Einspeisevergütung) for exported PV energy, while consumption uses the dynamic end-user price from your tariff. In Germany, the electricity price includes numerous components bundled into `tax`:
That means the practical question is often: | Component | German Name | Description |
|-----------|-----------|-------------|
| Spot price | Börsenstrompreis | Variable (= `energy_price` attribute) |
| Grid fees | Netzentgelte | Varies by grid operator |
| Electricity tax | Stromsteuer | Fixed per kWh |
| Concession fee | Konzessionsabgabe | Varies by municipality |
| Surcharges | Umlagen (§19, Offshore, KWKG) | Various regulatory surcharges |
| VAT | Mehrwertsteuer | 19% |
- consume/store energy locally now, or ### Template: Spot Price Share
- export now at your fixed feed-in rate
### Step 1: Create Input Number Helper for Feed-In Compensation A simple template sensor showing what percentage of your total price is the actual energy cost:
Create one helper for your current contractual feed-in rate in `€/kWh`.
**Settings → Devices & Services → Helpers → Create Helper → Number**
| Helper | Entity ID | Min | Max | Step | Unit | Example Value |
|--------|-----------|-----|-----|------|------|---------------|
| Einspeisevergütung | `input_number.einspeiseverguetung` | 0 | 1 | 0.0001 | €/kWh | 0.0778 |
:::note Keep this value up to date
Use the exact value from your contract or network operator statement. Typical values differ by commissioning date and plant setup (partial vs full feed-in).
:::
<details> <details>
<summary>Show YAML: Input Number Helper</summary> <summary>Show YAML: Spot Price Share</summary>
```yaml
input_number:
einspeiseverguetung:
name: Einspeisevergütung
min: 0
max: 1
step: 0.0001
unit_of_measurement: "€/kWh"
icon: mdi:transmission-tower-export
```
</details>
### Step 2: Template Sensors for Feed-In Decision Support
These sensors normalize your current price to `€/kWh`, compare it with your fixed feed-in compensation, and expose a clean binary signal for automations.
:::note Display-mode safe
`current_electricity_price` can be in `ct/kWh` or `€/kWh` depending on display mode. The template below normalizes automatically to `€/kWh`.
:::
<details>
<summary>Show YAML: Feed-In Decision Sensors</summary>
```yaml ```yaml
template: template:
- sensor: - sensor:
- name: "Current Electricity Price (EUR normalized)" - name: "Spot Price Share"
unique_id: current_electricity_price_eur_normalized unique_id: spot_price_share
unit_of_measurement: "€/kWh" unit_of_measurement: "%"
device_class: monetary
state: > state: >
{% set price_entity = 'sensor.<home_name>_current_electricity_price' %} {% set energy = state_attr('sensor.<home_name>_current_electricity_price', 'energy_price') %}
{% set total_raw = states(price_entity) | float(none) %} {% set total = states('sensor.<home_name>_current_electricity_price') | float %}
{% set price_unit = state_attr(price_entity, 'unit_of_measurement') %} {% if energy is not none and total > 0 %}
{% set unit_factor = 100 if price_unit == 'ct/kWh' else 1 %} {{ ((energy / total) * 100) | round(1) }}
{% if total_raw is not none %}
{{ (total_raw / unit_factor) | round(4) }}
{% else %} {% else %}
unavailable unavailable
{% endif %} {% endif %}
icon: mdi:currency-eur icon: mdi:chart-pie
- name: "Self-Consumption Advantage"
unique_id: self_consumption_advantage
unit_of_measurement: "€/kWh"
device_class: monetary
state: >
{% set import_price = states('sensor.current_electricity_price_eur_normalized') | float(none) %}
{% set feed_in = states('input_number.einspeiseverguetung') | float(none) %}
{% if import_price is not none and feed_in is not none %}
{{ (import_price - feed_in) | round(4) }}
{% else %}
unavailable
{% endif %}
icon: mdi:scale-balance
- binary_sensor:
- name: "Prefer Self-Consumption"
unique_id: prefer_self_consumption
state: >
{% set advantage = states('sensor.self_consumption_advantage') | float(none) %}
{{ advantage is not none and advantage > 0 }}
icon: mdi:home-lightning-bolt
```
</details>
### Step 3: Use in Automations
Use the binary sensor to switch behavior between export-oriented and self-consumption-oriented operation.
<details>
<summary>Show YAML: Example Automation (Battery Charging Strategy)</summary>
```yaml
automation:
- alias: "Battery: Prefer self-consumption when import price is higher than feed-in"
trigger:
- platform: state
entity_id: binary_sensor.prefer_self_consumption
action:
- choose:
- conditions:
- condition: state
entity_id: binary_sensor.prefer_self_consumption
state: "on"
sequence:
# Example: keep energy locally (charge battery / reduce export)
- service: switch.turn_on
target:
entity_id: switch.battery_charging
- conditions:
- condition: state
entity_id: binary_sensor.prefer_self_consumption
state: "off"
sequence:
# Example: allow more export to grid
- service: switch.turn_off
target:
entity_id: switch.battery_charging
``` ```
</details> </details>
@ -381,7 +258,7 @@ automation:
## 🇳🇴 Norway / 🇸🇪 Sweden: Grid & Tax Components ## 🇳🇴 Norway / 🇸🇪 Sweden: Grid & Tax Components
Norway and Sweden have their own fee structures, but the same pattern applies — use `input_number` helpers for the fixed/semi-fixed components and `energy_price` for the spot price (unit depends on your display mode). Norway and Sweden have their own fee structures, but the same pattern applies — use `input_number` helpers for the fixed/semi-fixed components and `energy_price` for the spot price.
**Contributions welcome!** If you have working template examples for Norway or Sweden, please share them in a [GitHub Discussion](https://github.com/jpawlowski/hass.tibber_prices/discussions). **Contributions welcome!** If you have working template examples for Norway or Sweden, please share them in a [GitHub Discussion](https://github.com/jpawlowski/hass.tibber_prices/discussions).

View file

@ -435,53 +435,30 @@ ${DIFF_CONTEXT}
Output ONLY the release notes. Start directly with the # title. Output ONLY the release notes. Start directly with the # title.
End after the Buy Me A Coffee button. No meta-commentary, no explanations." End after the Buy Me A Coffee button. No meta-commentary, no explanations."
# Save prompt to temp file for copilot
TEMP_PROMPT=$(mktemp)
echo "$PROMPT" >"$TEMP_PROMPT"
# Use Claude Sonnet for better user-focused, plain-language release notes. # Use Claude Sonnet for better user-focused, plain-language release notes.
# Haiku tends to echo technical commit language; Sonnet better translates to user benefits. # Haiku tends to echo technical commit language; Sonnet better translates to user benefits.
# Can override with: COPILOT_MODEL=claude-haiku-4.5 ./scripts/release/generate-notes # Can override with: COPILOT_MODEL=claude-haiku-4.5 ./scripts/release/generate-notes
COPILOT_MODEL="${COPILOT_MODEL:-claude-sonnet-4.6}" COPILOT_MODEL="${COPILOT_MODEL:-claude-sonnet-4.6}"
# Call copilot CLI (it will handle authentication interactively). # Call copilot CLI (it will handle authentication interactively)
# Flags matter here (recent copilot CLI versions, e.g. 1.0.68+): copilot --model "$COPILOT_MODEL" <"$TEMP_PROMPT" || {
# The prompt is piped into stdin (a real pipe, not a `<file` redirect and
# not the -p argument). Two prior approaches were tried and broke:
# - `-p "$PROMPT"` puts the whole prompt on the command line, which
# blows past the OS argv/environ size limit (ARG_MAX) on releases
# with many commits, failing with "Argument list too long".
# - `<"$TEMP_PROMPT"` (file redirect) gave copilot a seekable regular
# file as stdin; it was observed to sometimes respond "No prompt
# provided..." yet still exit 0 for that same input in a real
# terminal, silently producing empty release notes. A shell pipe
# presents stdin as a FIFO, matching what "via standard in" in
# copilot's own help text expects, and has no size limit.
# --available-tools (no value) disables all tools — this task only needs
# plain text generation from the embedded context, and without this the
# agent may attempt tool calls that silently stall/no-op in
# non-interactive mode, producing empty output (least-privilege, avoids
# needing --allow-all-tools).
# --silent suppresses the post-response stats block (Changes/AI
# Credits/Tokens/Resume) that newer versions print to stdout, which
# would otherwise leak into the generated release notes.
# --no-custom-instructions skips loading this repo's AGENTS.md, which is
# unrelated to release-note writing style and would just burn tokens.
# --no-color keeps the captured output free of ANSI escape codes.
COPILOT_OUTPUT=$(printf '%s' "$PROMPT" | copilot --model "$COPILOT_MODEL" --available-tools --silent --no-custom-instructions --no-color) || true
# Treat empty/whitespace-only output as a failure too: copilot has been
# observed to exit 0 while producing no content (e.g. when it could not
# obtain a prompt), which previously slipped past the "||" failure check.
if [[ -z "${COPILOT_OUTPUT//[[:space:]]/}" ]]; then
echo "" echo ""
log_info "${YELLOW}Warning: GitHub Copilot CLI failed, was not authenticated, or returned no output${NC}" log_info "${YELLOW}Warning: GitHub Copilot CLI failed or was not authenticated${NC}"
log_info "${YELLOW}Falling back to git-cliff${NC}" log_info "${YELLOW}Falling back to git-cliff${NC}"
rm -f "$TEMP_PROMPT"
if command -v git-cliff >/dev/null 2>&1; then if command -v git-cliff >/dev/null 2>&1; then
generate_with_gitcliff generate_with_gitcliff
else else
generate_with_manual generate_with_manual
fi fi
return return
fi }
printf "%s\n" "$COPILOT_OUTPUT" rm -f "$TEMP_PROMPT"
} }
# ============================================================================ # ============================================================================

View file

@ -1,76 +0,0 @@
"""Tests for pure internal helpers in find_cheapest_schedule.py."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from custom_components.tibber_prices.services.find_cheapest_schedule import _find_cheapest_window_in_pool
def _build_pool(minutes_and_prices: list[tuple[int, float]], base: datetime | None = None) -> list[dict]:
"""Build a pool of interval dicts from (minute_offset, price) tuples."""
base = base or datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
return [
{"startsAt": (base + timedelta(minutes=minute)).isoformat(), "total": price}
for minute, price in minutes_and_prices
]
class TestFindCheapestWindowInPoolTemporalGap:
"""Regression coverage: a temporal gap must not hide the cheapest window.
`_find_cheapest_window_in_pool` scans for contiguous available blocks. When
a block-in-progress hits a time gap (e.g. because price-level filtering
removed an interval from the middle of the range), the position where the
gap was detected is itself a valid, untested candidate window start and
must be retried not skipped.
"""
def test_cheapest_window_right_after_gap_is_found(self) -> None:
"""The cheapest 2-interval window sits right after a 30min time gap."""
# Minutes: 0, 15, [gap - 30 missing], 45, 60, 75, 90
# Cheapest contiguous pair by price sum is (45, 60) = 1 + 1 = 2.
pool = _build_pool([(0, 10.0), (15, 10.0), (45, 1.0), (60, 1.0), (75, 10.0), (90, 10.0)])
available = [True] * len(pool)
result = _find_cheapest_window_in_pool(pool, 2, available)
assert result is not None
start, end = result
assert (start, end) == (2, 4)
chosen = pool[start:end]
assert [iv["startsAt"] for iv in chosen] == [pool[2]["startsAt"], pool[3]["startsAt"]]
assert sum(iv["total"] for iv in chosen) == 2.0
def test_cheapest_window_immediately_before_gap_still_found(self) -> None:
"""A candidate window ending right before a gap must also still work."""
# Minutes: 0, 15, 30 [gap], 60, 75
# Cheapest contiguous pair is (0, 15) = 1 + 1 = 2, right before the gap.
pool = _build_pool([(0, 1.0), (15, 1.0), (30, 10.0), (60, 10.0), (75, 10.0)])
available = [True] * len(pool)
result = _find_cheapest_window_in_pool(pool, 2, available)
assert result == (0, 2)
def test_multiple_gaps_all_candidate_starts_considered(self) -> None:
"""Multiple gaps in a row must not compound-skip valid starts."""
# Minutes: 0, [gap], 30, [gap], 60, [gap], 90 — no 2 contiguous intervals
# exist at all here, so no window should be found despite many gaps.
pool = _build_pool([(0, 1.0), (30, 1.0), (60, 1.0), (90, 1.0)])
available = [True] * len(pool)
result = _find_cheapest_window_in_pool(pool, 2, available)
assert result is None
def test_unavailable_slot_is_still_correctly_skipped(self) -> None:
"""An unavailable (already-claimed) slot must still be skipped entirely."""
pool = _build_pool([(0, 1.0), (15, 1.0), (30, 1.0), (45, 1.0)])
# Claim index 1 (minute 15) as unavailable (e.g. assigned to another task).
available = [True, False, True, True]
result = _find_cheapest_window_in_pool(pool, 2, available)
# Only (30, 45) is a valid contiguous available pair.
assert result == (2, 4)

View file

@ -5,7 +5,6 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from zoneinfo import ZoneInfo
if TYPE_CHECKING: if TYPE_CHECKING:
from homeassistant.core import ServiceCall from homeassistant.core import ServiceCall
@ -352,150 +351,6 @@ async def test_block_handler_preserves_service_search_data(monkeypatch: pytest.M
assert response["must_finish_by"] == deadline.isoformat() assert response["must_finish_by"] == deadline.isoformat()
@pytest.mark.asyncio
async def test_block_handler_power_profile_blocks_duration_relaxation(monkeypatch: pytest.MonkeyPatch) -> None:
"""Regression: a power_profile must disable relaxation's duration-reduction phase.
power_profile is a fixed per-interval watt array matching the *original*
requested duration. If relaxation reduced the duration to fit a shorter
pool, the profile would be silently truncated from the front (dropping
trailing appliance-cycle phases) and used to weight/report a window that
doesn't match the user's declared profile. Only 3 intervals are available
here for a 4-interval (1h) request without the fix, relaxation's
duration phase would find and accept a 3-interval window using a
truncated profile; with the fix, duration reduction is skipped and
relaxation exhausts instead.
"""
intervals = _make_intervals([10.0, 10.0, 10.0]) # only 3 available, need 4
fake_tuple = _build_fake_entry_and_coordinator(intervals)
monkeypatch.setattr(block_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
monkeypatch.setattr(block_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
monkeypatch.setattr(
block_module,
"resolve_search_range",
lambda _call_data, _now, _home_tz: (
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
datetime(2026, 1, 1, 1, 0, tzinfo=UTC),
),
)
call = SimpleNamespace(
hass=object(),
data={
"duration": timedelta(hours=1), # 4 intervals required
"power_profile": [100, 200, 300, 400],
"use_base_unit": True,
"allow_relaxation": True,
"smooth_outliers": False,
},
)
response = cast("dict[str, Any]", await handle_find_cheapest_block(cast("ServiceCall", call)))
assert response["window_found"] is False
assert response["reason"] == "relaxation_exhausted"
# Duration must never be silently reduced below the original request.
assert response["duration_minutes"] == 60
class _FakeRangeFilteringPool:
"""Fake interval pool that honors start_time/end_time like the real pool.
Unlike `_FakePool`, this filters the static interval list by the
requested [start_time, end_time) range, so tests using it will fail if a
service handler resolves the wrong search range (regression guard for
GH issue #168).
"""
def __init__(self, intervals: list[dict]) -> None:
"""Store the full static interval list."""
self._intervals = intervals
async def get_intervals(
self, *, start_time: datetime, end_time: datetime, **_kwargs: object
) -> tuple[list[dict], bool]:
"""Return only intervals within [start_time, end_time)."""
filtered = [iv for iv in self._intervals if start_time <= datetime.fromisoformat(iv["startsAt"]) < end_time]
return filtered, False
@pytest.mark.asyncio
async def test_block_handler_must_finish_by_end_to_end_regression(monkeypatch: pytest.MonkeyPatch) -> None:
"""GH #168 regression: must_finish_by must actually constrain the found window.
Uses the real `apply_must_finish_by` and `resolve_search_range` (no mocking)
plus a range-filtering fake pool, reproducing the exact bug reported: a
naive `must_finish_by` datetime combined with `search_start_day_offset: 0`
used to be silently ignored, letting the found window run past the deadline.
"""
# Use UTC as the home timezone: HA's test environment default timezone
# (dt_util.DEFAULT_TIME_ZONE) is also UTC, so the naive `must_finish_by`
# datetime below localizes 1:1 without an additional offset shift. This
# keeps the test focused on the search-range regression rather than
# timezone-conversion arithmetic.
home_tz = ZoneInfo("UTC")
day_start = datetime(2026, 6, 29, 0, 0, tzinfo=home_tz)
# Full day of quarter-hour intervals: expensive by default, very cheap in
# the afternoon (12:00-16:00) and moderately cheap in the morning
# (08:00-12:00). Without the deadline, the cheapest 4h block is the
# afternoon one (which ends after the 12:00 deadline).
prices = [50.0] * 96
for i in range(32, 48): # 08:00-12:00
prices[i] = 20.0
for i in range(48, 64): # 12:00-16:00
prices[i] = 10.0
intervals = _make_intervals(prices, start=day_start)
pool = _FakeRangeFilteringPool(intervals)
entry = SimpleNamespace(
data={"home_id": "home_1", "currency": "EUR"},
options={},
runtime_data=SimpleNamespace(interval_pool=pool),
)
coordinator = SimpleNamespace(
api=object(),
_cached_user_data={"viewer": {"homes": [{"id": "home_1", "timeZone": "UTC"}]}},
)
coordinator_data = {"priceInfo": intervals}
monkeypatch.setattr(
block_module, "get_entry_and_data", lambda _hass, _entry_id: (entry, coordinator, coordinator_data)
)
# Fix "now" so the default search_start (no search_start_time given) is deterministic.
fixed_now = datetime(2026, 6, 28, 22, 0, tzinfo=UTC)
monkeypatch.setattr(block_module.dt_util, "now", lambda *_a, **_k: fixed_now)
# Matches what voluptuous' cv.datetime produces for the naive string
# "2026-06-29 12:00:00" reported in GH #168 (no tzinfo attached yet).
call = SimpleNamespace(
hass=object(),
data={
"duration": timedelta(hours=4),
"search_start_day_offset": 0,
"must_finish_by": datetime(2026, 6, 29, 12, 0),
"smooth_outliers": False,
"allow_relaxation": False,
"use_base_unit": True,
},
)
response = cast("dict[str, Any]", await handle_find_cheapest_block(cast("ServiceCall", call)))
deadline = datetime(2026, 6, 29, 12, 0, tzinfo=home_tz)
assert response["must_finish_by"] == deadline.isoformat()
assert response["search_end"] == deadline.isoformat()
assert response["window_found"] is True
window = cast("dict[str, Any]", response["window"])
window_end = datetime.fromisoformat(window["end"])
assert window_end <= deadline
# The morning block (08:00-12:00, price 20.0) must be selected, not the
# cheaper afternoon block that would violate the deadline.
assert window["price_mean"] == 20.0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_hours_handler_preserves_service_search_data(monkeypatch: pytest.MonkeyPatch) -> None: async def test_hours_handler_preserves_service_search_data(monkeypatch: pytest.MonkeyPatch) -> None:
"""Hours handler must pass resolved call data (not coordinator data) into search helpers.""" """Hours handler must pass resolved call data (not coordinator data) into search helpers."""

View file

@ -1,77 +0,0 @@
"""Tests for get_chartdata metadata statistics calculation."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from custom_components.tibber_prices.services.get_chartdata import _calculate_metadata
def _make_chart_data(prices: list[float], start: datetime | None = None) -> list[dict]:
"""Build minimal chart_data entries (start_time + price) for metadata calc."""
base = start or datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
return [
{
"start_time": (base + timedelta(minutes=15 * i)).isoformat(),
"price_per_kwh": price,
}
for i, price in enumerate(prices)
]
class TestCalculateMetadataMedian:
"""Regression coverage: median must use proper statistics.median, not naive indexing.
A naive `sorted(data)[len(data)//2]` returns the upper-middle value for
even-length datasets instead of averaging the two middle values. Since a
full day always has an even interval count (96 quarter-hours, 24 hours),
this bug silently affected nearly every "combined"/per-day median in the
chartdata metadata response.
"""
def test_median_for_even_length_dataset_is_averaged(self) -> None:
"""4 known prices: naive impl would report 30.0, correct median is 25.0."""
chart_data = _make_chart_data([10.0, 20.0, 30.0, 40.0])
metadata = _calculate_metadata(
chart_data=chart_data,
price_field="price_per_kwh",
start_time_field="start_time",
currency="EUR",
resolution="interval",
subunit_currency=False,
)
assert metadata["price_stats"]["combined"]["median"] == 25.0
def test_median_for_odd_length_dataset_is_middle_value(self) -> None:
"""3 known prices: median is simply the middle value."""
chart_data = _make_chart_data([10.0, 20.0, 30.0])
metadata = _calculate_metadata(
chart_data=chart_data,
price_field="price_per_kwh",
start_time_field="start_time",
currency="EUR",
resolution="interval",
subunit_currency=False,
)
assert metadata["price_stats"]["combined"]["median"] == 20.0
def test_median_position_reflects_corrected_median(self) -> None:
"""median_position must be derived from the corrected median, not the naive one."""
chart_data = _make_chart_data([10.0, 20.0, 30.0, 40.0])
metadata = _calculate_metadata(
chart_data=chart_data,
price_field="price_per_kwh",
start_time_field="start_time",
currency="EUR",
resolution="interval",
subunit_currency=False,
)
combined = metadata["price_stats"]["combined"]
# median=25.0, min=10.0, max=40.0 -> position = (25-10)/(40-10) = 0.5
assert combined["median_position"] == 0.5

View file

@ -203,60 +203,6 @@ async def test_plan_charging_can_meet_deadline_before_peak_period(monkeypatch: p
assert response["deadline"]["achieved_soc_kwh"] >= 4.0 assert response["deadline"]["achieved_soc_kwh"] >= 4.0
@pytest.mark.asyncio
async def test_plan_charging_max_cycles_does_not_break_deadline(monkeypatch: pytest.MonkeyPatch) -> None:
"""Cycle merging/trimming must never discard intervals required to meet a deadline.
Regression test for a bug where ``apply_segment_constraints`` could remove the
deadline-critical interval while trimming excess energy added by cycle bridging,
silently turning ``deadline_met`` false even though the overall target was reached.
"""
intervals = _make_intervals([0.80, 0.95, 0.95, 0.05])
fake_tuple = _build_fake_entry_and_coordinator(intervals)
monkeypatch.setattr(charging_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
monkeypatch.setattr(charging_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
monkeypatch.setattr(
charging_module,
"resolve_search_range",
lambda _call_data, _now, _home_tz: (
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
datetime(2026, 1, 1, 1, 0, tzinfo=UTC),
),
)
monkeypatch.setattr(charging_module, "get_display_unit_factor", lambda _entry: 1)
monkeypatch.setattr(charging_module, "get_display_unit_string", lambda _entry, _currency: "EUR/kWh")
monkeypatch.setattr(charging_module.dt_util, "now", lambda: datetime(2026, 1, 1, 0, 0, tzinfo=UTC))
call = SimpleNamespace(
hass=object(),
data={
"battery_capacity_kwh": 10.0,
"current_soc_percent": 20.0,
"target_soc_percent": 40.0,
"must_reach_soc_percent": 30.0,
"must_reach_by": datetime(2026, 1, 1, 0, 15, tzinfo=UTC),
"max_charge_power_w": 4000,
"max_cycles_per_day": 1,
"charging_efficiency": 1.0,
"use_base_unit": True,
"allow_relaxation": False,
},
)
response = cast("dict[str, Any]", await handle_plan_charging(cast("ServiceCall", call)))
assert response["intervals_found"] is True
assert response["charging"]["schedule"]["segment_count"] == 1
assert response["deadline"]["deadline_met"] is True
assert response["deadline"]["achieved_soc_kwh"] == 3.0
assert response["battery"]["target_met"] is True
assert response["battery"]["achieved_soc_kwh"] == 4.0
scheduled = cast("list[dict[str, Any]]", response["charging"]["schedule"]["intervals"])
assert scheduled[0]["price"] == 0.8 # the deadline-critical interval must survive trimming
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_plan_charging_can_filter_by_profitability(monkeypatch: pytest.MonkeyPatch) -> None: async def test_plan_charging_can_filter_by_profitability(monkeypatch: pytest.MonkeyPatch) -> None:
"""Economic filtering should keep only profitable intervals when reserve_for_discharge is enabled.""" """Economic filtering should keep only profitable intervals when reserve_for_discharge is enabled."""
@ -346,7 +292,7 @@ async def test_plan_charging_respects_min_charge_duration(monkeypatch: pytest.Mo
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_plan_charging_respects_max_cycles_per_day(monkeypatch: pytest.MonkeyPatch) -> None: async def test_plan_charging_respects_max_cycles_per_day(monkeypatch: pytest.MonkeyPatch) -> None:
"""Cycle merging must not overcharge beyond the requested target energy.""" """Multiple cheap isolated intervals should be bridged to satisfy max cycle limits."""
intervals = _make_intervals([0.10, 0.80, 0.11, 0.90, 0.12, 0.95, 0.50, 0.60]) intervals = _make_intervals([0.10, 0.80, 0.11, 0.90, 0.12, 0.95, 0.50, 0.60])
fake_tuple = _build_fake_entry_and_coordinator(intervals) fake_tuple = _build_fake_entry_and_coordinator(intervals)
@ -381,10 +327,7 @@ async def test_plan_charging_respects_max_cycles_per_day(monkeypatch: pytest.Mon
assert response["intervals_found"] is True assert response["intervals_found"] is True
assert response["charging"]["schedule"]["segment_count"] == 1 assert response["charging"]["schedule"]["segment_count"] == 1
assert response["charging"]["total_energy_kwh"] == 3.0
assert response["battery"]["achieved_soc_kwh"] == 5.0
assert response["battery"]["target_met"] is True
scheduled = cast("list[dict[str, Any]]", response["charging"]["schedule"]["intervals"]) scheduled = cast("list[dict[str, Any]]", response["charging"]["schedule"]["intervals"])
assert [interval["price"] for interval in scheduled] == [0.1, 0.8, 0.11] assert [interval["price"] for interval in scheduled[:5]] == [0.1, 0.8, 0.11, 0.9, 0.12]
assert response["warnings"] is None assert response["warnings"] is None

View file

@ -6,10 +6,7 @@ from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from custom_components.tibber_prices.services.charging.deadline_solver import resolve_deadline from custom_components.tibber_prices.services.charging.deadline_solver import resolve_deadline
from custom_components.tibber_prices.services.charging.power_scheduler import ( from custom_components.tibber_prices.services.charging.power_scheduler import build_power_schedule
apply_segment_constraints,
build_power_schedule,
)
def _make_intervals(prices: list[float]) -> list[dict[str, object]]: def _make_intervals(prices: list[float]) -> list[dict[str, object]]:
@ -54,85 +51,6 @@ def test_stepped_mode_uses_smallest_sufficient_step() -> None:
assert sorted(interval["power_w"] for interval in result["intervals"]) == [2000, 4000, 4000] assert sorted(interval["power_w"] for interval in result["intervals"]) == [2000, 4000, 4000]
def test_apply_segment_constraints_bridges_and_trims_to_target() -> None:
"""Bridging isolated cheap intervals must not leave more energy than requested.
Direct unit-level regression for the ``max_cycles_per_day`` overcharge bug: bridging
fills the gaps between segments with extra (non-essential) intervals, and the
subsequent trim step must remove exactly that surplus again.
"""
candidates = _make_intervals([0.10, 0.80, 0.11, 0.90, 0.12, 0.95, 0.50, 0.60])
schedule = build_power_schedule(candidates, 3.0, max_charge_power_w=4000, charging_efficiency=1.0)
assert schedule["total_grid_energy_kwh"] == 3.0
assert len(schedule["segments"]) == 3 # three isolated cheap intervals
schedule, warnings = apply_segment_constraints(
schedule,
candidates,
charging_efficiency=1.0,
max_cycles_per_day=1,
target_grid_energy_kwh=3.0,
interval_minutes=15,
)
assert warnings == []
assert len(schedule["segments"]) == 1
assert schedule["total_grid_energy_kwh"] == 3.0
assert [interval["total"] for interval in schedule["intervals"]] == [0.10, 0.80, 0.11]
def test_apply_segment_constraints_never_trims_below_target() -> None:
"""Trimming must never drop the plan below the required energy.
Fixed-power mode cannot reduce the last interval's power, so it can legitimately
overshoot the target by up to one interval's energy. This is expected physical
behavior (not constraint bridging) and must survive trimming unchanged.
"""
candidates = _make_intervals([0.10, 0.20])
schedule = build_power_schedule(candidates, 1.5, max_charge_power_w=4000, charging_efficiency=1.0)
assert schedule["total_grid_energy_kwh"] == 2.0 # rounded up from 1.5
schedule, warnings = apply_segment_constraints(
schedule,
candidates,
charging_efficiency=1.0,
target_grid_energy_kwh=1.5,
interval_minutes=15,
)
assert warnings == []
assert schedule["total_grid_energy_kwh"] == 2.0 # both intervals kept
assert len(schedule["intervals"]) == 2
def test_apply_segment_constraints_respects_protected_starts() -> None:
"""Protected intervals (e.g. deadline-critical) must survive trimming.
Even when a protected interval is the most expensive edge of the merged segment,
trimming must skip it and remove the next-best removable edge instead.
"""
candidates = _make_intervals([0.80, 0.95, 0.95, 0.05])
protected_start = candidates[0]["startsAt"]
schedule = build_power_schedule(candidates, 2.0, max_charge_power_w=4000, charging_efficiency=1.0)
assert [interval["total"] for interval in schedule["intervals"]] == [0.80, 0.05]
schedule, warnings = apply_segment_constraints(
schedule,
candidates,
charging_efficiency=1.0,
max_cycles_per_day=1,
target_grid_energy_kwh=2.0,
protected_starts=frozenset({protected_start}),
interval_minutes=15,
)
assert warnings == []
assert schedule["total_grid_energy_kwh"] == 2.0
starts = {interval["startsAt"] for interval in schedule["intervals"]}
assert protected_start in starts
def test_resolve_deadline_next_peak_period() -> None: def test_resolve_deadline_next_peak_period() -> None:
"""Deadline helper should resolve the next future peak period start.""" """Deadline helper should resolve the next future peak period start."""
now = datetime(2026, 1, 1, 0, 0, tzinfo=UTC) now = datetime(2026, 1, 1, 0, 0, tzinfo=UTC)

View file

@ -1,71 +0,0 @@
"""Tests for small pure helper functions in services/helpers.py."""
from __future__ import annotations
from custom_components.tibber_prices.services.helpers import check_min_distance_from_avg
class TestCheckMinDistanceFromAvgPositiveAverage:
"""Regression coverage for the common case: positive range average."""
def test_cheapest_passes_when_far_enough_below(self) -> None:
"""Window 10% below a positive average passes a 5% requirement."""
assert check_min_distance_from_avg(18.0, 20.0, 5.0, reverse=False) is True
def test_cheapest_fails_when_too_close(self) -> None:
"""Window only 2% below a positive average fails a 5% requirement."""
assert check_min_distance_from_avg(19.6, 20.0, 5.0, reverse=False) is False
def test_most_expensive_passes_when_far_enough_above(self) -> None:
"""Window 10% above a positive average passes a 5% requirement."""
assert check_min_distance_from_avg(22.0, 20.0, 5.0, reverse=True) is True
def test_most_expensive_fails_when_too_close(self) -> None:
"""Window only 2% above a positive average fails a 5% requirement."""
assert check_min_distance_from_avg(20.4, 20.0, 5.0, reverse=True) is False
class TestCheckMinDistanceFromAvgNegativeAverage:
"""Regression coverage for GH-reported sign bug: negative range average.
Tibber prices can go negative during grid oversupply. The threshold must
be computed as `avg ± abs(avg) * ratio` (not `avg * (1 ± ratio)`), since
multiplying a negative average directly flips the intended direction.
"""
def test_cheapest_passes_when_more_negative_than_avg(self) -> None:
"""A window that is 10% cheaper (more negative) than a -5 avg passes 5% req."""
# -5 - abs(-5)*0.05 = -5.25 → window must be <= -5.25
assert check_min_distance_from_avg(-5.6, -5.0, 5.0, reverse=False) is True
def test_cheapest_fails_when_less_negative_than_avg(self) -> None:
"""A window that is actually more expensive (less negative) than avg must fail.
Regression: the old buggy formula (avg * (1 - ratio)) computed a
threshold of -4.75 here, incorrectly letting -5.1 pass even though
it is only ~2% below avg (needs 5%).
"""
assert check_min_distance_from_avg(-5.1, -5.0, 5.0, reverse=False) is False
def test_most_expensive_passes_when_less_negative_than_avg(self) -> None:
"""A window 10% above (less negative than) a -5 avg passes a 5% req."""
# -5 + abs(-5)*0.05 = -4.75 → window must be >= -4.75
assert check_min_distance_from_avg(-4.4, -5.0, 5.0, reverse=True) is True
def test_most_expensive_fails_when_actually_cheaper_than_avg(self) -> None:
"""A window that is cheaper (more negative) than avg must fail the most-expensive check.
Regression: the old buggy formula (avg * (1 + ratio)) computed a
threshold of -5.25 here, incorrectly letting -5.1 pass even though
it is actually cheaper than the average, not more expensive.
"""
assert check_min_distance_from_avg(-5.1, -5.0, 5.0, reverse=True) is False
class TestCheckMinDistanceFromAvgEdgeCases:
"""Edge cases: zero average."""
def test_zero_average_always_passes(self) -> None:
"""A zero average makes percentage distance undefined; always pass."""
assert check_min_distance_from_avg(5.0, 0.0, 5.0, reverse=False) is True
assert check_min_distance_from_avg(-5.0, 0.0, 5.0, reverse=True) is True