Skip to content

Commit 4713e81

Browse files
authored
feat: Add raw_value to sensor attributes (Issue #40) (#41)
1 parent 80dce02 commit 4713e81

File tree

11 files changed

+295
-65
lines changed

11 files changed

+295
-65
lines changed

README.md

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,23 @@ The first selected source becomes your highest priority, and the integration wil
162162
- **Rate limiting** - Minimum 15-minute intervals
163163
- **Automatic retries** - Exponential backoff for failed requests (2s → 6s → 18s)
164164
- **Data caching** - Persistent storage with TTL
165-
- **Source fallback** - Try all sources in priority order
165+
- **Intelligent interval validation** - DST-aware validation ensures complete data:
166+
- **Normal days**: Expects 96 intervals (15-min × 96 = 24 hours)
167+
- **DST spring forward**: Expects 92 intervals (23 hours)
168+
- **DST fall back**: Expects 100 intervals (25 hours)
169+
- **Strict validation**: Allows only 1 missing interval (15 minutes) tolerance
170+
- **Automatic fallback**: Switches to alternative sources when data is incomplete
171+
- **Source fallback** - Try all sources in priority order until complete data is found
166172
- **Daily health check** - All configured sources validated once per day during special windows
167173
- **Source health monitoring** - Track which sources are working vs failed, with retry schedules
168174

175+
**Example:** If ENTSO-E returns 94/96 intervals (missing 30 minutes), the system automatically:
176+
1. Detects incomplete data (94 < 95 minimum required)
177+
2. Logs warning about missing intervals
178+
3. Tries next configured source (e.g., Energy Charts)
179+
4. Uses complete data from working source
180+
5. Caches complete result for future requests
181+
169182
## Architecture
170183

171184
**Data Flow:** API Client → Parser → Timezone Conversion → Currency Conversion → Cache → Sensors
@@ -178,7 +191,12 @@ The first selected source becomes your highest priority, and the integration wil
178191
### Timezone & Interval Handling
179192

180193
- **Source timezone detection** - Each API has known timezone behavior
181-
- **DST transitions** - Handles 92-100 intervals on transition days
194+
- **DST transitions** - Handles 92-100 intervals on transition days automatically
195+
- **Interval validation** - Ensures data completeness before acceptance:
196+
- Validates exact interval count matches expected (92/96/100 depending on DST)
197+
- Tolerates 1 missing interval (15 minutes) for API timing edge cases
198+
- Rejects incomplete data (2+ missing intervals = 30+ minutes)
199+
- Automatically tries alternative sources when primary source is incomplete
182200
- **15-minute alignment** - All data normalized to :00, :15, :30, :45 boundaries
183201
- **Home Assistant integration** - Displays in your configured timezone
184202

@@ -328,41 +346,62 @@ The price sensors expose interval prices through attributes in a standardized fo
328346
```json
329347
{
330348
"today_interval_prices": [
331-
{"time": "2025-10-14T00:00:00+02:00", "value": 0.0856},
332-
{"time": "2025-10-14T00:15:00+02:00", "value": 0.0842},
349+
{"time": "2025-10-14T00:00:00+02:00", "value": 0.0856, "raw_value": 0.0754},
350+
{"time": "2025-10-14T00:15:00+02:00", "value": 0.0842, "raw_value": 0.0740},
333351
...
334352
],
335353
"tomorrow_interval_prices": [
336-
{"time": "2025-10-15T00:00:00+02:00", "value": 0.0891},
354+
{"time": "2025-10-15T00:00:00+02:00", "value": 0.0891, "raw_value": 0.0789},
337355
...
338356
]
339357
}
340358
```
341359

342360
**Key Points:**
343-
- Each price entry contains a `time` (ISO 8601 datetime string) and `value` (float)
344-
- Times are in your Home Assistant's configured timezone
361+
- Each price entry contains:
362+
- `time`: ISO 8601 datetime string in your Home Assistant timezone
363+
- `value`: Final consumer price (with VAT, tariffs, and energy taxes applied)
364+
- `raw_value`: Market spot price (currency and unit converted only, no VAT/fees) _(New in v1.6.0)_
345365
- List contains 96 entries for a normal day (15-minute intervals)
346366
- During DST transitions: 92 entries (spring) or 100 entries (fall)
347367
- Compatible with EV Smart Charging, ApexCharts, and custom automations
348368

369+
**Price Calculation:**
370+
```
371+
value = ((raw_value + additional_tariff + energy_tax) × (1 + VAT%)) × display_unit_multiplier
372+
```
373+
374+
When no VAT, tariffs, or taxes are configured, `raw_value` equals `value`.
375+
349376
**Using in Templates:**
350377
```yaml
351-
# Get price at 14:00
378+
# Get final consumer price at 14:00
352379
{{ state_attr('sensor.gespot_current_price_se3', 'today_interval_prices')
353380
| selectattr('time', 'search', 'T14:00')
354381
| map(attribute='value')
355382
| first }}
356383

384+
# Get raw market price at 14:00 (without VAT/fees)
385+
{{ state_attr('sensor.gespot_current_price_se3', 'today_interval_prices')
386+
| selectattr('time', 'search', 'T14:00')
387+
| map(attribute='raw_value')
388+
| first }}
389+
357390
# Get all prices above 0.10
358391
{{ state_attr('sensor.gespot_current_price_se3', 'today_interval_prices')
359392
| map(attribute='value')
360393
| select('>', 0.10)
361394
| list }}
362395

363-
# Count hours with negative prices
396+
# Compare market prices to final prices
397+
{% set prices = state_attr('sensor.gespot_current_price_se3', 'today_interval_prices') %}
398+
Market avg: {{ prices | map(attribute='raw_value') | average | round(4) }}
399+
Final avg: {{ prices | map(attribute='value') | average | round(4) }}
400+
Difference: {{ ((prices | map(attribute='value') | average) - (prices | map(attribute='raw_value') | average)) | round(4) }}
401+
402+
# Count hours with negative prices (on market)
364403
{{ state_attr('sensor.gespot_current_price_se3', 'today_interval_prices')
365-
| map(attribute='value')
404+
| map(attribute='raw_value')
366405
| select('<', 0)
367406
| list
368407
| length }}
@@ -404,7 +443,12 @@ Then set this sensor as your energy cost sensor in the Energy Dashboard settings
404443
- **No data** - Check area is supported by selected source
405444
- **API key errors** - Verify ENTSO-E API key if using that source
406445
- **Missing tomorrow prices** - Available after 13:00 CET daily
407-
- **96 data points** - Correct! 15-minute intervals = 96 per day
446+
- **96 data points** - Correct! 15-minute intervals = 96 per day (92 on DST spring, 100 on DST fall)
447+
- **Incomplete data warnings** - If you see warnings about incomplete intervals:
448+
- System automatically tries alternative sources
449+
- Check `active_source` in sensor attributes to see which source is being used
450+
- Configure multiple sources for better reliability
451+
- Example: `[NL] Incomplete today data from entsoe: 94/96 intervals (missing 2)` → System switches to Energy Charts
408452

409453
**Source Health Monitoring:**
410454

custom_components/ge_spot/coordinator/data_processor.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,10 @@ async def process(self, data: Dict[str, Any]) -> Dict[str, Any]:
206206
final_today_prices = cached_today
207207
final_tomorrow_prices = cached_tomorrow
208208

209+
# Extract raw prices from cache (added for Issue #40)
210+
raw_today_prices = data.get("today_raw_prices", {})
211+
raw_tomorrow_prices = data.get("tomorrow_raw_prices", {})
212+
209213
# Preserve exchange rate info from cache
210214
ecb_rate = data.get("ecb_rate")
211215
ecb_updated = data.get("ecb_updated")
@@ -417,6 +421,7 @@ async def process(self, data: Dict[str, Any]) -> Dict[str, Any]:
417421
ecb_rate = None
418422
ecb_updated = None
419423
final_today_prices = {}
424+
raw_today_prices = {} # Store raw prices (without VAT/taxes/tariffs)
420425
# Get source unit from the input data, default to MWh if not present
421426
# For cached data, this might be inside the 'data' dict, or from the original fetch context
422427
source_unit = data.get("source_unit", EnergyUnit.MWH)
@@ -425,7 +430,7 @@ async def process(self, data: Dict[str, Any]) -> Dict[str, Any]:
425430
)
426431

427432
if normalized_today:
428-
converted_today, rate, rate_ts = (
433+
converted_today, raw_today, rate, rate_ts = (
429434
await self._currency_converter.convert_interval_prices(
430435
interval_prices=normalized_today,
431436
source_currency=input_source_currency, # Use determined input_source_currency
@@ -434,12 +439,14 @@ async def process(self, data: Dict[str, Any]) -> Dict[str, Any]:
434439
)
435440
)
436441
final_today_prices = converted_today
442+
raw_today_prices = raw_today
437443
if rate is not None:
438444
ecb_rate = rate
439445
ecb_updated = rate_ts
440446
final_tomorrow_prices = {}
447+
raw_tomorrow_prices = {} # Store raw prices (without VAT/taxes/tariffs)
441448
if normalized_tomorrow:
442-
converted_tomorrow, rate, rate_ts = (
449+
converted_tomorrow, raw_tomorrow, rate, rate_ts = (
443450
await self._currency_converter.convert_interval_prices(
444451
interval_prices=normalized_tomorrow,
445452
source_currency=input_source_currency, # Use determined input_source_currency
@@ -448,6 +455,7 @@ async def process(self, data: Dict[str, Any]) -> Dict[str, Any]:
448455
)
449456
)
450457
final_tomorrow_prices = converted_tomorrow
458+
raw_tomorrow_prices = raw_tomorrow
451459
if ecb_rate is None and rate is not None:
452460
ecb_rate = rate
453461
ecb_updated = rate_ts
@@ -469,6 +477,8 @@ async def process(self, data: Dict[str, Any]) -> Dict[str, Any]:
469477
),
470478
"today_interval_prices": final_today_prices,
471479
"tomorrow_interval_prices": final_tomorrow_prices,
480+
"today_raw_prices": raw_today_prices, # Raw prices without VAT/taxes/tariffs
481+
"tomorrow_raw_prices": raw_tomorrow_prices, # Raw prices without VAT/taxes/tariffs
472482
"raw_interval_prices_original": input_interval_raw, # Store the raw prices that went INTO normalization
473483
"current_price": None,
474484
"next_interval_price": None,

custom_components/ge_spot/coordinator/unified_price_manager.py

Lines changed: 127 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -847,17 +847,138 @@ async def fetch_data(self, force: bool = False) -> Dict[str, Any]:
847847
# Process the raw result (this is where parsing happens)
848848
processed_data = await self._process_result(result)
849849

850-
# Check data completeness
851-
has_today = processed_data and bool(
852-
processed_data.get("today_interval_prices")
850+
# Check data completeness with interval count validation
851+
from ..const.time import TimeInterval, DSTTransitionType
852+
from ..timezone.dst_handler import DSTHandler
853+
854+
expected_intervals = (
855+
TimeInterval.get_intervals_per_day()
856+
) # 96 for non-DST days
857+
858+
today_prices = (
859+
processed_data.get("today_interval_prices", {})
860+
if processed_data
861+
else {}
853862
)
854-
has_tomorrow = processed_data and bool(
855-
processed_data.get("tomorrow_interval_prices")
863+
tomorrow_prices = (
864+
processed_data.get("tomorrow_interval_prices", {})
865+
if processed_data
866+
else {}
867+
)
868+
869+
today_count = len(today_prices)
870+
tomorrow_count = len(tomorrow_prices)
871+
872+
# Check if we have data (non-empty dictionaries)
873+
has_today = bool(today_prices)
874+
has_tomorrow = bool(tomorrow_prices)
875+
876+
# Check for DST transitions to adjust expected interval counts
877+
dst_handler = DSTHandler(self._tz_service.target_timezone)
878+
879+
# Check today
880+
is_today_dst, today_dst_type = dst_handler.is_dst_transition_day(now)
881+
if is_today_dst:
882+
if today_dst_type == DSTTransitionType.SPRING_FORWARD:
883+
expected_today = (
884+
TimeInterval.get_intervals_per_day_dst_spring()
885+
) # 92
886+
else: # FALL_BACK
887+
expected_today = (
888+
TimeInterval.get_intervals_per_day_dst_fall()
889+
) # 100
890+
else:
891+
expected_today = expected_intervals # 96
892+
893+
# Check tomorrow
894+
tomorrow = now + timedelta(days=1)
895+
is_tomorrow_dst, tomorrow_dst_type = dst_handler.is_dst_transition_day(
896+
tomorrow
897+
)
898+
if is_tomorrow_dst:
899+
if tomorrow_dst_type == DSTTransitionType.SPRING_FORWARD:
900+
expected_tomorrow = (
901+
TimeInterval.get_intervals_per_day_dst_spring()
902+
) # 92
903+
else: # FALL_BACK
904+
expected_tomorrow = (
905+
TimeInterval.get_intervals_per_day_dst_fall()
906+
) # 100
907+
else:
908+
expected_tomorrow = expected_intervals # 96
909+
910+
# Check if the data is complete (correct number of intervals)
911+
# Allow only 1 missing interval for edge cases (API timing, rounding)
912+
# Stricter validation to catch incomplete data like 94/96
913+
min_acceptable_today = max(
914+
expected_today - 1, 90
915+
) # Never accept less than 90
916+
min_acceptable_tomorrow = max(expected_tomorrow - 1, 90)
917+
918+
today_complete = (
919+
today_count >= min_acceptable_today if has_today else False
920+
)
921+
tomorrow_complete = (
922+
tomorrow_count >= min_acceptable_tomorrow if has_tomorrow else False
923+
)
924+
925+
# Log interval counts for debugging
926+
if has_today or has_tomorrow:
927+
dst_info = []
928+
if is_today_dst:
929+
dst_info.append(f"today={today_dst_type}")
930+
if is_tomorrow_dst:
931+
dst_info.append(f"tomorrow={tomorrow_dst_type}")
932+
dst_str = f" (DST: {', '.join(dst_info)})" if dst_info else ""
933+
934+
_LOGGER.debug(
935+
f"[{self.area}] Interval counts: today={today_count}/{expected_today}, "
936+
f"tomorrow={tomorrow_count}/{expected_tomorrow}, "
937+
f"min_acceptable=(today>={min_acceptable_today}, tomorrow>={min_acceptable_tomorrow}){dst_str}"
938+
)
939+
940+
# Determine if tomorrow data is expected yet
941+
# Tomorrow data is typically published around 13:00 CET
942+
# Before that time, we should accept today-only data as complete
943+
current_hour = now.hour
944+
tomorrow_window_start = Network.Defaults.SPECIAL_HOUR_WINDOWS[1][
945+
0
946+
] # 13:00
947+
tomorrow_expected = (
948+
current_hour >= tomorrow_window_start
949+
) # After 13:00, we expect tomorrow data
950+
951+
# Data is complete if:
952+
# - We have both today AND tomorrow (always acceptable)
953+
# - OR we have today complete and tomorrow isn't expected yet (before 13:00)
954+
has_complete_data = today_complete and (
955+
tomorrow_complete or not tomorrow_expected
856956
)
857-
has_complete_data = has_today and has_tomorrow
858957
has_partial_data = (has_today or has_tomorrow) and not has_complete_data
859958
has_any_data = has_today or has_tomorrow
860959

960+
# Warn if we have data but it's incomplete (missing intervals)
961+
if has_today and not today_complete:
962+
dst_note = (
963+
f" (DST {today_dst_type} expects {expected_today})"
964+
if is_today_dst
965+
else ""
966+
)
967+
_LOGGER.warning(
968+
f"[{self.area}] Incomplete today data from {result.get('data_source', 'unknown')}: "
969+
f"{today_count}/{expected_today} intervals (missing {expected_today - today_count}){dst_note}"
970+
)
971+
if has_tomorrow and not tomorrow_complete:
972+
dst_note = (
973+
f" (DST {tomorrow_dst_type} expects {expected_tomorrow})"
974+
if is_tomorrow_dst
975+
else ""
976+
)
977+
_LOGGER.warning(
978+
f"[{self.area}] Incomplete tomorrow data from {result.get('data_source', 'unknown')}: "
979+
f"{tomorrow_count}/{expected_tomorrow} intervals (missing {expected_tomorrow - tomorrow_count}){dst_note}"
980+
)
981+
861982
# Get remaining sources for potential retry
862983
attempted_so_far = (
863984
result.get(

custom_components/ge_spot/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@
1515
"PyJWT>=2.6.0",
1616
"aiofiles"
1717
],
18-
"version": "v1.6.0"
18+
"version": "v1.6.1-beta4"
1919
}

0 commit comments

Comments
 (0)