Compare commits

...

16 commits

Author SHA1 Message Date
Julian Pawlowski
2f202dc186 refactor(generate-notes): improve Copilot CLI integration for release notes generation
Some checks failed
Lint / Ruff (push) Has been cancelled
Validate / Hassfest validation (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
Auto-Tag on Version Bump / Check and create version tag (push) Has been cancelled
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Has been cancelled
Enhance the handling of the Copilot CLI to ensure better user-focused output and prevent issues with empty responses. Adjustments include using a pipe for input and adding checks for output validity.

Impact: Users receive more accurate and user-friendly release notes without technical jargon.
2026-07-04 19:18:04 +00:00
Julian Pawlowski
1694431f26 chore(devcontainer): add AI extensions for enhanced development experience
Included Anthropic Claude and OpenAI ChatGPT extensions to support AI-assisted coding features.

User-Impact: Developers can leverage AI tools for improved productivity and code suggestions.
2026-07-04 18:50:24 +00:00
Julian Pawlowski
49ea70ed9f chore(auto-tag): explicitly dispatch release workflow after tag creation
Add a step to trigger the release notes workflow after creating a version tag. This ensures that the release notes are generated even when the tag push does not automatically trigger the workflow due to GitHub's recursion guard.

Impact: Users will receive timely release notes corresponding to new version tags.
2026-07-04 18:34:31 +00:00
Julian Pawlowski
de6da790ab chore(release): bump version to 0.32.0 2026-07-04 18:25:27 +00:00
Julian Pawlowski
569a7cb73a Merge branch 'main' of https://github.com/jpawlowski/hass.tibber_prices 2026-07-04 18:24:22 +00:00
Julian Pawlowski
13b9870f45 fix(get_chartdata): use proper statistics.median for chart metadata
_calculate_metadata()'s inline calc_stats() computed the median as a
naive sorted(data)[len(data) // 2], which returns the upper-middle
value for even-length datasets instead of averaging the two middle
values. A full day is always an even interval count (96 quarter-hours
or 24 hours), so this silently overstated "median" and
"median_position" in nearly every price_stats block of the
get_chartdata response. The rest of the codebase already has a
correct calculate_median() in utils/average.py; this was an isolated
inline duplicate with the bug.

Impact: get_chartdata's price_stats.combined/dayN.median and
median_position now report the statistically correct median for
chart annotations and metadata-driven dashboards.
2026-07-04 18:23:25 +00:00
Julian Pawlowski
1b207f0db1 fix(services): correct min_distance_from_avg direction for negative prices
check_min_distance_from_avg() computed the distance threshold as
range_avg * (1 ± ratio). Tibber prices can go negative during grid
oversupply, and multiplying a negative range_avg directly 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 check, and analogously wrong for
"cheapest" checks.

Compute the threshold as range_avg ± abs(range_avg) * ratio instead,
matching the sign-safe normalization pattern already used for
min_distance_from_avg in the period system
(coordinator/period_handlers/level_filtering.py). Behavior for
positive averages (the common case) is unchanged.

Used by find_cheapest_block, find_cheapest_hours, and plan_charging.

Impact: min_distance_from_avg now correctly filters cheapest/most
expensive windows during negative-price periods instead of silently
accepting windows in the wrong direction relative to the search range
average.
2026-07-04 18:23:06 +00:00
Julian Pawlowski
95004efa2d fix(services): prevent power_profile truncation during relaxation
find_cheapest_block, find_cheapest_hours, and find_cheapest_schedule
accept an optional power_profile (fixed per-interval watt array
matching the requested duration/task length). When allow_relaxation
was enabled and no full-duration window could be found, the
duration-reduction relaxation phase silently reduced the interval
count without shrinking power_profile accordingly. Downstream code
(find_cheapest_contiguous_window, calculate_window_statistics,
_find_cheapest_window_in_pool) then truncated the profile from the
front to match, dropping trailing appliance-cycle phases and using
the wrong per-interval weights for both window selection and the
reported cost estimate.

Disable duration-reduction relaxation whenever a power_profile is
supplied (find_cheapest_schedule guards per-task via
any_task_has_power_profile); distance and level-filter relaxation
phases remain available since they don't affect interval count.

Impact: Services with a power_profile no longer silently pick a
shorter window with mismatched power weighting during relaxation;
they now correctly report no window found if the full duration isn't
available, preserving the accuracy of appliance-cycle cost estimates.
2026-07-04 18:22:44 +00:00
Julian Pawlowski
2ad225f26d test(services): add end-to-end regression test for must_finish_by fix
Add a real (non-mocked) end-to-end test exercising apply_must_finish_by
and resolve_search_range together with a range-filtering fake pool,
reproducing the exact scenario from GH #168: a naive must_finish_by
datetime combined with search_start_day_offset: 0. The underlying fix
already landed in db5d172 but had no test covering the full request
pipeline (only individual helpers were mocked in existing tests).

Release-Notes: skip
User-Impact: none
2026-07-04 18:21:58 +00:00
Julian Pawlowski
36e9cdf4b9 fix(find_cheapest_schedule): retry valid window start after time gaps
_find_cheapest_window_in_pool() scans for contiguous available blocks
when scheduling multiple tasks. When a block-in-progress hit a
temporal gap (e.g. from price-level filtering removing intervals from
the middle of the search range, or missing API data), the scanner
jumped to i = j + 1 instead of i = j, silently skipping the interval
right after the gap as a valid — sometimes cheaper — window start.

Distinguish 'unavailable slot' (correctly skipped via j + 1) from
'temporal gap' (must retry at j, since that slot was never actually
tested as a window start).

Impact: find_cheapest_schedule can now find the true cheapest window
for a task when the search range contains time gaps, instead of
occasionally picking a more expensive window right after such a gap.
2026-07-04 18:18:42 +00:00
Julian Pawlowski
e4c208b376
Merge pull request #152 from ricklambrechts/update-dutch-pricing
Update Dutch energy price examples
2026-07-04 19:49:14 +02:00
Julian Pawlowski
b8e40bfa3b fix(services): prevent plan_charging overcharge from segment-constraint bridging
apply_segment_constraints could add far more grid energy than requested when
max_cycles_per_day or min_charge_duration_minutes forced bridging across
expensive gaps between cheap intervals. With max_cycles_per_day=1, isolated
cheap intervals were merged into one continuous segment by filling every gap
in between, without ever trimming the surplus back down, resulting in
achieved_soc_percent far above 100%.

Add a post-bridging trim step that removes segment-edge intervals (highest
price first) until total grid energy matches the requested target again,
while still respecting max_cycles_per_day/min_charge_duration_minutes and
never dropping below the target itself (fixed-power mode's expected
last-interval rounding overshoot is preserved).

A related edge case is also fixed: trimming could previously remove an
interval required to satisfy a must_reach_by deadline, silently flipping
deadline_met to False even though the overall energy target was still
reached. Deadline-critical intervals are now passed through as
protected_starts and are never removed during trimming.

Fixes #167

Impact: plan_charging no longer overcharges the battery/EV past the
requested target SoC when max_cycles_per_day or min_charge_duration_minutes
is set, and must_reach_by deadlines are honored even when those constraints
require bridging across expensive price gaps.
2026-07-04 17:43:04 +00:00
Julian Pawlowski
93d7d5de28 docs(community-examples): clarify energy price unit display for Norway and Sweden
Updated the description for Norway and Sweden's fee structures to specify that the energy price unit depends on the display mode, enhancing clarity for users implementing templates.

Impact: Users will have a clearer understanding of how to handle energy price units in their configurations.
2026-06-03 10:48:22 +00:00
Julian Pawlowski
c88e402d32 docs(community-examples): update Germany section for feed-in compensation details
Revise the Germany section to focus on feed-in compensation for exported PV energy and provide detailed steps for creating input number helpers and template sensors.

Impact: Users can better understand and implement feed-in compensation calculations in their Home Assistant setups.
2026-06-03 10:48:00 +00:00
Julian Pawlowski
8bdc366ed2 docs(community-examples): enhance country-specific price calculations section
Clarify the unit display modes for energy prices and provide guidance on template normalization for user-specific setups.

Impact: Users gain clearer instructions on adapting energy price calculations to their local currency display settings.
2026-06-03 10:47:25 +00:00
Rick Lambrechts
67602ac88d
Update Dutch energy price examples 2026-06-02 01:46:58 +02:00
18 changed files with 1086 additions and 166 deletions

View file

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

View file

@ -9,6 +9,7 @@ on:
permissions:
contents: write
actions: write # Needed to explicitly dispatch release.yml (see note below)
concurrency:
group: ${{ github.workflow }}
@ -66,6 +67,22 @@ jobs:
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
run: |
if [ "${{ steps.tag_check.outputs.exists }}" = "true" ]; then

View file

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

View file

@ -218,6 +218,245 @@ def _add_interval_if_available(
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(
schedule: dict[str, Any],
candidate_intervals: list[dict[str, Any]],
@ -225,9 +464,15 @@ def apply_segment_constraints(
charging_efficiency: float,
min_charge_duration_minutes: 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,
) -> 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] = []
selected_map = {interval["startsAt"]: dict(interval) for interval in schedule["intervals"]}
candidate_map = {interval["startsAt"]: interval for interval in candidate_intervals}
@ -236,103 +481,40 @@ def apply_segment_constraints(
minimum_power_w = int(schedule["minimum_power_w"])
if min_charge_duration_minutes:
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
)
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"]}
)
_extend_for_min_duration(
selected_map,
candidate_map=candidate_map,
candidates_sorted=candidates_sorted,
candidate_index=candidate_index,
minimum_power_w=minimum_power_w,
charging_efficiency=charging_efficiency,
interval_minutes=interval_minutes,
min_charge_duration_minutes=min_charge_duration_minutes,
warnings=warnings,
)
if max_cycles_per_day:
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
_merge_for_max_cycles(
selected_map,
candidate_map=candidate_map,
candidates_sorted=candidates_sorted,
candidate_index=candidate_index,
minimum_power_w=minimum_power_w,
charging_efficiency=charging_efficiency,
interval_minutes=interval_minutes,
max_cycles_per_day=max_cycles_per_day,
warnings=warnings,
)
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,
)
if target_grid_energy_kwh is not None:
selected_map = _trim_to_target_energy(
selected_map,
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,
)
selected_intervals = sorted(selected_map.values(), key=_interval_start)
segments = group_intervals_into_segments(selected_intervals)

View file

@ -368,7 +368,17 @@ async def _handle_find_block(
# --- Relaxation loop ---
if result is None and allow_relaxation:
max_reduction = calculate_max_duration_reduction_intervals(duration_intervals, duration_flexibility_minutes)
# A power_profile is a fixed per-interval watt array matching the original
# 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(
min_distance_from_avg=min_distance_from_avg,
max_price_level=max_price_level,

View file

@ -443,7 +443,18 @@ async def _handle_find_hours(
# --- Relaxation loop ---
if result is None and allow_relaxation:
max_reduction = calculate_max_duration_reduction_intervals(total_intervals, duration_flexibility_minutes)
# A power_profile is a fixed per-interval watt array matching the original
# 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)
steps = generate_relaxation_steps(
min_distance_from_avg=min_distance_from_avg,

View file

@ -229,6 +229,7 @@ def _find_cheapest_window_in_pool(
# and all intervals are contiguous in time (no gaps)
block: list[dict[str, Any]] = []
j = i
stopped_at_gap = False
while j < n and len(block) < duration_intervals:
if not available[j]:
break
@ -239,7 +240,10 @@ def _find_cheapest_window_in_pool(
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
if curr_dt - prev_dt != timedelta(minutes=INTERVAL_MINUTES):
# Gap in time — can't extend this block, skip to j+1
# Gap in time — can't extend this block, but slot j itself
# (already confirmed available above) is a fresh, untried
# candidate window start and must not be skipped.
stopped_at_gap = True
break
block.append(pool[j])
j += 1
@ -253,8 +257,15 @@ def _find_cheapest_window_in_pool(
best_sum = window_sum
best_start = i
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:
# Skip past the blocking unavailable/non-contiguous slot
# Skip past the blocking unavailable slot (or end of pool)
i = j + 1
if best_start == -1:
@ -560,7 +571,13 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
break
# Phase 2: Duration reduction (uniform across all tasks)
if best_unscheduled:
# Skipped when any task has a power_profile: it's a fixed per-interval
# 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)
max_reduction = calculate_max_duration_reduction_intervals(shortest_task, duration_flexibility_minutes)
for reduction in range(1, max_reduction + 1):

View file

@ -24,6 +24,7 @@ from __future__ import annotations
from datetime import datetime, timedelta
import math
import re
import statistics
from typing import TYPE_CHECKING, Any, Final
import voluptuous as vol
@ -174,7 +175,12 @@ def _calculate_metadata(
min_val = min(data)
max_val = max(data)
mean_val = sum(data) / len(data)
median_val = sorted(data)[len(data) // 2]
# CRITICAL: statistics.median() averages the two middle values for
# 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)
price_range = max_val - min_val

View file

@ -717,6 +717,15 @@ def check_min_distance_from_avg(
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.
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:
window_mean_base: Window mean price in BASE currency (not display unit).
range_avg: Search range average price in BASE currency.
@ -731,10 +740,11 @@ def check_min_distance_from_avg(
return True # Cannot calculate percentage difference from zero
distance_ratio = min_distance_pct / 100
distance_amount = abs(range_avg) * distance_ratio
if reverse:
# Most expensive: window mean must be >= avg * (1 + distance)
threshold = range_avg * (1 + distance_ratio)
# Most expensive: window mean must be >= avg + distance
threshold = range_avg + distance_amount
return window_mean_base >= threshold
# Cheapest: window mean must be <= avg * (1 - distance)
threshold = range_avg * (1 - distance_ratio)
# Cheapest: window mean must be <= avg - distance
threshold = range_avg - distance_amount
return window_mean_base <= threshold

View file

@ -576,12 +576,23 @@ def _attempt_plan(
if schedule["unallocated_grid_energy_kwh"] > 1e-6:
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,
candidates,
charging_efficiency=ctx.charging_efficiency,
min_charge_duration_minutes=ctx.min_charge_duration_minutes,
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,
)
scheduled_intervals = build_soc_progression_from_schedule(

View file

@ -12,7 +12,16 @@ This page collects **real-world examples** contributed by the community — temp
## 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. 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.
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**:
- 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?
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.
@ -34,11 +43,11 @@ In the Netherlands, the electricity price paid to consumers includes:
| Component | Dutch Name | Typical Value (2025) |
|-----------|-----------|---------------------|
| Spot price | Inkoopprijs | Variable (= `energy_price` attribute) |
| Spot price | Inkoopprijs | Variable (`energy_price` attribute; unit depends on display mode) |
| Energy tax | Energiebelasting | ~0.0916 €/kWh (excl. VAT) |
| VAT | BTW | 21% |
| Purchase fee | Inkoopvergoeding | ~0.0205 €/kWh |
| Sales fee | Verkoopvergoeding | ~0.0205 €/kWh |
| Sales fee | Verkoopvergoeding | ~-0.0205 €/kWh |
| VAT | BTW | 21% |
:::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.
@ -57,9 +66,13 @@ Create `input_number` helpers in Home Assistant for each fee component. This way
| Helper | Entity ID | Min | Max | Step | Unit | Example Value |
|--------|-----------|-----|-----|------|------|---------------|
| Energiebelasting | `input_number.energiebelasting` | 0 | 1 | 0.0001 | €/kWh | 0.0916 |
| BTW percentage | `input_number.btw_percentage` | 0 | 100 | 0.01 | % | 21 |
| Inkoopvergoeding | `input_number.inkoopvergoeding` | 0 | 1 | 0.0001 | €/kWh | 0.0205 |
| Verkoopvergoeding | `input_number.verkoopvergoeding` | 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 |
:::note Signed fee input
`input_number.verkoopvergoeding` is a signed value in this example, so negative values are allowed. Enter all fee components excluding VAT.
:::
<details>
<summary>Show YAML: Input Number Helpers</summary>
@ -75,13 +88,6 @@ input_number:
step: 0.0001
unit_of_measurement: "€/kWh"
icon: mdi:lightning-bolt
btw_percentage:
name: BTW Percentage
min: 0
max: 100
step: 0.01
unit_of_measurement: "%"
icon: mdi:percent
inkoopvergoeding:
name: Inkoopvergoeding
min: 0
@ -91,11 +97,18 @@ input_number:
icon: mdi:cash-minus
verkoopvergoeding:
name: Verkoopvergoeding
min: 0
min: -1
max: 1
step: 0.0001
unit_of_measurement: "€/kWh"
icon: mdi:cash-plus
btw_percentage:
name: BTW Percentage
min: 0
max: 100
step: 0.01
unit_of_measurement: "%"
icon: mdi:percent
```
</details>
@ -112,19 +125,26 @@ template:
- sensor:
# Feed-in compensation WITH saldering (current rules, until 2027)
# With saldering, you effectively earn the full consumer price
# minus the purchase fee, plus the sales fee.
# plus purchase and sales fee components (use negative verkoopvergoeding
# to offset inkoopvergoeding when your contract defines it that way).
- name: "Solar Feed-In Price (with Saldering)"
unique_id: solar_feed_in_saldering
unit_of_measurement: "€/kWh"
device_class: monetary
state: >
{% set energy = state_attr('sensor.<home_name>_current_electricity_price', 'energy_price') %}
{# Option A: current display-mode sensor (default) #}
{# 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 btw = states('input_number.btw_percentage') | float / 100 %}
{% set inkoop = states('input_number.inkoopvergoeding') | float %}
{% set verkoop = states('input_number.verkoopvergoeding') | float %}
{% if energy is not none %}
{{ ((energy + eb) * (1 + btw) - inkoop + verkoop) | round(4) }}
{% set btw = states('input_number.btw_percentage') | float / 100 %}
{% if energy_raw is not none %}
{% set energy = (energy_raw | float) / unit_factor %}
{{ ((energy + eb + inkoop + verkoop) * (1 + btw)) | round(4) }}
{% else %}
unavailable
{% endif %}
@ -132,17 +152,24 @@ template:
# Feed-in compensation WITHOUT saldering (after 2027)
# Without saldering, you only earn the raw spot price
# minus the purchase fee, plus the sales fee.
# plus purchase and sales fee components.
- name: "Solar Feed-In Price (without Saldering)"
unique_id: solar_feed_in_no_saldering
unit_of_measurement: "€/kWh"
device_class: monetary
state: >
{% set energy = state_attr('sensor.<home_name>_current_electricity_price', 'energy_price') %}
{# Option A: current display-mode sensor (default) #}
{# 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 verkoop = states('input_number.verkoopvergoeding') | float %}
{% if energy is not none %}
{{ (energy - inkoop + verkoop) | round(4) }}
{% set btw = states('input_number.btw_percentage') | float / 100 %}
{% if energy_raw is not none %}
{% set energy = (energy_raw | float) / unit_factor %}
{{ ((energy + inkoop + verkoop) * (1 + btw)) | round(4) }}
{% else %}
unavailable
{% endif %}
@ -187,6 +214,10 @@ automation:
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>
<summary>Show YAML: Preparing for the End of Saldering</summary>
@ -199,7 +230,7 @@ entities:
- type: attribute
entity: sensor.<home_name>_current_electricity_price
attribute: energy_price
name: "Spot Price (energy)"
name: "Spot Price (energy, ct/kWh)"
icon: mdi:transmission-tower
- entity: sensor.solar_feed_in_price_with_saldering
name: "Feed-In with Saldering"
@ -213,43 +244,135 @@ entities:
---
## 🇩🇪 Germany: Price Composition
## 🇩🇪 Germany: Feed-In Compensation
### Background
In Germany, the electricity price includes numerous components bundled into `tax`:
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.
| 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% |
That means the practical question is often:
### Template: Spot Price Share
- consume/store energy locally now, or
- export now at your fixed feed-in rate
A simple template sensor showing what percentage of your total price is the actual energy cost:
### Step 1: Create Input Number Helper for Feed-In Compensation
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>
<summary>Show YAML: Spot Price Share</summary>
<summary>Show YAML: Input Number Helper</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
template:
- sensor:
- name: "Spot Price Share"
unique_id: spot_price_share
unit_of_measurement: "%"
- name: "Current Electricity Price (EUR normalized)"
unique_id: current_electricity_price_eur_normalized
unit_of_measurement: "€/kWh"
device_class: monetary
state: >
{% set energy = state_attr('sensor.<home_name>_current_electricity_price', 'energy_price') %}
{% set total = states('sensor.<home_name>_current_electricity_price') | float %}
{% if energy is not none and total > 0 %}
{{ ((energy / total) * 100) | round(1) }}
{% set price_entity = 'sensor.<home_name>_current_electricity_price' %}
{% set total_raw = states(price_entity) | float(none) %}
{% set price_unit = state_attr(price_entity, 'unit_of_measurement') %}
{% set unit_factor = 100 if price_unit == 'ct/kWh' else 1 %}
{% if total_raw is not none %}
{{ (total_raw / unit_factor) | round(4) }}
{% else %}
unavailable
{% endif %}
icon: mdi:chart-pie
icon: mdi:currency-eur
- 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>
@ -258,7 +381,7 @@ template:
## 🇳🇴 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.
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).
**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,30 +435,53 @@ ${DIFF_CONTEXT}
Output ONLY the release notes. Start directly with the # title.
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.
# 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
COPILOT_MODEL="${COPILOT_MODEL:-claude-sonnet-4.6}"
# Call copilot CLI (it will handle authentication interactively)
copilot --model "$COPILOT_MODEL" <"$TEMP_PROMPT" || {
# Call copilot CLI (it will handle authentication interactively).
# Flags matter here (recent copilot CLI versions, e.g. 1.0.68+):
# 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 ""
log_info "${YELLOW}Warning: GitHub Copilot CLI failed or was not authenticated${NC}"
log_info "${YELLOW}Warning: GitHub Copilot CLI failed, was not authenticated, or returned no output${NC}"
log_info "${YELLOW}Falling back to git-cliff${NC}"
rm -f "$TEMP_PROMPT"
if command -v git-cliff >/dev/null 2>&1; then
generate_with_gitcliff
else
generate_with_manual
fi
return
}
fi
rm -f "$TEMP_PROMPT"
printf "%s\n" "$COPILOT_OUTPUT"
}
# ============================================================================

View file

@ -0,0 +1,76 @@
"""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,6 +5,7 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
from zoneinfo import ZoneInfo
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
@ -351,6 +352,150 @@ async def test_block_handler_preserves_service_search_data(monkeypatch: pytest.M
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
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."""

View file

@ -0,0 +1,77 @@
"""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,6 +203,60 @@ async def test_plan_charging_can_meet_deadline_before_peak_period(monkeypatch: p
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
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."""
@ -292,7 +346,7 @@ async def test_plan_charging_respects_min_charge_duration(monkeypatch: pytest.Mo
@pytest.mark.asyncio
async def test_plan_charging_respects_max_cycles_per_day(monkeypatch: pytest.MonkeyPatch) -> None:
"""Multiple cheap isolated intervals should be bridged to satisfy max cycle limits."""
"""Cycle merging must not overcharge beyond the requested target energy."""
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)
@ -327,7 +381,10 @@ async def test_plan_charging_respects_max_cycles_per_day(monkeypatch: pytest.Mon
assert response["intervals_found"] is True
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"])
assert [interval["price"] for interval in scheduled[:5]] == [0.1, 0.8, 0.11, 0.9, 0.12]
assert [interval["price"] for interval in scheduled] == [0.1, 0.8, 0.11]
assert response["warnings"] is None

View file

@ -6,7 +6,10 @@ from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
from custom_components.tibber_prices.services.charging.deadline_solver import resolve_deadline
from custom_components.tibber_prices.services.charging.power_scheduler import build_power_schedule
from custom_components.tibber_prices.services.charging.power_scheduler import (
apply_segment_constraints,
build_power_schedule,
)
def _make_intervals(prices: list[float]) -> list[dict[str, object]]:
@ -51,6 +54,85 @@ def test_stepped_mode_uses_smallest_sufficient_step() -> None:
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:
"""Deadline helper should resolve the next future peak period start."""
now = datetime(2026, 1, 1, 0, 0, tzinfo=UTC)

View file

@ -0,0 +1,71 @@
"""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