Add connect_segments parameter and fix ApexCharts header N/A display (#46)

* Initial plan

* Add connect_segments parameter to get_chartdata service for visual segment connections

Co-authored-by: jpawlowski <75446+jpawlowski@users.noreply.github.com>

* Address code review feedback: fix test logic and correct misleading comment

Co-authored-by: jpawlowski <75446+jpawlowski@users.noreply.github.com>

* Integrate PR45: Remove trailing null values for proper ApexCharts header display

Co-authored-by: jpawlowski <75446+jpawlowski@users.noreply.github.com>

* Add connect_segments translations for de, nb, nl, sv languages

Co-authored-by: jpawlowski <75446+jpawlowski@users.noreply.github.com>

* Changes before error encountered

Co-authored-by: jpawlowski <75446+jpawlowski@users.noreply.github.com>

* Fix hassfest validation: Move time_units from translations to custom_translations

Co-authored-by: jpawlowski <75446+jpawlowski@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jpawlowski <75446+jpawlowski@users.noreply.github.com>
This commit is contained in:
Copilot 2025-12-01 03:19:52 +01:00 committed by GitHub
parent b306a491e0
commit 49628f3394
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 381 additions and 71 deletions

View file

@ -174,10 +174,10 @@ class TibberPricesSubentryFlowHandler(ConfigSubentryFlow):
-7, -2, -30 -> "7 days - 02:30" (compact format when time is added) -7, -2, -30 -> "7 days - 02:30" (compact format when time is added)
""" """
# Get translations loaded by Home Assistant # Get translations from custom_translations (loaded via async_load_translations)
standard_translations_key = f"{DOMAIN}_standard_translations_{self.hass.config.language}" translations_key = f"{DOMAIN}_translations_{self.hass.config.language}"
translations = self.hass.data.get(standard_translations_key, {}) translations = self.hass.data.get(translations_key, {})
time_units = translations.get("common", {}).get("time_units", {}) time_units = translations.get("time_units", {})
# Fallback to English if translations not available # Fallback to English if translations not available
if not time_units: if not time_units:

View file

@ -477,5 +477,15 @@
"HOUSE": "Haus", "HOUSE": "Haus",
"COTTAGE": "Ferienhaus" "COTTAGE": "Ferienhaus"
}, },
"time_units": {
"day": "{count} Tag",
"days": "{count} Tagen",
"hour": "{count} Stunde",
"hours": "{count} Stunden",
"minute": "{count} Minute",
"minutes": "{count} Minuten",
"ago": "vor {parts}",
"now": "jetzt"
},
"attribution": "Daten bereitgestellt von Tibber" "attribution": "Daten bereitgestellt von Tibber"
} }

View file

@ -477,5 +477,15 @@
"HOUSE": "House", "HOUSE": "House",
"COTTAGE": "Cottage" "COTTAGE": "Cottage"
}, },
"time_units": {
"day": "{count} day",
"days": "{count} days",
"hour": "{count} hour",
"hours": "{count} hours",
"minute": "{count} minute",
"minutes": "{count} minutes",
"ago": "{parts} ago",
"now": "now"
},
"attribution": "Data provided by Tibber" "attribution": "Data provided by Tibber"
} }

View file

@ -482,5 +482,15 @@
"HOUSE": "Hus", "HOUSE": "Hus",
"COTTAGE": "Hytte" "COTTAGE": "Hytte"
}, },
"time_units": {
"day": "{count} dag",
"days": "{count} dager",
"hour": "{count} time",
"hours": "{count} timer",
"minute": "{count} minutt",
"minutes": "{count} minutter",
"ago": "{parts} siden",
"now": "nå"
},
"attribution": "Data levert av Tibber" "attribution": "Data levert av Tibber"
} }

View file

@ -482,5 +482,15 @@
"HOUSE": "Huis", "HOUSE": "Huis",
"COTTAGE": "Huisje" "COTTAGE": "Huisje"
}, },
"time_units": {
"day": "{count} dag",
"days": "{count} dagen",
"hour": "{count} uur",
"hours": "{count} uur",
"minute": "{count} minuut",
"minutes": "{count} minuten",
"ago": "{parts} geleden",
"now": "nu"
},
"attribution": "Gegevens geleverd door Tibber" "attribution": "Gegevens geleverd door Tibber"
} }

