diff --git a/CHANGELOG.md b/CHANGELOG.md index 358fcd0..0bddea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ Alle wichtigen Änderungen an diesem Projekt werden in dieser Datei dokumentiert Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/), und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). +## [3.5.1] - 2025-12-28 + +### Fixed +- **KRITISCHER BUG**: Preisschwelle wurde nicht angewendet + - `price_threshold` wurde geladen aber nie verwendet + - System lud auch bei Preisen über der Schwelle (z.B. 25.93ct bei Schwelle 25ct) + - Jetzt: Nur Stunden ≤ Preisschwelle werden für Ranking berücksichtigt + - Wenn alle Preise über Schwelle: Keine Ladung, bleibe im Auto-Modus +- **Besseres Logging**: Zeigt gefilterte Stunden an + - "X Stunden unter Schwelle, Y Stunden über Schwelle (werden ignoriert)" + - Schedule-Reason zeigt "Zu teuer: X.XXct (Schwelle: Yct)" +- **Warnung bei Teilladung**: Log-Warnung wenn nicht genug günstige Stunden verfügbar + +### Changed +- Ranking-Logik: Filtert zuerst nach Preisschwelle, dann Ranking der verbleibenden Stunden +- Schedule-Reasons verbessert für besseres Debugging + ## [3.5.0] - 2025-12-28 ### Removed diff --git a/HOTFIX_PRICE_THRESHOLD_v3.5.1.md b/HOTFIX_PRICE_THRESHOLD_v3.5.1.md new file mode 100644 index 0000000..158489e --- /dev/null +++ b/HOTFIX_PRICE_THRESHOLD_v3.5.1.md @@ -0,0 +1,205 @@ +# Hotfix: Preisschwelle wurde nicht angewendet (v3.5.1) + +## Problem + +Die Preisschwelle (`price_threshold`) wurde zwar aus den Input Helpern geladen, aber **nie im Code verwendet**. Das führte dazu, dass: + +- System lud auch bei Preisen ÜBER der Schwelle +- Beispiel: Schwelle 25ct, aber Ladung bei 25.93ct geplant +- User-Erwartung: Keine Ladung wenn alle Preise über Schwelle + +## Analyse + +```python +# battery_charging_optimizer.py:110 +'price_threshold': float(state.get('input_number.battery_optimizer_price_threshold') or 25), +# ✅ Wurde geladen + +# battery_charging_optimizer.py:317-340 (ALT) +for p in future_price_data: + charging_candidates.append({...}) # ❌ Keine Prüfung gegen threshold! +``` + +**Resultat**: Alle zukünftigen Stunden wurden in die Ranking-Liste aufgenommen, unabhängig vom Preis. + +## Lösung (v3.5.1) + +### 1. Filter VOR dem Ranking + +```python +# Zeile 315-334: Neuer Filter +price_threshold = config['price_threshold'] +affordable_hours = [] +expensive_hours = [] + +for p in future_price_data: + if p['price'] <= price_threshold: + affordable_hours.append(p) + else: + expensive_hours.append(p) + +# Wenn keine bezahlbaren Stunden verfügbar +if not affordable_hours: + log.warning(f"⚠️ Keine Stunden unter Preisschwelle {price_threshold} ct/kWh") + return create_auto_only_schedule(future_price_data) # Keine Ladung! +``` + +### 2. Ranking nur mit bezahlbaren Stunden + +```python +# Zeile 341: Nur affordable_hours verwenden +for p in affordable_hours: # Statt future_price_data + charging_candidates.append({...}) +``` + +### 3. Besseres Logging + +```python +log.info(f"💶 Preisschwelle: {price_threshold} ct/kWh") +log.info(f" - Stunden unter Schwelle: {len(affordable_hours)}") +log.info(f" - Stunden über Schwelle: {len(expensive_hours)} (werden ignoriert)") + +# Im Schedule: +reason = f"Zu teuer: {p['price']:.2f}ct (Schwelle: {price_threshold}ct)" +``` + +### 4. Warnung bei Teilladung + +```python +if actual_hours_needed < needed_hours: + log.warning(f"⚠️ Nur {actual_hours_needed} von {needed_hours} benötigten Stunden unter Preisschwelle") + log.warning(f" Batterie wird nur teilweise geladen") +``` + +## Verhalten vorher vs. nachher + +### Szenario: Schwelle 25ct, alle Preise 25-30ct + +**VORHER (v3.5.0)**: +``` +Preis-Array: [25.93, 26.45, 27.12, 28.50, ...] +→ Ranking: Sortiere alle nach Preis +→ Wähle günstigste: 25.93ct (Rang 1) +→ ✗ LÄDT bei 25.93ct (über Schwelle 25ct!) +``` + +**NACHHER (v3.5.1)**: +``` +Preis-Array: [25.93, 26.45, 27.12, 28.50, ...] +→ Filter: Alle über 25ct → affordable_hours = [] +→ ⚠️ Keine Stunden unter Preisschwelle 25ct +→ ✓ KEINE LADUNG (Auto-Modus) +``` + +### Szenario: Schwelle 25ct, Preise 20-30ct + +**VORHER (v3.5.0)**: +``` +Preis-Array: [20.50, 24.80, 25.93, 26.45, ...] +→ Ranking: [20.50, 24.80, 25.93, 26.45, ...] +→ Wähle Top 3: [20.50, 24.80, 25.93] +→ ✗ LÄDT auch bei 25.93ct (über Schwelle!) +``` + +**NACHHER (v3.5.1)**: +``` +Preis-Array: [20.50, 24.80, 25.93, 26.45, ...] +→ Filter: affordable_hours = [20.50, 24.80] +→ Ranking: [20.50, 24.80] +→ Wähle Top 2: [20.50, 24.80] +→ ✓ NUR unter Schwelle, 25.93ct ignoriert +→ Warnung: "Nur 2 von 3 benötigten Stunden" +``` + +## Log-Ausgaben neu (v3.5.1) + +``` +=== Batterie-Optimierung gestartet (v3.5.1 - Preisschwelle aktiv) === +Preise: Min=20.50, Max=30.45, Avg=25.67 ct/kWh +Verfügbare Ladekapazität: 5.00 kWh (bis 100% SOC) +🎯 Benötigte Ladestunden: 1 (bei 8000W pro Stunde) +💶 Preisschwelle: 25.0 ct/kWh + - Stunden unter Schwelle: 18 + - Stunden über Schwelle: 12 (werden ignoriert) +✓ Top 1 günstigste Stunden ausgewählt: + - Preise: 20.50 - 20.50 ct/kWh +``` + +Oder wenn KEINE günstigen Stunden: + +``` +💶 Preisschwelle: 25.0 ct/kWh + - Stunden unter Schwelle: 0 + - Stunden über Schwelle: 30 (werden ignoriert) +⚠️ Keine Stunden unter Preisschwelle 25.0 ct/kWh gefunden! + Günstigster Preis: 25.93 ct/kWh + → Keine Ladung, bleibe im Auto-Modus +``` + +## Impact + +**Severity**: 🔴 **CRITICAL** +- Führte zu ungewollten Ladevorgängen bei zu teuren Preisen +- Kosteneinsparungen wurden nicht realisiert +- User-Erwartung komplett ignoriert + +**Betroffene Versionen**: +- v3.5.0 (heute released) +- Wahrscheinlich auch v3.0.0 - v3.4.0 (zu prüfen) + +**Fix-Priorität**: SOFORT +- Hotfix v3.5.1 released +- User sollten SOFORT updaten + +## Migration 3.5.0 → 3.5.1 + +1. Update `battery_charging_optimizer.py` (v3.5.1) +2. PyScript neu laden +3. Neuberechnung: `pyscript.calculate_charging_schedule` +4. Log prüfen: Sollte "Preisschwelle: X ct/kWh" zeigen + +**Keine Breaking Changes** - Nur Bugfix. + +## Testing + +Test-Szenarien: + +1. **Alle Preise über Schwelle**: + - Setze `price_threshold` auf 20ct + - Wenn alle Preise > 20ct → Keine Ladung + - Log: "Keine Stunden unter Preisschwelle" + +2. **Mix aus günstigen/teuren Stunden**: + - Setze `price_threshold` auf 25ct + - Nur Stunden ≤ 25ct sollten im Plan sein + - Log: "X Stunden unter Schwelle, Y über Schwelle" + +3. **Alle Preise unter Schwelle**: + - Setze `price_threshold` auf 50ct + - Normales Verhalten wie bisher + - Log: "30 Stunden unter Schwelle, 0 über Schwelle" + +## Lessons Learned + +1. **Configuration muss verwendet werden**: + - Laden von Config ≠ Verwenden von Config + - Code-Review: Prüfe ob alle Config-Werte auch benutzt werden + +2. **User-Feedback ernst nehmen**: + - User hat Bug sofort entdeckt beim ersten Test + - Ohne User-Test wäre Bug unentdeckt geblieben + +3. **Logging ist essentiell**: + - Mit neuem Logging ist sofort ersichtlich was passiert + - "X Stunden unter Schwelle" macht Verhalten transparent + +## Danke + +Großes Dankeschön an Felix für das sofortige Melden des Bugs! 🙏 + +--- + +**Version**: 3.5.1 +**Datum**: 2025-12-28 +**Severity**: Critical +**Status**: ✅ Fixed diff --git a/battery_charging_optimizer.py b/battery_charging_optimizer.py index 3a743d5..50b233c 100644 --- a/battery_charging_optimizer.py +++ b/battery_charging_optimizer.py @@ -3,7 +3,10 @@ Battery Charging Optimizer für OpenEMS + GoodWe Nutzt das bestehende manuelle Steuerungssystem Speicherort: /config/pyscript/battery_charging_optimizer.py -Version: 3.5.0 - REMOVED: Sicherheitspuffer und Reservekapazität (Hardware hat eigene Puffer) +Version: 3.5.1 - FIXED: Preisschwelle wird jetzt korrekt angewendet (kritischer Bug) + Stunden über Preisschwelle werden komplett ignoriert + Keine Ladung wenn alle Preise über Schwelle liegen + v3.5.0 - REMOVED: Sicherheitspuffer und Reservekapazität (Hardware hat eigene Puffer) Batterie lädt jetzt bis 100% SOC CHANGED: Standardwerte - Preisschwelle 25ct, Ladeleistung 8000W FIXED: SOC-Plausibilitäts-Check (filtert 65535% Spikes beim Modus-Wechsel) @@ -32,7 +35,7 @@ def calculate_charging_schedule(): Nutzt Ranking-Methode: Wählt die N günstigsten Stunden aus """ - log.info("=== Batterie-Optimierung gestartet (v3.5.0 - Volle Ladung bis 100%) ===") + log.info("=== Batterie-Optimierung gestartet (v3.5.1 - Preisschwelle aktiv) ===") # Prüfe ob Optimierung aktiviert ist if state.get('input_boolean.battery_optimizer_enabled') != 'on': @@ -310,11 +313,35 @@ def optimize_charging(price_data, pv_forecast, current_soc, config): log.info(f"🎯 Benötigte Ladestunden: {needed_hours} (bei {max_charge_per_hour}W pro Stunde)") # ========================================== - # RANKING: Erstelle Kandidaten-Liste + # FILTER: Nur Stunden unter Preisschwelle + # ========================================== + price_threshold = config['price_threshold'] + affordable_hours = [] + expensive_hours = [] + + for p in future_price_data: + if p['price'] <= price_threshold: + affordable_hours.append(p) + else: + expensive_hours.append(p) + + log.info(f"💶 Preisschwelle: {price_threshold} ct/kWh") + log.info(f" - Stunden unter Schwelle: {len(affordable_hours)}") + log.info(f" - Stunden über Schwelle: {len(expensive_hours)} (werden ignoriert)") + + # Wenn keine bezahlbaren Stunden verfügbar, nicht laden + if not affordable_hours: + log.warning(f"⚠️ Keine Stunden unter Preisschwelle {price_threshold} ct/kWh gefunden!") + log.warning(f" Günstigster Preis: {min_price:.2f} ct/kWh") + log.warning(f" → Keine Ladung, bleibe im Auto-Modus") + return create_auto_only_schedule(future_price_data) + + # ========================================== + # RANKING: Erstelle Kandidaten-Liste (nur bezahlbare Stunden) # ========================================== charging_candidates = [] - for p in future_price_data: + for p in affordable_hours: # PV-Prognose für diese Stunde pv_wh = pv_forecast['hourly'].get(p['hour'], 0) @@ -336,8 +363,13 @@ def optimize_charging(price_data, pv_forecast, current_soc, config): # Sortiere nach Score (beste zuerst) charging_candidates.sort(key=lambda x: x['score']) - # Wähle die N besten Stunden - selected_hours = charging_candidates[:needed_hours] + # Wähle die N besten Stunden (begrenzt auf verfügbare bezahlbare Stunden) + actual_hours_needed = min(needed_hours, len(charging_candidates)) + selected_hours = charging_candidates[:actual_hours_needed] + + if actual_hours_needed < needed_hours: + log.warning(f"⚠️ Nur {actual_hours_needed} von {needed_hours} benötigten Stunden unter Preisschwelle") + log.warning(f" Batterie wird nur teilweise geladen") # ========================================== # Logging: Zeige Auswahl @@ -414,8 +446,11 @@ def optimize_charging(price_data, pv_forecast, current_soc, config): reason = "Automatik" # Debugging: Warum nicht geladen? - if p['datetime'] not in selected_datetimes: - # Finde Position im Ranking + if p['price'] > price_threshold: + # Über Preisschwelle + reason = f"Zu teuer: {p['price']:.2f}ct (Schwelle: {price_threshold}ct)" + elif p['datetime'] not in selected_datetimes: + # Finde Position im Ranking (nur in bezahlbaren Stunden) rank = 1 for candidate in charging_candidates: if candidate['datetime'] == p['datetime']: @@ -423,7 +458,7 @@ def optimize_charging(price_data, pv_forecast, current_soc, config): rank += 1 if rank <= len(charging_candidates): - reason = f"Rang {rank} (nicht unter Top {needed_hours})" + reason = f"Rang {rank} (nicht unter Top {actual_hours_needed})" schedule.append({ 'datetime': p['datetime'].isoformat(), diff --git a/pyscripts/battery_charging_optimizer.py b/pyscripts/battery_charging_optimizer.py index 3a743d5..50b233c 100644 --- a/pyscripts/battery_charging_optimizer.py +++ b/pyscripts/battery_charging_optimizer.py @@ -3,7 +3,10 @@ Battery Charging Optimizer für OpenEMS + GoodWe Nutzt das bestehende manuelle Steuerungssystem Speicherort: /config/pyscript/battery_charging_optimizer.py -Version: 3.5.0 - REMOVED: Sicherheitspuffer und Reservekapazität (Hardware hat eigene Puffer) +Version: 3.5.1 - FIXED: Preisschwelle wird jetzt korrekt angewendet (kritischer Bug) + Stunden über Preisschwelle werden komplett ignoriert + Keine Ladung wenn alle Preise über Schwelle liegen + v3.5.0 - REMOVED: Sicherheitspuffer und Reservekapazität (Hardware hat eigene Puffer) Batterie lädt jetzt bis 100% SOC CHANGED: Standardwerte - Preisschwelle 25ct, Ladeleistung 8000W FIXED: SOC-Plausibilitäts-Check (filtert 65535% Spikes beim Modus-Wechsel) @@ -32,7 +35,7 @@ def calculate_charging_schedule(): Nutzt Ranking-Methode: Wählt die N günstigsten Stunden aus """ - log.info("=== Batterie-Optimierung gestartet (v3.5.0 - Volle Ladung bis 100%) ===") + log.info("=== Batterie-Optimierung gestartet (v3.5.1 - Preisschwelle aktiv) ===") # Prüfe ob Optimierung aktiviert ist if state.get('input_boolean.battery_optimizer_enabled') != 'on': @@ -310,11 +313,35 @@ def optimize_charging(price_data, pv_forecast, current_soc, config): log.info(f"🎯 Benötigte Ladestunden: {needed_hours} (bei {max_charge_per_hour}W pro Stunde)") # ========================================== - # RANKING: Erstelle Kandidaten-Liste + # FILTER: Nur Stunden unter Preisschwelle + # ========================================== + price_threshold = config['price_threshold'] + affordable_hours = [] + expensive_hours = [] + + for p in future_price_data: + if p['price'] <= price_threshold: + affordable_hours.append(p) + else: + expensive_hours.append(p) + + log.info(f"💶 Preisschwelle: {price_threshold} ct/kWh") + log.info(f" - Stunden unter Schwelle: {len(affordable_hours)}") + log.info(f" - Stunden über Schwelle: {len(expensive_hours)} (werden ignoriert)") + + # Wenn keine bezahlbaren Stunden verfügbar, nicht laden + if not affordable_hours: + log.warning(f"⚠️ Keine Stunden unter Preisschwelle {price_threshold} ct/kWh gefunden!") + log.warning(f" Günstigster Preis: {min_price:.2f} ct/kWh") + log.warning(f" → Keine Ladung, bleibe im Auto-Modus") + return create_auto_only_schedule(future_price_data) + + # ========================================== + # RANKING: Erstelle Kandidaten-Liste (nur bezahlbare Stunden) # ========================================== charging_candidates = [] - for p in future_price_data: + for p in affordable_hours: # PV-Prognose für diese Stunde pv_wh = pv_forecast['hourly'].get(p['hour'], 0) @@ -336,8 +363,13 @@ def optimize_charging(price_data, pv_forecast, current_soc, config): # Sortiere nach Score (beste zuerst) charging_candidates.sort(key=lambda x: x['score']) - # Wähle die N besten Stunden - selected_hours = charging_candidates[:needed_hours] + # Wähle die N besten Stunden (begrenzt auf verfügbare bezahlbare Stunden) + actual_hours_needed = min(needed_hours, len(charging_candidates)) + selected_hours = charging_candidates[:actual_hours_needed] + + if actual_hours_needed < needed_hours: + log.warning(f"⚠️ Nur {actual_hours_needed} von {needed_hours} benötigten Stunden unter Preisschwelle") + log.warning(f" Batterie wird nur teilweise geladen") # ========================================== # Logging: Zeige Auswahl @@ -414,8 +446,11 @@ def optimize_charging(price_data, pv_forecast, current_soc, config): reason = "Automatik" # Debugging: Warum nicht geladen? - if p['datetime'] not in selected_datetimes: - # Finde Position im Ranking + if p['price'] > price_threshold: + # Über Preisschwelle + reason = f"Zu teuer: {p['price']:.2f}ct (Schwelle: {price_threshold}ct)" + elif p['datetime'] not in selected_datetimes: + # Finde Position im Ranking (nur in bezahlbaren Stunden) rank = 1 for candidate in charging_candidates: if candidate['datetime'] == p['datetime']: @@ -423,7 +458,7 @@ def optimize_charging(price_data, pv_forecast, current_soc, config): rank += 1 if rank <= len(charging_candidates): - reason = f"Rang {rank} (nicht unter Top {needed_hours})" + reason = f"Rang {rank} (nicht unter Top {actual_hours_needed})" schedule.append({ 'datetime': p['datetime'].isoformat(),