diff --git a/.env.local.example b/.env.local.example index 5ba8031..7c89e6b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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 diff --git a/lib/laden.ts b/lib/laden.ts index 3c7006e..89baf46 100644 --- a/lib/laden.ts +++ b/lib/laden.ts @@ -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 { 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 { 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((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(); + 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;