View file

@ -482,5 +482,15 @@
"HOUSE": "Hus", "HOUSE": "Hus",
"COTTAGE": "Stuga" "COTTAGE": "Stuga"
}, },
"time_units": {
"day": "{count} dag",
"days": "{count} dagar",
"hour": "{count} timme",
"hours": "{count} timmar",
"minute": "{count} minut",
"minutes": "{count} minuter",
"ago": "{parts} sedan",
"now": "nu"
},
"attribution": "Data tillhandahålls av Tibber" "attribution": "Data tillhandahålls av Tibber"
} }

View file

@ -158,6 +158,14 @@ get_chartdata:
- segments - segments
- all - all
translation_key: insert_nulls translation_key: insert_nulls
connect_segments:
name: Connect Segments
description: >-
[ONLY WITH insert_nulls='segments'] When enabled, adds connecting points at segment boundaries to visually connect different price level segments in stepline charts. When price goes DOWN at a boundary, adds a point with the lower price at the end of the current segment. When price goes UP, adds a hold point before the gap. This creates smooth visual transitions between segments instead of abrupt gaps.
required: false
default: false
selector:
boolean:
# === OUTPUT FORMAT === # === OUTPUT FORMAT ===
output_format: output_format:
name: Output Format name: Output Format

View file

@ -92,6 +92,7 @@ CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
[vol.In([PRICE_RATING_LOW, PRICE_RATING_NORMAL, PRICE_RATING_HIGH])], [vol.In([PRICE_RATING_LOW, PRICE_RATING_NORMAL, PRICE_RATING_HIGH])],
), ),
vol.Optional("insert_nulls", default="none"): vol.In(["none", "segments", "all"]), vol.Optional("insert_nulls", default="none"): vol.In(["none", "segments", "all"]),
vol.Optional("connect_segments", default=False): bool,
vol.Optional("add_trailing_null", default=False): bool, vol.Optional("add_trailing_null", default=False): bool,
vol.Optional("period_filter"): vol.In(["best_price", "peak_price"]), vol.Optional("period_filter"): vol.In(["best_price", "peak_price"]),
vol.Optional("start_time_field", default="start_time"): str, vol.Optional("start_time_field", default="start_time"): str,
@ -156,6 +157,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
include_rating_level = call.data.get("include_rating_level", False) include_rating_level = call.data.get("include_rating_level", False)
include_average = call.data.get("include_average", False) include_average = call.data.get("include_average", False)
insert_nulls = call.data.get("insert_nulls", "none") insert_nulls = call.data.get("insert_nulls", "none")
connect_segments = call.data.get("connect_segments", False)
add_trailing_null = call.data.get("add_trailing_null", False) add_trailing_null = call.data.get("add_trailing_null", False)
period_filter = call.data.get("period_filter") period_filter = call.data.get("period_filter")
# Filter values are already normalized to uppercase by schema validators # Filter values are already normalized to uppercase by schema validators
@ -336,6 +338,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
start_time = interval.get("startsAt") start_time = interval.get("startsAt")
price = interval.get("total") price = interval.get("total")
next_price = next_interval.get("total")
next_start_time = next_interval.get("startsAt") next_start_time = next_interval.get("startsAt")
if start_time is None or price is None: if start_time is None or price is None:
@ -370,24 +373,70 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
# Check if next interval is different level (segment boundary) # Check if next interval is different level (segment boundary)
if next_value != interval_value: if next_value != interval_value:
# Hold current price until next timestamp (stepline effect)
next_start_serialized = ( next_start_serialized = (
next_start_time.isoformat() next_start_time.isoformat()
if next_start_time and hasattr(next_start_time, "isoformat") if next_start_time and hasattr(next_start_time, "isoformat")
else next_start_time else next_start_time
) )
hold_point = {start_time_field: next_start_serialized, price_field: converted_price}
if include_level and "level" in interval:
hold_point[level_field] = interval["level"]
if include_rating_level and "rating_level" in interval:
hold_point[rating_level_field] = interval["rating_level"]
if include_average and day in day_averages:
hold_point[average_field] = day_averages[day]
chart_data.append(hold_point)
# Add NULL point to create gap if connect_segments and next_price is not None:
null_point = {start_time_field: next_start_serialized, price_field: None} # Connect segments visually by adding transition points
chart_data.append(null_point) # Convert next price for comparison and use
converted_next_price = (
round(next_price * 100, 2) if minor_currency else round(next_price, 4)
)
if round_decimals is not None:
converted_next_price = round(converted_next_price, round_decimals)
if next_price < price:
# Price goes DOWN: Add point at end of current segment with lower price
# This draws the line downward from current level
connect_point = {
start_time_field: next_start_serialized,
price_field: converted_next_price,
}
if include_level and "level" in interval:
connect_point[level_field] = interval["level"]
if include_rating_level and "rating_level" in interval:
connect_point[rating_level_field] = interval["rating_level"]
if include_average and day in day_averages:
connect_point[average_field] = day_averages[day]
chart_data.append(connect_point)
else:
# Price goes UP or stays same: Add hold point with current price
# This extends the current level to the boundary before the gap
hold_point = {
start_time_field: next_start_serialized,
price_field: converted_price,
}
if include_level and "level" in interval:
hold_point[level_field] = interval["level"]
if include_rating_level and "rating_level" in interval:
hold_point[rating_level_field] = interval["rating_level"]
if include_average and day in day_averages:
hold_point[average_field] = day_averages[day]
chart_data.append(hold_point)
# Add NULL point to create gap after transition
null_point = {start_time_field: next_start_serialized, price_field: None}
chart_data.append(null_point)
else:
# Original behavior: Hold current price until next timestamp
hold_point = {
start_time_field: next_start_serialized,
price_field: converted_price,
}
if include_level and "level" in interval:
hold_point[level_field] = interval["level"]
if include_rating_level and "rating_level" in interval:
hold_point[rating_level_field] = interval["rating_level"]
if include_average and day in day_averages:
hold_point[average_field] = day_averages[day]
chart_data.append(hold_point)
# Add NULL point to create gap
null_point = {start_time_field: next_start_serialized, price_field: None}
chart_data.append(null_point)
# Handle last interval of the day - extend to midnight # Handle last interval of the day - extend to midnight
if day_prices: if day_prices:
@ -531,6 +580,12 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
) )
) )
# Remove trailing null values from chart_data (for proper ApexCharts header display).
# Internal nulls at segment boundaries are preserved for gap visualization.
# Only trailing nulls cause issues with in_header showing "N/A".
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
# Convert to array of arrays format if requested # Convert to array of arrays format if requested
if output_format == "array_of_arrays": if output_format == "array_of_arrays":
array_fields_template = call.data.get("array_fields") array_fields_template = call.data.get("array_fields")

