mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-27 17:26:48 +00:00
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.
This commit is contained in:
parent
e94130953f
commit
b8e40bfa3b
4 changed files with 430 additions and 98 deletions
|
|
@ -218,6 +218,245 @@ def _add_interval_if_available(
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _constraints_satisfied(
|
||||||
|
intervals: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
max_cycles_per_day: int | None,
|
||||||
|
min_charge_duration_minutes: int | None,
|
||||||
|
interval_minutes: int,
|
||||||
|
) -> bool:
|
||||||
|
"""Check whether current interval selection satisfies active constraints."""
|
||||||
|
grouped_segments = group_intervals_into_segments(intervals)
|
||||||
|
|
||||||
|
if max_cycles_per_day and len(grouped_segments) > max_cycles_per_day:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if min_charge_duration_minutes:
|
||||||
|
required_intervals = max(1, math.ceil(min_charge_duration_minutes / interval_minutes))
|
||||||
|
if any(segment["interval_count"] < required_intervals for segment in grouped_segments):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _extend_for_min_duration(
|
||||||
|
selected_map: dict[str, dict[str, Any]],
|
||||||
|
*,
|
||||||
|
candidate_map: dict[str, dict[str, Any]],
|
||||||
|
candidates_sorted: list[dict[str, Any]],
|
||||||
|
candidate_index: dict[str, int],
|
||||||
|
minimum_power_w: int,
|
||||||
|
charging_efficiency: float,
|
||||||
|
interval_minutes: int,
|
||||||
|
min_charge_duration_minutes: int,
|
||||||
|
warnings: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Extend short segments by adding contiguous neighbor intervals."""
|
||||||
|
required_intervals = max(1, math.ceil(min_charge_duration_minutes / interval_minutes))
|
||||||
|
progress = True
|
||||||
|
|
||||||
|
while progress:
|
||||||
|
progress = False
|
||||||
|
selected_intervals = sorted(selected_map.values(), key=_interval_start)
|
||||||
|
segments = group_intervals_into_segments(selected_intervals)
|
||||||
|
|
||||||
|
for segment in segments:
|
||||||
|
if segment["interval_count"] >= required_intervals:
|
||||||
|
continue
|
||||||
|
|
||||||
|
while segment["interval_count"] < required_intervals:
|
||||||
|
first = segment["intervals"][0]["startsAt"]
|
||||||
|
last = segment["intervals"][-1]["startsAt"]
|
||||||
|
first_index = candidate_index[first]
|
||||||
|
last_index = candidate_index[last]
|
||||||
|
|
||||||
|
prev_interval = candidates_sorted[first_index - 1] if first_index > 0 else None
|
||||||
|
next_interval = candidates_sorted[last_index + 1] if last_index + 1 < len(candidates_sorted) else None
|
||||||
|
|
||||||
|
options: list[dict[str, Any]] = []
|
||||||
|
if (
|
||||||
|
prev_interval is not None
|
||||||
|
and _interval_start(candidate_map[first]) - _interval_start(prev_interval)
|
||||||
|
== timedelta(minutes=interval_minutes)
|
||||||
|
and prev_interval["startsAt"] not in selected_map
|
||||||
|
):
|
||||||
|
options.append(prev_interval)
|
||||||
|
|
||||||
|
if (
|
||||||
|
next_interval is not None
|
||||||
|
and _interval_start(next_interval) - _interval_start(candidate_map[last])
|
||||||
|
== timedelta(minutes=interval_minutes)
|
||||||
|
and next_interval["startsAt"] not in selected_map
|
||||||
|
):
|
||||||
|
options.append(next_interval)
|
||||||
|
|
||||||
|
if not options:
|
||||||
|
warnings.append("min_charge_duration_unreachable")
|
||||||
|
break
|
||||||
|
|
||||||
|
cheapest = min(options, key=lambda interval: (_sort_price(interval), _interval_start(interval)))
|
||||||
|
added = _add_interval_if_available(
|
||||||
|
selected_map,
|
||||||
|
candidate_map,
|
||||||
|
cheapest["startsAt"],
|
||||||
|
power_w=minimum_power_w,
|
||||||
|
charging_efficiency=charging_efficiency,
|
||||||
|
interval_minutes=interval_minutes,
|
||||||
|
)
|
||||||
|
if not added:
|
||||||
|
break
|
||||||
|
|
||||||
|
progress = True
|
||||||
|
selected_intervals = sorted(selected_map.values(), key=_interval_start)
|
||||||
|
segment = next(
|
||||||
|
seg
|
||||||
|
for seg in group_intervals_into_segments(selected_intervals)
|
||||||
|
if first in {iv["startsAt"] for iv in seg["intervals"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_for_max_cycles(
|
||||||
|
selected_map: dict[str, dict[str, Any]],
|
||||||
|
*,
|
||||||
|
candidate_map: dict[str, dict[str, Any]],
|
||||||
|
candidates_sorted: list[dict[str, Any]],
|
||||||
|
candidate_index: dict[str, int],
|
||||||
|
minimum_power_w: int,
|
||||||
|
charging_efficiency: float,
|
||||||
|
interval_minutes: int,
|
||||||
|
max_cycles_per_day: int,
|
||||||
|
warnings: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Bridge cheapest gaps until the cycle limit is satisfied."""
|
||||||
|
while True:
|
||||||
|
selected_intervals = sorted(selected_map.values(), key=_interval_start)
|
||||||
|
segments = group_intervals_into_segments(selected_intervals)
|
||||||
|
if len(segments) <= max_cycles_per_day:
|
||||||
|
break
|
||||||
|
|
||||||
|
best_gap: tuple[float, list[dict[str, Any]]] | None = None
|
||||||
|
for left, right in pairwise(segments):
|
||||||
|
left_end_index = candidate_index[left["intervals"][-1]["startsAt"]]
|
||||||
|
right_start_index = candidate_index[right["intervals"][0]["startsAt"]]
|
||||||
|
gap = candidates_sorted[left_end_index + 1 : right_start_index]
|
||||||
|
|
||||||
|
if not gap:
|
||||||
|
continue
|
||||||
|
if any(interval["startsAt"] in selected_map for interval in gap):
|
||||||
|
continue
|
||||||
|
if any(
|
||||||
|
_interval_start(gap[index + 1]) - _interval_start(gap[index]) != timedelta(minutes=interval_minutes)
|
||||||
|
for index in range(len(gap) - 1)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
penalty = sum(_sort_price(interval) for interval in gap)
|
||||||
|
if best_gap is None or penalty < best_gap[0]:
|
||||||
|
best_gap = (penalty, gap)
|
||||||
|
|
||||||
|
if best_gap is None:
|
||||||
|
warnings.append("max_cycles_unreachable")
|
||||||
|
break
|
||||||
|
|
||||||
|
for interval in best_gap[1]:
|
||||||
|
_add_interval_if_available(
|
||||||
|
selected_map,
|
||||||
|
candidate_map,
|
||||||
|
interval["startsAt"],
|
||||||
|
power_w=minimum_power_w,
|
||||||
|
charging_efficiency=charging_efficiency,
|
||||||
|
interval_minutes=interval_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_removable_edge_indices(
|
||||||
|
selected_intervals: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
total_grid_energy: float,
|
||||||
|
target_grid_energy_kwh: float,
|
||||||
|
max_cycles_per_day: int | None,
|
||||||
|
min_charge_duration_minutes: int | None,
|
||||||
|
interval_minutes: int,
|
||||||
|
protected_starts: frozenset[str] | None,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Return edge interval indices that can be removed while keeping constraints valid."""
|
||||||
|
removable_indices: list[int] = []
|
||||||
|
segments = group_intervals_into_segments(selected_intervals)
|
||||||
|
|
||||||
|
for segment in segments:
|
||||||
|
first_start = segment["intervals"][0]["startsAt"]
|
||||||
|
last_start = segment["intervals"][-1]["startsAt"]
|
||||||
|
|
||||||
|
for edge_start in (first_start, last_start):
|
||||||
|
if protected_starts is not None and edge_start in protected_starts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
edge_index = next(
|
||||||
|
(index for index, interval in enumerate(selected_intervals) if interval["startsAt"] == edge_start),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if edge_index is None or edge_index in removable_indices:
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate = selected_intervals[edge_index]
|
||||||
|
new_total = total_grid_energy - float(candidate["grid_energy_kwh"])
|
||||||
|
if new_total + _INTERVAL_TOLERANCE < target_grid_energy_kwh:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_selection = selected_intervals[:edge_index] + selected_intervals[edge_index + 1 :]
|
||||||
|
if not new_selection:
|
||||||
|
continue
|
||||||
|
if not _constraints_satisfied(
|
||||||
|
new_selection,
|
||||||
|
max_cycles_per_day=max_cycles_per_day,
|
||||||
|
min_charge_duration_minutes=min_charge_duration_minutes,
|
||||||
|
interval_minutes=interval_minutes,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
removable_indices.append(edge_index)
|
||||||
|
|
||||||
|
return removable_indices
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_to_target_energy(
|
||||||
|
selected_map: dict[str, dict[str, Any]],
|
||||||
|
*,
|
||||||
|
target_grid_energy_kwh: float,
|
||||||
|
max_cycles_per_day: int | None,
|
||||||
|
min_charge_duration_minutes: int | None,
|
||||||
|
interval_minutes: int,
|
||||||
|
protected_starts: frozenset[str] | None = None,
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Trim excess energy from selection by removing expensive edge intervals first.
|
||||||
|
|
||||||
|
Intervals whose ``startsAt`` is listed in ``protected_starts`` (for example, intervals
|
||||||
|
required to satisfy a ``must_reach_by`` deadline) are never removed, even if that means
|
||||||
|
the target energy cannot be fully reached through trimming alone.
|
||||||
|
"""
|
||||||
|
selected_intervals = sorted(selected_map.values(), key=_interval_start)
|
||||||
|
total_grid_energy = sum(float(interval["grid_energy_kwh"]) for interval in selected_intervals)
|
||||||
|
|
||||||
|
while selected_intervals and total_grid_energy > target_grid_energy_kwh + _INTERVAL_TOLERANCE:
|
||||||
|
removable_indices = _collect_removable_edge_indices(
|
||||||
|
selected_intervals,
|
||||||
|
total_grid_energy=total_grid_energy,
|
||||||
|
target_grid_energy_kwh=target_grid_energy_kwh,
|
||||||
|
max_cycles_per_day=max_cycles_per_day,
|
||||||
|
min_charge_duration_minutes=min_charge_duration_minutes,
|
||||||
|
interval_minutes=interval_minutes,
|
||||||
|
protected_starts=protected_starts,
|
||||||
|
)
|
||||||
|
if not removable_indices:
|
||||||
|
break
|
||||||
|
|
||||||
|
best_index = max(removable_indices, key=lambda index: _sort_price(selected_intervals[index]))
|
||||||
|
total_grid_energy -= float(selected_intervals[best_index]["grid_energy_kwh"])
|
||||||
|
del selected_intervals[best_index]
|
||||||
|
|
||||||
|
return {interval["startsAt"]: interval for interval in selected_intervals}
|
||||||
|
|
||||||
|
|
||||||
def apply_segment_constraints(
|
def apply_segment_constraints(
|
||||||
schedule: dict[str, Any],
|
schedule: dict[str, Any],
|
||||||
candidate_intervals: list[dict[str, Any]],
|
candidate_intervals: list[dict[str, Any]],
|
||||||
|
|
@ -225,9 +464,15 @@ def apply_segment_constraints(
|
||||||
charging_efficiency: float,
|
charging_efficiency: float,
|
||||||
min_charge_duration_minutes: int | None = None,
|
min_charge_duration_minutes: int | None = None,
|
||||||
max_cycles_per_day: int | None = None,
|
max_cycles_per_day: int | None = None,
|
||||||
|
target_grid_energy_kwh: float | None = None,
|
||||||
|
protected_starts: frozenset[str] | None = None,
|
||||||
interval_minutes: int = 15,
|
interval_minutes: int = 15,
|
||||||
) -> tuple[dict[str, Any], list[str]]:
|
) -> tuple[dict[str, Any], list[str]]:
|
||||||
"""Extend/bridge selected intervals to satisfy segment duration and cycle constraints."""
|
"""Extend/bridge selected intervals to satisfy segment duration and cycle constraints.
|
||||||
|
|
||||||
|
``protected_starts`` marks intervals (by ``startsAt``) that must never be removed while
|
||||||
|
trimming to ``target_grid_energy_kwh``, e.g. intervals required to meet a deadline.
|
||||||
|
"""
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
selected_map = {interval["startsAt"]: dict(interval) for interval in schedule["intervals"]}
|
selected_map = {interval["startsAt"]: dict(interval) for interval in schedule["intervals"]}
|
||||||
candidate_map = {interval["startsAt"]: interval for interval in candidate_intervals}
|
candidate_map = {interval["startsAt"]: interval for interval in candidate_intervals}
|
||||||
|
|
@ -236,103 +481,40 @@ def apply_segment_constraints(
|
||||||
minimum_power_w = int(schedule["minimum_power_w"])
|
minimum_power_w = int(schedule["minimum_power_w"])
|
||||||
|
|
||||||
if min_charge_duration_minutes:
|
if min_charge_duration_minutes:
|
||||||
required_intervals = max(1, math.ceil(min_charge_duration_minutes / interval_minutes))
|
_extend_for_min_duration(
|
||||||
progress = True
|
selected_map,
|
||||||
while progress:
|
candidate_map=candidate_map,
|
||||||
progress = False
|
candidates_sorted=candidates_sorted,
|
||||||
selected_intervals = sorted(selected_map.values(), key=_interval_start)
|
candidate_index=candidate_index,
|
||||||
segments = group_intervals_into_segments(selected_intervals)
|
minimum_power_w=minimum_power_w,
|
||||||
for segment in segments:
|
charging_efficiency=charging_efficiency,
|
||||||
if segment["interval_count"] >= required_intervals:
|
interval_minutes=interval_minutes,
|
||||||
continue
|
min_charge_duration_minutes=min_charge_duration_minutes,
|
||||||
while segment["interval_count"] < required_intervals:
|
warnings=warnings,
|
||||||
first = segment["intervals"][0]["startsAt"]
|
)
|
||||||
last = segment["intervals"][-1]["startsAt"]
|
|
||||||
first_index = candidate_index[first]
|
|
||||||
last_index = candidate_index[last]
|
|
||||||
|
|
||||||
prev_interval = candidates_sorted[first_index - 1] if first_index > 0 else None
|
|
||||||
next_interval = (
|
|
||||||
candidates_sorted[last_index + 1] if last_index + 1 < len(candidates_sorted) else None
|
|
||||||
)
|
|
||||||
|
|
||||||
prev_contiguous = False
|
|
||||||
next_contiguous = False
|
|
||||||
if prev_interval is not None:
|
|
||||||
prev_contiguous = _interval_start(candidate_map[first]) - _interval_start(
|
|
||||||
prev_interval
|
|
||||||
) == timedelta(minutes=interval_minutes)
|
|
||||||
if next_interval is not None:
|
|
||||||
next_contiguous = _interval_start(next_interval) - _interval_start(
|
|
||||||
candidate_map[last]
|
|
||||||
) == timedelta(minutes=interval_minutes)
|
|
||||||
|
|
||||||
options: list[dict[str, Any]] = []
|
|
||||||
if prev_interval is not None and prev_contiguous and prev_interval["startsAt"] not in selected_map:
|
|
||||||
options.append(prev_interval)
|
|
||||||
if next_interval is not None and next_contiguous and next_interval["startsAt"] not in selected_map:
|
|
||||||
options.append(next_interval)
|
|
||||||
if not options:
|
|
||||||
warnings.append("min_charge_duration_unreachable")
|
|
||||||
break
|
|
||||||
|
|
||||||
cheapest = min(options, key=lambda interval: (_sort_price(interval), _interval_start(interval)))
|
|
||||||
added = _add_interval_if_available(
|
|
||||||
selected_map,
|
|
||||||
candidate_map,
|
|
||||||
cheapest["startsAt"],
|
|
||||||
power_w=minimum_power_w,
|
|
||||||
charging_efficiency=charging_efficiency,
|
|
||||||
interval_minutes=interval_minutes,
|
|
||||||
)
|
|
||||||
if not added:
|
|
||||||
break
|
|
||||||
progress = True
|
|
||||||
selected_intervals = sorted(selected_map.values(), key=_interval_start)
|
|
||||||
segment = next(
|
|
||||||
seg
|
|
||||||
for seg in group_intervals_into_segments(selected_intervals)
|
|
||||||
if first in {iv["startsAt"] for iv in seg["intervals"]}
|
|
||||||
)
|
|
||||||
|
|
||||||
if max_cycles_per_day:
|
if max_cycles_per_day:
|
||||||
while True:
|
_merge_for_max_cycles(
|
||||||
selected_intervals = sorted(selected_map.values(), key=_interval_start)
|
selected_map,
|
||||||
segments = group_intervals_into_segments(selected_intervals)
|
candidate_map=candidate_map,
|
||||||
if len(segments) <= max_cycles_per_day:
|
candidates_sorted=candidates_sorted,
|
||||||
break
|
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
|
if target_grid_energy_kwh is not None:
|
||||||
for left, right in pairwise(segments):
|
selected_map = _trim_to_target_energy(
|
||||||
left_end_index = candidate_index[left["intervals"][-1]["startsAt"]]
|
selected_map,
|
||||||
right_start_index = candidate_index[right["intervals"][0]["startsAt"]]
|
target_grid_energy_kwh=target_grid_energy_kwh,
|
||||||
gap = candidates_sorted[left_end_index + 1 : right_start_index]
|
max_cycles_per_day=max_cycles_per_day,
|
||||||
if not gap:
|
min_charge_duration_minutes=min_charge_duration_minutes,
|
||||||
continue
|
interval_minutes=interval_minutes,
|
||||||
if any(interval["startsAt"] in selected_map for interval in gap):
|
protected_starts=protected_starts,
|
||||||
continue
|
)
|
||||||
if any(
|
|
||||||
_interval_start(gap[index + 1]) - _interval_start(gap[index]) != timedelta(minutes=interval_minutes)
|
|
||||||
for index in range(len(gap) - 1)
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
penalty = sum(_sort_price(interval) for interval in gap)
|
|
||||||
if best_gap is None or penalty < best_gap[0]:
|
|
||||||
best_gap = (penalty, gap)
|
|
||||||
|
|
||||||
if best_gap is None:
|
|
||||||
warnings.append("max_cycles_unreachable")
|
|
||||||
break
|
|
||||||
|
|
||||||
for interval in best_gap[1]:
|
|
||||||
_add_interval_if_available(
|
|
||||||
selected_map,
|
|
||||||
candidate_map,
|
|
||||||
interval["startsAt"],
|
|
||||||
power_w=minimum_power_w,
|
|
||||||
charging_efficiency=charging_efficiency,
|
|
||||||
interval_minutes=interval_minutes,
|
|
||||||
)
|
|
||||||
|
|
||||||
selected_intervals = sorted(selected_map.values(), key=_interval_start)
|
selected_intervals = sorted(selected_map.values(), key=_interval_start)
|
||||||
segments = group_intervals_into_segments(selected_intervals)
|
segments = group_intervals_into_segments(selected_intervals)
|
||||||
|
|
|
||||||
|
|
@ -576,12 +576,23 @@ def _attempt_plan(
|
||||||
if schedule["unallocated_grid_energy_kwh"] > 1e-6:
|
if schedule["unallocated_grid_energy_kwh"] > 1e-6:
|
||||||
return None, "energy_unreachable"
|
return None, "energy_unreachable"
|
||||||
|
|
||||||
|
# Deadline-critical intervals (selected to satisfy must_reach_soc by the deadline) must
|
||||||
|
# never be dropped by segment-constraint trimming, or deadline_met could silently become
|
||||||
|
# False even though the overall energy target is still satisfied.
|
||||||
|
protected_starts = (
|
||||||
|
frozenset(interval["startsAt"] for interval in schedule["pre_deadline"]["intervals"])
|
||||||
|
if "pre_deadline" in schedule
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
schedule, warnings = apply_segment_constraints(
|
schedule, warnings = apply_segment_constraints(
|
||||||
schedule,
|
schedule,
|
||||||
candidates,
|
candidates,
|
||||||
charging_efficiency=ctx.charging_efficiency,
|
charging_efficiency=ctx.charging_efficiency,
|
||||||
min_charge_duration_minutes=ctx.min_charge_duration_minutes,
|
min_charge_duration_minutes=ctx.min_charge_duration_minutes,
|
||||||
max_cycles_per_day=ctx.max_cycles_per_day,
|
max_cycles_per_day=ctx.max_cycles_per_day,
|
||||||
|
target_grid_energy_kwh=effective_energy_needed_grid_kwh,
|
||||||
|
protected_starts=protected_starts,
|
||||||
interval_minutes=INTERVAL_MINUTES,
|
interval_minutes=INTERVAL_MINUTES,
|
||||||
)
|
)
|
||||||
scheduled_intervals = build_soc_progression_from_schedule(
|
scheduled_intervals = build_soc_progression_from_schedule(
|
||||||
|
|
|
||||||
|
|
@ -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
|
assert response["deadline"]["achieved_soc_kwh"] >= 4.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_plan_charging_max_cycles_does_not_break_deadline(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Cycle merging/trimming must never discard intervals required to meet a deadline.
|
||||||
|
|
||||||
|
Regression test for a bug where ``apply_segment_constraints`` could remove the
|
||||||
|
deadline-critical interval while trimming excess energy added by cycle bridging,
|
||||||
|
silently turning ``deadline_met`` false even though the overall target was reached.
|
||||||
|
"""
|
||||||
|
intervals = _make_intervals([0.80, 0.95, 0.95, 0.05])
|
||||||
|
fake_tuple = _build_fake_entry_and_coordinator(intervals)
|
||||||
|
|
||||||
|
monkeypatch.setattr(charging_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
|
||||||
|
monkeypatch.setattr(charging_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
charging_module,
|
||||||
|
"resolve_search_range",
|
||||||
|
lambda _call_data, _now, _home_tz: (
|
||||||
|
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
|
||||||
|
datetime(2026, 1, 1, 1, 0, tzinfo=UTC),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(charging_module, "get_display_unit_factor", lambda _entry: 1)
|
||||||
|
monkeypatch.setattr(charging_module, "get_display_unit_string", lambda _entry, _currency: "EUR/kWh")
|
||||||
|
monkeypatch.setattr(charging_module.dt_util, "now", lambda: datetime(2026, 1, 1, 0, 0, tzinfo=UTC))
|
||||||
|
|
||||||
|
call = SimpleNamespace(
|
||||||
|
hass=object(),
|
||||||
|
data={
|
||||||
|
"battery_capacity_kwh": 10.0,
|
||||||
|
"current_soc_percent": 20.0,
|
||||||
|
"target_soc_percent": 40.0,
|
||||||
|
"must_reach_soc_percent": 30.0,
|
||||||
|
"must_reach_by": datetime(2026, 1, 1, 0, 15, tzinfo=UTC),
|
||||||
|
"max_charge_power_w": 4000,
|
||||||
|
"max_cycles_per_day": 1,
|
||||||
|
"charging_efficiency": 1.0,
|
||||||
|
"use_base_unit": True,
|
||||||
|
"allow_relaxation": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = cast("dict[str, Any]", await handle_plan_charging(cast("ServiceCall", call)))
|
||||||
|
|
||||||
|
assert response["intervals_found"] is True
|
||||||
|
assert response["charging"]["schedule"]["segment_count"] == 1
|
||||||
|
assert response["deadline"]["deadline_met"] is True
|
||||||
|
assert response["deadline"]["achieved_soc_kwh"] == 3.0
|
||||||
|
assert response["battery"]["target_met"] is True
|
||||||
|
assert response["battery"]["achieved_soc_kwh"] == 4.0
|
||||||
|
|
||||||
|
scheduled = cast("list[dict[str, Any]]", response["charging"]["schedule"]["intervals"])
|
||||||
|
assert scheduled[0]["price"] == 0.8 # the deadline-critical interval must survive trimming
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_plan_charging_can_filter_by_profitability(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_plan_charging_can_filter_by_profitability(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Economic filtering should keep only profitable intervals when reserve_for_discharge is enabled."""
|
"""Economic filtering should keep only profitable intervals when reserve_for_discharge is enabled."""
|
||||||
|
|
@ -292,7 +346,7 @@ async def test_plan_charging_respects_min_charge_duration(monkeypatch: pytest.Mo
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_plan_charging_respects_max_cycles_per_day(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_plan_charging_respects_max_cycles_per_day(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""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])
|
intervals = _make_intervals([0.10, 0.80, 0.11, 0.90, 0.12, 0.95, 0.50, 0.60])
|
||||||
fake_tuple = _build_fake_entry_and_coordinator(intervals)
|
fake_tuple = _build_fake_entry_and_coordinator(intervals)
|
||||||
|
|
||||||
|
|
@ -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["intervals_found"] is True
|
||||||
assert response["charging"]["schedule"]["segment_count"] == 1
|
assert response["charging"]["schedule"]["segment_count"] == 1
|
||||||
|
assert response["charging"]["total_energy_kwh"] == 3.0
|
||||||
|
assert response["battery"]["achieved_soc_kwh"] == 5.0
|
||||||
|
assert response["battery"]["target_met"] is True
|
||||||
|
|
||||||
scheduled = cast("list[dict[str, Any]]", response["charging"]["schedule"]["intervals"])
|
scheduled = cast("list[dict[str, Any]]", response["charging"]["schedule"]["intervals"])
|
||||||
assert [interval["price"] for interval in scheduled[: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
|
assert response["warnings"] is None
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ from datetime import UTC, datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from custom_components.tibber_prices.services.charging.deadline_solver import resolve_deadline
|
from custom_components.tibber_prices.services.charging.deadline_solver import resolve_deadline
|
||||||
from custom_components.tibber_prices.services.charging.power_scheduler import 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]]:
|
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]
|
assert sorted(interval["power_w"] for interval in result["intervals"]) == [2000, 4000, 4000]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_segment_constraints_bridges_and_trims_to_target() -> None:
|
||||||
|
"""Bridging isolated cheap intervals must not leave more energy than requested.
|
||||||
|
|
||||||
|
Direct unit-level regression for the ``max_cycles_per_day`` overcharge bug: bridging
|
||||||
|
fills the gaps between segments with extra (non-essential) intervals, and the
|
||||||
|
subsequent trim step must remove exactly that surplus again.
|
||||||
|
"""
|
||||||
|
candidates = _make_intervals([0.10, 0.80, 0.11, 0.90, 0.12, 0.95, 0.50, 0.60])
|
||||||
|
schedule = build_power_schedule(candidates, 3.0, max_charge_power_w=4000, charging_efficiency=1.0)
|
||||||
|
assert schedule["total_grid_energy_kwh"] == 3.0
|
||||||
|
assert len(schedule["segments"]) == 3 # three isolated cheap intervals
|
||||||
|
|
||||||
|
schedule, warnings = apply_segment_constraints(
|
||||||
|
schedule,
|
||||||
|
candidates,
|
||||||
|
charging_efficiency=1.0,
|
||||||
|
max_cycles_per_day=1,
|
||||||
|
target_grid_energy_kwh=3.0,
|
||||||
|
interval_minutes=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert warnings == []
|
||||||
|
assert len(schedule["segments"]) == 1
|
||||||
|
assert schedule["total_grid_energy_kwh"] == 3.0
|
||||||
|
assert [interval["total"] for interval in schedule["intervals"]] == [0.10, 0.80, 0.11]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_segment_constraints_never_trims_below_target() -> None:
|
||||||
|
"""Trimming must never drop the plan below the required energy.
|
||||||
|
|
||||||
|
Fixed-power mode cannot reduce the last interval's power, so it can legitimately
|
||||||
|
overshoot the target by up to one interval's energy. This is expected physical
|
||||||
|
behavior (not constraint bridging) and must survive trimming unchanged.
|
||||||
|
"""
|
||||||
|
candidates = _make_intervals([0.10, 0.20])
|
||||||
|
schedule = build_power_schedule(candidates, 1.5, max_charge_power_w=4000, charging_efficiency=1.0)
|
||||||
|
assert schedule["total_grid_energy_kwh"] == 2.0 # rounded up from 1.5
|
||||||
|
|
||||||
|
schedule, warnings = apply_segment_constraints(
|
||||||
|
schedule,
|
||||||
|
candidates,
|
||||||
|
charging_efficiency=1.0,
|
||||||
|
target_grid_energy_kwh=1.5,
|
||||||
|
interval_minutes=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert warnings == []
|
||||||
|
assert schedule["total_grid_energy_kwh"] == 2.0 # both intervals kept
|
||||||
|
assert len(schedule["intervals"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_segment_constraints_respects_protected_starts() -> None:
|
||||||
|
"""Protected intervals (e.g. deadline-critical) must survive trimming.
|
||||||
|
|
||||||
|
Even when a protected interval is the most expensive edge of the merged segment,
|
||||||
|
trimming must skip it and remove the next-best removable edge instead.
|
||||||
|
"""
|
||||||
|
candidates = _make_intervals([0.80, 0.95, 0.95, 0.05])
|
||||||
|
protected_start = candidates[0]["startsAt"]
|
||||||
|
|
||||||
|
schedule = build_power_schedule(candidates, 2.0, max_charge_power_w=4000, charging_efficiency=1.0)
|
||||||
|
assert [interval["total"] for interval in schedule["intervals"]] == [0.80, 0.05]
|
||||||
|
|
||||||
|
schedule, warnings = apply_segment_constraints(
|
||||||
|
schedule,
|
||||||
|
candidates,
|
||||||
|
charging_efficiency=1.0,
|
||||||
|
max_cycles_per_day=1,
|
||||||
|
target_grid_energy_kwh=2.0,
|
||||||
|
protected_starts=frozenset({protected_start}),
|
||||||
|
interval_minutes=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert warnings == []
|
||||||
|
assert schedule["total_grid_energy_kwh"] == 2.0
|
||||||
|
starts = {interval["startsAt"] for interval in schedule["intervals"]}
|
||||||
|
assert protected_start in starts
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_deadline_next_peak_period() -> None:
|
def test_resolve_deadline_next_peak_period() -> None:
|
||||||
"""Deadline helper should resolve the next future peak period start."""
|
"""Deadline helper should resolve the next future peak period start."""
|
||||||
now = datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
|
now = datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue