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(); 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 { 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((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(); 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; }