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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
// 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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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