Compare commits

...

2 commits

Author SHA1 Message Date
Julian Pawlowski
3057642cba refactor(icons): streamline service icon definitions and enhance chartdata sections
Some checks failed
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Has been cancelled
Validate / Hassfest validation (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
Removed unnecessary sections from the get_apexcharts_yaml service and added new fields for search tuning and cost estimation in chartdata services. This improves the clarity and usability of the service definitions.

Impact: Users will benefit from a more concise and organized service structure, enhancing the overall experience.
2026-04-19 12:35:13 +00:00
Julian Pawlowski
60b2de0379 refactor(periods): replace cross-day extension with bidirectional bridging
The old extension algorithm extended a single late-evening period forward
past midnight by appending qualifying intervals one-directionally.  This
caused false extensions (e.g. peak 19:45–21:30 extended to 01:00 by
pulling in 14 declining-price intervals).

Replace with bidirectional bridging: two independently qualifying periods
on both sides of midnight are merged only when separated by a small gap
(≤4 intervals = 1 hour) and the combined period passes the CV quality
gate (≤25%).  A period ending well before midnight is no longer touched.

User-Impact: none
2026-04-19 11:47:45 +00:00
8 changed files with 168 additions and 293 deletions

View file

@ -720,9 +720,7 @@ 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
- Pattern: "Uses reference price from start day of the period for consistency" (same as period statistics) - **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.
- 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`):

View file

@ -251,9 +251,11 @@ def calculate_periods(
time=time, time=time,
) )
# Step 8: Cross-day extension for late-night periods # Step 8: Cross-day bridging for midnight-split periods
# If a best-price period ends near midnight and tomorrow has continued low prices, # If two periods exist on both sides of midnight separated by a small gap
# extend the period across midnight to give users the full cheap window # (artifact of per-day reference price changes), merge them into one period.
# 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,

View file

@ -541,8 +541,8 @@ def filter_superseded_periods(
""" """
from .types import ( # noqa: PLC0415 from .types import ( # noqa: PLC0415
CROSS_DAY_LATE_PERIOD_START_HOUR, CROSS_DAY_EARLY_MORNING_HOUR,
CROSS_DAY_MAX_EXTENSION_HOUR, CROSS_DAY_SUPERSESSION_START_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_LATE_PERIOD_START_HOUR, CROSS_DAY_SUPERSESSION_START_HOUR,
CROSS_DAY_MAX_EXTENSION_HOUR, CROSS_DAY_EARLY_MORNING_HOUR,
) )
_LOGGER.debug( _LOGGER.debug(
@ -708,113 +708,31 @@ def filter_weak_peak_periods(
return kept return kept
def _is_period_eligible_for_extension( def _gap_spans_midnight(a_end: datetime, b_start: datetime) -> bool:
period: dict,
today: date,
late_hour_threshold: int,
) -> bool:
""" """
Check if a period is eligible for cross-day extension. Check if the gap between two periods spans a midnight boundary.
Eligibility criteria: Uses the last covered moment of period A (end - 1 minute, since end is
- Period has valid start and end times exclusive) to determine the calendar day. Returns True when A's last
- Period ends on today (not yesterday or tomorrow) interval is on a different (earlier) date than B's first interval.
- Period ends late (after late_hour_threshold, e.g. 20:00)
Note: ``end`` is an *exclusive* boundary the first moment after the last Examples:
interval. A period whose last interval starts at 23:45 has ``end = 00:00`` A ends 00:00 (last interval 23:45 same day), B starts 00:15 True
the next calendar day. We therefore derive the effective date/hour from A ends 23:30, B starts 00:00 next day True
``end 1 minute`` so that such periods are correctly recognised as ending A ends 21:30, B starts 22:00 same day False
"today, late in the evening".
""" """
period_end = period.get("end") a_last_moment = a_end - timedelta(minutes=1)
period_start = period.get("start") return a_last_moment.date() < b_start.date()
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 _find_extension_intervals( def _collect_period_prices(
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 from original period for CV calculation.""" """Collect prices within a time range from the price lookup."""
prices: list[float] = [] prices: list[float] = []
current = period_start current = period_start
while current < period_end: while current < period_end:
@ -825,33 +743,29 @@ def _collect_original_period_prices(
return prices return prices
def _build_extended_period( def _build_bridged_period(
period: dict, period_a: dict,
extension_intervals: list[dict], period_b: dict,
combined_prices: list[float], combined_prices: list[float],
combined_cv: float, combined_cv: float,
interval_duration: timedelta, gap_intervals: int,
) -> dict: ) -> dict:
"""Create extended period dict with updated statistics.""" """Create a merged period dict from two bridged periods with updated statistics."""
period_start = period["start"] bridged = period_a.copy()
period_end = period["end"] bridged["end"] = period_b["end"]
new_end = period_end + (interval_duration * len(extension_intervals)) bridged["duration_minutes"] = int((period_b["end"] - period_a["start"]).total_seconds() / 60)
bridged["period_interval_count"] = len(combined_prices)
bridged["cross_day_bridged"] = True
bridged["cross_day_bridge_gap_intervals"] = gap_intervals
extended = period.copy() # Recalculate price statistics for the combined period
extended["end"] = new_end bridged["price_min"] = min(combined_prices)
extended["duration_minutes"] = int((new_end - period_start).total_seconds() / 60) bridged["price_max"] = max(combined_prices)
extended["period_interval_count"] = len(combined_prices) bridged["price_mean"] = sum(combined_prices) / len(combined_prices)
extended["cross_day_extended"] = True bridged["price_spread"] = bridged["price_max"] - bridged["price_min"]
extended["cross_day_extension_intervals"] = len(extension_intervals) bridged["price_coefficient_variation_%"] = round(combined_cv, 1)
# Recalculate price statistics return bridged
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(
@ -863,20 +777,24 @@ def extend_periods_across_midnight(
reverse_sort: bool, reverse_sort: bool,
) -> list[dict]: ) -> list[dict]:
""" """
Extend late-night periods across midnight if favorable prices continue. Bridge periods across midnight when separated by a small gap.
When a period ends close to midnight and tomorrow's data shows continued When two independently qualifying periods exist on either side of midnight,
favorable prices, extend the period into the next day. This prevents separated only by a few non-qualifying intervals (typically caused by per-day
artificial period breaks at midnight when it's actually better to continue. reference price changes at the day boundary), merge them into a single period.
Example: Best price period 22:00-23:45 today could extend to 04:00 tomorrow Key principle: requires evidence on BOTH sides of midnight.
if prices remain low overnight. A period ending at 21:30 will NOT be bridged it ended because prices
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:
- Only extends periods ending after CROSS_DAY_LATE_PERIOD_START_HOUR (20:00) - Requires periods on BOTH sides of the midnight boundary
- Won't extend beyond CROSS_DAY_MAX_EXTENSION_HOUR (08:00) next day - Gap between periods must be CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS (4 = 1 hour)
- Extension must pass same flex criteria as original period - Quality Gate (CV check) applies to the merged 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)
@ -886,22 +804,14 @@ 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 extensions applied Updated list of period summaries with bridges 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 ( # noqa: PLC0415 from .types import CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS, PERIOD_MAX_CV # 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 not all_prices: if not period_summaries or len(period_summaries) < 2 or not all_prices:
return period_summaries return period_summaries
# Build price lookup by timestamp # Build price lookup by timestamp
@ -911,114 +821,77 @@ 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()
# Max extension time (e.g., 08:00 tomorrow) # Sort periods by start time for pairwise comparison
max_extension_time = time.start_of_local_day(now) + timedelta(days=1, hours=CROSS_DAY_MAX_EXTENSION_HOUR) sorted_periods = sorted(period_summaries, key=lambda p: p.get("start") or now)
extended_summaries = [] result: list[dict] = []
skip_indices: set[int] = set()
for period in period_summaries: for i, period_a in enumerate(sorted_periods):
# Check eligibility for extension if i in skip_indices:
if not _is_period_eligible_for_extension(period, today, CROSS_DAY_LATE_PERIOD_START_HOUR):
extended_summaries.append(period)
continue continue
# Get tomorrow's reference prices # Try to bridge with the next period
tomorrow_ref = ref_prices.get(tomorrow) or ref_prices.get(str(tomorrow)) if i + 1 < len(sorted_periods):
tomorrow_avg = avg_prices.get(tomorrow) or avg_prices.get(str(tomorrow)) period_b = sorted_periods[i + 1]
a_end = period_a.get("end")
b_start = period_b.get("start")
if tomorrow_ref is None or tomorrow_avg is None: if (
extended_summaries.append(period) a_end and b_start and _gap_spans_midnight(a_end, b_start) and b_start >= a_end # No overlap
continue ):
gap = b_start - a_end
gap_intervals = int(gap.total_seconds() / interval_duration.total_seconds())
# Set up criteria for extension check if gap_intervals <= CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS:
criteria = TibberPricesIntervalCriteria( # Collect all prices from A.start through B.end (including gap)
ref_price=tomorrow_ref, combined_prices = _collect_period_prices(
avg_price=tomorrow_avg, period_a["start"],
flex=flex, period_b["end"],
min_distance_from_avg=min_distance, price_lookup,
reverse_sort=reverse_sort, interval_duration,
) )
# Collect original prices once (reused for cap calculation, deviation gate, and CV check) if combined_prices:
original_prices = _collect_original_period_prices( combined_cv = calculate_coefficient_of_variation(combined_prices)
period["start"],
period["end"],
price_lookup,
interval_duration,
)
# Calculate max extension intervals: min(hard cap, proportional cap) if combined_cv is not None and combined_cv <= PERIOD_MAX_CV:
original_interval_count = max(1, len(original_prices)) bridged = _build_bridged_period(
proportional_cap = int(original_interval_count * CROSS_DAY_PROPORTIONAL_EXTENSION_FACTOR) period_a,
max_intervals = min(CROSS_DAY_MAX_EXTENSION_INTERVALS, proportional_cap) period_b,
combined_prices,
combined_cv,
gap_intervals,
)
_LOGGER.info(
"Cross-day bridge: Merged %s-%s + %s-%s%s-%s (gap=%d intervals, CV=%.1f%%)",
period_a["start"].strftime("%H:%M"),
period_a["end"].strftime("%H:%M"),
period_b["start"].strftime("%H:%M"),
period_b["end"].strftime("%H:%M"),
bridged["start"].strftime("%H:%M"),
bridged["end"].strftime("%H:%M"),
gap_intervals,
combined_cv,
)
result.append(bridged)
skip_indices.add(i + 1)
continue
# Original period mean price for deviation gate _LOGGER_DETAILS.debug(
period_mean_price = sum(original_prices) / len(original_prices) if original_prices else 0.0 "%sCross-day bridge rejected %s-%s + %s-%s: CV=%.1f%% > %.1f%%",
INDENT_L0,
period_a["start"].strftime("%H:%M"),
period_a["end"].strftime("%H:%M"),
period_b["start"].strftime("%H:%M"),
period_b["end"].strftime("%H:%M"),
combined_cv or 0,
PERIOD_MAX_CV,
)
# Find extension intervals (with cap + price deviation gate) result.append(period_a)
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: return result
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)
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, max=%d, CV=%.1f%%)",
period["start"].strftime("%H:%M"),
period["end"].strftime("%H:%M"),
extended_period["end"].strftime("%H:%M"),
len(extension_intervals),
max_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

View file

@ -29,18 +29,20 @@ 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 Extension: Time window constants # Cross-Day Bridging: Merge periods separated by the midnight boundary
# When a period ends late in the day and tomorrow data is available, # When two independently qualifying periods exist on both sides of midnight,
# we can extend it past midnight if prices remain favorable # separated only by a small gap (artifact of per-day reference price changes),
CROSS_DAY_LATE_PERIOD_START_HOUR = 20 # Consider periods starting at 20:00 or later for extension # merge them into a single period.
CROSS_DAY_MAX_EXTENSION_HOUR = 8 # Don't extend beyond 08:00 next day (covers typical night low) # Key principle: requires periods on BOTH sides — a period ending at 21:30
CROSS_DAY_MAX_EXTENSION_INTERVALS = 16 # Hard cap: max 4 hours of extension (16 × 15-minute intervals) # will not be bridged because it ended naturally, not due to midnight.
CROSS_DAY_PROPORTIONAL_EXTENSION_FACTOR = 2.0 # Extension ≤ 2× original period length CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS = 4 # Max gap: 4 intervals (1 hour) to bridge across midnight
CROSS_DAY_MAX_PRICE_DEVIATION = 0.15 # Stop if price deviates >15% from original period mean CROSS_DAY_EARLY_MORNING_HOUR = 8 # Don't extend beyond 08:00 next day (covers typical night low)
# 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

View file

@ -16,15 +16,7 @@
} }
}, },
"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"
@ -35,6 +27,8 @@
"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"
} }
}, },
@ -44,6 +38,8 @@
"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"
} }
}, },
@ -53,6 +49,8 @@
"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"
} }
}, },
@ -62,16 +60,18 @@
"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"
} }
} }

View file

@ -64,7 +64,6 @@ 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:
@ -139,7 +138,6 @@ get_chartdata:
fields: fields:
subunit_currency: subunit_currency:
required: false required: false
default: false
example: true example: true
selector: selector:
boolean: boolean:
@ -163,12 +161,10 @@ 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:
@ -204,29 +200,24 @@ 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:
@ -441,12 +432,10 @@ 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:
@ -611,12 +600,10 @@ 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:
@ -785,12 +772,10 @@ 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:
@ -959,12 +944,10 @@ 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:
@ -1118,12 +1101,10 @@ 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:

View file

@ -535,6 +535,24 @@ 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

View file

@ -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 &amp; Quality<br/><small>Remove too-short periods,<br/>calculate statistics</small>"] D --> E["📏 Phase 4: Duration &amp; Quality<br/><small>Remove too-short periods,<br/>calculate statistics</small>"]
E --> F["🌙 Phase 5: Cross-Day Handling<br/><small>Extend across midnight,<br/>filter day-boundary artifacts</small>"] E --> F["🌙 Phase 5: Cross-Day Handling<br/><small>Bridge midnight-split periods,<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** | 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 | | **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 |
| **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,11 +274,12 @@ 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 extension:** **Cross-midnight bridging:**
Late-evening periods (starting after 20:00) are extended into the next day if prices remain favorable. Three safety limits apply: 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.
- Maximum 4 hours of extension
- Extension can't exceed 2× the original period length Safety limits:
- Extension stops if prices deviate more than 15% from the original period's mean - Maximum gap of 4 intervals (1 hour) between the two periods
- 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:
@ -897,7 +898,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-day extensions** are capped in length and stop when prices deviate significantly - **Cross-midnight bridging** merges periods split by midnight only when qualifying periods exist on **both** sides (gap ≤ 1 hour, CV quality gate applies)
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.
@ -944,7 +945,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 quality checks prevent misleading period artifacts - ✅ **Automatic handling:** Cross-day bridging and 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
--- ---