Feature: Börsenstrompreis-Chart (EPEX SPOT via aWATTar) im Energie-Dashboard (v1.8.0)
- /api/spot-price: Proxy zu api.awattar.de/v1/marketdata, 15-min Cache - EUR/MWh → ct/kWh Konvertierung - SVG-Balkenchart 24h-Prognose, gleiche Breite wie Flussdiagramm (viewBox 520) - Farbe nach Preistertil: grün/gelb/rot; aktuelle Stunde hervorgehoben - Aktuellen Preis als ct/kWh-Label in der Kopfzeile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -545,6 +545,29 @@ def api_import_config():
|
||||
threading.Thread(target=_restart_all, daemon=True).start()
|
||||
return jsonify({"ok": True, "inverters": len(inverters)})
|
||||
|
||||
_spot_cache: Dict[str, Any] = {"ts": 0.0, "data": []}
|
||||
_SPOT_TTL = 900 # 15 Minuten
|
||||
|
||||
@app.get("/api/spot-price")
|
||||
def api_spot_price():
|
||||
import urllib.request as _ur
|
||||
global _spot_cache
|
||||
now = time.time()
|
||||
if now - _spot_cache["ts"] < _SPOT_TTL and _spot_cache["data"]:
|
||||
return jsonify({"ok": True, "data": _spot_cache["data"]})
|
||||
try:
|
||||
with _ur.urlopen("https://api.awattar.de/v1/marketdata", timeout=8) as r:
|
||||
raw = json.loads(r.read())
|
||||
entries = [
|
||||
{"ts": int(d["start_timestamp"] // 1000), "price": round(d["marketprice"] / 10, 2)}
|
||||
for d in raw.get("data", [])
|
||||
]
|
||||
_spot_cache = {"ts": now, "data": entries}
|
||||
return jsonify({"ok": True, "data": entries})
|
||||
except Exception as e:
|
||||
log.warning("Spot-Price API Fehler: %s", e)
|
||||
return jsonify({"ok": False, "data": _spot_cache.get("data", [])})
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return send_from_directory(WEB_DIR, "index.html")
|
||||
|
||||
@@ -372,7 +372,57 @@ function switchTab(name) {
|
||||
|
||||
// ── Energy Dashboard ──────────────────────────────────────────
|
||||
|
||||
function renderEnergy(inverters, aggregates, period) {
|
||||
function renderSpotChart(spotData) {
|
||||
if (!spotData || !spotData.length) return '';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const startH = Math.floor(now / 3600) * 3600;
|
||||
const hours = spotData.filter(d => d.ts >= startH && d.ts < startH + 86400).sort((a,b) => a.ts - b.ts);
|
||||
if (hours.length < 2) return '';
|
||||
|
||||
const prices = hours.map(h => h.price);
|
||||
const minP = Math.min(...prices), maxP = Math.max(...prices);
|
||||
const rangeP = maxP - minP || 1;
|
||||
const q1 = minP + rangeP / 3, q2 = minP + 2 * rangeP / 3;
|
||||
const priceCol = p => p <= q1 ? '#3fb950' : p <= q2 ? '#f0c040' : '#f85149';
|
||||
|
||||
const W=520, H=108, PL=34, PR=8, PT=14, PB=22;
|
||||
const cW=W-PL-PR, cH=H-PT-PB, bW=cW/hours.length;
|
||||
|
||||
const bars = hours.map((h,i) => {
|
||||
const isNow = now >= h.ts && now < h.ts+3600;
|
||||
const barH = Math.max(2, ((h.price-minP)/rangeP)*cH);
|
||||
const x=(PL+i*bW), y=PT+cH-barH;
|
||||
const col = priceCol(h.price);
|
||||
const hh = new Date(h.ts*1000).getHours();
|
||||
const tl = hh%6===0 ? `<text x="${(x+bW/2).toFixed(1)}" y="${H-3}" text-anchor="middle" font-size="8" fill="#8b949e">${String(hh).padStart(2,'0')}h</text>` : '';
|
||||
const pl = isNow ? `<text x="${(x+bW/2).toFixed(1)}" y="${(y-3).toFixed(1)}" text-anchor="middle" font-size="8.5" font-weight="700" fill="${col}">${h.price.toFixed(1)}</text>` : '';
|
||||
return `<rect x="${(x+1).toFixed(1)}" y="${y.toFixed(1)}" width="${(bW-2).toFixed(1)}" height="${barH.toFixed(1)}" fill="${col}" opacity="${isNow?1:.6}" rx="1.5"/>
|
||||
${isNow?`<rect x="${x.toFixed(1)}" y="${PT}" width="${bW.toFixed(1)}" height="${cH}" fill="none" stroke="rgba(255,255,255,.25)" stroke-width="1.5" rx="1.5"/>`:''}
|
||||
${tl}${pl}`;
|
||||
}).join('');
|
||||
|
||||
const yTicks = [[minP,PT+cH],[maxP,PT]].map(([p,y]) =>
|
||||
`<line x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}" stroke="#30363d" stroke-width="0.5"/>
|
||||
<text x="${PL-3}" y="${(y+3).toFixed(1)}" text-anchor="end" font-size="8" fill="#8b949e">${p.toFixed(0)}</text>`
|
||||
).join('');
|
||||
|
||||
const cur = hours.find(h => now>=h.ts && now<h.ts+3600);
|
||||
const curLabel = cur
|
||||
? `<span style="font-size:16px;font-weight:700;color:${priceCol(cur.price)}">${cur.price.toFixed(2)} ct/kWh</span><span style="font-size:10px;color:#8b949e;margin-left:6px">Jetzt</span>`
|
||||
: '';
|
||||
|
||||
return `<div style="border-top:1px solid #30363d;padding:14px 14px 4px">
|
||||
<div style="display:flex;align-items:baseline;gap:6px;margin-bottom:8px">
|
||||
<span style="font-size:10px;font-weight:700;letter-spacing:.08em;color:#8b949e;text-transform:uppercase">EPEX SPOT · 24h</span>
|
||||
${curLabel}
|
||||
</div>
|
||||
<svg viewBox="0 0 ${W} ${H}" width="100%" style="display:block" xmlns="http://www.w3.org/2000/svg">
|
||||
${yTicks}${bars}
|
||||
</svg>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderEnergy(inverters, aggregates, period, spotData) {
|
||||
const el = document.getElementById("energy-content");
|
||||
if (!aggregates || !Object.keys(aggregates).length) {
|
||||
el.innerHTML = '<div class="no-data">Warte auf erste Messung…</div>';
|
||||
@@ -536,8 +586,10 @@ function renderEnergy(inverters, aggregates, period) {
|
||||
const cards = sectionCards(mon.label || 'Diesen Monat', mon, C.imp) +
|
||||
sectionCards(yr.label || 'Dieses Jahr', yr, C.imp);
|
||||
|
||||
const spotHtml = renderSpotChart(spotData);
|
||||
|
||||
el.innerHTML = `<div class="energy-wrap">
|
||||
<div class="energy-svg-wrap">${svg}</div>
|
||||
<div class="energy-svg-wrap">${svg}${spotHtml}</div>
|
||||
${cards ? `<div style="margin-top:16px">${cards}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
@@ -554,7 +606,8 @@ async function refreshData() {
|
||||
keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte";
|
||||
renderLive(d.inverters || {}, d.aggregates || {});
|
||||
const period = await fetchJSON(api("api/period-energy")).catch(() => ({}));
|
||||
renderEnergy(d.inverters || {}, d.aggregates || {}, period);
|
||||
const spot = await fetchJSON(api("api/spot-price")).catch(() => ({}));
|
||||
renderEnergy(d.inverters || {}, d.aggregates || {}, period, (spot.data || []));
|
||||
} catch(e) {
|
||||
document.getElementById("pill-mqtt").className = "pill err";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user