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:
+3
-1
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user