mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Compare commits
5 commits
2b63440933
...
63c3404fbd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63c3404fbd | ||
|
|
7783a0b629 | ||
|
|
63a187fe5c | ||
|
|
9a4ee04cfa | ||
|
|
d66b3f4ec0 |
11 changed files with 240 additions and 45 deletions
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
python-version: "3.14"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: "0.9.3"
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,22 @@ def calculate_reference_prices(intervals_by_day: dict[date, list[dict]], *, reve
|
|||
return ref_prices
|
||||
|
||||
|
||||
def _trim_trailing_gaps(period: list[dict]) -> list[dict]:
|
||||
"""Remove trailing gap-tolerance intervals from a period.
|
||||
|
||||
Gap-tolerance intervals at the trailing edge of a period represent
|
||||
the transition out of the period's price level (e.g., the first
|
||||
NORMAL interval after a sequence of EXPENSIVE ones). Keeping them
|
||||
shifts the reported period end by up to gap_count intervals.
|
||||
Interior gaps (surrounded by qualifying intervals on both sides)
|
||||
are kept because they represent brief dips within an otherwise
|
||||
continuous period.
|
||||
"""
|
||||
while period and period[-1].get("is_level_gap", False):
|
||||
period = period[:-1]
|
||||
return period
|
||||
|
||||
|
||||
def build_periods(
|
||||
all_prices: list[dict],
|
||||
price_context: dict[str, Any],
|
||||
|
|
@ -171,11 +187,19 @@ def build_periods(
|
|||
effective_criteria = criteria._replace(flex=criteria.flex + geo_bonus) if geo_bonus > 0 else criteria
|
||||
in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, effective_criteria)
|
||||
|
||||
# Cross-day boundary validation for peak periods:
|
||||
# Cross-day boundary validation (symmetric for best AND peak periods):
|
||||
# Overnight intervals (00:00-05:59) must ALSO qualify against the previous
|
||||
# day's reference price. Without this, prices like 30ct become "peak" against
|
||||
# tomorrow's lower max (35ct) but weren't peak against today's higher max (39ct).
|
||||
if reverse_sort and in_flex and starts_at.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
|
||||
# day's reference price. This prevents day-boundary artifacts in BOTH directions:
|
||||
#
|
||||
# PEAK example: A 30ct interval becomes "peak" against tomorrow's lower max (35ct)
|
||||
# but wasn't peak against today's higher max (39ct).
|
||||
# BEST example: An 8ct interval becomes "best" against today's lower min (5ct, flex
|
||||
# allows ≤7.5ct) but actually a 7ct interval qualifying today wouldn't have
|
||||
# qualified yesterday when min was 4ct (flex allows ≤6ct).
|
||||
#
|
||||
# In both cases the apparent "extreme" is just a relative shift between adjacent
|
||||
# days, not a genuine outlier worth reporting.
|
||||
if in_flex and starts_at.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
|
||||
prev_day = date_key - timedelta(days=1)
|
||||
prev_criteria = criteria_by_day.get(prev_day)
|
||||
if prev_criteria is not None:
|
||||
|
|
@ -227,14 +251,21 @@ def build_periods(
|
|||
}
|
||||
)
|
||||
elif current_period:
|
||||
# Criteria no longer met, end current period
|
||||
periods.append(current_period)
|
||||
# Criteria no longer met, end current period.
|
||||
# Trim trailing gap-tolerance intervals: these sit at the boundary
|
||||
# between the period's price level and the next, and would shift
|
||||
# the period end by up to gap_count intervals.
|
||||
current_period = _trim_trailing_gaps(current_period)
|
||||
if current_period:
|
||||
periods.append(current_period)
|
||||
current_period = []
|
||||
consecutive_gaps = 0 # Reset gap counter
|
||||
|
||||
# Add final period if exists
|
||||
if current_period:
|
||||
periods.append(current_period)
|
||||
current_period = _trim_trailing_gaps(current_period)
|
||||
if current_period:
|
||||
periods.append(current_period)
|
||||
|
||||
# Log detailed filter statistics
|
||||
if intervals_checked > 0:
|
||||
|
|
@ -690,6 +721,12 @@ def _is_period_eligible_for_extension(
|
|||
- Period ends on today (not yesterday or tomorrow)
|
||||
- Period ends late (after late_hour_threshold, e.g. 20:00)
|
||||
|
||||
Note: ``end`` is an *exclusive* boundary — the first moment after the last
|
||||
interval. A period whose last interval starts at 23:45 has ``end = 00:00``
|
||||
the next calendar day. We therefore derive the effective date/hour from
|
||||
``end − 1 minute`` so that such periods are correctly recognised as ending
|
||||
"today, late in the evening".
|
||||
|
||||
"""
|
||||
period_end = period.get("end")
|
||||
period_start = period.get("start")
|
||||
|
|
@ -697,10 +734,13 @@ def _is_period_eligible_for_extension(
|
|||
if not period_end or not period_start:
|
||||
return False
|
||||
|
||||
if period_end.date() != today:
|
||||
# Derive last-covered moment (exclusive end → inclusive last moment)
|
||||
effective_end = period_end - timedelta(minutes=1)
|
||||
|
||||
if effective_end.date() != today:
|
||||
return False
|
||||
|
||||
return period_end.hour >= late_hour_threshold
|
||||
return effective_end.hour >= late_hour_threshold
|
||||
|
||||
|
||||
def _find_extension_intervals(
|
||||
|
|
|
|||
|
|
@ -89,6 +89,20 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
|
|||
The newer period's relaxation attributes override the older period's.
|
||||
Takes the earliest start time and latest end time.
|
||||
|
||||
Price statistics are recombined from both periods so the merged period
|
||||
reflects the actual span (rather than only period1's stats):
|
||||
- price_min: min(period1.price_min, period2.price_min)
|
||||
- price_max: max(period1.price_max, period2.price_max)
|
||||
- price_spread: max - min (recomputed)
|
||||
- price_mean: weighted by period_interval_count when available, else
|
||||
weighted by duration_minutes (kept simple - exact mean would require
|
||||
raw interval prices that aren't carried in the period dict).
|
||||
|
||||
Note: price_median and price_coefficient_variation_% are intentionally NOT
|
||||
recomputed because they cannot be derived from summary stats. They retain
|
||||
period1's values; downstream consumers must treat them as approximate for
|
||||
merged periods (the `merged_from` marker indicates this).
|
||||
|
||||
Relaxation attributes from the newer period (period2) override those from period1:
|
||||
- relaxation_active
|
||||
- relaxation_level
|
||||
|
|
@ -102,7 +116,8 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
|
|||
period2: Second period (newer relaxed period with higher flex)
|
||||
|
||||
Returns:
|
||||
Merged period dict with combined time span and newer period's attributes
|
||||
Merged period dict with combined time span, recomputed price extremes,
|
||||
and the newer period's relaxation attributes.
|
||||
|
||||
"""
|
||||
# Take earliest start and latest end
|
||||
|
|
@ -110,7 +125,7 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
|
|||
merged_end = max(period1["end"], period2["end"])
|
||||
merged_duration = int((merged_end - merged_start).total_seconds() / 60)
|
||||
|
||||
# Start with period1 as base
|
||||
# Start with period1 as base (keeps period_position, period_count_*, ratings, etc.)
|
||||
merged = period1.copy()
|
||||
|
||||
# Update time boundaries
|
||||
|
|
@ -118,6 +133,39 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
|
|||
merged["end"] = merged_end
|
||||
merged["duration_minutes"] = merged_duration
|
||||
|
||||
# Recombine price extremes from both periods
|
||||
p1_min = period1.get("price_min")
|
||||
p2_min = period2.get("price_min")
|
||||
p1_max = period1.get("price_max")
|
||||
p2_max = period2.get("price_max")
|
||||
|
||||
if p1_min is not None and p2_min is not None:
|
||||
merged["price_min"] = round(min(float(p1_min), float(p2_min)), 4)
|
||||
if p1_max is not None and p2_max is not None:
|
||||
merged["price_max"] = round(max(float(p1_max), float(p2_max)), 4)
|
||||
if merged.get("price_min") is not None and merged.get("price_max") is not None:
|
||||
merged["price_spread"] = round(float(merged["price_max"]) - float(merged["price_min"]), 4)
|
||||
|
||||
# Weighted mean: prefer interval count, fall back to duration
|
||||
p1_mean = period1.get("price_mean")
|
||||
p2_mean = period2.get("price_mean")
|
||||
if p1_mean is not None and p2_mean is not None:
|
||||
p1_weight = period1.get("period_interval_count") or period1.get("duration_minutes") or 1
|
||||
p2_weight = period2.get("period_interval_count") or period2.get("duration_minutes") or 1
|
||||
total_weight = p1_weight + p2_weight
|
||||
if total_weight > 0:
|
||||
merged["price_mean"] = round(
|
||||
(float(p1_mean) * p1_weight + float(p2_mean) * p2_weight) / total_weight,
|
||||
4,
|
||||
)
|
||||
|
||||
# Combine interval count if both have it (overlaps will overcount slightly,
|
||||
# which is acceptable for the weighted-mean use case above)
|
||||
p1_iv = period1.get("period_interval_count")
|
||||
p2_iv = period2.get("period_interval_count")
|
||||
if p1_iv is not None and p2_iv is not None:
|
||||
merged["period_interval_count"] = int(p1_iv) + int(p2_iv)
|
||||
|
||||
# Override with period2's relaxation attributes (newer/higher flex wins)
|
||||
relaxation_attrs = [
|
||||
"relaxation_active",
|
||||
|
|
@ -132,7 +180,8 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
|
|||
if attr in period2:
|
||||
merged[attr] = period2[attr]
|
||||
|
||||
# Mark as merged (for debugging)
|
||||
# Mark as merged (for debugging) - downstream consumers can detect that
|
||||
# price_median / price_coefficient_variation_% are approximate.
|
||||
merged["merged_from"] = {
|
||||
"period1_start": period1["start"].isoformat(),
|
||||
"period1_end": period1["end"].isoformat(),
|
||||
|
|
@ -141,7 +190,7 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
|
|||
}
|
||||
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%sMerged periods: %s-%s + %s-%s → %s-%s (duration: %d min)",
|
||||
"%sMerged periods: %s-%s + %s-%s → %s-%s (duration: %d min, mean: %s)",
|
||||
INDENT_L2,
|
||||
period1["start"].strftime("%H:%M"),
|
||||
period1["end"].strftime("%H:%M"),
|
||||
|
|
@ -150,6 +199,7 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
|
|||
merged_start.strftime("%H:%M"),
|
||||
merged_end.strftime("%H:%M"),
|
||||
merged_duration,
|
||||
merged.get("price_mean"),
|
||||
)
|
||||
|
||||
return merged
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from .types import (
|
|||
INDENT_L2,
|
||||
LOW_PRICE_QUALITY_BYPASS_THRESHOLD,
|
||||
PERIOD_MAX_CV,
|
||||
RELAXATION_FLEX_INCREMENT,
|
||||
TibberPricesPeriodConfig,
|
||||
)
|
||||
|
||||
|
|
@ -284,6 +285,7 @@ def _try_min_duration_fallback(
|
|||
existing_periods: list[dict],
|
||||
prices_by_day: dict[date, list[dict]],
|
||||
time: TibberPricesTimeService,
|
||||
max_relaxation_attempts: int = 0,
|
||||
day_patterns_by_date: dict | None = None,
|
||||
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
|
||||
"""
|
||||
|
|
@ -351,19 +353,32 @@ def _try_min_duration_fallback(
|
|||
current_min_duration,
|
||||
)
|
||||
|
||||
# Create modified config with shorter min_period_length
|
||||
# Use maxed-out flex (50%) since we're in fallback mode
|
||||
# Create modified config with shorter min_period_length.
|
||||
# IMPORTANT: We deliberately do NOT max out flex/min_distance here.
|
||||
# Going to MAX_FLEX_HARD_LIMIT (50%) and disabling min_distance + level filter
|
||||
# made every interval qualify on flat-price days, producing phantom periods that
|
||||
# don't represent any real "best/peak" structure. Instead we keep the relaxation's
|
||||
# final flex (the highest the user accepted via max_relaxation_attempts) and
|
||||
# only:
|
||||
# - drop the level filter (it was already dropped during the last relaxation step)
|
||||
# - halve min_distance_from_avg (instead of zeroing it) so genuinely flat days
|
||||
# still surface no period rather than a misleading one.
|
||||
# The shorter min_period_length is what actually unlocks new candidates.
|
||||
relaxation_final_flex = min(
|
||||
abs(config.flex) + max(1, max_relaxation_attempts) * RELAXATION_FLEX_INCREMENT,
|
||||
MAX_FLEX_HARD_LIMIT,
|
||||
)
|
||||
fallback_config = TibberPricesPeriodConfig(
|
||||
reverse_sort=config.reverse_sort,
|
||||
flex=MAX_FLEX_HARD_LIMIT, # Max flex
|
||||
min_distance_from_avg=0, # Disable min_distance in fallback
|
||||
flex=relaxation_final_flex,
|
||||
min_distance_from_avg=config.min_distance_from_avg * 0.5,
|
||||
min_period_length=current_min_duration,
|
||||
threshold_low=config.threshold_low,
|
||||
threshold_high=config.threshold_high,
|
||||
threshold_volatility_moderate=config.threshold_volatility_moderate,
|
||||
threshold_volatility_high=config.threshold_volatility_high,
|
||||
threshold_volatility_very_high=config.threshold_volatility_very_high,
|
||||
level_filter=None, # Disable level filter
|
||||
level_filter="any", # Already effectively any after relaxation; keeps gap logic intact
|
||||
gap_count=config.gap_count,
|
||||
extend_to_extreme=config.extend_to_extreme,
|
||||
max_extension_intervals=config.max_extension_intervals,
|
||||
|
|
@ -548,16 +563,21 @@ def calculate_periods_with_relaxation(
|
|||
time_range: tuple[datetime, datetime] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate periods with optional per-day filter relaxation.
|
||||
Calculate periods with optional global filter relaxation and per-day target tracking.
|
||||
|
||||
NEW: Each day gets its own independent relaxation loop. Today can be in Phase 1
|
||||
while tomorrow is in Phase 3, ensuring each day finds enough periods.
|
||||
Strategy: a single global relaxation loop iterates flex levels (3% steps from
|
||||
the configured base flex up to MAX_FLEX_HARD_LIMIT). After every step we re-run
|
||||
period detection across all available days and check, per day, how many quality
|
||||
periods (CV ≤ PERIOD_MAX_CV) have accumulated. Days that already meet the target
|
||||
(`min_periods`) are not re-processed; the loop exits as soon as **all** days meet
|
||||
their target. Days with very flat prices automatically need only 1 period
|
||||
(see `_compute_day_effective_min`).
|
||||
|
||||
If min_periods is not reached with normal filters, this function gradually
|
||||
relaxes filters in multiple phases FOR EACH DAY SEPARATELY:
|
||||
If after all flex levels some days still have ZERO periods, a last-resort
|
||||
`min_period_length` fallback is attempted (see `_try_min_duration_fallback`).
|
||||
|
||||
Phase 1: Increase flex threshold step-by-step (up to max_relaxation_attempts)
|
||||
Phase 2: Disable level filter (set to "any")
|
||||
Phase 2: Disable level filter (set to "any") in combination with each flex step
|
||||
|
||||
Args:
|
||||
all_prices: All price data points
|
||||
|
|
@ -818,6 +838,7 @@ def calculate_periods_with_relaxation(
|
|||
existing_periods=all_periods,
|
||||
prices_by_day=prices_by_day,
|
||||
time=time,
|
||||
max_relaxation_attempts=max_relaxation_attempts,
|
||||
day_patterns_by_date=day_patterns_by_date,
|
||||
)
|
||||
|
||||
|
|
@ -915,7 +936,7 @@ def relax_all_prices(
|
|||
# Import here to avoid circular dependency
|
||||
from .core import calculate_periods # noqa: PLC0415
|
||||
|
||||
flex_increment = 0.03 # 3% per step (hard-coded for reliability)
|
||||
flex_increment = RELAXATION_FLEX_INCREMENT # 3% per step (see types.py for rationale)
|
||||
base_flex = abs(config.flex)
|
||||
original_level_filter = config.level_filter
|
||||
existing_periods = list(baseline_periods) # Start with baseline
|
||||
|
|
|
|||
|
|
@ -56,6 +56,13 @@ PEAK_MIN_PREMIUM_ABOVE_AVG_PCT = 10.0 # Peak mean must be ≥ 10% above daily a
|
|||
# but weren't peak against today's higher max.
|
||||
CROSS_DAY_OVERNIGHT_VALIDATION_HOUR = 6 # Validate 00:00-05:59 against previous day too
|
||||
|
||||
# Relaxation flex increment per step (decimal, e.g. 0.03 = 3% per step).
|
||||
# Hard-coded for reliability and predictability across all callers (see
|
||||
# docs/developer/docs/period-calculation-theory.md). Keeps escalation moderate
|
||||
# even when the user configures a high base flex (a high base would otherwise
|
||||
# cause runaway escalation, e.g. base 40% × 1.25 → 50% in a single step).
|
||||
RELAXATION_FLEX_INCREMENT = 0.03
|
||||
|
||||
# Log indentation levels for visual hierarchy
|
||||
INDENT_L0 = "" # Top level (calculate_periods_with_relaxation)
|
||||
INDENT_L1 = " " # Per-day loop
|
||||
|
|
|
|||
|
|
@ -501,6 +501,42 @@ All three thresholds are internal correctness guards, not user preferences:
|
|||
|
||||
---
|
||||
|
||||
## Cross-Day Boundary Validation
|
||||
|
||||
### Why Boundary Validation Exists
|
||||
|
||||
Each calendar day has its own min, max, and average price. An interval near midnight is evaluated against the reference of the day it belongs to (`its own day` rule, see `period_building.py`). This is fair across day boundaries because price uncertainty differs between the last quarter of one day and the first quarter of the next. However, it introduces an artifact:
|
||||
|
||||
- An interval can pass the flex/distance test against its own day **only because** the neighbouring day happens to have a more extreme reference (lower min for "best", higher max for "peak"). The interval is not actually a meaningful extreme — it is just sitting on a relative slope between two days.
|
||||
|
||||
### Symmetric Dual-Day Check (Best _and_ Peak)
|
||||
|
||||
Implementation: `period_building.py` → inside `build_periods()` interval loop.
|
||||
|
||||
Intervals starting in `[00:00, CROSS_DAY_OVERNIGHT_VALIDATION_HOUR)` (default 06:00) must additionally pass the previous day's flex test:
|
||||
|
||||
```python
|
||||
if in_flex and starts_at.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
|
||||
prev_criteria = criteria_by_day.get(date_key - timedelta(days=1))
|
||||
if prev_criteria is not None:
|
||||
in_prev_flex, _ = check_interval_criteria(price_for_criteria, prev_criteria)
|
||||
if not in_prev_flex:
|
||||
in_flex = False # boundary artifact, drop it
|
||||
```
|
||||
|
||||
The check applies to **both** directions:
|
||||
|
||||
- **Peak example:** Tomorrow's max=35 ct, today's max=39 ct, flex=15% → 30 ct passes against tomorrow (≤35 ct) but not against today (≤33.15 ct). It is dropped.
|
||||
- **Best example:** Today's min=5 ct, yesterday's min=4 ct, flex=50% → 7 ct passes against today (≤7.5 ct) but not against yesterday (≤6 ct). It is dropped.
|
||||
|
||||
Without the symmetric check, both directions produced "false extremes" purely from inter-day reference shifts. The check adds at most one extra criteria evaluation per qualifying interval and only for the first ~6 hours of each day.
|
||||
|
||||
### Hour Cutoff Choice
|
||||
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
## Relaxation Strategy
|
||||
|
||||
### Purpose
|
||||
|
|
@ -610,6 +646,44 @@ Day 2025-11-19:
|
|||
|
||||
---
|
||||
|
||||
## Last-Resort Min-Duration Fallback
|
||||
|
||||
### When It Triggers
|
||||
|
||||
After every flex level has been tried during relaxation, some days may still have **zero** periods (not just below target). For those days only, `_try_min_duration_fallback()` (`relaxation.py`) progressively shortens `min_period_length`:
|
||||
|
||||
```text
|
||||
60 min → 45 min → 30 min (MIN_DURATION_FALLBACK_MINIMUM)
|
||||
```
|
||||
|
||||
A shorter minimum lets a 2-interval qualifying window (30 min) become a real period instead of being discarded.
|
||||
|
||||
### What Stays Strict
|
||||
|
||||
The fallback intentionally does **not** maximise every other knob. Earlier versions disabled the level filter, set `min_distance_from_avg = 0` and pinned flex at `MAX_FLEX_HARD_LIMIT (50%)`. The combination accepted essentially any interval and produced phantom periods on flat-price days where no real "best" or "peak" structure exists.
|
||||
|
||||
The current behaviour is:
|
||||
|
||||
| Knob | Fallback value | Why |
|
||||
|------|---------------|-----|
|
||||
| `flex` | `min(base_flex + max_relaxation_attempts × RELAXATION_FLEX_INCREMENT, MAX_FLEX_HARD_LIMIT)` | Same flex the user implicitly accepted via the relaxation cap |
|
||||
| `min_distance_from_avg` | `config.min_distance_from_avg × 0.5` | Halved, not zeroed — flat days still produce no period |
|
||||
| `level_filter` | `"any"` | Already effectively any after the last relaxation step |
|
||||
| `min_period_length` | progressively shortened | The actual lever that unlocks new candidates |
|
||||
|
||||
### Marker Attributes
|
||||
|
||||
Periods produced by the fallback get:
|
||||
|
||||
- `duration_fallback_active: true`
|
||||
- `duration_fallback_min_length: <minutes>`
|
||||
- `relaxation_active: true`
|
||||
- `relaxation_level: "duration_fallback=<n>min"`
|
||||
|
||||
Downstream consumers (binary sensors, diagnostics) can detect these and surface a less confident UI state if desired.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Key Files and Functions
|
||||
|
|
@ -1358,4 +1432,5 @@ Low volatility (< 15%) means classification changes are less economically signif
|
|||
|
||||
## Changelog
|
||||
|
||||
- **2026-04-17**: Documented symmetric cross-day boundary validation (best _and_ peak), smarter min-duration fallback (no longer maxes out flex/min_distance), and the new `RELAXATION_FLEX_INCREMENT` constant. Recorded that period merging now recombines `price_min`/`price_max`/`price_spread`/`price_mean` (weighted by interval count) instead of inheriting only from the older period.
|
||||
- **2025-11-19**: Initial documentation of Flex/Distance interaction and Relaxation strategy fixes
|
||||
|
|
|
|||
8
docs/developer/package-lock.json
generated
8
docs/developer/package-lock.json
generated
|
|
@ -23,7 +23,7 @@
|
|||
"@docusaurus/module-type-aliases": "^3.10.0",
|
||||
"@docusaurus/tsconfig": "^3.10.0",
|
||||
"@docusaurus/types": "^3.10.0",
|
||||
"typescript": "~6.0.2"
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
|
|
@ -19885,9 +19885,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
"@docusaurus/module-type-aliases": "^3.10.0",
|
||||
"@docusaurus/tsconfig": "^3.10.0",
|
||||
"@docusaurus/types": "^3.10.0",
|
||||
"typescript": "~6.0.2"
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
|
|
|||
|
|
@ -281,10 +281,10 @@ Late-evening periods (starting after 20:00) are extended into the next day if pr
|
|||
- Extension stops if prices deviate more than 15% from the original period's mean
|
||||
|
||||
**Day-boundary artifact filtering:**
|
||||
Each day has its own min/max/avg — so the same absolute price can qualify as "cheap" on one day but not the next. The integration catches misleading artifacts with several automatic checks:
|
||||
- **Peak periods** near midnight must qualify against **both** adjacent days' statistics
|
||||
- **Peak periods** must exceed the daily average by at least 10% (overnight periods use the higher average of both days)
|
||||
- Early-morning "peaks" that are significantly weaker than yesterday's late-evening peak are recognized as artifacts and filtered out
|
||||
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:
|
||||
- **Both Best _and_ Peak periods** near midnight (00:00-05:59) must qualify against **both** adjacent days' reference prices. Without this, a 7 ct interval could become "best" only because today's minimum happens to be lower than yesterday's, or a 30 ct interval could become "peak" only because tomorrow's maximum happens to be lower than today's.
|
||||
- **Peak periods** must exceed the daily average by at least 10% (overnight periods use the higher average of both days).
|
||||
- Early-morning "peaks" that are significantly weaker than yesterday's late-evening peak are recognized as artifacts and filtered out.
|
||||
|
||||
These checks run automatically — no configuration needed.
|
||||
|
||||
|
|
@ -315,11 +315,13 @@ flowchart LR
|
|||
|
||||
If all relaxation steps are exhausted and some days still have **zero** periods:
|
||||
|
||||
- Minimum duration is progressively reduced: 60 → 45 → 30 minutes
|
||||
- All other filters are maximally relaxed (50% flex, no distance or level filter)
|
||||
- Periods found this way are marked with `duration_fallback_active: true`
|
||||
- Minimum duration is progressively reduced: 60 → 45 → 30 minutes (so a shorter price window is enough to qualify as a period).
|
||||
- The flex level reached during relaxation is kept (typically 45-48%) — it is **not** maxed out further.
|
||||
- The minimum-distance-from-average filter is halved (not disabled) so genuinely flat days still surface no period rather than a misleading one.
|
||||
- The price level filter is dropped (any level allowed).
|
||||
- Periods found this way are marked with `duration_fallback_active: true`.
|
||||
|
||||
This ensures that every day has at least one period, even under extreme market conditions.
|
||||
This ensures that every day has at least one period under realistic market conditions, while still suppressing phantom periods on extremely flat days where no meaningful "best" or "peak" window exists.
|
||||
|
||||
### Visual Example
|
||||
|
||||
|
|
@ -893,7 +895,7 @@ Daily average: 19 ct/kWh
|
|||
|
||||
The [cross-day handling](#phase-5-cross-day-handling) automatically prevents misleading period boundaries at midnight:
|
||||
|
||||
- **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
|
||||
- **Cross-day extensions** are capped in length and stop when prices deviate significantly
|
||||
|
||||
|
|
|
|||
8
docs/user/package-lock.json
generated
8
docs/user/package-lock.json
generated
|
|
@ -24,7 +24,7 @@
|
|||
"@docusaurus/module-type-aliases": "^3.10.0",
|
||||
"@docusaurus/tsconfig": "^3.10.0",
|
||||
"@docusaurus/types": "^3.10.0",
|
||||
"typescript": "~6.0.2"
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
|
|
@ -20086,9 +20086,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
"@docusaurus/module-type-aliases": "^3.10.0",
|
||||
"@docusaurus/tsconfig": "^3.10.0",
|
||||
"@docusaurus/types": "^3.10.0",
|
||||
"typescript": "~6.0.2"
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
|
|
|||
Loading…
Reference in a new issue