View file

@ -77,17 +77,7 @@
} }
}, },
"common": { "common": {
"step_progress": "{step_num} / {total_steps}", "step_progress": "{step_num} / {total_steps}"
"time_units": {
"day": "{count} Tag",
"days": "{count} Tagen",
"hour": "{count} Stunde",
"hours": "{count} Stunden",
"minute": "{count} Minute",
"minutes": "{count} Minuten",
"ago": "vor {parts}",
"now": "jetzt"
}
}, },
"config_subentries": { "config_subentries": {
"home": { "home": {
@ -877,6 +867,10 @@
"name": "NULL-Werte einfügen", "name": "NULL-Werte einfügen",
"description": "Steuert das Einfügen von NULL-Werten für gefilterte Daten. 'none' (Standard): Keine NULL-Werte, nur passende Intervalle. 'segments': NULL-Punkte an Segmentgrenzen für saubere Lücken in Diagrammen hinzufügen (empfohlen für Stufenliniendiagramme). 'all': NULL für alle Zeitstempel einfügen, bei denen der Filter nicht übereinstimmt (nützlich für kontinuierliche Zeitreihenvisualisierung)." "description": "Steuert das Einfügen von NULL-Werten für gefilterte Daten. 'none' (Standard): Keine NULL-Werte, nur passende Intervalle. 'segments': NULL-Punkte an Segmentgrenzen für saubere Lücken in Diagrammen hinzufügen (empfohlen für Stufenliniendiagramme). 'all': NULL für alle Zeitstempel einfügen, bei denen der Filter nicht übereinstimmt (nützlich für kontinuierliche Zeitreihenvisualisierung)."
}, },
"connect_segments": {
"name": "Segmente verbinden",
"description": "[NUR MIT insert_nulls='segments'] Wenn aktiviert, werden an Segmentgrenzen Verbindungspunkte hinzugefügt, um verschiedene Preisstufen-Segmente in Stufenliniendiagrammen visuell zu verbinden. Bei fallendem Preis wird ein Punkt mit dem niedrigeren Preis am Ende des aktuellen Segments hinzugefügt. Bei steigendem Preis wird ein Haltepunkt vor der Lücke hinzugefügt. Dies erzeugt sanfte visuelle Übergänge zwischen Segmenten anstelle von abrupten Lücken."
},
"add_trailing_null": { "add_trailing_null": {
"name": "Abschließenden Null-Punkt hinzufügen", "name": "Abschließenden Null-Punkt hinzufügen",
"description": "[BEIDE FORMATE] Füge einen finalen Datenpunkt mit Nullwerten (außer Zeitstempel) am Ende hinzu. Einige Diagrammbibliotheken benötigen dies, um Extrapolation/Interpolation zum Viewport-Rand bei Verwendung von Stufendarstellung zu verhindern. Deaktiviert lassen, es sei denn, dein Diagramm benötigt es." "description": "[BEIDE FORMATE] Füge einen finalen Datenpunkt mit Nullwerten (außer Zeitstempel) am Ende hinzu. Einige Diagrammbibliotheken benötigen dies, um Extrapolation/Interpolation zum Viewport-Rand bei Verwendung von Stufendarstellung zu verhindern. Deaktiviert lassen, es sei denn, dein Diagramm benötigt es."

View file

@ -77,17 +77,7 @@
} }
}, },
"common": { "common": {
"step_progress": "{step_num} / {total_steps}", "step_progress": "{step_num} / {total_steps}"
"time_units": {
"day": "{count} day",
"days": "{count} days",
"hour": "{count} hour",
"hours": "{count} hours",
"minute": "{count} minute",
"minutes": "{count} minutes",
"ago": "{parts} ago",
"now": "now"
}
}, },
"config_subentries": { "config_subentries": {
"home": { "home": {
@ -873,6 +863,10 @@
"name": "Insert NULL Values", "name": "Insert NULL Values",
"description": "Control NULL value insertion for filtered data. 'none' (default): No NULL values, only matching intervals. 'segments': Add NULL points at segment boundaries for clean gaps in charts (recommended for stepline charts). 'all': Insert NULL for all timestamps where filter doesn't match (useful for continuous time series visualization)." "description": "Control NULL value insertion for filtered data. 'none' (default): No NULL values, only matching intervals. 'segments': Add NULL points at segment boundaries for clean gaps in charts (recommended for stepline charts). 'all': Insert NULL for all timestamps where filter doesn't match (useful for continuous time series visualization)."
}, },
"connect_segments": {
"name": "Connect Segments",
"description": "[ONLY WITH insert_nulls='segments'] When enabled, adds connecting points at segment boundaries to visually connect different price level segments in stepline charts. When price goes DOWN at a boundary, adds a point with the lower price at the end of the current segment. When price goes UP, adds a hold point before the gap. This creates smooth visual transitions between segments instead of abrupt gaps."
},
"add_trailing_null": { "add_trailing_null": {
"name": "Add Trailing Null Point", "name": "Add Trailing Null Point",
"description": "[BOTH FORMATS] Add a final data point with null values (except timestamp) at the end. Some chart libraries need this to prevent extrapolation/interpolation to the viewport edge when using stepline rendering. Leave disabled unless your chart requires it." "description": "[BOTH FORMATS] Add a final data point with null values (except timestamp) at the end. Some chart libraries need this to prevent extrapolation/interpolation to the viewport edge when using stepline rendering. Leave disabled unless your chart requires it."

View file

@ -77,17 +77,7 @@
} }
}, },
"common": { "common": {
"step_progress": "{step_num} / {total_steps}", "step_progress": "{step_num} / {total_steps}"
"time_units": {
"day": "{count} dag",
"days": "{count} dager",
"hour": "{count} time",
"hours": "{count} timer",
"minute": "{count} minutt",
"minutes": "{count} minutter",
"ago": "{parts} siden",
"now": "nå"
}
}, },
"config_subentries": { "config_subentries": {
"home": { "home": {
@ -873,6 +863,10 @@
"name": "Sett inn NULL-verdier", "name": "Sett inn NULL-verdier",
"description": "Kontroller innsetting av NULL-verdier for filtrerte data. 'none' (standard): Ingen NULL-verdier, bare matchende intervaller. 'segments': Legg til NULL-punkter ved segmentgrenser for rene hull i diagrammer (anbefalt for trinnlinjediagrammer). 'all': Sett inn NULL for alle tidsstempler der filteret ikke samsvarer (nyttig for kontinuerlig tidsserievisualisering)." "description": "Kontroller innsetting av NULL-verdier for filtrerte data. 'none' (standard): Ingen NULL-verdier, bare matchende intervaller. 'segments': Legg til NULL-punkter ved segmentgrenser for rene hull i diagrammer (anbefalt for trinnlinjediagrammer). 'all': Sett inn NULL for alle tidsstempler der filteret ikke samsvarer (nyttig for kontinuerlig tidsserievisualisering)."
}, },
"connect_segments": {
"name": "Koble segmenter",
"description": "[KUN MED insert_nulls='segments'] Når aktivert, legges tilkoblingspunkter til ved segmentgrenser for å visuelt koble ulike prisnivå-segmenter i trinnlinjediagrammer. Når prisen går NED, legges et punkt med lavere pris til på slutten av gjeldende segment. Når prisen går OPP, legges et holdepunkt til før hullet. Dette skaper jevne visuelle overganger mellom segmenter i stedet for brå hull."
},
"add_trailing_null": { "add_trailing_null": {
"name": "Legg til avsluttende null-punkt", "name": "Legg til avsluttende null-punkt",
"description": "[BEGGE FORMATER] Legg til et siste datapunkt med nullverdier (unntatt tidsstempel) på slutten. Noen diagrambiblioteker trenger dette for å forhindre ekstrapolering/interpolering til visningsportens kant ved bruk av trinnlinje-rendering. La være deaktivert med mindre diagrammet ditt krever det." "description": "[BEGGE FORMATER] Legg til et siste datapunkt med nullverdier (unntatt tidsstempel) på slutten. Noen diagrambiblioteker trenger dette for å forhindre ekstrapolering/interpolering til visningsportens kant ved bruk av trinnlinje-rendering. La være deaktivert med mindre diagrammet ditt krever det."

View file

@ -77,17 +77,7 @@
} }
}, },
"common": { "common": {
"step_progress": "{step_num} / {total_steps}", "step_progress": "{step_num} / {total_steps}"
"time_units": {
"day": "{count} dag",
"days": "{count} dagen",
"hour": "{count} uur",
"hours": "{count} uur",
"minute": "{count} minuut",
"minutes": "{count} minuten",
"ago": "{parts} geleden",
"now": "nu"
}
}, },
"config_subentries": { "config_subentries": {
"home": { "home": {
@ -873,6 +863,10 @@
"name": "NULL-waarden invoegen", "name": "NULL-waarden invoegen",
"description": "Beheer het invoegen van NULL-waarden voor gefilterde gegevens. 'none' (standaard): Geen NULL-waarden, alleen overeenkomende intervallen. 'segments': Voeg NULL-punten toe bij segmentgrenzen voor schone gaten in grafieken (aanbevolen voor traplijngrafieken). 'all': Voeg NULL in voor alle tijdstempels waarbij het filter niet overeenkomt (handig voor continue tijdreeksvisualisatie)." "description": "Beheer het invoegen van NULL-waarden voor gefilterde gegevens. 'none' (standaard): Geen NULL-waarden, alleen overeenkomende intervallen. 'segments': Voeg NULL-punten toe bij segmentgrenzen voor schone gaten in grafieken (aanbevolen voor traplijngrafieken). 'all': Voeg NULL in voor alle tijdstempels waarbij het filter niet overeenkomt (handig voor continue tijdreeksvisualisatie)."
}, },
"connect_segments": {
"name": "Segmenten verbinden",
"description": "[ALLEEN MET insert_nulls='segments'] Indien ingeschakeld, worden verbindingspunten toegevoegd bij segmentgrenzen om verschillende prijsniveau-segmenten visueel te verbinden in traplijngrafieken. Wanneer de prijs DAALT, wordt een punt met de lagere prijs toegevoegd aan het einde van het huidige segment. Wanneer de prijs STIJGT, wordt een houdpunt toegevoegd vóór de gat. Dit creëert vloeiende visuele overgangen tussen segmenten in plaats van abrupte gaten."
},
"add_trailing_null": { "add_trailing_null": {
"name": "Voeg afsluitend null-punt toe", "name": "Voeg afsluitend null-punt toe",
"description": "[BEIDE FORMATEN] Voeg een laatste datapunt met null-waarden (behalve tijdstempel) toe aan het einde. Sommige diagrambibliotheken hebben dit nodig om extrapolatie/interpolatie naar de rand van het viewport te voorkomen bij stepline-weergave. Laat uitgeschakeld tenzij je diagram dit vereist." "description": "[BEIDE FORMATEN] Voeg een laatste datapunt met null-waarden (behalve tijdstempel) toe aan het einde. Sommige diagrambibliotheken hebben dit nodig om extrapolatie/interpolatie naar de rand van het viewport te voorkomen bij stepline-weergave. Laat uitgeschakeld tenzij je diagram dit vereist."

View file

@ -77,17 +77,7 @@
} }
}, },
"common": { "common": {
"step_progress": "{step_num} / {total_steps}", "step_progress": "{step_num} / {total_steps}"
"time_units": {
"day": "{count} dag",
"days": "{count} dagar",
"hour": "{count} timme",
"hours": "{count} timmar",
"minute": "{count} minut",
"minutes": "{count} minuter",
"ago": "{parts} sedan",
"now": "nu"
}
}, },
"config_subentries": { "config_subentries": {
"home": { "home": {
@ -873,6 +863,10 @@
"name": "Infoga NULL-värden", "name": "Infoga NULL-värden",
"description": "Kontrollera infogning av NULL-värden för filtrerad data. 'none' (standard): Inga NULL-värden, endast matchande intervall. 'segments': Lägg till NULL-punkter vid segmentgränser för rena luckor i diagram (rekommenderas för steglinjediagram). 'all': Infoga NULL för alla tidsstämplar där filtret inte matchar (användbart för kontinuerlig tidsserievisualisering)." "description": "Kontrollera infogning av NULL-värden för filtrerad data. 'none' (standard): Inga NULL-värden, endast matchande intervall. 'segments': Lägg till NULL-punkter vid segmentgränser för rena luckor i diagram (rekommenderas för steglinjediagram). 'all': Infoga NULL för alla tidsstämplar där filtret inte matchar (användbart för kontinuerlig tidsserievisualisering)."
}, },
"connect_segments": {
"name": "Anslut segment",
"description": "[ENDAST MED insert_nulls='segments'] När aktiverad, läggs anslutningspunkter till vid segmentgränser för att visuellt ansluta olika prisnivå-segment i steglinjediagram. När priset går NER läggs en punkt med lägre pris till i slutet av nuvarande segment. När priset går UPP läggs en hållpunkt till före luckan. Detta skapar mjuka visuella övergångar mellan segment istället för abrupta luckor."
},
"add_trailing_null": { "add_trailing_null": {
"name": "Lägg till avslutande null-punkt", "name": "Lägg till avslutande null-punkt",
"description": "[BÅDA FORMATEN] Lägg till en sista datapunkt med nullvärden (utom tidsstämpel) i slutet. Vissa diagrambibliotek behöver detta för att förhindra extrapolering/interpolering till visningsportens kant vid användning av trappstegsrendering. Lämna inaktiverad om inte ditt diagram kräver det." "description": "[BÅDA FORMATEN] Lägg till en sista datapunkt med nullvärden (utom tidsstämpel) i slutet. Vissa diagrambibliotek behöver detta för att förhindra extrapolering/interpolering till visningsportens kant vid användning av trappstegsrendering. Lämna inaktiverad om inte ditt diagram kräver det."

View file

@ -0,0 +1,227 @@
"""Test connect_segments feature for ApexCharts segment boundaries."""
from datetime import UTC, datetime, timedelta
def make_interval(start_time: datetime, price: float, level: str) -> dict:
"""Create a price interval for testing."""
return {
"startsAt": start_time,
"total": price,
"level": level,
}
def make_day_prices(base_time: datetime) -> list[dict]:
"""Create sample price data with level transitions."""
return [
# First segment: CHEAP at low price
make_interval(base_time, 0.10, "CHEAP"),
make_interval(base_time + timedelta(minutes=15), 0.11, "CHEAP"),
# Transition: CHEAP -> NORMAL (price goes UP)
make_interval(base_time + timedelta(minutes=30), 0.20, "NORMAL"),
make_interval(base_time + timedelta(minutes=45), 0.21, "NORMAL"),
# Transition: NORMAL -> CHEAP (price goes DOWN)
make_interval(base_time + timedelta(minutes=60), 0.12, "CHEAP"),
make_interval(base_time + timedelta(minutes=75), 0.13, "CHEAP"),
]
class TestConnectSegmentsLogic:
"""Test the connect_segments transition logic."""
def test_price_direction_detection_down(self) -> None:
"""Test that price going down is correctly detected."""
current_price = 0.20
next_price = 0.12
assert next_price < current_price, "Should detect price going down"
def test_price_direction_detection_up(self) -> None:
"""Test that price going up is correctly detected."""
current_price = 0.11
next_price = 0.20
assert next_price > current_price, "Should detect price going up"
def test_price_direction_detection_same(self) -> None:
"""Test that same price is handled correctly (treated as up)."""
current_price = 0.15
next_price = 0.15
assert not (next_price < current_price), "Same price should not be treated as 'down'"
def test_sample_data_structure(self) -> None:
"""Test that sample data has expected structure."""
base_time = datetime(2025, 12, 1, 0, 0, tzinfo=UTC)
prices = make_day_prices(base_time)
assert len(prices) == 6, "Should have 6 intervals"
# Check first transition (CHEAP -> NORMAL at index 1->2)
assert prices[1]["level"] == "CHEAP"
assert prices[2]["level"] == "NORMAL"
assert prices[2]["total"] > prices[1]["total"], "Price should go UP at this transition"
# Check second transition (NORMAL -> CHEAP at index 3->4)
assert prices[3]["level"] == "NORMAL"
assert prices[4]["level"] == "CHEAP"
assert prices[4]["total"] < prices[3]["total"], "Price should go DOWN at this transition"
class TestConnectSegmentsOutput:
"""Test the expected output format with connect_segments enabled."""
def test_transition_point_down_has_lower_price(self) -> None:
"""
When price goes DOWN at boundary, the transition point should have the lower price.
This creates a visual line going downward from the current segment level.
"""
current_price = 0.20
next_price = 0.12
# With connect_segments=True and price going down:
# The transition point should use next_price (the lower price)
# This draws the line downward from current segment level
is_price_going_down = next_price < current_price
transition_price = next_price # Use next price when going down
assert is_price_going_down, "Price should be going down"
assert transition_price == 0.12
def test_transition_point_up_has_current_price(self) -> None:
"""
When price goes UP at boundary, the hold point should have current price.
This creates a visual hold at current level before the gap.
"""
current_price = 0.11
next_price = 0.20
# With connect_segments=True and price going up:
# The hold point should use current_price (extend current level)
is_price_going_up = next_price >= current_price
hold_price = current_price # Extend current level when going up
assert is_price_going_up, "Price should be going up"
assert hold_price == 0.11
class TestSegmentBoundaryDetection:
"""Test detection of segment boundaries."""
def test_same_level_no_boundary(self) -> None:
"""Intervals with same level should not create boundary."""
interval_value = "CHEAP"
next_value = "CHEAP"
is_boundary = next_value != interval_value
assert not is_boundary
def test_different_level_creates_boundary(self) -> None:
"""Intervals with different level should create boundary."""
interval_value = "CHEAP"
next_value = "NORMAL"
is_boundary = next_value != interval_value
assert is_boundary
def test_filter_match_check(self) -> None:
"""Test that filter matching works correctly."""
filter_values = ["CHEAP"]
interval_value = "CHEAP"
next_value = "NORMAL"
matches_filter = interval_value in filter_values
assert matches_filter, "CHEAP should match filter"
next_matches = next_value in filter_values
assert not next_matches, "NORMAL should not match filter"
class TestPriceConversion:
"""Test price conversion logic used in connect_segments."""
def test_minor_currency_conversion(self) -> None:
"""Test conversion to minor currency (cents/øre)."""
price = 0.12 # EUR
minor_currency = True
converted = round(price * 100, 2) if minor_currency else round(price, 4)
assert converted == 12.0, "0.12 EUR should be 12 cents"
def test_major_currency_rounding(self) -> None:
"""Test major currency precision."""
price = 0.123456
minor_currency = False
converted = round(price * 100, 2) if minor_currency else round(price, 4)
assert converted == 0.1235, "Should round to 4 decimal places"
def test_custom_rounding(self) -> None:
"""Test custom decimal rounding."""
price = 0.12345
converted = round(price, 4)
round_decimals = 2
final = round(converted, round_decimals)
assert final == 0.12, "Should round to 2 decimal places"
class TestTrailingNullRemoval:
"""Test trailing null value removal for ApexCharts header display."""
def test_trailing_nulls_removed(self) -> None:
"""Test that trailing null values are removed from chart_data."""
price_field = "price_per_kwh"
chart_data = [
{"start_time": "2025-12-01T00:00:00", price_field: 10.0},
{"start_time": "2025-12-01T00:15:00", price_field: 12.0},
{"start_time": "2025-12-01T00:30:00", price_field: None}, # Trailing null
{"start_time": "2025-12-01T00:45:00", price_field: None}, # Trailing null
]
# Simulate the trailing null removal logic
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
assert len(chart_data) == 2, "Should have 2 items after removing trailing nulls"
assert chart_data[-1][price_field] == 12.0, "Last item should be the last non-null price"
def test_internal_nulls_preserved(self) -> None:
"""Test that internal null values are preserved for gap visualization."""
price_field = "price_per_kwh"
chart_data = [
{"start_time": "2025-12-01T00:00:00", price_field: 10.0},
{"start_time": "2025-12-01T00:15:00", price_field: None}, # Internal null (gap)
{"start_time": "2025-12-01T00:30:00", price_field: 12.0},
{"start_time": "2025-12-01T00:45:00", price_field: None}, # Trailing null
]
# Simulate the trailing null removal logic
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
assert len(chart_data) == 3, "Should have 3 items after removing trailing null"
assert chart_data[1][price_field] is None, "Internal null should be preserved"
assert chart_data[-1][price_field] == 12.0, "Last item should be the last non-null price"
def test_no_nulls_unchanged(self) -> None:
"""Test that chart_data without trailing nulls is unchanged."""
price_field = "price_per_kwh"
chart_data = [
{"start_time": "2025-12-01T00:00:00", price_field: 10.0},
{"start_time": "2025-12-01T00:15:00", price_field: 12.0},
]
original_length = len(chart_data)
# Simulate the trailing null removal logic
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
assert len(chart_data) == original_length, "Data without trailing nulls should be unchanged"
def test_empty_data_handled(self) -> None:
"""Test that empty chart_data is handled without error."""
price_field = "price_per_kwh"
chart_data: list[dict] = []
# Simulate the trailing null removal logic - should not raise
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
assert chart_data == [], "Empty data should remain empty"