c7f69ba7b9
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>
141 lines
5.0 KiB
TypeScript
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;
|
|
}
|