mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Accepts battery parameters (capacity, current/target SoC, max power) and returns a cost-minimized charging schedule with per-interval power, SoC progression, and total cost — no manual duration calculation needed. Supports fixed, continuous (min_charge_power_w), and stepped (charge_power_steps_w) charging modes, deadline-aware two-pass planning (must_reach_soc + must_reach_by / must_reach_by_event), and round-trip economics (expected_discharge_price, reserve_for_discharge, max_cost_per_kwh) for arbitrage use cases. Includes min_charge_duration and max_cycles_per_day constraints. Groups deadline fields (must_reach_soc_*, must_reach_by, must_reach_by_event) into a dedicated section so a deadline use case can be configured in one place. Battery section lists capacity before the percent SoC fields that depend on it. Response exposes stable reason codes (already_at_target, energy_unreachable, energy_unreachable_by_ deadline, no_intervals_after_economic_filter, …) documented in the service description and user docs.
135 lines
5.1 KiB
Python
135 lines
5.1 KiB
Python
"""Deadline helpers for the plan_charging service."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from custom_components.tibber_prices.services.helpers import localize_to_home_tz
|
|
from custom_components.tibber_prices.utils.price_window import group_intervals_into_segments
|
|
|
|
from .power_scheduler import build_power_schedule
|
|
|
|
if TYPE_CHECKING:
|
|
from zoneinfo import ZoneInfo
|
|
|
|
_DEADLINE_EVENTS = frozenset({"next_peak_period", "next_best_period_end", "midnight"})
|
|
|
|
|
|
def get_deadline_events() -> frozenset[str]:
|
|
"""Return the supported deadline event selector values."""
|
|
return _DEADLINE_EVENTS
|
|
|
|
|
|
def resolve_deadline(
|
|
*,
|
|
coordinator_data: dict[str, Any],
|
|
now: datetime,
|
|
home_tz: ZoneInfo,
|
|
must_reach_by: datetime | None = None,
|
|
must_reach_by_event: str | None = None,
|
|
) -> tuple[datetime | None, str | None]:
|
|
"""Resolve an absolute deadline from an explicit datetime or a known event."""
|
|
if must_reach_by is not None and must_reach_by_event is not None:
|
|
raise ValueError("deadline_conflict")
|
|
|
|
if must_reach_by is not None:
|
|
return localize_to_home_tz(must_reach_by, home_tz), "explicit"
|
|
|
|
if must_reach_by_event is None:
|
|
return None, None
|
|
|
|
if must_reach_by_event not in _DEADLINE_EVENTS:
|
|
raise ValueError("deadline_event_not_available")
|
|
|
|
if must_reach_by_event == "midnight":
|
|
next_day = (now + timedelta(days=1)).date()
|
|
return datetime.combine(next_day, datetime.min.time(), tzinfo=home_tz), "midnight"
|
|
|
|
periods_data = coordinator_data.get("pricePeriods", {})
|
|
if must_reach_by_event == "next_peak_period":
|
|
periods = periods_data.get("peak_price", {}).get("periods", [])
|
|
for period in periods:
|
|
start = period.get("start")
|
|
if start and start > now:
|
|
return start, "next_peak_period"
|
|
raise ValueError("deadline_event_not_available")
|
|
|
|
periods = periods_data.get("best_price", {}).get("periods", [])
|
|
for period in periods:
|
|
end = period.get("end")
|
|
if end and end > now:
|
|
return end, "next_best_period_end"
|
|
raise ValueError("deadline_event_not_available")
|
|
|
|
|
|
def build_deadline_schedule(
|
|
candidate_intervals: list[dict[str, Any]],
|
|
*,
|
|
total_energy_needed_grid_kwh: float,
|
|
energy_needed_by_deadline_grid_kwh: float,
|
|
deadline: datetime,
|
|
max_charge_power_w: int,
|
|
charging_efficiency: float,
|
|
min_charge_power_w: int | None = None,
|
|
charge_power_steps_w: list[int] | None = None,
|
|
grid_import_limit_w: int | None = None,
|
|
interval_minutes: int = 15,
|
|
) -> dict[str, Any]:
|
|
"""Build a two-pass schedule that satisfies a minimum SoC by a deadline."""
|
|
deadline_intervals = [interval for interval in candidate_intervals if _interval_start(interval) < deadline]
|
|
pre_deadline = build_power_schedule(
|
|
deadline_intervals,
|
|
energy_needed_by_deadline_grid_kwh,
|
|
max_charge_power_w=max_charge_power_w,
|
|
charging_efficiency=charging_efficiency,
|
|
min_charge_power_w=min_charge_power_w,
|
|
charge_power_steps_w=charge_power_steps_w,
|
|
grid_import_limit_w=grid_import_limit_w,
|
|
interval_minutes=interval_minutes,
|
|
)
|
|
|
|
used_timestamps = {interval["startsAt"] for interval in pre_deadline["intervals"]}
|
|
remaining_candidates = [interval for interval in candidate_intervals if interval["startsAt"] not in used_timestamps]
|
|
remaining_energy = max(0.0, total_energy_needed_grid_kwh - pre_deadline["total_grid_energy_kwh"])
|
|
|
|
post_deadline = build_power_schedule(
|
|
remaining_candidates,
|
|
remaining_energy,
|
|
max_charge_power_w=max_charge_power_w,
|
|
charging_efficiency=charging_efficiency,
|
|
min_charge_power_w=min_charge_power_w,
|
|
charge_power_steps_w=charge_power_steps_w,
|
|
grid_import_limit_w=grid_import_limit_w,
|
|
interval_minutes=interval_minutes,
|
|
)
|
|
|
|
combined_intervals = sorted(
|
|
[*pre_deadline["intervals"], *post_deadline["intervals"]],
|
|
key=_interval_start,
|
|
)
|
|
|
|
return {
|
|
"intervals": combined_intervals,
|
|
"segments": group_intervals_into_segments(combined_intervals),
|
|
"deadline": deadline,
|
|
"pre_deadline": pre_deadline,
|
|
"post_deadline": post_deadline,
|
|
"total_grid_energy_kwh": round(
|
|
pre_deadline["total_grid_energy_kwh"] + post_deadline["total_grid_energy_kwh"], 6
|
|
),
|
|
"total_stored_energy_kwh": round(
|
|
pre_deadline["total_stored_energy_kwh"] + post_deadline["total_stored_energy_kwh"], 6
|
|
),
|
|
"unallocated_grid_energy_kwh": round(post_deadline["unallocated_grid_energy_kwh"], 6),
|
|
"deadline_unallocated_grid_energy_kwh": round(pre_deadline["unallocated_grid_energy_kwh"], 6),
|
|
"mode": pre_deadline["mode"],
|
|
"effective_max_power_w": pre_deadline["effective_max_power_w"],
|
|
"allowed_steps": pre_deadline["allowed_steps"],
|
|
"minimum_power_w": pre_deadline["minimum_power_w"],
|
|
}
|
|
|
|
|
|
def _interval_start(interval: dict[str, Any]) -> datetime:
|
|
starts_at = interval["startsAt"]
|
|
return datetime.fromisoformat(starts_at) if isinstance(starts_at, str) else starts_at
|