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
|
INFLUX_UNIT_FACTOR=0.001
|
||||||
|
|
||||||
# E-Auto-Lade-Erkennung (31d-Diagramm): Leistung (W) laenger als
|
# 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
|
INFLUX_POWER_FIELD=leistung
|
||||||
LADEN_THRESHOLD_KW=11
|
LADEN_THRESHOLD_KW=11
|
||||||
LADEN_MIN_HOURS=2
|
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 POWER_FIELD = process.env.INFLUX_POWER_FIELD || 'leistung'; // momentane Leistung in W
|
||||||
const TZ = process.env.INFLUX_TZ || 'Europe/Berlin';
|
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_THRESHOLD_KW = parseFloat(process.env.LADEN_THRESHOLD_KW || '11');
|
||||||
export const LADEN_MIN_HOURS = parseFloat(process.env.LADEN_MIN_HOURS || '2');
|
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 });
|
const influx = new InfluxDB({ url: URL, token: TOKEN, timeout: 90_000 });
|
||||||
|
|
||||||
@@ -28,19 +32,40 @@ function isCurrentMonth(month: string): boolean {
|
|||||||
return month >= cur;
|
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:
|
* 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),
|
* 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.
|
* passend zu den x-Werten der 31d-Balken.
|
||||||
*
|
*
|
||||||
* Erkennung serverseitig per Flux `stateDuration` (laeuft je Monat ~10-15s auf
|
* Laeuft je Monat ~10-15s auf den Sekundendaten, daher Caching.
|
||||||
* den Sekundendaten), daher Caching: abgeschlossene Monate dauerhaft.
|
|
||||||
*/
|
*/
|
||||||
export async function fetchLadetage(month: string): Promise<number[]> {
|
export async function fetchLadetage(month: string): Promise<number[]> {
|
||||||
const thresholdW = LADEN_THRESHOLD_KW * 1000;
|
const thresholdW = LADEN_THRESHOLD_KW * 1000;
|
||||||
const minSeconds = Math.round(LADEN_MIN_HOURS * 3600);
|
const minDurationMs = LADEN_MIN_HOURS * 3600 * 1000;
|
||||||
const key = `${month}|${thresholdW}|${minSeconds}`;
|
const maxGapMs = LADEN_MAX_GAP_MIN * 60 * 1000;
|
||||||
|
const key = `${month}|${thresholdW}|${minDurationMs}|${maxGapMs}`;
|
||||||
|
|
||||||
const hit = cache.get(key);
|
const hit = cache.get(key);
|
||||||
if (hit && (!isCurrentMonth(month) || Date.now() - hit.cachedAt < CURRENT_MONTH_TTL)) {
|
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 });
|
const { start, stop } = resolveRange('31d', { month });
|
||||||
// 24h Vorlauf, damit ueber Mitternacht laufende Ladungen am Monatsanfang
|
// 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 queryStart = new Date(start.getTime() - 24 * 3600 * 1000);
|
||||||
|
|
||||||
const flux = `
|
const flux = `
|
||||||
import "timezone"
|
import "timezone"
|
||||||
option location = timezone.location(name: "${TZ}")
|
option location = timezone.location(name: "${TZ}")
|
||||||
|
|
||||||
threshold = ${thresholdW}.0
|
|
||||||
|
|
||||||
from(bucket: "${BUCKET}")
|
from(bucket: "${BUCKET}")
|
||||||
|> range(start: ${queryStart.toISOString()}, stop: ${stop.toISOString()})
|
|> range(start: ${queryStart.toISOString()}, stop: ${stop.toISOString()})
|
||||||
|> filter(fn: (r) => r._measurement == "${MEASUREMENT}")
|
|> filter(fn: (r) => r._measurement == "${MEASUREMENT}")
|
||||||
|> filter(fn: (r) => r._field == "${POWER_FIELD}")
|
|> filter(fn: (r) => r._field == "${POWER_FIELD}")
|
||||||
|> stateDuration(fn: (r) => r._value > threshold, unit: 1s)
|
|> aggregateWindow(every: 1m, fn: mean, createEmpty: false)
|
||||||
|> filter(fn: (r) => r.stateDuration >= ${minSeconds})
|
|> keep(columns: ["_time", "_value"])
|
||||||
|> aggregateWindow(every: 1d, fn: max, createEmpty: false)
|
|
||||||
|> timeShift(duration: -1d)
|
|
||||||
|> keep(columns: ["_time"])
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Minuten ueber der Schwelle einsammeln (aufsteigend)
|
||||||
|
const aboveMinutes: number[] = [];
|
||||||
const queryApi = influx.getQueryApi(ORG);
|
const queryApi = influx.getQueryApi(ORG);
|
||||||
const minTs = start.getTime();
|
|
||||||
const maxTs = stop.getTime();
|
|
||||||
const days: number[] = [];
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
queryApi.queryRows(flux, {
|
queryApi.queryRows(flux, {
|
||||||
next(row, tableMeta) {
|
next(row, tableMeta) {
|
||||||
const o = tableMeta.toObject(row);
|
const o = tableMeta.toObject(row);
|
||||||
const ts = new Date(o._time as string).getTime();
|
const w = Number(o._value);
|
||||||
if (ts >= minTs && ts < maxTs) days.push(ts);
|
if (Number.isFinite(w) && w > thresholdW) {
|
||||||
|
aboveMinutes.push(new Date(o._time as string).getTime());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
error: reject,
|
error: reject,
|
||||||
complete: resolve,
|
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() });
|
cache.set(key, { days, cachedAt: Date.now() });
|
||||||
return days;
|
return days;
|
||||||
|
|||||||
Reference in New Issue
Block a user