@@ -0,0 +1,532 @@
/* ==========================================================================
Speicher-Ansicht: die Batterie bis zur einzelnen Zelle.
const s = new Speicher(behaelter);
s.zeichnen();
s.aktualisieren(mqttData); // jede Sekunde, auch wenn verborgen
Live kommt alles über MQTT: solarManager/SOC und P_Akku vom Wechselrichter,
solarManager/byd/# direkt aus der BMU (gatherBYDData.py im SolarManager -
Zellspannungen, Temperaturfühler, SOH, Zähler). Der Verlauf kommt aus
ajax/speicher.php.
Ohne BMU-Daten (andere Batterie, Sammler aus) bleibt die Ansicht nützlich:
Ladestand, Leistung und ihr Verlauf stehen trotzdem da, Turm und
Zellwerte machen einem Hinweis Platz.
Die Zellen sind durchgezählt wie bei der BMU: Modul m hat die Zellen
(m-1)*16+1 bis m*16 und die Fühler (m-1)*8+1 bis m*8.
========================================================================== */
( function ( ) {
"use strict" ;
// Nennenergie eines Moduls in kWh. HVM: 2,76; HVS: 2,56. Die BMU meldet die
// Zahl der Module, aber nicht diese Größe.
const KWH _JE _MODUL = 2.76 ;
const ZELLEN _JE _MODUL = 16 ;
const FUEHLER _JE _MODUL = 8 ;
const VERALTET _S = 600 ;
const NS = "http://www.w3.org/2000/svg" ;
const komma = ( z , n = 1 ) => Number ( z ) . toFixed ( n ) . replace ( "." , "," ) ;
const tausend = z => Math . round ( z ) . toString ( ) . replace ( /\B(?=(\d{3})+(?!\d))/g , "." ) ;
const uhrzeit = d => String ( d . getHours ( ) ) . padStart ( 2 , "0" ) + ":" + String ( d . getMinutes ( ) ) . padStart ( 2 , "0" ) ;
const schutz = s => String ( s ) . replace ( /[&<>"]/g , c => ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ } [ c ] ) ) ;
function leser ( daten ) {
return {
da ( t ) { return daten [ t ] !== undefined ; } ,
zahl ( t ) { const v = Number ( String ( daten [ t ] ) ) ; return isFinite ( v ) ? v : 0 ; } ,
text ( t ) { return daten [ t ] === undefined ? "" : String ( daten [ t ] ) ; } ,
json ( t ) { try { return JSON . parse ( String ( daten [ t ] ) ) ; } catch ( e ) { return null ; } } ,
} ;
}
/** Abweichung -1..1 als Farbe: unter dem Mittel blau, im Mittel grün, darüber hellgrün. */
function zellFarbe ( t ) {
const mix = ( a , b , f ) => a . map ( ( x , i ) => Math . round ( x + ( b [ i ] - x ) * f ) ) ;
const blau = [ 79 , 143 , 184 ] , gruen = [ 108 , 190 , 88 ] , hell = [ 176 , 222 , 128 ] ;
const c = t < 0 ? mix ( gruen , blau , Math . min ( 1 , - t ) ) : mix ( gruen , hell , Math . min ( 1 , t ) ) ;
return "rgb(" + c . join ( "," ) + ")" ;
}
function tempFarbe ( t ) {
const f = Math . max ( 0 , Math . min ( 1 , ( t - 10 ) / 30 ) ) ;
const kalt = [ 79 , 143 , 184 ] , mild = [ 108 , 190 , 88 ] , warm = [ 232 , 163 , 58 ] ;
const c = f < 0.5 ? kalt . map ( ( x , i ) => Math . round ( x + ( mild [ i ] - x ) * f * 2 ) )
: mild . map ( ( x , i ) => Math . round ( x + ( warm [ i ] - x ) * ( f - 0.5 ) * 2 ) ) ;
return "rgb(" + c . join ( "," ) + ")" ;
}
class Speicher {
constructor ( behaelter , opt = { } ) {
this . behaelter = behaelter ;
this . kwhJeModul = opt . kwhJeModul || KWH _JE _MODUL ;
this . modul = null ; // gewähltes Modul (0-basiert), null = selbst wählen
this . zeitraum = 24 ; // Stunden
this . verlauf = null ;
this . zuletzt = { } ;
new ResizeObserver ( ( ) => this . diagrammeZeichnen ( ) ) . observe ( behaelter ) ;
this . sichtbar = false ;
new IntersectionObserver ( e => {
const vorher = this . sichtbar ;
this . sichtbar = e [ 0 ] . isIntersecting ;
this . behaelter . classList . toggle ( "spk-pause" , ! this . sichtbar ) ;
// Beim ersten Hinsehen den Verlauf holen - vorher kostet er nur.
if ( this . sichtbar && ! vorher && ! this . verlauf ) this . verlaufHolen ( ) ;
} ) . observe ( behaelter ) ;
setInterval ( ( ) => { if ( this . sichtbar && ! document . hidden ) this . verlaufHolen ( ) ; } , 5 * 60000 ) ;
}
/* --------------------------------------------------------- Zeichnen */
zeichnen ( ) {
this . behaelter . innerHTML = `
<div class="spk">
<div class="spk-meldung" hidden></div>
<div class="spk-kennzahlen"></div>
<div class="spk-mitte">
<div class="spk-fach spk-turm-fach">
<div class="spk-kopf"><h3 class="spk-ueberschrift">Module und Zellen</h3><span class="spk-stand"></span></div>
<div class="spk-turm"></div>
<div class="spk-legende">
<span><i style="background: ${ zellFarbe ( - 1 ) } "></i>unter dem Mittel</span>
<span><i style="background: ${ zellFarbe ( 0 ) } "></i>im Mittel</span>
<span><i style="background: ${ zellFarbe ( 1 ) } "></i>darüber</span>
</div>
</div>
<div class="spk-fach spk-detail" aria-live="polite"></div>
</div>
<div class="spk-diagramme">
<div class="spk-fach">
<div class="spk-kopf">
<h3 class="spk-ueberschrift">Ladestand und Leistung</h3>
<div class="spk-zeitraum" role="group" aria-label="Zeitraum">
<button type="button" data-stunden="24" class="aktiv">24 h</button>
<button type="button" data-stunden="168">7 Tage</button>
<button type="button" data-stunden="720">30 Tage</button>
</div>
</div>
<div class="spk-diagramm" data-diagramm="soc"></div>
</div>
<div class="spk-fach">
<div class="spk-kopf"><h3 class="spk-ueberschrift">Zellspreizung und Temperatur</h3></div>
<div class="spk-diagramm" data-diagramm="bmu"></div>
</div>
</div>
</div> ` ;
const $ = s => this . behaelter . querySelector ( s ) ;
this . dom = {
meldung : $ ( ".spk-meldung" ) , kennzahlen : $ ( ".spk-kennzahlen" ) , turm : $ ( ".spk-turm" ) ,
stand : $ ( ".spk-stand" ) , detail : $ ( ".spk-detail" ) ,
soc : $ ( '[data-diagramm="soc"]' ) , bmu : $ ( '[data-diagramm="bmu"]' ) ,
} ;
this . behaelter . querySelector ( ".spk-zeitraum" ) . addEventListener ( "click" , ev => {
const b = ev . target . closest ( "[data-stunden]" ) ;
if ( ! b ) return ;
this . zeitraum = Number ( b . dataset . stunden ) ;
b . parentElement . querySelectorAll ( "button" ) . forEach ( x => x . classList . toggle ( "aktiv" , x === b ) ) ;
this . verlaufHolen ( ) ;
} ) ;
this . dom . turm . addEventListener ( "click" , ev => {
const b = ev . target . closest ( "[data-modul]" ) ;
if ( ! b ) return ;
this . modul = Number ( b . dataset . modul ) ;
this . zuletzt . detail = null ;
this . zuletzt . turm = null ;
if ( this . stand ) this . anzeigen ( ) ;
} ) ;
if ( this . stand ) this . anzeigen ( ) ;
}
/* ---------------------------------------------------- Aktualisieren */
aktualisieren ( daten ) {
const m = leser ( daten ) ;
const b = "solarManager/byd/" ;
const bmu = m . da ( b + "stand" ) ;
const s = {
bmu ,
socWr : m . zahl ( "solarManager/SOC" ) ,
pWr : - m . zahl ( "solarManager/P_Akku" ) , // positiv = laden
grenze : m . zahl ( "solarManager/crgMaxPct" ) ,
} ;
if ( bmu ) {
Object . assign ( s , {
ok : m . zahl ( b + "ok" ) > 0 ,
stand : m . zahl ( b + "stand" ) ,
soc : m . zahl ( b + "soc" ) , soh : m . zahl ( b + "soh" ) ,
spannung : m . zahl ( b + "spannung" ) , strom : m . zahl ( b + "strom" ) ,
tMin : m . zahl ( b + "tMin" ) , tMax : m . zahl ( b + "tMax" ) ,
zelleMin : m . zahl ( b + "zelleMin" ) , zelleMax : m . zahl ( b + "zelleMax" ) ,
zelleMinNr : m . zahl ( b + "zelleMinNr" ) , zelleMaxNr : m . zahl ( b + "zelleMaxNr" ) ,
spreizung : m . zahl ( b + "spreizung" ) , ausgleich : m . zahl ( b + "ausgleich" ) ,
fehler : m . zahl ( b + "fehler" ) , fehlertext : m . text ( b + "fehlertext" ) ,
geladen : m . zahl ( b + "geladen" ) , entladen : m . zahl ( b + "entladen" ) ,
module : m . zahl ( b + "module" ) , firmware : m . text ( b + "firmware" ) , seriennummer : m . text ( b + "seriennummer" ) ,
zellenRoh : m . text ( b + "zellen" ) , tempRoh : m . text ( b + "temperaturen" ) ,
} ) ;
if ( s . zellenRoh !== ( this . stand && this . stand . zellenRoh ) ) {
s . zellen = m . json ( b + "zellen" ) || [ ] ;
s . temps = m . json ( b + "temperaturen" ) || [ ] ;
} else {
s . zellen = this . stand . zellen ;
s . temps = this . stand . temps ;
}
}
this . stand = s ;
if ( this . dom ) this . anzeigen ( ) ;
}
anzeigen ( ) {
const s = this . stand ;
const alt = s . bmu && Date . now ( ) / 1000 - s . stand > VERALTET _S ;
// Meldung oben
let meldung = "" ;
if ( ! s . bmu ) meldung = "Von der Batteriesteuerung (BMU) kommen keine Daten – gezeigt wird, was der Wechselrichter weiß." ;
else if ( alt ) meldung = "Die Werte der BMU sind veraltet: letzter Stand " + uhrzeit ( new Date ( s . stand * 1000 ) ) + " Uhr." ;
else if ( s . fehler ) meldung = "Die BMU meldet: " + s . fehlertext ;
if ( this . dom . meldung . textContent !== meldung ) {
this . dom . meldung . textContent = meldung ;
this . dom . meldung . hidden = ! meldung ;
this . dom . meldung . classList . toggle ( "spk-fehler" , ! ! ( s . bmu && s . fehler && ! alt ) ) ;
}
this . kennzahlenZeigen ( s ) ;
// Laden/Entladen als Bewegung im Turm
this . dom . turm . classList . toggle ( "spk-laden" , s . pWr > 50 ) ;
this . dom . turm . classList . toggle ( "spk-entladen" , s . pWr < - 50 ) ;
if ( s . bmu && s . zellen && s . zellen . length ) {
const sig = s . zellenRoh + "|" + s . tempRoh + "|" + this . modul ;
if ( this . zuletzt . turm !== sig ) { this . zuletzt . turm = sig ; this . turmZeigen ( s ) ; }
const stand = "Messung " + uhrzeit ( new Date ( s . stand * 1000 ) ) ;
if ( this . dom . stand . textContent !== stand ) this . dom . stand . textContent = stand ;
} else if ( this . zuletzt . turm !== "leer" ) {
this . zuletzt . turm = "leer" ;
this . dom . turm . innerHTML = '<p class="spk-leer">Ohne Daten der BMU gibt es keine Werte je Modul und Zelle.</p>' ;
this . dom . detail . innerHTML = "" ;
}
}
/* ------------------------------------------------------ Kennzahlen */
kennzahlenZeigen ( s ) {
const soc = s . bmu ? s . soc : s . socWr ;
const module = s . bmu && s . module ? s . module : 0 ;
const kwh = module * this . kwhJeModul ;
const p = s . pWr ;
let prognose = "in Ruhe" ;
if ( p > 50 && kwh ) {
const h = kwh * ( 100 - soc ) / 100 / ( p / 1000 ) ;
prognose = soc >= 99.5 ? "voll" : "voll um " + uhrzeit ( new Date ( Date . now ( ) + h * 3600000 ) ) ;
} else if ( p < - 50 && kwh ) {
const h = kwh * soc / 100 / ( - p / 1000 ) ;
prognose = "leer um " + uhrzeit ( new Date ( Date . now ( ) + h * 3600000 ) ) ;
}
if ( p > 50 && s . grenze > 0 && s . grenze < 100 ) prognose += " · begrenzt auf " + Math . round ( s . grenze ) + " %" ;
const kacheln = [
{ titel : "Ladestand" , zeichen : "\uF187" , wert : Math . round ( soc ) + " %" , neben : kwh ? "≈ " + komma ( kwh * soc / 100 ) + " von " + komma ( kwh ) + " kWh" : "" , balken : soc } ,
{ titel : "Leistung" , zeichen : p > 50 ? "\uF185" : "\uF46E" , wert : ( p > 50 ? "+" : p < - 50 ? "− " : "" ) + komma ( Math . abs ( p ) < 50 ? 0 : Math . abs ( p ) / 1000 , 2 ) + " kW" ,
neben : ( p > 50 ? "lädt · " : p < - 50 ? "entlädt · " : "" ) + prognose , klasse : p > 50 ? "spk-gut" : p < - 50 ? "spk-warm" : "" } ,
] ;
if ( s . bmu ) {
const zyklen = kwh ? s . entladen / kwh : 0 ;
kacheln . push (
{ titel : "Gesundheit" , zeichen : "\uF76E" , wert : komma ( s . soh , 0 ) + " %" , neben : "SOH laut BMU" } ,
{ titel : "Zellspreizung" , zeichen : "\uF304" , wert : tausend ( s . spreizung ) + " mV" ,
neben : komma ( s . zelleMin / 1000 , 3 ) + "– " + komma ( s . zelleMax / 1000 , 3 ) + " V" ,
klasse : s . spreizung > 30 ? "spk-warm" : "" } ,
{ titel : "Temperatur" , zeichen : "\uF5CD" , wert : Math . round ( s . tMin ) + "– " + Math . round ( s . tMax ) + " °C" , neben : ( s . temps || [ ] ) . length + " Fühler" } ,
{ titel : "Spannung" , zeichen : "\uF66B" , wert : komma ( s . spannung ) + " V" , neben : komma ( s . strom ) + " A" } ,
{ titel : "Durchsatz" , zeichen : "\uF130" , wert : tausend ( s . entladen ) + " kWh" ,
neben : "≈ " + tausend ( zyklen ) + " Zyklen · " + ( s . geladen ? Math . round ( s . entladen / s . geladen * 100 ) : 0 ) + " % zurück" ,
titelLang : "entladen seit Inbetriebnahme; Zyklen gerechnet mit " + komma ( kwh ) + " kWh; zurück = entladen je geladen" } ,
{ titel : "Zustand" , zeichen : s . fehler ? "\uF33A" : "\uF26A" , wert : s . fehler ? "Fehler" : "in Ordnung" ,
neben : ( s . firmware || "" ) + ( s . ausgleich ? " · gleicht " + s . ausgleich + " Zellen aus" : "" ) , klasse : s . fehler ? "spk-krit" : "spk-gut" } ,
) ;
}
const html = kacheln . map ( k => `
<div class="spk-kachel ${ k . klasse || "" } " ${ k . titelLang ? ` title=" ${ schutz ( k . titelLang ) } " ` : "" } >
<span class="spk-kachel-titel"><i> ${ k . zeichen } </i> ${ k . titel } </span>
<b> ${ schutz ( k . wert ) } </b>
<small> ${ schutz ( k . neben ) } </small>
${ k . balken !== undefined ? ` <span class="spk-kachel-balken"><span style="width: ${ Math . max ( 0 , Math . min ( 100 , k . balken ) ) } %"></span></span> ` : "" }
</div> ` ) . join ( "" ) ;
if ( this . zuletzt . kennzahlen === html ) return ;
// Beim ersten Mal ganz, danach Kachel für Kachel - der Balken soll gleiten, nicht springen.
const alte = this . dom . kennzahlen . children ;
if ( alte . length !== kacheln . length ) {
this . dom . kennzahlen . innerHTML = html ;
} else {
const neu = document . createElement ( "div" ) ;
neu . innerHTML = html ;
[ ... neu . children ] . forEach ( ( k , i ) => {
const a = alte [ i ] ;
if ( a . className !== k . className ) a . className = k . className ;
[ "b" , "small" , ".spk-kachel-titel" ] . forEach ( sel => {
if ( a . querySelector ( sel ) . innerHTML !== k . querySelector ( sel ) . innerHTML ) a . querySelector ( sel ) . innerHTML = k . querySelector ( sel ) . innerHTML ;
} ) ;
const bal = k . querySelector ( ".spk-kachel-balken span" ) ;
if ( bal ) a . querySelector ( ".spk-kachel-balken span" ) . style . width = bal . style . width ;
} ) ;
}
this . zuletzt . kennzahlen = html ;
}
/* ------------------------------------------------------------ Turm */
turmZeigen ( s ) {
const z = s . zellen , t = s . temps || [ ] ;
const module = Math . max ( 1 , Math . round ( z . length / ZELLEN _JE _MODUL ) ) ;
const gueltig = z . filter ( v => v > 2000 && v < 4500 ) ;
const mittel = gueltig . reduce ( ( a , v ) => a + v , 0 ) / ( gueltig . length || 1 ) ;
const skala = Math . max ( 3 , ... gueltig . map ( v => Math . abs ( v - mittel ) ) ) ;
this . mittel = mittel ; this . skala = skala ;
const daten = [ ] ;
for ( let i = 0 ; i < module ; i ++ ) {
const zellen = z . slice ( i * ZELLEN _JE _MODUL , ( i + 1 ) * ZELLEN _JE _MODUL ) ;
const temps = t . slice ( i * FUEHLER _JE _MODUL , ( i + 1 ) * FUEHLER _JE _MODUL ) ;
daten . push ( {
zellen , temps ,
min : Math . min ( ... zellen ) , max : Math . max ( ... zellen ) ,
tMin : temps . length ? Math . min ( ... temps ) : null , tMax : temps . length ? Math . max ( ... temps ) : null ,
} ) ;
}
this . moduldaten = daten ;
if ( this . modul === null || this . modul >= module ) {
// Ohne Wahl das Modul mit der größten Spreizung - dort gibt es am meisten zu sehen.
let best = 0 ;
daten . forEach ( ( d , i ) => { if ( d . max - d . min > daten [ best ] . max - daten [ best ] . min ) best = i ; } ) ;
this . modulAuto = best ;
}
const gewaehlt = this . modul !== null && this . modul < module ? this . modul : this . modulAuto ;
// Oben das Modul 1 wie im Turm selbst: BMU oben, Module darunter.
const kopf = `
<div class="spk-bmu">
<span class="spk-led ${ s . ok ? " an" : "" } " title=" ${ s . ok ? "BMU liefert Daten" : "keine aktuellen Daten" } "></span>
<b>BMU</b><span> ${ schutz ( s . seriennummer || "" ) } </span>
</div> ` ;
const reihen = daten . map ( ( d , i ) => `
<button type="button" class="spk-modul ${ i === gewaehlt ? " gewaehlt" : "" } " data-modul=" ${ i } " style="--i: ${ i } "
aria-pressed=" ${ i === gewaehlt } " aria-label="Modul ${ i + 1 } : ${ komma ( d . min / 1000 , 3 ) } bis ${ komma ( d . max / 1000 , 3 ) } Volt">
<span class="spk-nr">M ${ i + 1 } </span>
<span class="spk-zellen"> ${ d . zellen . map ( ( v , j ) => {
const a = ( v - mittel ) / skala ;
return ` <i style="--h: ${ ( 52 + a * 40 ) . toFixed ( 0 ) } %;--f: ${ zellFarbe ( a ) } ;--j: ${ j } " title="Zelle ${ i * ZELLEN _JE _MODUL + j + 1 } : ${ tausend ( v ) } mV"></i> ` ;
} ).join("")}</span>
<span class="spk-werte"><b> ${ komma ( d . min / 1000 , 3 ) } – ${ komma ( d . max / 1000 , 3 ) } V</b><small> ${ d . tMin !== null ? d . tMin + "– " + d . tMax + " °C" : "" } </small></span>
</button> ` ) . join ( "" ) ;
this . dom . turm . innerHTML = kopf + reihen + '<div class="spk-sockel"></div>' ;
this . detailZeigen ( s , gewaehlt ) ;
}
detailZeigen ( s , nr ) {
const d = this . moduldaten [ nr ] ;
const spr = d . max - d . min ;
const mittel = this . mittel ;
const lo = Math . min ( d . min , mittel ) - 3 , hi = Math . max ( d . max , mittel ) + 3 ;
const B = 320 , H = 150 , L = 38 , U = 20 ;
const X = j => L + j * ( ( B - L - 4 ) / d . zellen . length ) ;
const bw = ( B - L - 4 ) / d . zellen . length - 3 ;
const Y = v => 8 + ( hi - v ) / ( hi - lo ) * ( H - U - 8 ) ;
const stufen = [ ] ;
const schritt = Math . max ( 1 , Math . ceil ( ( hi - lo ) / 4 ) ) ;
for ( let v = Math . ceil ( lo / schritt ) * schritt ; v <= hi ; v += schritt ) stufen . push ( v ) ;
const balken = d . zellen . map ( ( v , j ) => {
const a = ( v - mittel ) / this . skala ;
const nrGesamt = nr * ZELLEN _JE _MODUL + j + 1 ;
const markiert = nrGesamt === s . zelleMinNr || nrGesamt === s . zelleMaxNr ;
return ` <rect class="spk-zelle ${ markiert ? " markiert" : "" } " x=" ${ X ( j ) . toFixed ( 1 ) } " y=" ${ Y ( v ) . toFixed ( 1 ) } " width=" ${ bw . toFixed ( 1 ) } "
height=" ${ ( H - U - Y ( v ) ) . toFixed ( 1 ) } " rx="2" style="fill: ${ zellFarbe ( a ) } ;--j: ${ j } "><title>Zelle ${ nrGesamt } : ${ tausend ( v ) } mV</title></rect> ` ;
} ) . join ( "" ) ;
const temps = d . temps . map ( ( t , j ) => ` <span style="--f: ${ tempFarbe ( t ) } " title="Fühler ${ nr * FUEHLER _JE _MODUL + j + 1 } "> ${ t } °</span> ` ) . join ( "" ) ;
const html = `
<div class="spk-kopf"><h3 class="spk-ueberschrift">Modul ${ nr + 1 } </h3>
<span class="spk-pille ${ spr > 20 ? "warn" : "ok" } "> ${ spr > 20 ? "ungleich" : "ausgeglichen" } </span></div>
<div class="spk-gross"><b> ${ spr } mV</b><span>Spreizung der ${ d . zellen . length } Zellen</span></div>
<svg class="spk-zellchart" viewBox="0 0 ${ B } ${ H } " role="img" aria-label="Zellspannungen Modul ${ nr + 1 } ">
${ stufen . map ( v => ` <line x1=" ${ L } " x2=" ${ B } " y1=" ${ Y ( v ) . toFixed ( 1 ) } " y2=" ${ Y ( v ) . toFixed ( 1 ) } " class="spk-gitter"/><text x=" ${ L - 5 } " y=" ${ ( Y ( v ) + 3 ) . toFixed ( 1 ) } " class="spk-achse" text-anchor="end"> ${ v } </text> ` ) . join ( "" ) }
<line x1=" ${ L } " x2=" ${ B } " y1=" ${ Y ( mittel ) . toFixed ( 1 ) } " y2=" ${ Y ( mittel ) . toFixed ( 1 ) } " class="spk-mittel"/>
${ balken }
<text x=" ${ X ( 0 ) + bw / 2 } " y=" ${ H - 5 } " class="spk-achse" text-anchor="middle">1</text>
<text x=" ${ X ( d . zellen . length - 1 ) + bw / 2 } " y=" ${ H - 5 } " class="spk-achse" text-anchor="middle"> ${ d . zellen . length } </text>
</svg>
<div class="spk-notiz">Zellspannung in mV · gestrichelt: Mittel aller Zellen · weiß umrandet: tiefste und höchste Zelle der Batterie</div>
<div class="spk-fuehler" aria-label="Temperaturfühler"> ${ temps } </div>
<dl class="spk-paare">
<dt>Modulspannung</dt><dd> ${ komma ( d . zellen . reduce ( ( a , v ) => a + v , 0 ) / 1000 ) } V</dd>
<dt>Zellen</dt><dd> ${ komma ( d . min / 1000 , 3 ) } – ${ komma ( d . max / 1000 , 3 ) } V</dd>
<dt>Temperatur</dt><dd> ${ d . tMin !== null ? d . tMin + " – " + d . tMax + " °C" : "– " } </dd>
<dt>Ganze Batterie</dt><dd>Zelle ${ s . zelleMinNr } am tiefsten, ${ s . zelleMaxNr } am höchsten</dd>
</dl> ` ;
this . dom . detail . innerHTML = html ;
}
/* -------------------------------------------------------- Verlauf */
async verlaufHolen ( ) {
try {
const antwort = await fetch ( "ajax/speicher.php?was=verlauf&stunden=" + this . zeitraum , { cache : "no-store" } ) ;
const d = await antwort . json ( ) ;
if ( ! antwort . ok || d . error ) throw new Error ( d . error || "HTTP " + antwort . status ) ;
this . verlauf = d ;
this . diagrammeZeichnen ( true ) ;
} catch ( e ) {
if ( this . dom ) {
this . dom . soc . innerHTML = this . dom . bmu . innerHTML = '<p class="spk-leer">Verlauf nicht abrufbar: ' + schutz ( e . message ) + "</p>" ;
}
}
}
diagrammeZeichnen ( neu ) {
if ( ! this . verlauf || ! this . dom || ! this . dom . soc . clientWidth ) return ;
const v = this . verlauf ;
diagramm ( this . dom . soc , {
von : v . von , bis : v . bis , neu ,
links : { min : 0 , max : 100 , einheit : "%" } ,
rechts : { einheit : "kW" , symmetrisch : true } ,
reihen : [
{ name : "Leistung" , achse : "rechts" , werte : v . soc . map ( r => [ r [ 0 ] , r [ 2 ] / 1000 ] ) , flaeche : true , farbe : "#6cbe58" , farbeNegativ : "#e8a33a" , format : w => ( w > 0 ? "+" : "" ) + komma ( w , 2 ) + " kW" } ,
{ name : "Ladestand" , achse : "links" , werte : v . soc . map ( r => [ r [ 0 ] , r [ 1 ] ] ) , farbe : "#e3e8ec" , format : w => komma ( w , 0 ) + " %" } ,
] ,
} ) ;
if ( ! v . bmu . length ) {
this . dom . bmu . innerHTML = '<p class="spk-leer">Noch keine Werte der BMU in diesem Zeitraum – sie werden seit 15.09.2026 alle 5 Minuten gespeichert.</p>' ;
return ;
}
// Die BMU wird erst seit 15.09.2026 mitgeschrieben: reichen die Werte
// nicht über den halben Zeitraum, beginnt die Achse kurz vor dem ersten.
const erster = v . bmu [ 0 ] [ 0 ] ;
const bmuVon = erster - v . von > ( v . bis - v . von ) / 2 ? Math . max ( v . von , erster - 1800 ) : v . von ;
diagramm ( this . dom . bmu , {
von : bmuVon , bis : v . bis , neu ,
links : { min : 0 , einheit : "mV" } ,
rechts : { einheit : "°C" } ,
reihen : [
{ name : "Spreizung" , achse : "links" , werte : v . bmu . map ( r => [ r [ 0 ] , r [ 1 ] ] ) , flaeche : true , farbe : "#8fb8de" , format : w => komma ( w , 0 ) + " mV" } ,
{ name : "wärmster Fühler" , achse : "rechts" , werte : v . bmu . map ( r => [ r [ 0 ] , r [ 3 ] ] ) , farbe : "#e8a33a" , format : w => komma ( w , 0 ) + " °C" } ,
{ name : "kältester Fühler" , achse : "rechts" , werte : v . bmu . map ( r => [ r [ 0 ] , r [ 2 ] ] ) , farbe : "#4f8fb8" , format : w => komma ( w , 0 ) + " °C" } ,
] ,
} ) ;
}
}
/* ------------------------------------------------------------------------
Linien-/Flächendiagramm mit zwei Achsen und Anzeige beim Überfahren.
Klein gehalten: genau das, was die zwei Diagramme hier brauchen.
------------------------------------------------------------------------ */
function diagramm ( ziel , o ) {
const B = Math . max ( 280 , ziel . clientWidth ) , H = 190 ;
const L = 40 , R = 46 , O = 22 , U = 24 ;
const alle = seite => o . reihen . filter ( r => r . achse === seite ) . flatMap ( r => r . werte . map ( w => w [ 1 ] ) ) ;
const achse = ( seite , cfg ) => {
const w = alle ( seite ) ;
let min = cfg . min !== undefined ? cfg . min : Math . min ( ... w ) ;
let max = cfg . max !== undefined ? cfg . max : Math . max ( ... w ) ;
if ( cfg . symmetrisch ) { const m = Math . max ( 0.5 , Math . abs ( min ) , Math . abs ( max ) ) ; min = - m ; max = m ; }
if ( ! isFinite ( min ) || ! isFinite ( max ) ) { min = 0 ; max = 1 ; }
if ( max - min < 1e-6 ) { max = min + 1 ; }
const pad = cfg . max !== undefined ? 0 : ( max - min ) * 0.1 ;
return { min : cfg . min !== undefined ? min : min - pad , max : max + pad , einheit : cfg . einheit } ;
} ;
const ax = { links : achse ( "links" , o . links ) , rechts : achse ( "rechts" , o . rechts ) } ;
const X = t => L + ( t - o . von ) / ( o . bis - o . von ) * ( B - L - R ) ;
const Y = ( seite , v ) => O + ( ax [ seite ] . max - v ) / ( ax [ seite ] . max - ax [ seite ] . min ) * ( H - O - U ) ;
// Zeitachse
const spanne = o . bis - o . von ;
const schritt = spanne <= 86400 * 1.5 ? 3 * 3600 : spanne <= 86400 * 8 ? 86400 : 5 * 86400 ;
const ticks = [ ] ;
const start = new Date ( o . von * 1000 ) ;
start . setMinutes ( 0 , 0 , 0 ) ;
if ( schritt >= 86400 ) start . setHours ( 0 ) ;
for ( let t = start . getTime ( ) / 1000 ; t <= o . bis ; t += schritt ) {
if ( t < o . von ) continue ;
const d = new Date ( t * 1000 ) ;
if ( schritt < 86400 && d . getHours ( ) % 3 ) continue ;
ticks . push ( [ t , schritt < 86400 ? String ( d . getHours ( ) ) . padStart ( 2 , "0" ) + ":00"
: [ "So" , "Mo" , "Di" , "Mi" , "Do" , "Fr" , "Sa" ] [ d . getDay ( ) ] + " " + d . getDate ( ) + "." ] ) ;
}
const gitterWerte = seite => [ 0 , 0.25 , 0.5 , 0.75 , 1 ] . map ( f => ax [ seite ] . min + ( ax [ seite ] . max - ax [ seite ] . min ) * f ) ;
const zahl = v => Math . abs ( v ) >= 10 ? Math . round ( v ) : komma ( v , 1 ) ;
// Pfade; Lücken über dem Dreifachen des üblichen Abstands werden nicht verbunden.
const pfad = ( r , flaeche ) => {
if ( ! r . werte . length ) return "" ;
const abst = r . werte . length > 1 ? ( r . werte [ r . werte . length - 1 ] [ 0 ] - r . werte [ 0 ] [ 0 ] ) / ( r . werte . length - 1 ) : 300 ;
let d = "" , zug = [ ] ;
const nullY = Y ( r . achse , Math . max ( ax [ r . achse ] . min , Math . min ( ax [ r . achse ] . max , 0 ) ) ) ;
const zu = ( ) => {
if ( ! zug . length ) return ;
d += "M" + zug . map ( p => p [ 0 ] . toFixed ( 1 ) + " " + p [ 1 ] . toFixed ( 1 ) ) . join ( "L" ) ;
if ( flaeche ) d += ` L ${ zug [ zug . length - 1 ] [ 0 ] . toFixed ( 1 ) } ${ nullY . toFixed ( 1 ) } L ${ zug [ 0 ] [ 0 ] . toFixed ( 1 ) } ${ nullY . toFixed ( 1 ) } Z ` ;
zug = [ ] ;
} ;
r . werte . forEach ( ( w , i ) => {
if ( i && w [ 0 ] - r . werte [ i - 1 ] [ 0 ] > abst * 3 + 60 ) zu ( ) ;
zug . push ( [ X ( w [ 0 ] ) , Y ( r . achse , w [ 1 ] ) ] ) ;
} ) ;
zu ( ) ;
return d ;
} ;
const id = "spkd" + Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) ;
let svg = ` <svg class="spk-verlauf ${ o . neu ? " neu" : "" } " viewBox="0 0 ${ B } ${ H } " width=" ${ B } " height=" ${ H } "> ` ;
gitterWerte ( "links" ) . forEach ( v => {
const y = Y ( "links" , v ) . toFixed ( 1 ) ;
svg += ` <line x1=" ${ L } " x2=" ${ B - R } " y1=" ${ y } " y2=" ${ y } " class="spk-gitter"/><text x=" ${ L - 6 } " y=" ${ + y + 3 } " class="spk-achse" text-anchor="end"> ${ zahl ( v ) } </text> ` ;
} ) ;
gitterWerte ( "rechts" ) . forEach ( v => {
svg += ` <text x=" ${ B - R + 6 } " y=" ${ Y ( "rechts" , v ) + 3 } " class="spk-achse"> ${ zahl ( v ) } </text> ` ;
} ) ;
svg += ` <text x=" ${ L - 6 } " y="9" class="spk-achse spk-einheit" text-anchor="end"> ${ ax . links . einheit } </text> ` ;
svg += ` <text x=" ${ B - R + 6 } " y="9" class="spk-achse spk-einheit"> ${ ax . rechts . einheit } </text> ` ;
ticks . forEach ( ( [ t , s ] ) => { svg += ` <text x=" ${ X ( t ) . toFixed ( 1 ) } " y=" ${ H - 6 } " class="spk-achse" text-anchor="middle"> ${ s } </text> ` ; } ) ;
o . reihen . forEach ( ( r , i ) => {
if ( r . flaeche ) {
const nullY = Y ( r . achse , Math . max ( ax [ r . achse ] . min , Math . min ( ax [ r . achse ] . max , 0 ) ) ) ;
svg += ` <defs><clipPath id=" ${ id } -o ${ i } "><rect x="0" y="0" width=" ${ B } " height=" ${ nullY } "/></clipPath>
<clipPath id=" ${ id } -u ${ i } "><rect x="0" y=" ${ nullY } " width=" ${ B } " height=" ${ H } "/></clipPath></defs> ` ;
const d = pfad ( r , true ) ;
svg += ` <path d=" ${ d } " clip-path="url(# ${ id } -o ${ i } )" class="spk-flaeche" style="fill: ${ r . farbe } "/> ` ;
if ( r . farbeNegativ ) svg += ` <path d=" ${ d } " clip-path="url(# ${ id } -u ${ i } )" class="spk-flaeche" style="fill: ${ r . farbeNegativ } "/> ` ;
}
svg += ` <path d=" ${ pfad ( r , false ) } " class="spk-linie" pathLength="1" style="stroke: ${ r . farbe } ;--i: ${ i } "/> ` ;
} ) ;
svg += ` <line class="spk-zeiger" y1=" ${ O } " y2=" ${ H - U } " x1="-10" x2="-10"/> ` ;
svg += ` <rect x=" ${ L } " y="0" width=" ${ B - L - R } " height=" ${ H } " fill="transparent" class="spk-fang"/></svg> ` ;
svg += ` <div class="spk-tipp" hidden></div> ` ;
svg += ` <div class="spk-reihen"> ${ o . reihen . map ( r => ` <span><i style="background: ${ r . farbe } "></i> ${ r . name } </span> ` ) . join ( "" ) } </div> ` ;
ziel . innerHTML = svg ;
// Überfahren: nächster Zeitpunkt, alle Reihen ablesen.
const el = ziel . querySelector ( "svg" ) , zeiger = el . querySelector ( ".spk-zeiger" ) , tipp = ziel . querySelector ( ".spk-tipp" ) ;
const zeigen = ev => {
const r = el . getBoundingClientRect ( ) ;
const x = ( ev . clientX - r . left ) / r . width * B ;
const t = o . von + ( x - L ) / ( B - L - R ) * ( o . bis - o . von ) ;
const zeilen = o . reihen . map ( reihe => {
let best = null ;
for ( const w of reihe . werte ) if ( ! best || Math . abs ( w [ 0 ] - t ) < Math . abs ( best [ 0 ] - t ) ) best = w ;
return best && Math . abs ( best [ 0 ] - t ) < 3 * 3600 ? { reihe , w : best } : null ;
} ) . filter ( Boolean ) ;
if ( ! zeilen . length ) { tipp . hidden = true ; zeiger . setAttribute ( "x1" , - 10 ) ; zeiger . setAttribute ( "x2" , - 10 ) ; return ; }
const tx = X ( zeilen [ 0 ] . w [ 0 ] ) ;
zeiger . setAttribute ( "x1" , tx ) ; zeiger . setAttribute ( "x2" , tx ) ;
const d = new Date ( zeilen [ 0 ] . w [ 0 ] * 1000 ) ;
tipp . innerHTML = ` <b> ${ d . getDate ( ) } . ${ d . getMonth ( ) + 1 } . ${ uhrzeit ( d ) } </b> ` + zeilen . map ( z =>
` <span><i style="background: ${ z . reihe . farbe } "></i> ${ z . reihe . name } <em> ${ z . reihe . format ( z . w [ 1 ] ) } </em></span> ` ) . join ( "" ) ;
tipp . hidden = false ;
const links = tx / B * r . width ;
tipp . style . left = Math . min ( r . width - tipp . offsetWidth - 4 , Math . max ( 4 , links + 12 ) ) + "px" ;
} ;
el . addEventListener ( "pointermove" , zeigen ) ;
el . addEventListener ( "pointerleave" , ( ) => { tipp . hidden = true ; zeiger . setAttribute ( "x1" , - 10 ) ; zeiger . setAttribute ( "x2" , - 10 ) ; } ) ;
}
window . Speicher = Speicher ;
} ) ( ) ;