Files
strom-next/lib/laden.ts
T
admin c7f69ba7b9 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>
2026-06-28 15:23:59 +02:00

141 lines
5.0 KiB
TypeScript

import { InfluxDB } from '@influxdata/influxdb-client';
import { resolveRange } from '@/lib/ranges';
const URL = process.env.INFLUX_URL || 'http://nuccy:8086';
const TOKEN = process.env.INFLUX_TOKEN || '';
const ORG = process.env.INFLUX_ORG || 'citysensor';
const BUCKET = process.env.INFLUX_BUCKET || 'strom';
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 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 });
interface CacheEntry {
days: number[];
cachedAt: number;
}
const cache = new Map<string, CacheEntry>();
const CURRENT_MONTH_TTL = 5 * 60 * 1000; // laufender Monat: 5 min; abgeschlossene Monate: dauerhaft
function isCurrentMonth(month: string): boolean {
const now = new Date();
const cur = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
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.
*
* 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.
*
* 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 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)) {
return hit.days;
}
const { start, stop } = resolveRange('31d', { month });
// 24h Vorlauf, damit ueber Mitternacht laufende Ladungen am Monatsanfang
// korrekt erkannt werden.
const queryStart = new Date(start.getTime() - 24 * 3600 * 1000);
const flux = `
import "timezone"
option location = timezone.location(name: "${TZ}")
from(bucket: "${BUCKET}")
|> range(start: ${queryStart.toISOString()}, stop: ${stop.toISOString()})
|> filter(fn: (r) => r._measurement == "${MEASUREMENT}")
|> filter(fn: (r) => r._field == "${POWER_FIELD}")
|> 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);
await new Promise<void>((resolve, reject) => {
queryApi.queryRows(flux, {
next(row, tableMeta) {
const o = tableMeta.toObject(row);
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;
}