mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-28 09:36:49 +00:00
Compare commits
No commits in common. "3057642cba4fc013d735b4725a3f46e863286d13" and "303a7c7835cb2e544017cd19f601cfe2916d8b76" have entirely different histories.
3057642cba
...
303a7c7835
8 changed files with 293 additions and 168 deletions
|
|
@ -720,7 +720,9 @@ FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # WARNING at 30% base flex
|
||||||
|
|
||||||
- Periods can **cross midnight** (day boundaries) naturally
|
- Periods can **cross midnight** (day boundaries) naturally
|
||||||
- Reference price locked to **period start day** for consistency across the entire period
|
- Reference price locked to **period start day** for consistency across the entire period
|
||||||
- **Cross-day bridging**: Merges midnight-split periods (requires evidence on BOTH sides, gap ≤1h, CV ≤25%). See `docs/developer/docs/period-calculation-theory.md` → "Cross-Midnight Bridging" for details.
|
- Pattern: "Uses reference price from start day of the period for consistency" (same as period statistics)
|
||||||
|
- Example: Period starting 23:45 on Day 1 continues into Day 2 using Day 1's daily_min as reference
|
||||||
|
- This prevents artificial splits at midnight when prices remain favorable across the boundary
|
||||||
|
|
||||||
**Default Configuration Values** (`const.py`):
|
**Default Configuration Values** (`const.py`):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -251,11 +251,9 @@ def calculate_periods(
|
||||||
time=time,
|
time=time,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 8: Cross-day bridging for midnight-split periods
|
# Step 8: Cross-day extension for late-night periods
|
||||||
# If two periods exist on both sides of midnight separated by a small gap
|
# If a best-price period ends near midnight and tomorrow has continued low prices,
|
||||||
# (artifact of per-day reference price changes), merge them into one period.
|
# extend the period across midnight to give users the full cheap window
|
||||||
# Requires evidence on BOTH sides — periods ending well before midnight
|
|
||||||
# are NOT extended because they ended naturally.
|
|
||||||
period_summaries = extend_periods_across_midnight(
|
period_summaries = extend_periods_across_midnight(
|
||||||
period_summaries,
|
period_summaries,
|
||||||
all_prices_sorted,
|
all_prices_sorted,
|
||||||
|
|
|
||||||
|
|
@ -541,8 +541,8 @@ def filter_superseded_periods(
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from .types import ( # noqa: PLC0415
|
from .types import ( # noqa: PLC0415
|
||||||
CROSS_DAY_EARLY_MORNING_HOUR,
|
CROSS_DAY_LATE_PERIOD_START_HOUR,
|
||||||
CROSS_DAY_SUPERSESSION_START_HOUR,
|
CROSS_DAY_MAX_EXTENSION_HOUR,
|
||||||
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
|
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -564,8 +564,8 @@ def filter_superseded_periods(
|
||||||
period_summaries,
|
period_summaries,
|
||||||
today,
|
today,
|
||||||
tomorrow,
|
tomorrow,
|
||||||
CROSS_DAY_SUPERSESSION_START_HOUR,
|
CROSS_DAY_LATE_PERIOD_START_HOUR,
|
||||||
CROSS_DAY_EARLY_MORNING_HOUR,
|
CROSS_DAY_MAX_EXTENSION_HOUR,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
|
|
@ -708,31 +708,113 @@ def filter_weak_peak_periods(
|
||||||
return kept
|
return kept
|
||||||
|
|
||||||
|
|
||||||
def _gap_spans_midnight(a_end: datetime, b_start: datetime) -> bool:
|
def _is_period_eligible_for_extension(
|
||||||
|
period: dict,
|
||||||
|
today: date,
|
||||||
|
late_hour_threshold: int,
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the gap between two periods spans a midnight boundary.
|
Check if a period is eligible for cross-day extension.
|
||||||
|
|
||||||
Uses the last covered moment of period A (end - 1 minute, since end is
|
Eligibility criteria:
|
||||||
exclusive) to determine the calendar day. Returns True when A's last
|
- Period has valid start and end times
|
||||||
interval is on a different (earlier) date than B's first interval.
|
- Period ends on today (not yesterday or tomorrow)
|
||||||
|
- Period ends late (after late_hour_threshold, e.g. 20:00)
|
||||||
|
|
||||||
Examples:
|
Note: ``end`` is an *exclusive* boundary — the first moment after the last
|
||||||
A ends 00:00 (last interval 23:45 same day), B starts 00:15 → True
|
interval. A period whose last interval starts at 23:45 has ``end = 00:00``
|
||||||
A ends 23:30, B starts 00:00 next day → True
|
the next calendar day. We therefore derive the effective date/hour from
|
||||||
A ends 21:30, B starts 22:00 same day → False
|
``end − 1 minute`` so that such periods are correctly recognised as ending
|
||||||
|
"today, late in the evening".
|
||||||
|
|
||||||
"""
|
"""
|
||||||
a_last_moment = a_end - timedelta(minutes=1)
|
period_end = period.get("end")
|
||||||
return a_last_moment.date() < b_start.date()
|
period_start = period.get("start")
|
||||||
|
|
||||||
|
if not period_end or not period_start:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Derive last-covered moment (exclusive end → inclusive last moment)
|
||||||
|
effective_end = period_end - timedelta(minutes=1)
|
||||||
|
|
||||||
|
if effective_end.date() != today:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return effective_end.hour >= late_hour_threshold
|
||||||
|
|
||||||
|
|
||||||
def _collect_period_prices(
|
def _find_extension_intervals(
|
||||||
|
period_end: datetime,
|
||||||
|
price_lookup: dict[str, dict],
|
||||||
|
criteria: Any,
|
||||||
|
max_extension_time: datetime,
|
||||||
|
interval_duration: timedelta,
|
||||||
|
*,
|
||||||
|
max_intervals: int = 0,
|
||||||
|
period_mean_price: float = 0.0,
|
||||||
|
max_price_deviation: float = 0.0,
|
||||||
|
reverse_sort: bool = False,
|
||||||
|
) -> 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.
|
||||||
|
|
||||||
|
Additional guards:
|
||||||
|
- max_intervals: Hard cap on number of extension intervals (0 = unlimited)
|
||||||
|
- period_mean_price + max_price_deviation: Stop extending when the candidate
|
||||||
|
interval's price deviates too far from the original period's mean price.
|
||||||
|
For peak periods (reverse_sort=True): stops when price drops below
|
||||||
|
mean × (1 - deviation). For best periods: stops when price rises above
|
||||||
|
mean × (1 + deviation).
|
||||||
|
|
||||||
|
"""
|
||||||
|
from .level_filtering import check_interval_criteria # noqa: PLC0415
|
||||||
|
|
||||||
|
extension_intervals: list[dict] = []
|
||||||
|
check_time = period_end
|
||||||
|
|
||||||
|
while check_time < max_extension_time:
|
||||||
|
# Hard cap on extension length
|
||||||
|
if max_intervals > 0 and len(extension_intervals) >= max_intervals:
|
||||||
|
break
|
||||||
|
|
||||||
|
price_data = price_lookup.get(check_time.isoformat())
|
||||||
|
if not price_data:
|
||||||
|
break # No more data
|
||||||
|
|
||||||
|
price = float(price_data["total"])
|
||||||
|
|
||||||
|
# Price deviation gate: stop if price drifts too far from original period mean
|
||||||
|
if period_mean_price > 0 and max_price_deviation > 0:
|
||||||
|
if reverse_sort:
|
||||||
|
# Peak: stop if price drops below mean × (1 - deviation)
|
||||||
|
if price < period_mean_price * (1 - max_price_deviation):
|
||||||
|
break
|
||||||
|
elif price > period_mean_price * (1 + max_price_deviation):
|
||||||
|
# Best: stop if price rises above mean × (1 + deviation)
|
||||||
|
break
|
||||||
|
|
||||||
|
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_start: datetime,
|
||||||
period_end: datetime,
|
period_end: datetime,
|
||||||
price_lookup: dict[str, dict],
|
price_lookup: dict[str, dict],
|
||||||
interval_duration: timedelta,
|
interval_duration: timedelta,
|
||||||
) -> list[float]:
|
) -> list[float]:
|
||||||
"""Collect prices within a time range from the price lookup."""
|
"""Collect prices from original period for CV calculation."""
|
||||||
prices: list[float] = []
|
prices: list[float] = []
|
||||||
current = period_start
|
current = period_start
|
||||||
while current < period_end:
|
while current < period_end:
|
||||||
|
|
@ -743,29 +825,33 @@ def _collect_period_prices(
|
||||||
return prices
|
return prices
|
||||||
|
|
||||||
|
|
||||||
def _build_bridged_period(
|
def _build_extended_period(
|
||||||
period_a: dict,
|
period: dict,
|
||||||
period_b: dict,
|
extension_intervals: list[dict],
|
||||||
combined_prices: list[float],
|
combined_prices: list[float],
|
||||||
combined_cv: float,
|
combined_cv: float,
|
||||||
gap_intervals: int,
|
interval_duration: timedelta,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a merged period dict from two bridged periods with updated statistics."""
|
"""Create extended period dict with updated statistics."""
|
||||||
bridged = period_a.copy()
|
period_start = period["start"]
|
||||||
bridged["end"] = period_b["end"]
|
period_end = period["end"]
|
||||||
bridged["duration_minutes"] = int((period_b["end"] - period_a["start"]).total_seconds() / 60)
|
new_end = period_end + (interval_duration * len(extension_intervals))
|
||||||
bridged["period_interval_count"] = len(combined_prices)
|
|
||||||
bridged["cross_day_bridged"] = True
|
|
||||||
bridged["cross_day_bridge_gap_intervals"] = gap_intervals
|
|
||||||
|
|
||||||
# Recalculate price statistics for the combined period
|
extended = period.copy()
|
||||||
bridged["price_min"] = min(combined_prices)
|
extended["end"] = new_end
|
||||||
bridged["price_max"] = max(combined_prices)
|
extended["duration_minutes"] = int((new_end - period_start).total_seconds() / 60)
|
||||||
bridged["price_mean"] = sum(combined_prices) / len(combined_prices)
|
extended["period_interval_count"] = len(combined_prices)
|
||||||
bridged["price_spread"] = bridged["price_max"] - bridged["price_min"]
|
extended["cross_day_extended"] = True
|
||||||
bridged["price_coefficient_variation_%"] = round(combined_cv, 1)
|
extended["cross_day_extension_intervals"] = len(extension_intervals)
|
||||||
|
|
||||||
return bridged
|
# 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["price_coefficient_variation_%"] = round(combined_cv, 1)
|
||||||
|
|
||||||
|
return extended
|
||||||
|
|
||||||
|
|
||||||
def extend_periods_across_midnight(
|
def extend_periods_across_midnight(
|
||||||
|
|
@ -777,24 +863,20 @@ def extend_periods_across_midnight(
|
||||||
reverse_sort: bool,
|
reverse_sort: bool,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Bridge periods across midnight when separated by a small gap.
|
Extend late-night periods across midnight if favorable prices continue.
|
||||||
|
|
||||||
When two independently qualifying periods exist on either side of midnight,
|
When a period ends close to midnight and tomorrow's data shows continued
|
||||||
separated only by a few non-qualifying intervals (typically caused by per-day
|
favorable prices, extend the period into the next day. This prevents
|
||||||
reference price changes at the day boundary), merge them into a single period.
|
artificial period breaks at midnight when it's actually better to continue.
|
||||||
|
|
||||||
Key principle: requires evidence on BOTH sides of midnight.
|
Example: Best price period 22:00-23:45 today could extend to 04:00 tomorrow
|
||||||
A period ending at 21:30 will NOT be bridged — it ended because prices
|
if prices remain low overnight.
|
||||||
changed, not because of midnight. Only genuine midnight-split periods
|
|
||||||
(where favorable conditions continue on both sides) are merged.
|
|
||||||
|
|
||||||
Example: Best price period 22:00-23:45 today + period 00:15-03:00 tomorrow
|
|
||||||
→ Bridged into 22:00-03:00 (if gap ≤ 4 intervals and CV passes).
|
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Requires periods on BOTH sides of the midnight boundary
|
- Only extends periods ending after CROSS_DAY_LATE_PERIOD_START_HOUR (20:00)
|
||||||
- Gap between periods must be ≤ CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS (4 = 1 hour)
|
- Won't extend beyond CROSS_DAY_MAX_EXTENSION_HOUR (08:00) next day
|
||||||
- Quality Gate (CV check) applies to the merged period
|
- Extension must pass same flex criteria as original period
|
||||||
|
- Quality Gate (CV check) applies to extended period
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
period_summaries: List of period summary dicts (already processed)
|
period_summaries: List of period summary dicts (already processed)
|
||||||
|
|
@ -804,14 +886,22 @@ def extend_periods_across_midnight(
|
||||||
reverse_sort: True for peak price, False for best price
|
reverse_sort: True for peak price, False for best price
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated list of period summaries with bridges applied
|
Updated list of period summaries with extensions applied
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation # noqa: PLC0415
|
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation # noqa: PLC0415
|
||||||
|
|
||||||
from .types import CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS, PERIOD_MAX_CV # noqa: PLC0415
|
from .types import ( # noqa: PLC0415
|
||||||
|
CROSS_DAY_LATE_PERIOD_START_HOUR,
|
||||||
|
CROSS_DAY_MAX_EXTENSION_HOUR,
|
||||||
|
CROSS_DAY_MAX_EXTENSION_INTERVALS,
|
||||||
|
CROSS_DAY_MAX_PRICE_DEVIATION,
|
||||||
|
CROSS_DAY_PROPORTIONAL_EXTENSION_FACTOR,
|
||||||
|
PERIOD_MAX_CV,
|
||||||
|
TibberPricesIntervalCriteria,
|
||||||
|
)
|
||||||
|
|
||||||
if not period_summaries or len(period_summaries) < 2 or not all_prices:
|
if not period_summaries or not all_prices:
|
||||||
return period_summaries
|
return period_summaries
|
||||||
|
|
||||||
# Build price lookup by timestamp
|
# Build price lookup by timestamp
|
||||||
|
|
@ -821,77 +911,114 @@ def extend_periods_across_midnight(
|
||||||
if interval_time:
|
if interval_time:
|
||||||
price_lookup[interval_time.isoformat()] = price_data
|
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()
|
now = time.now()
|
||||||
|
today = now.date()
|
||||||
|
tomorrow = today + timedelta(days=1)
|
||||||
interval_duration = time.get_interval_duration()
|
interval_duration = time.get_interval_duration()
|
||||||
|
|
||||||
# Sort periods by start time for pairwise comparison
|
# Max extension time (e.g., 08:00 tomorrow)
|
||||||
sorted_periods = sorted(period_summaries, key=lambda p: p.get("start") or now)
|
max_extension_time = time.start_of_local_day(now) + timedelta(days=1, hours=CROSS_DAY_MAX_EXTENSION_HOUR)
|
||||||
|
|
||||||
result: list[dict] = []
|
extended_summaries = []
|
||||||
skip_indices: set[int] = set()
|
|
||||||
|
|
||||||
for i, period_a in enumerate(sorted_periods):
|
for period in period_summaries:
|
||||||
if i in skip_indices:
|
# Check eligibility for extension
|
||||||
|
if not _is_period_eligible_for_extension(period, today, CROSS_DAY_LATE_PERIOD_START_HOUR):
|
||||||
|
extended_summaries.append(period)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Try to bridge with the next period
|
# Get tomorrow's reference prices
|
||||||
if i + 1 < len(sorted_periods):
|
tomorrow_ref = ref_prices.get(tomorrow) or ref_prices.get(str(tomorrow))
|
||||||
period_b = sorted_periods[i + 1]
|
tomorrow_avg = avg_prices.get(tomorrow) or avg_prices.get(str(tomorrow))
|
||||||
a_end = period_a.get("end")
|
|
||||||
b_start = period_b.get("start")
|
|
||||||
|
|
||||||
if (
|
if tomorrow_ref is None or tomorrow_avg is None:
|
||||||
a_end and b_start and _gap_spans_midnight(a_end, b_start) and b_start >= a_end # No overlap
|
extended_summaries.append(period)
|
||||||
):
|
continue
|
||||||
gap = b_start - a_end
|
|
||||||
gap_intervals = int(gap.total_seconds() / interval_duration.total_seconds())
|
|
||||||
|
|
||||||
if gap_intervals <= CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS:
|
# Set up criteria for extension check
|
||||||
# Collect all prices from A.start through B.end (including gap)
|
criteria = TibberPricesIntervalCriteria(
|
||||||
combined_prices = _collect_period_prices(
|
ref_price=tomorrow_ref,
|
||||||
period_a["start"],
|
avg_price=tomorrow_avg,
|
||||||
period_b["end"],
|
flex=flex,
|
||||||
|
min_distance_from_avg=min_distance,
|
||||||
|
reverse_sort=reverse_sort,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collect original prices once (reused for cap calculation, deviation gate, and CV check)
|
||||||
|
original_prices = _collect_original_period_prices(
|
||||||
|
period["start"],
|
||||||
|
period["end"],
|
||||||
price_lookup,
|
price_lookup,
|
||||||
interval_duration,
|
interval_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
if combined_prices:
|
# Calculate max extension intervals: min(hard cap, proportional cap)
|
||||||
|
original_interval_count = max(1, len(original_prices))
|
||||||
|
proportional_cap = int(original_interval_count * CROSS_DAY_PROPORTIONAL_EXTENSION_FACTOR)
|
||||||
|
max_intervals = min(CROSS_DAY_MAX_EXTENSION_INTERVALS, proportional_cap)
|
||||||
|
|
||||||
|
# Original period mean price for deviation gate
|
||||||
|
period_mean_price = sum(original_prices) / len(original_prices) if original_prices else 0.0
|
||||||
|
|
||||||
|
# Find extension intervals (with cap + price deviation gate)
|
||||||
|
extension_intervals = _find_extension_intervals(
|
||||||
|
period["end"],
|
||||||
|
price_lookup,
|
||||||
|
criteria,
|
||||||
|
max_extension_time,
|
||||||
|
interval_duration,
|
||||||
|
max_intervals=max_intervals,
|
||||||
|
period_mean_price=period_mean_price,
|
||||||
|
max_price_deviation=CROSS_DAY_MAX_PRICE_DEVIATION,
|
||||||
|
reverse_sort=reverse_sort,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not extension_intervals:
|
||||||
|
extended_summaries.append(period)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# CV check using already-collected original prices
|
||||||
|
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)
|
combined_cv = calculate_coefficient_of_variation(combined_prices)
|
||||||
|
|
||||||
if combined_cv is not None and combined_cv <= PERIOD_MAX_CV:
|
if combined_cv is not None and combined_cv <= PERIOD_MAX_CV:
|
||||||
bridged = _build_bridged_period(
|
# Extension passes quality gate
|
||||||
period_a,
|
extended_period = _build_extended_period(
|
||||||
period_b,
|
period,
|
||||||
|
extension_intervals,
|
||||||
combined_prices,
|
combined_prices,
|
||||||
combined_cv,
|
combined_cv,
|
||||||
gap_intervals,
|
interval_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Cross-day bridge: Merged %s-%s + %s-%s → %s-%s (gap=%d intervals, CV=%.1f%%)",
|
"Cross-day extension: Period %s-%s extended to %s (+%d intervals, max=%d, CV=%.1f%%)",
|
||||||
period_a["start"].strftime("%H:%M"),
|
period["start"].strftime("%H:%M"),
|
||||||
period_a["end"].strftime("%H:%M"),
|
period["end"].strftime("%H:%M"),
|
||||||
period_b["start"].strftime("%H:%M"),
|
extended_period["end"].strftime("%H:%M"),
|
||||||
period_b["end"].strftime("%H:%M"),
|
len(extension_intervals),
|
||||||
bridged["start"].strftime("%H:%M"),
|
max_intervals,
|
||||||
bridged["end"].strftime("%H:%M"),
|
|
||||||
gap_intervals,
|
|
||||||
combined_cv,
|
combined_cv,
|
||||||
)
|
)
|
||||||
result.append(bridged)
|
extended_summaries.append(extended_period)
|
||||||
skip_indices.add(i + 1)
|
else:
|
||||||
continue
|
# Extension would exceed quality gate
|
||||||
|
|
||||||
_LOGGER_DETAILS.debug(
|
_LOGGER_DETAILS.debug(
|
||||||
"%sCross-day bridge rejected %s-%s + %s-%s: CV=%.1f%% > %.1f%%",
|
"%sCross-day extension rejected for period %s-%s: CV=%.1f%% > %.1f%%",
|
||||||
INDENT_L0,
|
INDENT_L0,
|
||||||
period_a["start"].strftime("%H:%M"),
|
period["start"].strftime("%H:%M"),
|
||||||
period_a["end"].strftime("%H:%M"),
|
period["end"].strftime("%H:%M"),
|
||||||
period_b["start"].strftime("%H:%M"),
|
|
||||||
period_b["end"].strftime("%H:%M"),
|
|
||||||
combined_cv or 0,
|
combined_cv or 0,
|
||||||
PERIOD_MAX_CV,
|
PERIOD_MAX_CV,
|
||||||
)
|
)
|
||||||
|
extended_summaries.append(period)
|
||||||
|
|
||||||
result.append(period_a)
|
return extended_summaries
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
|
||||||
|
|
@ -29,20 +29,18 @@ PERIOD_MAX_CV = 25.0 # 25% max coefficient of variation within a period
|
||||||
# Value: 10 ct / 100 = 0.10 EUR/NOK
|
# Value: 10 ct / 100 = 0.10 EUR/NOK
|
||||||
LOW_PRICE_QUALITY_BYPASS_THRESHOLD = 0.10 # EUR/NOK major unit (= 10 ct/øre)
|
LOW_PRICE_QUALITY_BYPASS_THRESHOLD = 0.10 # EUR/NOK major unit (= 10 ct/øre)
|
||||||
|
|
||||||
# Cross-Day Bridging: Merge periods separated by the midnight boundary
|
# Cross-Day Extension: Time window constants
|
||||||
# When two independently qualifying periods exist on both sides of midnight,
|
# When a period ends late in the day and tomorrow data is available,
|
||||||
# separated only by a small gap (artifact of per-day reference price changes),
|
# we can extend it past midnight if prices remain favorable
|
||||||
# merge them into a single period.
|
CROSS_DAY_LATE_PERIOD_START_HOUR = 20 # Consider periods starting at 20:00 or later for extension
|
||||||
# Key principle: requires periods on BOTH sides — a period ending at 21:30
|
CROSS_DAY_MAX_EXTENSION_HOUR = 8 # Don't extend beyond 08:00 next day (covers typical night low)
|
||||||
# will not be bridged because it ended naturally, not due to midnight.
|
CROSS_DAY_MAX_EXTENSION_INTERVALS = 16 # Hard cap: max 4 hours of extension (16 × 15-minute intervals)
|
||||||
CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS = 4 # Max gap: 4 intervals (1 hour) to bridge across midnight
|
CROSS_DAY_PROPORTIONAL_EXTENSION_FACTOR = 2.0 # Extension ≤ 2× original period length
|
||||||
CROSS_DAY_EARLY_MORNING_HOUR = 8 # Don't extend beyond 08:00 next day (covers typical night low)
|
CROSS_DAY_MAX_PRICE_DEVIATION = 0.15 # Stop if price deviates >15% from original period mean
|
||||||
|
|
||||||
# Cross-Day Supersession: When tomorrow data arrives, late-night periods that are
|
# Cross-Day Supersession: When tomorrow data arrives, late-night periods that are
|
||||||
# worse than early-morning tomorrow periods become obsolete.
|
# worse than early-morning tomorrow periods become obsolete
|
||||||
# A today period is "superseded" if tomorrow has a significantly better alternative.
|
# A today period is "superseded" if tomorrow has a significantly better alternative
|
||||||
# Uses START hour (not end hour) because we want to catch periods starting late evening.
|
|
||||||
CROSS_DAY_SUPERSESSION_START_HOUR = 20 # Periods starting at 20:00+ can be superseded by tomorrow
|
|
||||||
SUPERSESSION_PRICE_IMPROVEMENT_PCT = 10.0 # Tomorrow must be at least 10% cheaper to supersede
|
SUPERSESSION_PRICE_IMPROVEMENT_PCT = 10.0 # Tomorrow must be at least 10% cheaper to supersede
|
||||||
|
|
||||||
# Peak Price Quality: Minimum premium above daily average to qualify as genuine peak
|
# Peak Price Quality: Minimum premium above daily average to qualify as genuine peak
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"get_apexcharts_yaml": {
|
"get_apexcharts_yaml": {
|
||||||
"service": "mdi:chart-line"
|
"service": "mdi:chart-line",
|
||||||
|
"sections": {
|
||||||
|
"entry_id": "mdi:identifier",
|
||||||
|
"day": "mdi:calendar-range",
|
||||||
|
"level_type": "mdi:format-list-bulleted-type",
|
||||||
|
"resolution": "mdi:timer-sand",
|
||||||
|
"highlight_best_price": "mdi:battery-charging-low",
|
||||||
|
"highlight_peak_price": "mdi:battery-alert"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"refresh_user_data": {
|
"refresh_user_data": {
|
||||||
"service": "mdi:refresh"
|
"service": "mdi:refresh"
|
||||||
|
|
@ -27,8 +35,6 @@
|
||||||
"search_range": "mdi:calendar-search",
|
"search_range": "mdi:calendar-search",
|
||||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||||
"price_filter": "mdi:filter-variant",
|
"price_filter": "mdi:filter-variant",
|
||||||
"search_tuning": "mdi:cog-outline",
|
|
||||||
"cost_estimation": "mdi:lightning-bolt",
|
|
||||||
"output": "mdi:tune-variant"
|
"output": "mdi:tune-variant"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -38,8 +44,6 @@
|
||||||
"search_range": "mdi:calendar-search",
|
"search_range": "mdi:calendar-search",
|
||||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||||
"price_filter": "mdi:filter-variant",
|
"price_filter": "mdi:filter-variant",
|
||||||
"search_tuning": "mdi:cog-outline",
|
|
||||||
"cost_estimation": "mdi:lightning-bolt",
|
|
||||||
"output": "mdi:tune-variant"
|
"output": "mdi:tune-variant"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -49,8 +53,6 @@
|
||||||
"search_range": "mdi:calendar-search",
|
"search_range": "mdi:calendar-search",
|
||||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||||
"price_filter": "mdi:filter-variant",
|
"price_filter": "mdi:filter-variant",
|
||||||
"search_tuning": "mdi:cog-outline",
|
|
||||||
"cost_estimation": "mdi:lightning-bolt",
|
|
||||||
"output": "mdi:tune-variant"
|
"output": "mdi:tune-variant"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -60,18 +62,16 @@
|
||||||
"search_range": "mdi:calendar-search",
|
"search_range": "mdi:calendar-search",
|
||||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||||
"price_filter": "mdi:filter-variant",
|
"price_filter": "mdi:filter-variant",
|
||||||
"search_tuning": "mdi:cog-outline",
|
|
||||||
"cost_estimation": "mdi:lightning-bolt",
|
|
||||||
"output": "mdi:tune-variant"
|
"output": "mdi:tune-variant"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"find_cheapest_schedule": {
|
"find_cheapest_schedule": {
|
||||||
"service": "mdi:calendar-check",
|
"service": "mdi:calendar-check",
|
||||||
"sections": {
|
"sections": {
|
||||||
|
"scheduling_options": "mdi:format-list-numbered",
|
||||||
"search_range": "mdi:calendar-search",
|
"search_range": "mdi:calendar-search",
|
||||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||||
"price_filter": "mdi:filter-variant",
|
"price_filter": "mdi:filter-variant",
|
||||||
"search_tuning": "mdi:cog-outline",
|
|
||||||
"output": "mdi:tune-variant"
|
"output": "mdi:tune-variant"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ get_apexcharts_yaml:
|
||||||
boolean:
|
boolean:
|
||||||
highlight_peak_price:
|
highlight_peak_price:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
example: false
|
example: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
|
|
@ -138,6 +139,7 @@ get_chartdata:
|
||||||
fields:
|
fields:
|
||||||
subunit_currency:
|
subunit_currency:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
example: true
|
example: true
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
|
|
@ -161,10 +163,12 @@ get_chartdata:
|
||||||
translation_key: insert_nulls
|
translation_key: insert_nulls
|
||||||
connect_segments:
|
connect_segments:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
add_trailing_null:
|
add_trailing_null:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
format:
|
format:
|
||||||
|
|
@ -200,24 +204,29 @@ get_chartdata:
|
||||||
fields:
|
fields:
|
||||||
include_level:
|
include_level:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
example: true
|
example: true
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
include_rating_level:
|
include_rating_level:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
example: true
|
example: true
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
include_average:
|
include_average:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
include_energy:
|
include_energy:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
include_tax:
|
include_tax:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
start_time_field:
|
start_time_field:
|
||||||
|
|
@ -432,10 +441,12 @@ find_cheapest_block:
|
||||||
fields:
|
fields:
|
||||||
include_comparison_details:
|
include_comparison_details:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
use_base_unit:
|
use_base_unit:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
|
|
||||||
|
|
@ -600,10 +611,12 @@ find_most_expensive_block:
|
||||||
fields:
|
fields:
|
||||||
include_comparison_details:
|
include_comparison_details:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
use_base_unit:
|
use_base_unit:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
|
|
||||||
|
|
@ -772,10 +785,12 @@ find_cheapest_hours:
|
||||||
fields:
|
fields:
|
||||||
include_comparison_details:
|
include_comparison_details:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
use_base_unit:
|
use_base_unit:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
|
|
||||||
|
|
@ -944,10 +959,12 @@ find_most_expensive_hours:
|
||||||
fields:
|
fields:
|
||||||
include_comparison_details:
|
include_comparison_details:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
use_base_unit:
|
use_base_unit:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
|
|
||||||
|
|
@ -1101,10 +1118,12 @@ find_cheapest_schedule:
|
||||||
fields:
|
fields:
|
||||||
include_comparison_details:
|
include_comparison_details:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
use_base_unit:
|
use_base_unit:
|
||||||
required: false
|
required: false
|
||||||
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
boolean:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -535,24 +535,6 @@ Without the symmetric check, both directions produced "false extremes" purely fr
|
||||||
|
|
||||||
`CROSS_DAY_OVERNIGHT_VALIDATION_HOUR = 6` (in `types.py`) covers the typical overnight low/high window. Beyond that, intra-day price dynamics dominate and the boundary artifact disappears naturally.
|
`CROSS_DAY_OVERNIGHT_VALIDATION_HOUR = 6` (in `types.py`) covers the typical overnight low/high window. Beyond that, intra-day price dynamics dominate and the boundary artifact disappears naturally.
|
||||||
|
|
||||||
### Cross-Midnight Bridging
|
|
||||||
|
|
||||||
Separate from boundary *validation* (which filters artifacts), cross-midnight *bridging* merges periods that were split by the day boundary.
|
|
||||||
|
|
||||||
**Problem:** Per-day reference prices change at midnight. Two intervals — one at 23:45 and one at 00:15 — are evaluated against different daily minimums/maximums. Even if prices are nearly identical, one may qualify and the other may not, splitting what should be a single period into two fragments.
|
|
||||||
|
|
||||||
**Solution:** After period detection completes, the integration checks for pairs of periods where:
|
|
||||||
|
|
||||||
1. **Both sides have evidence:** One period ends near midnight, the other starts shortly after midnight
|
|
||||||
2. **Gap is small:** At most `CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS` intervals (4 = 1 hour) separate the two periods
|
|
||||||
3. **Quality passes:** The merged period's coefficient of variation must stay ≤ `PERIOD_MAX_CV` (25%)
|
|
||||||
|
|
||||||
When all conditions are met, the two periods are merged into one. The merged period inherits the start of the earlier period and the end of the later period, with recalculated statistics.
|
|
||||||
|
|
||||||
**Key design principle:** Bridging requires qualifying periods on **both** sides of midnight. A period that ends at 21:30 will *not* be bridged — it ended because prices changed, not because of midnight. Only genuine midnight-split periods (where favorable conditions exist on both sides of the boundary) are merged.
|
|
||||||
|
|
||||||
**Implementation:** `period_building.py` → `extend_periods_across_midnight()` (function name kept for backwards compatibility, but algorithm is now bidirectional bridging). Helper functions: `_gap_spans_midnight()`, `_collect_period_prices()`, `_build_bridged_period()`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Relaxation Strategy
|
## Relaxation Strategy
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ flowchart TD
|
||||||
B --> C["📐 Phase 2: Day Pattern Detection<br/><small>Classify each day's shape<br/>(valley, peak, duck curve, flat…)</small>"]
|
B --> C["📐 Phase 2: Day Pattern Detection<br/><small>Classify each day's shape<br/>(valley, peak, duck curve, flat…)</small>"]
|
||||||
C --> D["🔍 Phase 3: Period Detection<br/><small>Find continuous intervals matching<br/>flex + distance + level criteria</small>"]
|
C --> D["🔍 Phase 3: Period Detection<br/><small>Find continuous intervals matching<br/>flex + distance + level criteria</small>"]
|
||||||
D --> E["📏 Phase 4: Duration & Quality<br/><small>Remove too-short periods,<br/>calculate statistics</small>"]
|
D --> E["📏 Phase 4: Duration & Quality<br/><small>Remove too-short periods,<br/>calculate statistics</small>"]
|
||||||
E --> F["🌙 Phase 5: Cross-Day Handling<br/><small>Bridge midnight-split periods,<br/>filter day-boundary artifacts</small>"]
|
E --> F["🌙 Phase 5: Cross-Day Handling<br/><small>Extend across midnight,<br/>filter day-boundary artifacts</small>"]
|
||||||
F --> G{"Enough periods<br/>per day?"}
|
F --> G{"Enough periods<br/>per day?"}
|
||||||
G -->|Yes| H["✅ Done"]
|
G -->|Yes| H["✅ Done"]
|
||||||
G -->|No| I["🔄 Phase 6: Relaxation<br/><small>Gradually loosen filters<br/>(+3% flex per step)</small>"]
|
G -->|No| I["🔄 Phase 6: Relaxation<br/><small>Gradually loosen filters<br/>(+3% flex per step)</small>"]
|
||||||
|
|
@ -131,7 +131,7 @@ flowchart TD
|
||||||
| **2. Day Patterns** | Classifies each day's price shape (valley, peak, duck curve, flat…) | Enables geometric flex bonuses — periods in a detected valley/peak zone get extra margin |
|
| **2. Day Patterns** | Classifies each day's price shape (valley, peak, duck curve, flat…) | Enables geometric flex bonuses — periods in a detected valley/peak zone get extra margin |
|
||||||
| **3. Period Detection** | Scans all intervals through flex, distance, and level filters | Core logic: finds contiguous blocks where prices are close to the daily min (or max) |
|
| **3. Period Detection** | Scans all intervals through flex, distance, and level filters | Core logic: finds contiguous blocks where prices are close to the daily min (or max) |
|
||||||
| **4. Duration & Quality** | Removes periods shorter than the configured minimum, calculates statistics | A 15-minute "period" isn't useful for running an appliance |
|
| **4. Duration & Quality** | Removes periods shorter than the configured minimum, calculates statistics | A 15-minute "period" isn't useful for running an appliance |
|
||||||
| **5. Cross-Day Handling** | Bridges midnight-split periods, filters day-boundary artifacts | Without this, a cheap period split by midnight into two fragments can't be recognized as one continuous period |
|
| **5. Cross-Day Handling** | Extends late-evening periods across midnight, filters day-boundary artifacts | Without this, a cheap period at 23:00-00:00 can't continue into 00:00-02:00 even if prices stay low |
|
||||||
| **6. Relaxation** | Loosens filters step by step (+3% flex) until enough periods are found | On some days, the configured flex isn't enough to find 2 periods — relaxation adapts automatically |
|
| **6. Relaxation** | Loosens filters step by step (+3% flex) until enough periods are found | On some days, the configured flex isn't enough to find 2 periods — relaxation adapts automatically |
|
||||||
| **7. Fallback** | Progressively reduces minimum duration (60→45→30 min) | Last resort for days where even full relaxation finds zero periods |
|
| **7. Fallback** | Progressively reduces minimum duration (60→45→30 min) | Last resort for days where even full relaxation finds zero periods |
|
||||||
|
|
||||||
|
|
@ -274,12 +274,11 @@ For each surviving period, the integration calculates statistics: mean, median,
|
||||||
|
|
||||||
Since the integration processes yesterday + today + tomorrow together, periods can naturally span midnight. This phase ensures correct behavior at day boundaries:
|
Since the integration processes yesterday + today + tomorrow together, periods can naturally span midnight. This phase ensures correct behavior at day boundaries:
|
||||||
|
|
||||||
**Cross-midnight bridging:**
|
**Cross-midnight extension:**
|
||||||
When two independently qualifying periods exist on **both sides** of midnight — separated only by a small gap (max 1 hour) caused by the per-day reference price change at the day boundary — they are merged into a single period. This requires evidence on both sides: a period ending at 21:30 will **not** be bridged, because it ended naturally (prices changed), not because of midnight. Only genuine midnight-split periods are merged.
|
Late-evening periods (starting after 20:00) are extended into the next day if prices remain favorable. Three safety limits apply:
|
||||||
|
- Maximum 4 hours of extension
|
||||||
Safety limits:
|
- Extension can't exceed 2× the original period length
|
||||||
- Maximum gap of 4 intervals (1 hour) between the two periods
|
- Extension stops if prices deviate more than 15% from the original period's mean
|
||||||
- The merged period must pass the CV quality gate (≤ 25% coefficient of variation)
|
|
||||||
|
|
||||||
**Day-boundary artifact filtering:**
|
**Day-boundary artifact filtering:**
|
||||||
Each day has its own min/max/avg — so the same absolute price can qualify as "cheap" or "peak" on one day but not the next. The integration catches these misleading artifacts with several automatic checks:
|
Each day has its own min/max/avg — so the same absolute price can qualify as "cheap" or "peak" on one day but not the next. The integration catches these misleading artifacts with several automatic checks:
|
||||||
|
|
@ -898,7 +897,7 @@ The [cross-day handling](#phase-5-cross-day-handling) automatically prevents mis
|
||||||
|
|
||||||
- **Best _and_ Peak periods** near midnight are validated against **both** adjacent days' statistics
|
- **Best _and_ Peak periods** near midnight are validated against **both** adjacent days' statistics
|
||||||
- **Peak periods** must exceed the daily average by at least 10%, with overnight periods checked against the higher average of both days
|
- **Peak periods** must exceed the daily average by at least 10%, with overnight periods checked against the higher average of both days
|
||||||
- **Cross-midnight bridging** merges periods split by midnight only when qualifying periods exist on **both** sides (gap ≤ 1 hour, CV quality gate applies)
|
- **Cross-day extensions** are capped in length and stop when prices deviate significantly
|
||||||
|
|
||||||
These checks run automatically and require no configuration. They ensure that midnight period boundaries reflect genuine price differences, not just day-boundary artifacts.
|
These checks run automatically and require no configuration. They ensure that midnight period boundaries reflect genuine price differences, not just day-boundary artifacts.
|
||||||
|
|
||||||
|
|
@ -945,7 +944,7 @@ automation:
|
||||||
|
|
||||||
**Summary:**
|
**Summary:**
|
||||||
- ✅ **Expected behavior:** Each day has independent price statistics — midnight is a natural boundary
|
- ✅ **Expected behavior:** Each day has independent price statistics — midnight is a natural boundary
|
||||||
- ✅ **Automatic handling:** Cross-day bridging and quality checks prevent misleading period artifacts
|
- ✅ **Automatic handling:** Cross-day quality checks prevent misleading period artifacts
|
||||||
- ✅ **Extra safety:** Use volatility sensors or absolute price thresholds in automations for additional robustness
|
- ✅ **Extra safety:** Use volatility sensors or absolute price thresholds in automations for additional robustness
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue