mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-27 17:26:48 +00:00
Compare commits
16 commits
e94130953f
...
2f202dc186
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f202dc186 | ||
|
|
1694431f26 | ||
|
|
49ea70ed9f | ||
|
|
de6da790ab | ||
|
|
569a7cb73a | ||
|
|
13b9870f45 | ||
|
|
1b207f0db1 | ||
|
|
95004efa2d | ||
|
|
2ad225f26d | ||
|
|
36e9cdf4b9 | ||
|
|
e4c208b376 | ||
|
|
b8e40bfa3b | ||
|
|
93d7d5de28 | ||
|
|
c88e402d32 | ||
|
|
8bdc366ed2 | ||
|
|
67602ac88d |
18 changed files with 1086 additions and 166 deletions
|
|
@ -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,
|
||||
|
|
|
|||
17
.github/workflows/auto-tag.yml
vendored
17
.github/workflows/auto-tag.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,5 +11,5 @@
|
|||
"requirements": [
|
||||
"aiofiles>=23.2.1"
|
||||
],
|
||||
"version": "0.32.0b1"
|
||||
"version": "0.32.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
|
|
|
|||
76
tests/services/test_find_cheapest_schedule_internals.py
Normal file
76
tests/services/test_find_cheapest_schedule_internals.py
Normal 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)
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
77
tests/services/test_get_chartdata_metadata.py
Normal file
77
tests/services/test_get_chartdata_metadata.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
71
tests/services/test_service_helpers.py
Normal file
71
tests/services/test_service_helpers.py
Normal 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
|
||||
Loading…
Reference in a new issue