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:
@@ -11,3 +11,9 @@ INFLUX_ROLLUP_MEASUREMENT=arbeit_hourly
|
||||
INFLUX_FIELD=arbeit
|
||||
# Umrechnung auf kWh: Zaehlerstand liegt in Wh vor -> 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
|
||||
|
||||
@@ -60,6 +60,16 @@ from(bucket:"strom")
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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
@@ -13,12 +13,24 @@ interface BarChartProps {
|
||||
// Optionale feste Achsengrenzen (z.B. komplettes Jahr -> immer 12 Monate sichtbar)
|
||||
xMin?: 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 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 isMonthly = every === '1mo';
|
||||
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',
|
||||
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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -50,6 +50,32 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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(
|
||||
async (sel: string) => {
|
||||
setIsLoading(true);
|
||||
@@ -83,9 +109,15 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(value);
|
||||
fetchLaden(value);
|
||||
// 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
|
||||
// sichtbar, Restmonate bleiben ohne Balken.
|
||||
let xMin: number | undefined;
|
||||
@@ -138,7 +170,7 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => fetchData(value)}
|
||||
onClick={handleApply}
|
||||
disabled={isLoading}
|
||||
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>
|
||||
) : (
|
||||
<>
|
||||
<BarChart title={title} data={data} every={every} color={color} xMin={xMin} xMax={xMax} />
|
||||
{data.length > 0 && (
|
||||
<div className="text-right text-sm font-semibold mt-2 px-2">
|
||||
{totalLabel}:{' '}
|
||||
{total.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} kWh
|
||||
</div>
|
||||
)}
|
||||
<BarChart
|
||||
title={title}
|
||||
data={data}
|
||||
every={every}
|
||||
color={color}
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user