diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 87e733b..c253b80 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -16,6 +16,7 @@ jobs: key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /home/ubuntu/balkonkraftwerk-staging - git pull origin dev + git fetch origin dev + git reset --hard origin/dev sudo systemctl restart balkonkraftwerk-staging - echo "Staging Update erfolgreich. Test-App läuft auf Port 5001!" \ No newline at end of file + echo "Staging hart auf Stand von 'dev' gesetzt. Port 5001 läuft!" \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 240c733..6c2cb33 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,6 +16,7 @@ jobs: key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /home/ubuntu/ - git pull origin main + # Verhindert Divergenz-Fehler durch striktes Fast-Forward + git pull origin main --ff-only sudo systemctl restart balkonkraftwerk - echo "Update erfolgreich. App wurde neu gestartet!" \ No newline at end of file + echo "Produktiv-Update via Fast-Forward erfolgreich. App läuft!" \ No newline at end of file diff --git a/database.py b/database.py index 61cbdda..4272f1c 100644 --- a/database.py +++ b/database.py @@ -75,7 +75,15 @@ def init_db(): c.execute("SELECT COUNT(*) FROM prices") if c.fetchone()[0] == 0: c.execute("INSERT INTO prices (valid_from, price) VALUES ('2026-01-01', 0.329)") - + + # 5. User Settings Tabelle + c.execute('''CREATE TABLE IF NOT EXISTS user_settings + (key TEXT PRIMARY KEY, value TEXT)''') + c.execute('''INSERT OR IGNORE INTO user_settings + (key, value) VALUES ('removed_cards', '[]')''') + c.execute('''INSERT OR IGNORE INTO user_settings + (key, value) VALUES ('dashboard_layout', NULL)''') + conn.commit() conn.close() print("Datenbank erfolgreich initialisiert.") diff --git a/ml_logic.py b/ml_logic.py index f6d3a87..1d48364 100644 --- a/ml_logic.py +++ b/ml_logic.py @@ -60,8 +60,8 @@ def build_training_data(): def train_model(): X, y = build_training_data() # Da wir mehr Features haben, sollten wir mind. 10-15 Tage haben für ein erstes Training - if len(X) < 8: #15!!! - print(f"⚠️ Nicht genug Trainingsdaten ({len(X)}/8).") #15!!! + if len(X) < 15: + print(f"⚠️ Nicht genug Trainingsdaten ({len(X)}/15).") return None # Feature-Liste erweitert diff --git a/routes.py b/routes.py index c2edb38..1dc99c4 100644 --- a/routes.py +++ b/routes.py @@ -2,6 +2,7 @@ import sqlite3 import datetime import requests +import json import time import math import numpy as np @@ -1049,3 +1050,51 @@ def shap_summary(): ] return jsonify(sorted(result, key=lambda x: x["mean_abs_shap"], reverse=True)) + +@api_bp.route('/api/layout', methods=['GET']) +def get_layout(): + conn = get_db_connection() + c = conn.cursor() + c.execute("SELECT value FROM user_settings WHERE key = 'dashboard_layout'") + row = c.fetchone() + conn.close() + if row: + return jsonify({"layout": json.loads(row[0])}), 200 + return jsonify({"layout": None}), 200 + +@api_bp.route('/api/layout', methods=['POST']) +def save_layout(): + try: + data = request.get_json() + if not data: + return jsonify({"error": "Ungültiges JSON"}), 400 + + layout_json = data.get('layout') + password = data.get('pw') + + if password != ADMIN_PASS: + return jsonify({"error": "Nicht autorisiert"}), 403 + + conn = get_db_connection() + c = conn.cursor() + + if layout_json == "RESET": + c.execute("DELETE FROM user_settings WHERE key = 'dashboard_layout'") + print("Layout wurde zurückgesetzt.") + elif layout_json is not None: + # WICHTIG: Wir konvertieren das Objekt explizit in einen String für die DB + layout_string = json.dumps(layout_json) + c.execute("INSERT OR REPLACE INTO user_settings (key, value) VALUES (?, ?)", + ('dashboard_layout', layout_string)) + print("Layout erfolgreich gespeichert.") + else: + conn.close() + return jsonify({"error": "Kein Layout-Inhalt empfangen"}), 400 + + conn.commit() + conn.close() + return jsonify({"status": "gespeichert"}), 200 + + except Exception as e: + print(f"Server-Fehler: {str(e)}") + return jsonify({"error": "Interner Server Fehler", "details": str(e)}), 500 diff --git a/static/css/style.css b/static/css/style.css index d6afeb4..0a39d1a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -158,31 +158,40 @@ transform 0.15s ease; } - .main-grid { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: 20px; - row-gap: 25px; - margin-bottom: 25px; + .grid-stack { + margin-left: -20px; + margin-right: -20px; + background: transparent !important; + box-shadow: 0 !important; + border: 0 !important; + border-radius: 0 !important; + } + + .grid-stack-item-content { + xoverflow-x: hidden; + xoverflow-y: auto; + overflow: visible !important; + background: transparent !important; + box-shadow: none !important; } - - /* Reihe 1 */ - .main-grid > *:nth-child(1) { grid-column: span 1; } - .main-grid > *:nth-child(2) { grid-column: span 2; } - - /* Reihe 2 & 3: Volle Breite */ - .main-grid > *:nth-child(3), - .main-grid > *:nth-child(4) { grid-column: span 3; } - - /* Reihe 4: 2fr zu 1fr */ - .main-grid > *:nth-child(5) { grid-column: span 2; } - .main-grid > *:nth-child(6) { grid-column: span 1; } - - /* AB Reihe 5 (Element 7 und alle folgenden): Volle Breite */ - .main-grid > *:nth-child(n + 7) { - grid-column: span 3; + + .edit-mode .grid-stack-item-content { + cursor: grab; + border: 1px dashed var(--accent); + } + + .edit-mode .grid-stack-item-content:active { + cursor: grabbing; } + :where(.card, .chart-card) + :where(.card, .chart-card) { + margin-top: 0; + } + + .chart-card { + height: 100%; /* Ersetzt die starren 400px */ + } + .card { background: var(--card-bg); border-radius: 24px; @@ -190,7 +199,7 @@ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.03); display: flex; flex-direction: column; - justify-content: center; + justify-content: start; } .card-gradient { @@ -295,10 +304,6 @@ margin-top: 25px; } - .main-grid > :where(.card, .chart-card) { - margin-top: 0; - } - .card-peak { background: linear-gradient(135deg, #4b79ff 0%, #6dd5fa 100%); box-shadow: 0 15px 35px rgba(75, 121, 255, 0.25); @@ -384,13 +389,6 @@ } @media (max-width: 900px) { - .main-grid { - grid-template-columns: 1fr; - } - - .main-grid > * { - grid-column: span 1 !important; - } .top-controls { flex-direction: column; diff --git a/static/js/layout.js b/static/js/layout.js new file mode 100644 index 0000000..b5ed4d1 --- /dev/null +++ b/static/js/layout.js @@ -0,0 +1,133 @@ +let dashboardGrid; +let currentPw = ""; + +// 1. Grid initialisieren +function initGridstack() { + dashboardGrid = GridStack.init({ + cellHeight: 50, + margin: 20, + animate: true, + staticGrid: true, + disableOneColumnMode: false, + oneColumnModeDomSort: true + }); + + dashboardGrid.on('change', function(event, items) { + saveLayout(); + }); +} + +// 2. Layout speichern (Nur in DB und nur wenn PW da ist) +async function saveLayout() { + if (!dashboardGrid || !currentPw || currentPw === "") { + return; + } + + const layoutData = dashboardGrid.save(); + + try { + const response = await fetch('/api/layout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + layout: layoutData, + pw: currentPw + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Speichern fehlgeschlagen:", errorText); + } + } catch (e) { + console.error("Netzwerkfehler beim Speichern:", e); + } +} + +// 3. Layout beim Starten laden +async function loadLayout() { + let savedLayout = null; + try { + const response = await fetch('/api/layout'); + if (response.ok) { + const data = await response.json(); + if (data && data.layout) savedLayout = data.layout; + } + } catch (e) { console.error("DB Load failed", e); } + + // Wenn ein Layout auf dem Server existiert, anwenden + if (savedLayout && dashboardGrid) { + dashboardGrid.removeAll(); + dashboardGrid.load(savedLayout); + console.log("Globales Layout erfolgreich geladen."); + } +} + +// 4. Reset-Funktion für den Button im Admin-Bereich +async function resetDatabaseLayout() { + if (!confirm("Möchtest du das Layout für ALLE Nutzer auf den Standard zurücksetzen?")) return; + + if (!currentPw) return alert("Bitte erst als Admin einloggen!"); + + const res = await fetch('/api/layout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + layout: "RESET", // Signalwort für das Python-Backend + pw: currentPw + }) + }); + + if (res.ok) { + location.reload(); // Seite neu laden, um Standard-HTML zu zeigen + } +} + +document.addEventListener("DOMContentLoaded", async () => { + // --- PHASE 1: Das Gerüst aufbauen --- + initGridstack(); + await loadLayout(); // Wartet, bis Boxen aus DB oder LocalStorage da sind + + // --- PHASE 2: Startwerte für Datumsfelder setzen --- + const t = new Date().toISOString().split('T')[0]; + const startInput = document.getElementById('start'); + const endInput = document.getElementById('end'); + + if (startInput && endInput) { + startInput.value = t; + endInput.value = t; + startInput.addEventListener('change', updateQuickButtonsActiveState); + endInput.addEventListener('change', updateQuickButtonsActiveState); + } + + // --- PHASE 3: Daten in die Boxen pumpen --- + // Wir prüfen bei jeder Funktion, ob sie existiert, um Fehler zu vermeiden + try { + if (typeof fetchData === "function") await fetchData(); + if (typeof updateWeather === "function") updateWeather(); + if (typeof updateLive === "function") updateLive(); + if (typeof updatePeaks === "function") updatePeaks(); + if (typeof updateQuickButtonsActiveState === "function") updateQuickButtonsActiveState(); + + // ML-Funktionen + if (typeof loadForecast === "function") loadForecast(); + if (typeof loadGlobalShap === "function") loadGlobalShap(); + if (typeof loadFeatureImportance === "function") loadFeatureImportance(); + + // Heatmaps + if (typeof initHeatmapYears === "function") initHeatmapYears(); + if (typeof initHourlyHeatmap === "function") initHourlyHeatmap(); + + } catch (err) { + console.error("Fehler beim initialen Daten-Load:", err); + } + + // --- PHASE 4: Intervalle für Updates starten --- + setInterval(() => { if (typeof updateLive === "function") updateLive(); }, 5000); + setInterval(() => { if (typeof fetchData === "function") fetchData(); }, 60000); + setInterval(() => { if (typeof updatePeaks === "function") updatePeaks(); }, 60000); + setInterval(() => { if (typeof checkLoadingStatus === "function") checkLoadingStatus(); }, 500); + +}); \ No newline at end of file diff --git a/static/js/script.js b/static/js/script.js index d76de3e..4047ebf 100644 --- a/static/js/script.js +++ b/static/js/script.js @@ -2,7 +2,6 @@ let myChart; let panelChart; let dcDonutChart; let activeDonutIndex = null; // null = Gesamtansicht, 0 = Panel 1, 1 = Panel 2 -let currentPw = ""; const ROI_DEBUG_FORCE_COMPLETE = false; @@ -27,30 +26,43 @@ async function checkLoadingStatus() { } async function unlockAdmin() { - const pw = prompt("Passwort zur Anpassung des Stromtarifs:"); - if (!pw) return; - const res = await fetch('/api/auth', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - pw: pw - }) - }); - if (res.ok) { - currentPw = pw; - document.getElementById('unlockBtn').style.display = 'none'; - document.getElementById('adminArea').style.display = 'flex'; - loadTariffs(); - } else { - alert("Falsches Passwort!"); - } + const pw = prompt("Passwort zur Administration:"); + if (!pw) return; + + const res = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pw: pw }) + }); + + if (res.ok) { + currentPw = pw; + + document.getElementById('unlockBtn').style.display = 'none'; + document.getElementById('adminArea').style.display = 'flex'; + + if (dashboardGrid) { + // Grid bearbeitbar machen + dashboardGrid.setStatic(false); + document.getElementById('dashboard-grid').classList.add('edit-mode'); + } + + loadTariffs(); + } else { + alert("Falsches Passwort!"); + } } function closeAdmin() { - document.getElementById('adminArea').style.display = 'none'; - document.getElementById('unlockBtn').style.display = 'block'; + currentPw = ""; + document.getElementById('adminArea').style.display = 'none'; + document.getElementById('unlockBtn').style.display = 'block'; + + if (dashboardGrid) { + // Grid wieder für alle sperren + dashboardGrid.setStatic(true); + document.getElementById('dashboard-grid').classList.remove('edit-mode'); + } } async function loadTariffs() { @@ -579,8 +591,12 @@ async function updateROI() { roiCard.classList.remove('roi-complete'); badgeContainer.innerHTML = ''; } - updateROIForecast(); - } catch (e) { + if (typeof updateROIForecast === "function") { + updateROIForecast(); + } else { + console.log("updateROIForecast ist noch nicht implementiert."); + } + } catch (e) { console.error("ROI Fehler", e); } @@ -716,7 +732,11 @@ document.getElementById("heatmapYearSelect") document.addEventListener("DOMContentLoaded", initHeatmapYears); +let heatmapInitialized = false; + async function initHourlyHeatmap() { + if (heatmapInitialized) return; + heatmapInitialized = true; const res = await fetch("/api/heatmap_hourly"); const data = await res.json(); @@ -836,6 +856,16 @@ async function loadHourlyHeatmap(month) { // Beim Laden der Seite aufrufen document.addEventListener("DOMContentLoaded", initHourlyHeatmap); +// ========================= +// FORECAST JS +// ========================= + +const appState = { + forecast: [], + activeIndex: 0, + chart: null +}; + function prettyFeatureName(key) { const featureNames = { clouds: "Mittlerer Bewölkungsgrad", @@ -852,186 +882,351 @@ function prettyFeatureName(key) { return featureNames[key] || key; } -async function loadForecast() { +// 🔒 VALIDATION + ERROR UI - const response = await fetch("/api/forecast"); - const data = await response.json(); +function validateForecastData(forecast) { - console.log("Forecast API:", data); + if (!Array.isArray(forecast) || forecast.length === 0) { + return "Keine Forecast-Daten vorhanden"; + } - const forecast = data.forecast || []; - const mae = data.mae || 0; + for (const f of forecast) { - if (forecast.length === 0) { - console.warn("Keine Forecast Daten vorhanden"); - return; - } + if (f.kwh_pred == null || isNaN(f.kwh_pred)) { + return "Ungültige Prognosewerte (kwh_pred fehlt)"; + } - const totalKwh = forecast.reduce((sum, d) => sum + d.kwh_pred, 0); - const totalEur = forecast.reduce((sum, d) => sum + d.eur_pred, 0); + if (f.kwh_lower == null || f.kwh_upper == null) { + return "Unsicherheitsband unvollständig"; + } - document.getElementById('forecast-total-kwh').innerHTML = `${totalKwh.toFixed(2)}kWh`; + if (!f.date) { + return "Datum fehlt in Forecast"; + } - document.getElementById("forecast-total-eur").innerHTML = `${totalEur.toFixed(2)}`; + } - document.getElementById("forecast-mae").innerHTML = `${mae.toFixed(2)}kWh`; + return null; +} +function showForecastError(message) { + + const canvas = document.getElementById("forecastChart"); + const container = canvas.parentElement; + + container.innerHTML = ` +
+ ⚠️ Forecast Fehler:
+ ${message} +
+ `; +} - // ========================= - // Forecast Chart mit Unsicherheitsband - // ========================= +function validateShapData(point) { - const labels = forecast.map(f => { - const parts = f.date.split("-"); - return `${parts[2]}.${parts[1]}.${parts[0]}`; - }); - const values = forecast.map(f => f.kwh_pred); + if (!point) return "Kein Datenpunkt vorhanden"; - const lower = forecast.map(f => Number(f.kwh_lower)); - const upper = forecast.map(f => Number(f.kwh_upper)); + if (!point.shap || typeof point.shap !== "object") { + return "SHAP-Werte fehlen"; + } - const ctx = document.getElementById("forecastChart"); + if (Object.keys(point.shap).length === 0) { + return "SHAP-Daten sind leer"; + } - if (window.forecastChartInstance) { - window.forecastChartInstance.destroy(); - } + if (point.kwh_pred == null) { + return "Prognosewert fehlt"; + } - window.forecastChartInstance = new Chart(ctx, { - type: 'line', - data: { - labels: labels, - datasets: [ - { - label: '90% Quantil', - data: upper, - borderColor: 'transparent', - pointRadius: 0, - fill: false, - tooltipHidden: true - }, - { - label: 'Unsicherheitsband (80%)', - data: lower, - borderColor: 'transparent', - borderWidth: 0, - pointRadius: 0, - fill: '-1', - backgroundColor: 'rgba(239,68,68,0.18)', - }, - { + return null; +} + +function showShapError(message) { + const card = document.getElementById("shapDetailCard"); + card.style.display = "block"; + card.innerHTML = ` +
+ ⚠️ SHAP Fehler:
+ ${message} +
+ `; +} + +function getTodayForecastPoint(forecastData) { + const todayStr = new Date().toISOString().split("T")[0]; + return forecastData.find(d => d.date === todayStr); +} + +function setActiveIndex(index) { + if (!appState.chart || index < 0) return; + appState.activeIndex = index; + appState.chart.setActiveElements([{ + datasetIndex: 2, + index: index + }]); + appState.chart.update(); + showShapDetails(appState.forecast[index]); +} + +async function loadForecast() { + + const response = await fetch("/api/forecast"); + const data = await response.json(); + + console.log("Forecast API:", data); + + const forecast = data.forecast || []; + const mae = data.mae || 0; + + if (forecast.length === 0) { + console.warn("Keine Forecast Daten vorhanden"); + return; + } + + const validationError = validateForecastData(forecast); + + if (validationError) { + console.error("Forecast Validation Error:", validationError, forecast); + showForecastError(validationError); + return; + } + + appState.forecast = forecast; + + const totalKwh = forecast.reduce((sum, d) => sum + d.kwh_pred, 0); + const totalEur = forecast.reduce((sum, d) => sum + d.eur_pred, 0); + + document.getElementById('forecast-total-kwh').innerHTML = `${totalKwh.toFixed(2)}kWh`; + document.getElementById("forecast-total-eur").innerHTML = `${totalEur.toFixed(2)}`; + document.getElementById("forecast-mae").innerHTML = `${mae.toFixed(2)}kWh`; + + const labels = forecast.map(f => { + const parts = f.date.split("-"); + return `${parts[2]}.${parts[1]}.${parts[0]}`; + }); + const values = forecast.map(f => f.kwh_pred); + + const lower = forecast.map(f => Number(f.kwh_lower)); + const upper = forecast.map(f => Number(f.kwh_upper)); + + const ctx = document.getElementById("forecastChart"); + + if (window.forecastChartInstance) { + window.forecastChartInstance.destroy(); + } + + window.forecastChartInstance = new Chart(ctx, { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: '90% Quantil', + data: upper, + borderColor: 'transparent', + pointRadius: 0, + fill: false, + tooltipHidden: true + }, + { + label: 'Unsicherheitsband (80%)', + data: lower, + borderColor: 'transparent', + borderWidth: 0, + pointRadius: 0, + fill: '-1', + backgroundColor: 'rgba(239,68,68,0.18)', + }, + { label: 'Prognose', data: values, borderColor: '#ef4444', backgroundColor: '#ef4444', tension: 0.3, - pointRadius: 6, - pointHoverRadius: 10, + + // 🔴 STANDARD vs AKTIV + pointRadius: (ctx) => { + return ctx.dataIndex === appState.activeIndex ? 8 : 6; + }, + + pointBackgroundColor: (ctx) => { + return ctx.dataIndex === appState.activeIndex + ? '#ffffff' // aktiv → weiß innen + : '#ef4444'; // normal → rot + }, + + pointBorderColor: '#ef4444', + + pointBorderWidth: (ctx) => { + return ctx.dataIndex === appState.activeIndex ? 3 : 0; + }, + hitRadius: 20, + + // 🟡 HOVER LOGIK (WICHTIG!) + pointHoverRadius: (ctx) => { + return ctx.dataIndex === appState.activeIndex ? 8 : 10; + }, + + pointHoverBackgroundColor: (ctx) => { + return ctx.dataIndex === appState.activeIndex + ? '#ffffff' // aktiv → KEINE Änderung + : '#ef4444'; // normal → bleibt rot + }, + + pointHoverBorderColor: '#ef4444', + + pointHoverBorderWidth: (ctx) => { + return ctx.dataIndex === appState.activeIndex + ? 3 // aktiv → KEINE Änderung + : 0; // normal → kein Rand beim Hover + }, + fill: false - } - ] - }, - options: { - responsive: true, - interaction: { - mode: 'index', - intersect: false - }, - plugins: { - legend: { - display: false - }, - tooltip: { - enabled: false, // Standard deaktivieren - external: function(context) { - let tooltipEl = document.getElementById('forecast-chart-tooltip'); - - if (!tooltipEl) { - tooltipEl = document.createElement('div'); - tooltipEl.id = 'forecast-chart-tooltip'; - // Wir fügen deine CSS-Klasse hinzu! - tooltipEl.classList.add('heatmap-tooltip'); - // Überschreiben einiger Werte für die Chart-Positionierung - Object.assign(tooltipEl.style, { - opacity: 1, - pointerEvents: 'none', - position: 'absolute', - transition: 'opacity 0.15s ease', - bottom: 'auto', // Reset von deinem CSS - left: '0px', - top: '0px', - transform: 'translate(-50%, -110%)', // Zentriert über dem Punkt - whiteSpace: 'nowrap', - zIndex: '100' - }); - document.body.appendChild(tooltipEl); - } - - const tooltipModel = context.tooltip; - if (tooltipModel.opacity === 0) { - tooltipEl.style.opacity = 0; - return; - } - - if (tooltipModel.body) { - const index = tooltipModel.dataPoints[0].dataIndex; - const f = forecast[index]; - - tooltipEl.innerHTML = ` -
- ${labels[index]} -
- -
- - Prognose: ${f.kwh_pred.toFixed(3)} kWh -
- -
-
- - Max (90% Quantil): ${Number(f.kwh_upper).toFixed(2)} kWh -
-
- - Min (10% Quantil): ${Number(f.kwh_lower).toFixed(2)} kWh -
-
- -
- 80% Wahrscheinlichkeits-Intervall -
- `; - } - - const position = context.chart.canvas.getBoundingClientRect(); - tooltipEl.style.opacity = 1; - tooltipEl.style.left = position.left + window.pageXOffset + tooltipModel.caretX + 'px'; - tooltipEl.style.top = position.top + window.pageYOffset + tooltipModel.caretY + 'px'; + }, + ] + }, + options: { + responsive: true, + interaction: { + mode: 'index', + intersect: false + }, + plugins: { + legend: { + display: false + }, + tooltip: { + enabled: false, + external: function(context) { + let tooltipEl = document.getElementById('forecast-chart-tooltip'); + + if (!tooltipEl) { + tooltipEl = document.createElement('div'); + tooltipEl.id = 'forecast-chart-tooltip'; + tooltipEl.classList.add('heatmap-tooltip'); + Object.assign(tooltipEl.style, { + opacity: 1, + pointerEvents: 'none', + position: 'absolute', + transition: 'opacity 0.15s ease', + bottom: 'auto', + left: '0px', + top: '0px', + transform: 'translate(-50%, -110%)', + whiteSpace: 'nowrap', + zIndex: '100' + }); + document.body.appendChild(tooltipEl); + } + + const tooltipModel = context.tooltip; + if (tooltipModel.opacity === 0) { + tooltipEl.style.opacity = 0; + return; + } + + if (tooltipModel.body) { + const index = tooltipModel.dataPoints[0].dataIndex; + const f = forecast[index]; + + tooltipEl.innerHTML = ` +
+ ${labels[index]} +
+ +
+ + Prognose: ${f.kwh_pred.toFixed(3)} kWh +
+ +
+
+ + Max (90% Quantil): ${Number(f.kwh_upper).toFixed(2)} kWh +
+
+ + Min (10% Quantil): ${Number(f.kwh_lower).toFixed(2)} kWh +
+
+ +
+ 80% Wahrscheinlichkeits-Intervall +
+ `; + } + + const position = context.chart.canvas.getBoundingClientRect(); + tooltipEl.style.opacity = 1; + tooltipEl.style.left = position.left + window.pageXOffset + tooltipModel.caretX + 'px'; + tooltipEl.style.top = position.top + window.pageYOffset + tooltipModel.caretY + 'px'; + } + } + }, + scales: { + y: { + beginAtZero: true + } + }, + + onClick: (event, elements, chart) => { + const points = chart.getElementsAtEventForMode( + event, + 'index', + { intersect: false }, + true + ); + if (points.length) { + setActiveIndex(points[0].index); + } + }, + + onHover: (event, elements, chart) => { + if (elements.length) { + const hoverIndex = elements[0].index; + if (hoverIndex !== appState.activeIndex) { + chart.setActiveElements([{ + datasetIndex: 2, + index: hoverIndex + }]); + chart.update('none'); } + } else { + chart.setActiveElements([{ + datasetIndex: 2, + index: appState.activeIndex + }]); + chart.update('none'); } - }, - scales: { - y: { - beginAtZero: true - } - }, - onClick: (event, elements, chart) => { - const points = chart.getElementsAtEventForMode( - event, - 'index', { - intersect: false - }, - true - ); - if (points.length) { - const index = points[0].index; - showShapDetails(forecast[index]); - } - } - } - }); -} + } + } + }); + + appState.chart = window.forecastChartInstance; + + const todayPoint = getTodayForecastPoint(forecast) || forecast[0]; + + if (todayPoint) { + const todayIndex = forecast.findIndex(f => f.date === todayPoint.date); + + document.getElementById("shapDetailCard").style.display = "block"; + + setActiveIndex(todayIndex); + } + } async function loadFeatureImportance() { @@ -1059,7 +1254,7 @@ async function loadFeatureImportance() { legend: { display: false } - } + }, } }); @@ -1097,7 +1292,7 @@ async function loadGlobalShap() { legend: { display: false } - } + }, } }); @@ -1109,6 +1304,14 @@ async function loadGlobalShap() { function showShapDetails(point) { + const validationError = validateShapData(point); + + if (validationError) { + console.error("SHAP Validation Error:", validationError, point); + showShapError(validationError); + return; + } + // ===== Punkt global merken für ResizeObserver ===== window._lastShapPoint = point; @@ -1387,23 +1590,4 @@ function showShapDetails(point) { } document.getElementById("seasonInterpretation").innerText = text; -} - - -const t = new Date().toISOString().split('T')[0]; -document.getElementById('start').value = t; -document.getElementById('end').value = t; -document.getElementById('start').addEventListener('change', updateQuickButtonsActiveState); -document.getElementById('end').addEventListener('change', updateQuickButtonsActiveState); -fetchData(); -updateWeather(); -updateLive(); -updatePeaks(); -updateQuickButtonsActiveState(); -loadForecast(); -loadGlobalShap(); -loadFeatureImportance(); -setInterval(updateLive, 5000); -setInterval(fetchData, 60000); -setInterval(updatePeaks, 60000); -setInterval(checkLoadingStatus, 500); \ No newline at end of file +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 6d17c01..2909428 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5,7 +5,9 @@ Balkonkraftwerk Analytics + +
@@ -33,7 +35,7 @@

- +
- Deine Tarife + Deine Stromtarife
-
- - -
-
-
Daten werden geladen...
-
-
Gesamtertrag
-
0.00 kWh -
-
-
Ersparnis in Euro
-
0.00 €
+ display:flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #eee; + padding-bottom: 10px; + margin-bottom: 5px; + margin-top: 2.5em; + "> + Grid-Layout anpassen +
+
-
-
- Live -
-
-
-
Leistung
-
0 W +
+ +
+ +
+
+
+ -
-
-
Stromstärke
-
0.000 mA -
-
-
-
Spannung
-
0 V +
Gesamtertrag
+
0.00 kWh
+
+
Ersparnis in Euro
+
0.00 €
-
- -
- Verlauf & Ersparnis - Balken: kWh | Linie: € -
-
- + +
+
+
+
+ Live +
+
+
Leistung
0 W
+
Stromstärke
0.000 mA
+
Spannung
0 V
+
+
-
- -
- Panel-Vergleich (DC) - Stacked: kWh -
-
- + +
+
+
+ +
+ Verlauf & Ersparnis + Balken: kWh | Linie: € +
+
+ +
+
-
- -
-
Tagesverlauf & Dominanz der Panels (DC)
- -
-
-
- Panel 1 -
- Panel 2 + +
+
+
+ +
+ Panel-Vergleich (DC) + Stacked: kWh +
+
+ +
+
-
-
- Live Verteilung nach Panels (DC) -
-
- -
-
-
-
Panel 1
-
0%
-
-
-
Panel 2
-
0%
+ +
+
+
+ +
+
Tagesverlauf & Dominanz der Panels (DC)
+ +
+
+
+ Panel 1 +
+ Panel 2 +
-
-
Leistungs-Rekorde (AC)
-
- 0.0 - W -
-
-
-
Heute
-
0.0 W
-
-
-
All-Time
-
0.0 W
+ +
+
+
+
+ Live Verteilung nach Panels (DC) +
+
+ +
+
+
Panel 1
0%
+
Panel 2
0%
+
-
-
ROI Fortschritt
-
0,00 €
-
Gesamt erzeugt: 0 kWh
-
-
+ +
+
+
+
Leistungs-Rekorde (AC)
+
0.0 W
+
+
Heute
0.0 W
+
All-Time
0.0 W
+
+
-
0%
-
-
-
-
Jahres-Heatmap
- -
-
- -
- Wenig -
- Viel + +
+
+
+
ROI Fortschritt
+
0,00 €
+
Gesamt erzeugt: 0 kWh
+
+
0%
+
+
- - - -
-
Prognose
-

ML-basierte 7-Tage Prognose auf Basis von Ertrag, Bewölkung, Sonnenhöhe und Wettervorhersage.

-
-
-
7-Tage-Prognose
-
- kWh + +
+
+
+
+
Jahres-Heatmap
+
-
-
-
Ersparnis
-
- +
+
+ Wenig +
+ Viel
-
-
MAE
-
- kWh +
+
+ +
+
+
+
Prognose
+

ML-basierte 7-Tage Prognose auf Basis von Ertrag, Bewölkung, Sonnenhöhe und Wettervorhersage.

+ +
+
7-Tage-Prognose
- kWh
+
Ersparnis
-
+
MAE
- kWh
+ +
- -