mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
feat(periods): cross-day extension and supersession
Intelligent handling when tomorrow's price data arrives: 1. Cross-Day Extension - Late-night periods (starting ≥20:00) can extend past midnight - Extension continues while prices remain below daily_min × (1+flex) - Maximum extension to 08:00 next day (covers typical night low) 2. Period Supersession - Obsolete late-night today periods filtered when tomorrow is better - Tomorrow must be ≥10% cheaper to supersede (SUPERSESSION_PRICE_IMPROVEMENT_PCT) - Prevents stale relaxation periods from persisting Impact: Late-night periods reflect tomorrow's data when available.
This commit is contained in:
parent
5ef0396c8b
commit
4158e7b1fd
2 changed files with 449 additions and 3 deletions
|
|
@ -16,8 +16,10 @@ from .period_building import (
|
||||||
add_interval_ends,
|
add_interval_ends,
|
||||||
build_periods,
|
build_periods,
|
||||||
calculate_reference_prices,
|
calculate_reference_prices,
|
||||||
|
extend_periods_across_midnight,
|
||||||
filter_periods_by_end_date,
|
filter_periods_by_end_date,
|
||||||
filter_periods_by_min_length,
|
filter_periods_by_min_length,
|
||||||
|
filter_superseded_periods,
|
||||||
split_intervals_by_day,
|
split_intervals_by_day,
|
||||||
)
|
)
|
||||||
from .period_statistics import (
|
from .period_statistics import (
|
||||||
|
|
@ -188,7 +190,7 @@ def calculate_periods(
|
||||||
# Sensors filter further for today+tomorrow, services can access all cached periods
|
# Sensors filter further for today+tomorrow, services can access all cached periods
|
||||||
raw_periods = filter_periods_by_end_date(raw_periods, time=time)
|
raw_periods = filter_periods_by_end_date(raw_periods, time=time)
|
||||||
|
|
||||||
# Step 8: Extract lightweight period summaries (no full price data)
|
# Step 7: Extract lightweight period summaries (no full price data)
|
||||||
# Note: Periods are filtered by end date to keep yesterday/today/tomorrow.
|
# Note: Periods are filtered by end date to keep yesterday/today/tomorrow.
|
||||||
# This preserves periods that started day-before-yesterday but end yesterday.
|
# This preserves periods that started day-before-yesterday but end yesterday.
|
||||||
thresholds = TibberPricesThresholdConfig(
|
thresholds = TibberPricesThresholdConfig(
|
||||||
|
|
@ -207,6 +209,26 @@ def calculate_periods(
|
||||||
time=time,
|
time=time,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Step 8: Cross-day extension for late-night periods
|
||||||
|
# If a best-price period ends near midnight and tomorrow has continued low prices,
|
||||||
|
# extend the period across midnight to give users the full cheap window
|
||||||
|
period_summaries = extend_periods_across_midnight(
|
||||||
|
period_summaries,
|
||||||
|
all_prices_sorted,
|
||||||
|
price_context,
|
||||||
|
time=time,
|
||||||
|
reverse_sort=reverse_sort,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 9: Filter superseded periods
|
||||||
|
# When tomorrow data is available, late-night today periods that were found via
|
||||||
|
# relaxation may be obsolete if tomorrow has significantly better alternatives
|
||||||
|
period_summaries = filter_superseded_periods(
|
||||||
|
period_summaries,
|
||||||
|
time=time,
|
||||||
|
reverse_sort=reverse_sort,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"periods": period_summaries, # Lightweight summaries only
|
"periods": period_summaries, # Lightweight summaries only
|
||||||
"metadata": {
|
"metadata": {
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,12 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING
|
from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
|
||||||
from .level_filtering import (
|
from .level_filtering import (
|
||||||
|
|
@ -281,3 +280,428 @@ def filter_periods_by_end_date(periods: list[list[dict]], *, time: TibberPricesT
|
||||||
filtered.append(period)
|
filtered.append(period)
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def _categorize_periods_for_supersession(
|
||||||
|
period_summaries: list[dict],
|
||||||
|
today: date,
|
||||||
|
tomorrow: date,
|
||||||
|
late_hour_threshold: int,
|
||||||
|
early_hour_limit: int,
|
||||||
|
) -> tuple[list[dict], list[dict], list[dict]]:
|
||||||
|
"""Categorize periods into today-late, tomorrow-early, and other."""
|
||||||
|
today_late: list[dict] = []
|
||||||
|
tomorrow_early: list[dict] = []
|
||||||
|
other: list[dict] = []
|
||||||
|
|
||||||
|
for period in period_summaries:
|
||||||
|
period_start = period.get("start")
|
||||||
|
period_end = period.get("end")
|
||||||
|
|
||||||
|
if not period_start or not period_end:
|
||||||
|
other.append(period)
|
||||||
|
# Today late-night periods: START today at or after late_hour_threshold (e.g., 20:00)
|
||||||
|
# Note: period_end could be tomorrow (e.g., 23:30-00:00 spans midnight)
|
||||||
|
elif period_start.date() == today and period_start.hour >= late_hour_threshold:
|
||||||
|
today_late.append(period)
|
||||||
|
# Tomorrow early-morning periods: START tomorrow before early_hour_limit (e.g., 08:00)
|
||||||
|
elif period_start.date() == tomorrow and period_start.hour < early_hour_limit:
|
||||||
|
tomorrow_early.append(period)
|
||||||
|
else:
|
||||||
|
other.append(period)
|
||||||
|
|
||||||
|
return today_late, tomorrow_early, other
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_superseded_today_periods(
|
||||||
|
today_late_periods: list[dict],
|
||||||
|
best_tomorrow: dict,
|
||||||
|
best_tomorrow_price: float,
|
||||||
|
improvement_threshold: float,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Filter today periods that are superseded by a better tomorrow period."""
|
||||||
|
kept: list[dict] = []
|
||||||
|
|
||||||
|
for today_period in today_late_periods:
|
||||||
|
today_price = today_period.get("price_mean")
|
||||||
|
|
||||||
|
if today_price is None:
|
||||||
|
kept.append(today_period)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate how much better tomorrow is (as percentage)
|
||||||
|
improvement_pct = ((today_price - best_tomorrow_price) / today_price * 100) if today_price > 0 else 0
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Supersession check: Today %s-%s (%.4f) vs Tomorrow %s-%s (%.4f) = %.1f%% improvement (threshold: %.1f%%)",
|
||||||
|
today_period["start"].strftime("%H:%M"),
|
||||||
|
today_period["end"].strftime("%H:%M"),
|
||||||
|
today_price,
|
||||||
|
best_tomorrow["start"].strftime("%H:%M"),
|
||||||
|
best_tomorrow["end"].strftime("%H:%M"),
|
||||||
|
best_tomorrow_price,
|
||||||
|
improvement_pct,
|
||||||
|
improvement_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
if improvement_pct >= improvement_threshold:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Period superseded: Today %s-%s (%.2f) replaced by Tomorrow %s-%s (%.2f, %.1f%% better)",
|
||||||
|
today_period["start"].strftime("%H:%M"),
|
||||||
|
today_period["end"].strftime("%H:%M"),
|
||||||
|
today_price,
|
||||||
|
best_tomorrow["start"].strftime("%H:%M"),
|
||||||
|
best_tomorrow["end"].strftime("%H:%M"),
|
||||||
|
best_tomorrow_price,
|
||||||
|
improvement_pct,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
kept.append(today_period)
|
||||||
|
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def filter_superseded_periods(
|
||||||
|
period_summaries: list[dict],
|
||||||
|
*,
|
||||||
|
time: TibberPricesTimeService,
|
||||||
|
reverse_sort: bool,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Filter out late-night today periods that are superseded by better tomorrow periods.
|
||||||
|
|
||||||
|
When tomorrow's data becomes available, some late-night periods that were found
|
||||||
|
through relaxation may no longer make sense. If tomorrow has a significantly
|
||||||
|
better period in the early morning, the late-night today period is obsolete.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Today 23:30-00:00 at 0.70 kr (found via relaxation, was best available)
|
||||||
|
- Tomorrow 04:00-05:30 at 0.50 kr (much better alternative)
|
||||||
|
→ The today period is superseded and should be filtered out
|
||||||
|
|
||||||
|
This only applies to best-price periods (reverse_sort=False).
|
||||||
|
Peak-price periods are not filtered this way.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from .types import ( # noqa: PLC0415
|
||||||
|
CROSS_DAY_LATE_PERIOD_START_HOUR,
|
||||||
|
CROSS_DAY_MAX_EXTENSION_HOUR,
|
||||||
|
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"filter_superseded_periods called: %d periods, reverse_sort=%s",
|
||||||
|
len(period_summaries) if period_summaries else 0,
|
||||||
|
reverse_sort,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only filter for best-price periods
|
||||||
|
if reverse_sort or not period_summaries:
|
||||||
|
return period_summaries
|
||||||
|
|
||||||
|
now = time.now()
|
||||||
|
today = now.date()
|
||||||
|
tomorrow = today + timedelta(days=1)
|
||||||
|
|
||||||
|
# Categorize periods
|
||||||
|
today_late, tomorrow_early, other = _categorize_periods_for_supersession(
|
||||||
|
period_summaries,
|
||||||
|
today,
|
||||||
|
tomorrow,
|
||||||
|
CROSS_DAY_LATE_PERIOD_START_HOUR,
|
||||||
|
CROSS_DAY_MAX_EXTENSION_HOUR,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Supersession categorization: today_late=%d, tomorrow_early=%d, other=%d",
|
||||||
|
len(today_late),
|
||||||
|
len(tomorrow_early),
|
||||||
|
len(other),
|
||||||
|
)
|
||||||
|
|
||||||
|
# If no tomorrow early periods, nothing to compare against
|
||||||
|
if not tomorrow_early:
|
||||||
|
_LOGGER.debug("No tomorrow early periods - skipping supersession check")
|
||||||
|
return period_summaries
|
||||||
|
|
||||||
|
# Find the best tomorrow early period (lowest mean price)
|
||||||
|
best_tomorrow = min(tomorrow_early, key=lambda p: p.get("price_mean", float("inf")))
|
||||||
|
best_tomorrow_price = best_tomorrow.get("price_mean")
|
||||||
|
|
||||||
|
if best_tomorrow_price is None:
|
||||||
|
return period_summaries
|
||||||
|
|
||||||
|
# Filter superseded today periods
|
||||||
|
kept_today = _filter_superseded_today_periods(
|
||||||
|
today_late,
|
||||||
|
best_tomorrow,
|
||||||
|
best_tomorrow_price,
|
||||||
|
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reconstruct and sort by start time
|
||||||
|
result = other + kept_today + tomorrow_early
|
||||||
|
result.sort(key=lambda p: p.get("start") or time.now())
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _is_period_eligible_for_extension(
|
||||||
|
period: dict,
|
||||||
|
today: date,
|
||||||
|
late_hour_threshold: int,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a period is eligible for cross-day extension.
|
||||||
|
|
||||||
|
Eligibility criteria:
|
||||||
|
- Period has valid start and end times
|
||||||
|
- Period ends on today (not yesterday or tomorrow)
|
||||||
|
- Period ends late (after late_hour_threshold, e.g. 20:00)
|
||||||
|
|
||||||
|
"""
|
||||||
|
period_end = period.get("end")
|
||||||
|
period_start = period.get("start")
|
||||||
|
|
||||||
|
if not period_end or not period_start:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if period_end.date() != today:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return period_end.hour >= late_hour_threshold
|
||||||
|
|
||||||
|
|
||||||
|
def _find_extension_intervals(
|
||||||
|
period_end: datetime,
|
||||||
|
price_lookup: dict[str, dict],
|
||||||
|
criteria: Any,
|
||||||
|
max_extension_time: datetime,
|
||||||
|
interval_duration: timedelta,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Find consecutive intervals after period_end that meet criteria.
|
||||||
|
|
||||||
|
Iterates forward from period_end, adding intervals while they
|
||||||
|
meet the flex and min_distance criteria. Stops at first failure
|
||||||
|
or when reaching max_extension_time.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from .level_filtering import check_interval_criteria # noqa: PLC0415
|
||||||
|
|
||||||
|
extension_intervals: list[dict] = []
|
||||||
|
check_time = period_end
|
||||||
|
|
||||||
|
while check_time < max_extension_time:
|
||||||
|
price_data = price_lookup.get(check_time.isoformat())
|
||||||
|
if not price_data:
|
||||||
|
break # No more data
|
||||||
|
|
||||||
|
price = float(price_data["total"])
|
||||||
|
in_flex, meets_min_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
if not (in_flex and meets_min_distance):
|
||||||
|
break # Criteria no longer met
|
||||||
|
|
||||||
|
extension_intervals.append(price_data)
|
||||||
|
check_time = check_time + interval_duration
|
||||||
|
|
||||||
|
return extension_intervals
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_original_period_prices(
|
||||||
|
period_start: datetime,
|
||||||
|
period_end: datetime,
|
||||||
|
price_lookup: dict[str, dict],
|
||||||
|
interval_duration: timedelta,
|
||||||
|
) -> list[float]:
|
||||||
|
"""Collect prices from original period for CV calculation."""
|
||||||
|
prices: list[float] = []
|
||||||
|
current = period_start
|
||||||
|
while current < period_end:
|
||||||
|
price_data = price_lookup.get(current.isoformat())
|
||||||
|
if price_data:
|
||||||
|
prices.append(float(price_data["total"]))
|
||||||
|
current = current + interval_duration
|
||||||
|
return prices
|
||||||
|
|
||||||
|
|
||||||
|
def _build_extended_period(
|
||||||
|
period: dict,
|
||||||
|
extension_intervals: list[dict],
|
||||||
|
combined_prices: list[float],
|
||||||
|
combined_cv: float,
|
||||||
|
interval_duration: timedelta,
|
||||||
|
) -> dict:
|
||||||
|
"""Create extended period dict with updated statistics."""
|
||||||
|
period_start = period["start"]
|
||||||
|
period_end = period["end"]
|
||||||
|
new_end = period_end + (interval_duration * len(extension_intervals))
|
||||||
|
|
||||||
|
extended = period.copy()
|
||||||
|
extended["end"] = new_end
|
||||||
|
extended["duration_minutes"] = int((new_end - period_start).total_seconds() / 60)
|
||||||
|
extended["period_interval_count"] = len(combined_prices)
|
||||||
|
extended["cross_day_extended"] = True
|
||||||
|
extended["cross_day_extension_intervals"] = len(extension_intervals)
|
||||||
|
|
||||||
|
# Recalculate price statistics
|
||||||
|
extended["price_min"] = min(combined_prices)
|
||||||
|
extended["price_max"] = max(combined_prices)
|
||||||
|
extended["price_mean"] = sum(combined_prices) / len(combined_prices)
|
||||||
|
extended["price_spread"] = extended["price_max"] - extended["price_min"]
|
||||||
|
extended["coefficient_of_variation"] = round(combined_cv, 1)
|
||||||
|
|
||||||
|
return extended
|
||||||
|
|
||||||
|
|
||||||
|
def extend_periods_across_midnight(
|
||||||
|
period_summaries: list[dict],
|
||||||
|
all_prices: list[dict],
|
||||||
|
price_context: dict[str, Any],
|
||||||
|
*,
|
||||||
|
time: TibberPricesTimeService,
|
||||||
|
reverse_sort: bool,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Extend late-night periods across midnight if favorable prices continue.
|
||||||
|
|
||||||
|
When a period ends close to midnight and tomorrow's data shows continued
|
||||||
|
favorable prices, extend the period into the next day. This prevents
|
||||||
|
artificial period breaks at midnight when it's actually better to continue.
|
||||||
|
|
||||||
|
Example: Best price period 22:00-23:45 today could extend to 04:00 tomorrow
|
||||||
|
if prices remain low overnight.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Only extends periods ending after CROSS_DAY_LATE_PERIOD_START_HOUR (20:00)
|
||||||
|
- Won't extend beyond CROSS_DAY_MAX_EXTENSION_HOUR (08:00) next day
|
||||||
|
- Extension must pass same flex criteria as original period
|
||||||
|
- Quality Gate (CV check) applies to extended period
|
||||||
|
|
||||||
|
Args:
|
||||||
|
period_summaries: List of period summary dicts (already processed)
|
||||||
|
all_prices: All price intervals including tomorrow
|
||||||
|
price_context: Dict with ref_prices, avg_prices, flex, min_distance_from_avg
|
||||||
|
time: Time service instance
|
||||||
|
reverse_sort: True for peak price, False for best price
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated list of period summaries with extensions applied
|
||||||
|
|
||||||
|
"""
|
||||||
|
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation # noqa: PLC0415
|
||||||
|
|
||||||
|
from .types import ( # noqa: PLC0415
|
||||||
|
CROSS_DAY_LATE_PERIOD_START_HOUR,
|
||||||
|
CROSS_DAY_MAX_EXTENSION_HOUR,
|
||||||
|
PERIOD_MAX_CV,
|
||||||
|
TibberPricesIntervalCriteria,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not period_summaries or not all_prices:
|
||||||
|
return period_summaries
|
||||||
|
|
||||||
|
# Build price lookup by timestamp
|
||||||
|
price_lookup: dict[str, dict] = {}
|
||||||
|
for price_data in all_prices:
|
||||||
|
interval_time = time.get_interval_time(price_data)
|
||||||
|
if interval_time:
|
||||||
|
price_lookup[interval_time.isoformat()] = price_data
|
||||||
|
|
||||||
|
ref_prices = price_context.get("ref_prices", {})
|
||||||
|
avg_prices = price_context.get("avg_prices", {})
|
||||||
|
flex = price_context.get("flex", 0.15)
|
||||||
|
min_distance = price_context.get("min_distance_from_avg", 0)
|
||||||
|
|
||||||
|
now = time.now()
|
||||||
|
today = now.date()
|
||||||
|
tomorrow = today + timedelta(days=1)
|
||||||
|
interval_duration = time.get_interval_duration()
|
||||||
|
|
||||||
|
# Max extension time (e.g., 08:00 tomorrow)
|
||||||
|
max_extension_time = time.start_of_local_day(now) + timedelta(days=1, hours=CROSS_DAY_MAX_EXTENSION_HOUR)
|
||||||
|
|
||||||
|
extended_summaries = []
|
||||||
|
|
||||||
|
for period in period_summaries:
|
||||||
|
# Check eligibility for extension
|
||||||
|
if not _is_period_eligible_for_extension(period, today, CROSS_DAY_LATE_PERIOD_START_HOUR):
|
||||||
|
extended_summaries.append(period)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get tomorrow's reference prices
|
||||||
|
tomorrow_ref = ref_prices.get(tomorrow) or ref_prices.get(str(tomorrow))
|
||||||
|
tomorrow_avg = avg_prices.get(tomorrow) or avg_prices.get(str(tomorrow))
|
||||||
|
|
||||||
|
if tomorrow_ref is None or tomorrow_avg is None:
|
||||||
|
extended_summaries.append(period)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Set up criteria for extension check
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=tomorrow_ref,
|
||||||
|
avg_price=tomorrow_avg,
|
||||||
|
flex=flex,
|
||||||
|
min_distance_from_avg=min_distance,
|
||||||
|
reverse_sort=reverse_sort,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find extension intervals
|
||||||
|
extension_intervals = _find_extension_intervals(
|
||||||
|
period["end"],
|
||||||
|
price_lookup,
|
||||||
|
criteria,
|
||||||
|
max_extension_time,
|
||||||
|
interval_duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not extension_intervals:
|
||||||
|
extended_summaries.append(period)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Collect all prices for CV check
|
||||||
|
original_prices = _collect_original_period_prices(
|
||||||
|
period["start"],
|
||||||
|
period["end"],
|
||||||
|
price_lookup,
|
||||||
|
interval_duration,
|
||||||
|
)
|
||||||
|
extension_prices = [float(p["total"]) for p in extension_intervals]
|
||||||
|
combined_prices = original_prices + extension_prices
|
||||||
|
|
||||||
|
# Quality Gate: Check CV of extended period
|
||||||
|
combined_cv = calculate_coefficient_of_variation(combined_prices)
|
||||||
|
|
||||||
|
if combined_cv is not None and combined_cv <= PERIOD_MAX_CV:
|
||||||
|
# Extension passes quality gate
|
||||||
|
extended_period = _build_extended_period(
|
||||||
|
period,
|
||||||
|
extension_intervals,
|
||||||
|
combined_prices,
|
||||||
|
combined_cv,
|
||||||
|
interval_duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
"Cross-day extension: Period %s-%s extended to %s (+%d intervals, CV=%.1f%%)",
|
||||||
|
period["start"].strftime("%H:%M"),
|
||||||
|
period["end"].strftime("%H:%M"),
|
||||||
|
extended_period["end"].strftime("%H:%M"),
|
||||||
|
len(extension_intervals),
|
||||||
|
combined_cv,
|
||||||
|
)
|
||||||
|
extended_summaries.append(extended_period)
|
||||||
|
else:
|
||||||
|
# Extension would exceed quality gate
|
||||||
|
_LOGGER_DETAILS.debug(
|
||||||
|
"%sCross-day extension rejected for period %s-%s: CV=%.1f%% > %.1f%%",
|
||||||
|
INDENT_L0,
|
||||||
|
period["start"].strftime("%H:%M"),
|
||||||
|
period["end"].strftime("%H:%M"),
|
||||||
|
combined_cv or 0,
|
||||||
|
PERIOD_MAX_CV,
|
||||||
|
)
|
||||||
|
extended_summaries.append(period)
|
||||||
|
|
||||||
|
return extended_summaries
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue