E-Auto-Lade-Erkennung im 31d-Diagramm

Markiert im 31d-Diagramm die Tage rot, an denen das E-Auto geladen wurde:
Leistung (Feld 'leistung', W) lag laenger als LADEN_MIN_HOURS (Default 2h)
ununterbrochen ueber LADEN_THRESHOLD_KW (Default 11 kW).

- Erkennung serverseitig per Flux stateDuration auf den Sekundendaten
  (24h Vorlauf fuer ueber Mitternacht laufende Ladungen an der Monatsgrenze)
- /api/laden?month=YYYY-MM, Ergebnis gecacht (abgeschlossene Monate dauerhaft,
  laufender Monat 5 min) -> ~15s nur beim ersten Aufruf
- Markierung wird asynchron nachgeladen, Balken erscheinen sofort
- Schwelle/Dauer per Env (LADEN_THRESHOLD_KW, LADEN_MIN_HOURS) einstellbar
- BarChart: optionales highlight-Set faerbt passende Balken

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 14:49:44 +02:00
parent 3649bf4e22
commit 46c8057155
6 changed files with 218 additions and 10 deletions
+6
View File
@@ -11,3 +11,9 @@ INFLUX_ROLLUP_MEASUREMENT=arbeit_hourly
INFLUX_FIELD=arbeit INFLUX_FIELD=arbeit
# Umrechnung auf kWh: Zaehlerstand liegt in Wh vor -> 0.001 # Umrechnung auf kWh: Zaehlerstand liegt in Wh vor -> 0.001
INFLUX_UNIT_FACTOR=0.001 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
INFLUX_POWER_FIELD=leistung
LADEN_THRESHOLD_KW=11
LADEN_MIN_HOURS=2
+10
View File
@@ -60,6 +60,16 @@ from(bucket:"strom")
X-Achse: Datum/Zeit · Y-Achse: kWh X-Achse: Datum/Zeit · Y-Achse: kWh
### E-Auto-Lade-Erkennung (31d)
Im 31d-Diagramm werden Tage **rot** markiert, an denen das E-Auto geladen wurde:
Die Leistung (Feld `leistung`, W) lag **länger als `LADEN_MIN_HOURS` (Default 2 h)
ununterbrochen über `LADEN_THRESHOLD_KW` (Default 11 kW)** — dem Anschlusswert des
Autos. Erkennung serverseitig per Flux `stateDuration` auf den Sekundendaten
(`/api/laden?month=YYYY-MM`); läuft ~15 s je Monat und wird daher gecacht
(abgeschlossene Monate dauerhaft). Die Markierung wird asynchron nachgeladen,
sodass die Balken sofort erscheinen. Schwelle/Dauer sind per Env einstellbar.
## Setup ## Setup
```bash ```bash
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { fetchLadetage, LADEN_THRESHOLD_KW, LADEN_MIN_HOURS } from '@/lib/laden';
// GET /api/laden?month=YYYY-MM
// Liefert die Tage (Tages-Anfangs-Zeitstempel ms), an denen das E-Auto geladen wurde.
export async function GET(request: NextRequest) {
const month = request.nextUrl.searchParams.get('month');
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
return NextResponse.json(
{ success: false, error: 'Parameter month=YYYY-MM erforderlich.' },
{ status: 400 }
);
}
try {
const days = await fetchLadetage(month);
return NextResponse.json(
{ success: true, days, thresholdKw: LADEN_THRESHOLD_KW, minHours: LADEN_MIN_HOURS },
{ headers: { 'Cache-Control': 'no-store' } }
);
} catch (error) {
console.error('Laden detection error:', error);
return NextResponse.json(
{ success: false, error: 'Fehler bei der Lade-Erkennung.' },
{ status: 500 }
);
}
}
+20 -2
View File
@@ -13,12 +13,24 @@ interface BarChartProps {
// Optionale feste Achsengrenzen (z.B. komplettes Jahr -> immer 12 Monate sichtbar) // Optionale feste Achsengrenzen (z.B. komplettes Jahr -> immer 12 Monate sichtbar)
xMin?: number; xMin?: number;
xMax?: number; xMax?: number;
// Hervorzuhebende Balken (Zeitstempel-Set, z.B. E-Auto-Ladetage) + Farbe
highlight?: Set<number>;
highlightColor?: string;
} }
const HOUR_MS = 60 * 60 * 1000; const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS; const DAY_MS = 24 * HOUR_MS;
export default function BarChart({ title, data, every, color, xMin, xMax }: BarChartProps) { export default function BarChart({
title,
data,
every,
color,
xMin,
xMax,
highlight,
highlightColor = '#c0392b',
}: BarChartProps) {
const isHourly = every === '1h'; const isHourly = every === '1h';
const isMonthly = every === '1mo'; const isMonthly = every === '1mo';
const tooltipDateFormat = isHourly ? '%d.%m.%Y %H:%M' : isMonthly ? '%B %Y' : '%d.%m.%Y'; const tooltipDateFormat = isHourly ? '%d.%m.%Y %H:%M' : isMonthly ? '%B %Y' : '%d.%m.%Y';
@@ -76,7 +88,13 @@ export default function BarChart({ title, data, every, color, xMin, xMax }: BarC
{ {
type: 'column', type: 'column',
name: 'Verbrauch', name: 'Verbrauch',
data, // markierte Balken (z.B. Ladetage) bekommen eine eigene Farbe
data:
highlight && highlight.size
? data.map(([x, y]) =>
highlight.has(x) ? { x, y, color: highlightColor } : { x, y }
)
: data,
color, color,
}, },
], ],
+63 -8
View File
@@ -50,6 +50,32 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// E-Auto-Ladetage (nur 31d): werden asynchron nachgeladen
const [ladeDays, setLadeDays] = useState<Set<number>>(new Set());
const [ladeLoading, setLadeLoading] = useState(false);
const [ladeMeta, setLadeMeta] = useState<{ thresholdKw: number; minHours: number } | null>(null);
const fetchLaden = useCallback(
async (sel: string) => {
if (range !== '31d') return;
setLadeLoading(true);
setLadeDays(new Set());
try {
const res = await fetch(`/api/laden?month=${sel}`, { cache: 'no-store' });
const json = await res.json();
if (json.success) {
setLadeDays(new Set<number>(json.days));
setLadeMeta({ thresholdKw: json.thresholdKw, minHours: json.minHours });
}
} catch {
// Markierung ist sekundaer -> Fehler still ignorieren
} finally {
setLadeLoading(false);
}
},
[range]
);
const fetchData = useCallback( const fetchData = useCallback(
async (sel: string) => { async (sel: string) => {
setIsLoading(true); setIsLoading(true);
@@ -83,9 +109,15 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
useEffect(() => { useEffect(() => {
fetchData(value); fetchData(value);
fetchLaden(value);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const handleApply = () => {
fetchData(value);
fetchLaden(value);
};
// Jahres-Chart: Achse immer ueber das komplette Kalenderjahr -> alle 12 Monate // Jahres-Chart: Achse immer ueber das komplette Kalenderjahr -> alle 12 Monate
// sichtbar, Restmonate bleiben ohne Balken. // sichtbar, Restmonate bleiben ohne Balken.
let xMin: number | undefined; let xMin: number | undefined;
@@ -138,7 +170,7 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
</div> </div>
<button <button
onClick={() => fetchData(value)} onClick={handleApply}
disabled={isLoading} disabled={isLoading}
className="bg-[#85B7D7] hover:bg-[#6a9fc5] px-5 py-2 text-sm rounded-lg transition-colors shadow-md disabled:opacity-50" className="bg-[#85B7D7] hover:bg-[#6a9fc5] px-5 py-2 text-sm rounded-lg transition-colors shadow-md disabled:opacity-50"
> >
@@ -156,13 +188,36 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
<div className="text-center py-16 text-gray-500">Lade Daten</div> <div className="text-center py-16 text-gray-500">Lade Daten</div>
) : ( ) : (
<> <>
<BarChart title={title} data={data} every={every} color={color} xMin={xMin} xMax={xMax} /> <BarChart
{data.length > 0 && ( title={title}
<div className="text-right text-sm font-semibold mt-2 px-2"> data={data}
{totalLabel}:{' '} every={every}
{total.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} kWh color={color}
</div> xMin={xMin}
)} xMax={xMax}
highlight={range === '31d' ? ladeDays : undefined}
/>
<div className="flex justify-between items-center mt-2 px-2 text-sm">
{/* Legende: E-Auto-Ladetage (nur 31d) */}
{range === '31d' ? (
<span className="text-gray-600 flex items-center gap-1.5">
<span className="inline-block w-3 h-3 rounded-sm" style={{ backgroundColor: '#c0392b' }} />
{ladeLoading
? 'E-Auto-Ladung wird geprüft…'
: ladeDays.size > 0
? `E-Auto geladen (> ${ladeMeta?.thresholdKw ?? ''} kW, ≥ ${ladeMeta?.minHours ?? ''} h)`
: 'Keine E-Auto-Ladung erkannt'}
</span>
) : (
<span />
)}
{data.length > 0 && (
<span className="font-semibold whitespace-nowrap">
{totalLabel}:{' '}
{total.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} kWh
</span>
)}
</div>
</> </>
)} )}
</div> </div>
+91
View File
@@ -0,0 +1,91 @@
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 ununterbrochen ueber THRESHOLD_KW
export const LADEN_THRESHOLD_KW = parseFloat(process.env.LADEN_THRESHOLD_KW || '11');
export const LADEN_MIN_HOURS = parseFloat(process.env.LADEN_MIN_HOURS || '2');
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;
}
/**
* 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),
* 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.
*/
export async function fetchLadetage(month: string): Promise<number[]> {
const thresholdW = LADEN_THRESHOLD_KW * 1000;
const minSeconds = Math.round(LADEN_MIN_HOURS * 3600);
const key = `${month}|${thresholdW}|${minSeconds}`;
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 (stateDuration startet sonst erst an der Range-Grenze).
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"])
`;
const queryApi = influx.getQueryApi(ORG);
const minTs = start.getTime();
const maxTs = stop.getTime();
const days: number[] = [];
await new Promise<void>((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);
},
error: reject,
complete: resolve,
});
});
cache.set(key, { days, cachedAt: Date.now() });
return days;
}