Lade-Erkennung: kurze Einbrueche ueberbruecken

Statt durchgehend (stateDuration, das bei jedem Einbruch zurueckgesetzt
wurde) werden jetzt Minuten ueber der Schwelle zu Sessions zusammengefasst
und Einbrueche bis LADEN_MAX_GAP_MIN (Default 10 min) ueberbrueckt. Eine
Session ab LADEN_MIN_HOURS gilt als Ladung.

Damit werden z.B. Ladungen mit kurzem Leistungs-Einbruch (Bsp. 2026-06-20,
~14:58-17:01 mit Einbruch um 16:35) korrekt erkannt.

- Erkennung in Node auf Minuten-Mittelwerten (aggregateWindow mean, Pushdown
  -> schneller als zuvor, ~5s statt ~13s je Monat)
- Tages-Zuordnung Berlin-genau, passt exakt zu den 31d-Balken
- neuer Env-Parameter LADEN_MAX_GAP_MIN

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 15:23:59 +02:00
parent 46c8057155
commit c7f69ba7b9
2 changed files with 74 additions and 23 deletions
+3 -1
View File
@@ -13,7 +13,9 @@ INFLUX_FIELD=arbeit
INFLUX_UNIT_FACTOR=0.001
# E-Auto-Lade-Erkennung (31d-Diagramm): Leistung (W) laenger als
# LADEN_MIN_HOURS ununterbrochen ueber LADEN_THRESHOLD_KW -> Tag markiert
# LADEN_MIN_HOURS ueber LADEN_THRESHOLD_KW -> Tag markiert. Kurze Einbrueche
# (z.B. waehrend des Ladens) bis LADEN_MAX_GAP_MIN Minuten werden ueberbrueckt.
INFLUX_POWER_FIELD=leistung
LADEN_THRESHOLD_KW=11
LADEN_MIN_HOURS=2
LADEN_MAX_GAP_MIN=10
+71 -22
View File
@@ -9,9 +9,13 @@ const MEASUREMENT = process.env.INFLUX_MEASUREMENT || 'vzlogger';
const POWER_FIELD = process.env.INFLUX_POWER_FIELD || 'leistung'; // momentane Leistung in W
const TZ = process.env.INFLUX_TZ || 'Europe/Berlin';
// E-Auto-Ladung: Leistung laenger als MIN_HOURS ununterbrochen ueber THRESHOLD_KW
// E-Auto-Ladung: Leistung laenger als MIN_HOURS ueber THRESHOLD_KW, wobei kurze
// Einbrueche bis MAX_GAP_MIN ueberbrueckt werden (z.B. waehrend des Ladens).
export const LADEN_THRESHOLD_KW = parseFloat(process.env.LADEN_THRESHOLD_KW || '11');
export const LADEN_MIN_HOURS = parseFloat(process.env.LADEN_MIN_HOURS || '2');
export const LADEN_MAX_GAP_MIN = parseFloat(process.env.LADEN_MAX_GAP_MIN || '10');
const MIN_MS = 60 * 1000;
const influx = new InfluxDB({ url: URL, token: TOKEN, timeout: 90_000 });
@@ -28,19 +32,40 @@ function isCurrentMonth(month: string): boolean {
return month >= cur;
}
// Beginn des Berliner Kalendertages (= x-Wert der 31d-Balken) zu einem Zeitstempel
function dayStart(ts: number): number {
const d = new Date(ts);
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
// Alle Berliner Tage, die ein Zeitraum [start, end] beruehrt
function daysInSpan(start: number, end: number): number[] {
const out: number[] = [];
for (let cur = dayStart(start); cur <= end; ) {
out.push(cur);
const d = new Date(cur);
d.setDate(d.getDate() + 1);
cur = d.getTime();
}
return out;
}
/**
* Ermittelt die Tage eines Monats, an denen das E-Auto geladen wurde:
* Leistung (`leistung`, W) lag laenger als LADEN_MIN_HOURS ununterbrochen ueber
* LADEN_THRESHOLD_KW. Rueckgabe = Tages-Anfangs-Zeitstempel (ms, Berliner Tag),
* Ermittelt die Tage eines Monats, an denen das E-Auto geladen wurde.
*
* Vorgehen: Minuten-Mittel der Leistung holen, Minuten ueber der Schwelle zu
* Sessions zusammenfassen und dabei Einbrueche bis MAX_GAP_MIN ueberbruecken.
* Eine Session ab Gesamtdauer MIN_HOURS gilt als Ladung; die beruehrten Tage
* werden markiert. Rueckgabe = Tages-Anfangs-Zeitstempel (ms, Berliner Tag),
* passend zu den x-Werten der 31d-Balken.
*
* Erkennung serverseitig per Flux `stateDuration` (laeuft je Monat ~10-15s auf
* den Sekundendaten), daher Caching: abgeschlossene Monate dauerhaft.
* Laeuft je Monat ~10-15s auf den Sekundendaten, daher Caching.
*/
export async function fetchLadetage(month: string): Promise<number[]> {
const thresholdW = LADEN_THRESHOLD_KW * 1000;
const minSeconds = Math.round(LADEN_MIN_HOURS * 3600);
const key = `${month}|${thresholdW}|${minSeconds}`;
const minDurationMs = LADEN_MIN_HOURS * 3600 * 1000;
const maxGapMs = LADEN_MAX_GAP_MIN * 60 * 1000;
const key = `${month}|${thresholdW}|${minDurationMs}|${maxGapMs}`;
const hit = cache.get(key);
if (hit && (!isCurrentMonth(month) || Date.now() - hit.cachedAt < CURRENT_MONTH_TTL)) {
@@ -49,42 +74,66 @@ export async function fetchLadetage(month: string): Promise<number[]> {
const { start, stop } = resolveRange('31d', { month });
// 24h Vorlauf, damit ueber Mitternacht laufende Ladungen am Monatsanfang
// korrekt erkannt werden (stateDuration startet sonst erst an der Range-Grenze).
// korrekt erkannt werden.
const queryStart = new Date(start.getTime() - 24 * 3600 * 1000);
const flux = `
import "timezone"
option location = timezone.location(name: "${TZ}")
threshold = ${thresholdW}.0
from(bucket: "${BUCKET}")
|> range(start: ${queryStart.toISOString()}, stop: ${stop.toISOString()})
|> filter(fn: (r) => r._measurement == "${MEASUREMENT}")
|> filter(fn: (r) => r._field == "${POWER_FIELD}")
|> stateDuration(fn: (r) => r._value > threshold, unit: 1s)
|> filter(fn: (r) => r.stateDuration >= ${minSeconds})
|> aggregateWindow(every: 1d, fn: max, createEmpty: false)
|> timeShift(duration: -1d)
|> keep(columns: ["_time"])
|> aggregateWindow(every: 1m, fn: mean, createEmpty: false)
|> keep(columns: ["_time", "_value"])
`;
// Minuten ueber der Schwelle einsammeln (aufsteigend)
const aboveMinutes: number[] = [];
const queryApi = influx.getQueryApi(ORG);
const minTs = start.getTime();
const maxTs = stop.getTime();
const days: number[] = [];
await new Promise<void>((resolve, reject) => {
queryApi.queryRows(flux, {
next(row, tableMeta) {
const o = tableMeta.toObject(row);
const ts = new Date(o._time as string).getTime();
if (ts >= minTs && ts < maxTs) days.push(ts);
const w = Number(o._value);
if (Number.isFinite(w) && w > thresholdW) {
aboveMinutes.push(new Date(o._time as string).getTime());
}
},
error: reject,
complete: resolve,
});
});
aboveMinutes.sort((a, b) => a - b);
// Sessions bilden, kurze Einbrueche (<= maxGapMs) ueberbruecken
const markedDays = new Set<number>();
if (aboveMinutes.length > 0) {
let sessionStart = aboveMinutes[0];
let prev = aboveMinutes[0];
const finish = (s: number, e: number) => {
// Sessiondauer inkl. der letzten Minute
if (e - s + MIN_MS >= minDurationMs) {
for (const day of daysInSpan(s, e)) markedDays.add(day);
}
};
for (let i = 1; i < aboveMinutes.length; i++) {
if (aboveMinutes[i] - prev <= maxGapMs) {
prev = aboveMinutes[i];
} else {
finish(sessionStart, prev);
sessionStart = aboveMinutes[i];
prev = aboveMinutes[i];
}
}
finish(sessionStart, prev);
}
// nur Tage des angefragten Monats (Vorlauftag verwerfen)
const days = [...markedDays]
.filter((ts) => ts >= start.getTime() && ts < stop.getTime())
.sort((a, b) => a - b);
cache.set(key, { days, cachedAt: Date.now() });
return days;