diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..0dcc482 --- /dev/null +++ b/api/README.md @@ -0,0 +1,514 @@ +# Wetterstation API + +REST API zum Abrufen von Wetterdaten aus der PostgreSQL-Datenbank. + +## Übersicht + +Die API basiert auf **FastAPI** und bietet Endpunkte für aktuelle Wetterdaten, historische Zeitreihen, Statistiken und aggregierte Daten. + +- **Version:** 1.0.0 +- **Framework:** FastAPI mit Uvicorn +- **Datenbank:** PostgreSQL +- **Interaktive API-Dokumentation:** `/docs` (Swagger UI) oder `/redoc` (ReDoc) + +## Starten der API + +### Lokal (Development) + +```bash +cd api +python main.py +``` + +Die API läuft dann auf `http://localhost:8000` + +### Docker (Production) + +```bash +docker compose up -d +``` + +## Umgebungsvariablen + +Die API benötigt folgende Umgebungsvariablen (definiert in `.env`): + +```env +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=wetterstation +DB_USER=wetterstation_user +DB_PASSWORD= +``` + +## Endpunkte + +### 📋 General + +#### `GET /` +**Root-Endpunkt mit API-Informationen** + +**Response:** +```json +{ + "message": "Wetterstation API", + "version": "1.0.0", + "docs": "/docs" +} +``` + +--- + +#### `GET /health` +**Health Check - Prüft API- und Datenbankstatus** + +**Response:** +```json +{ + "status": "ok", + "database": "connected", + "timestamp": "2026-03-23T14:30:00" +} +``` + +--- + +### 🌡️ Weather Data + +#### `GET /weather/latest` +**Gibt die neuesten Wetterdaten zurück** + +**Response Model:** `WeatherData` + +**Beispiel:** +```json +{ + "id": 123456, + "datetime": "2026-03-23T14:30:00Z", + "temperature": 15.5, + "humidity": 65, + "pressure": 1013.2, + "wind_speed": 12.5, + "wind_gust": 18.7, + "wind_dir": 225.0, + "rain": 0.0, + "rain_rate": 0.0, + "received_at": "2026-03-23T14:30:05" +} +``` + +--- + +#### `GET /weather/current` +**Alias für `/weather/latest` - gibt aktuelle Wetterdaten zurück** + +--- + +#### `GET /weather/history` +**Gibt historische Wetterdaten der letzten X Stunden zurück** + +**Query Parameter:** +- `hours` (optional): Anzahl Stunden zurück (1-168, default: 24) +- `limit` (optional): Maximale Anzahl Datensätze (1-10000, default: 1000) + +**Beispiel:** +```bash +GET /weather/history?hours=48&limit=500 +``` + +**Response:** Array von `WeatherData` + +--- + +#### `GET /weather/range` +**Gibt Wetterdaten für einen bestimmten Zeitraum zurück** + +**Query Parameter:** +- `start` (erforderlich): Startdatum (ISO 8601) +- `end` (erforderlich): Enddatum (ISO 8601) +- `limit` (optional): Maximale Anzahl Datensätze (1-50000, default: 10000) + +**Beispiel:** +```bash +GET /weather/range?start=2026-03-01T00:00:00Z&end=2026-03-23T23:59:59Z&limit=5000 +``` + +**Response:** Array von `WeatherData` + +--- + +#### `GET /weather/temperature` +**Gibt nur Temperatur-Zeitreihen zurück (optimiert für Diagramme)** + +**Query Parameter:** +- `hours` (optional): Anzahl Stunden zurück (1-168, default: 24) + +**Response:** +```json +[ + { + "datetime": "2026-03-23T14:00:00Z", + "temperature": 15.3 + }, + { + "datetime": "2026-03-23T14:05:00Z", + "temperature": 15.5 + } +] +``` + +--- + +#### `GET /weather/wind` +**Gibt nur Wind-Daten zurück (Geschwindigkeit, Richtung, Böen)** + +**Query Parameter:** +- `hours` (optional): Anzahl Stunden zurück (1-168, default: 24) + +**Response:** +```json +[ + { + "datetime": "2026-03-23T14:00:00Z", + "wind_speed": 12.5, + "wind_gust": 18.7, + "wind_dir": 225.0 + } +] +``` + +--- + +#### `GET /weather/rain` +**Gibt nur Regen-Daten zurück** + +**Query Parameter:** +- `hours` (optional): Anzahl Stunden zurück (1-168, default: 24) + +**Response:** +```json +[ + { + "datetime": "2026-03-23T14:00:00Z", + "rain": 0.5, + "rain_rate": 2.3 + } +] +``` + +--- + +### 📊 Statistics + +#### `GET /weather/stats` +**Gibt aggregierte Statistiken für den angegebenen Zeitraum zurück** + +**Query Parameter:** +- `hours` (optional): Zeitraum in Stunden (1-168, default: 24) + +**Response Model:** `WeatherStats` + +**Beispiel:** +```json +{ + "avg_temperature": 15.2, + "min_temperature": 8.5, + "max_temperature": 22.1, + "avg_humidity": 65.3, + "avg_pressure": 1013.5, + "avg_wind_speed": 10.2, + "max_wind_gust": 28.5, + "total_rain": 3.2, + "data_points": 288 +} +``` + +--- + +#### `GET /weather/daily` +**Gibt tägliche Statistiken für die letzten X Tage zurück** + +**Query Parameter:** +- `days` (optional): Anzahl Tage zurück (1-90, default: 7) + +**Response:** Array von `WeatherStats` mit `date` Feld + +--- + +### 📈 Aggregated Data + +Die aggregierten Endpunkte sind optimiert für Langzeit-Visualisierungen und reduzieren die Datenmenge durch Mittelwertbildung. + +#### `GET /weather/hourly-aggregated` +**Gibt stündlich aggregierte Wetterdaten zurück (Stundenmittel)** + +**Query Parameter:** +- `days` (optional): Anzahl Tage zurück (1-60, default: 7) + +**Response:** Array von `WeatherData` (stündlich aggregiert) + +**Verwendung:** Ideal für 7-Tage- und 30-Tage-Ansichten + +--- + +#### `GET /weather/daily-aggregated` +**Gibt täglich aggregierte Wetterdaten zurück (Tagesmittel)** + +**Query Parameter:** +- `days` (optional): Anzahl Tage zurück (1-730, default: 365) + +**Response:** Array von `WeatherData` (täglich aggregiert) + +**Besonderheit:** Bei `days >= 365` werden automatisch **alle verfügbaren Daten** zurückgegeben (nicht nur die letzten 365 Tage). + +**Verwendung:** Ideal für Jahresübersicht (365-Tage-Ansicht) + +--- + +#### `GET /weather/rain-daily` +**Gibt tägliche Regensummen zurück** + +**Query Parameter:** +- `days` (optional): Anzahl Tage zurück (1-365, default: 30) + +**Response:** +```json +[ + { + "date": "2026-03-23T00:00:00Z", + "total_rain": 5.2 + }, + { + "date": "2026-03-22T00:00:00Z", + "total_rain": 0.0 + } +] +``` + +**Verwendung:** Ideal für 7-Tage- und 30-Tage-Regen-Diagramme + +--- + +#### `GET /weather/rain-weekly` +**Gibt wöchentliche Regensummen zurück (Woche = Mo-So)** + +**Query Parameter:** +- `days` (optional): Anzahl Tage zurück (1-730, default: 365) + +**Response:** +```json +[ + { + "week_start": "2026-03-17T00:00:00Z", + "total_rain": 12.5 + } +] +``` + +**Besonderheit:** Bei `days >= 365` werden automatisch **alle verfügbaren Daten** zurückgegeben. + +**Verwendung:** Ideal für Jahresübersicht (365-Tage-Ansicht) + +--- + +## Datenmodelle + +### WeatherData + +```typescript +{ + id: number + datetime: string (ISO 8601) + temperature: number | null // °C + humidity: number | null // % + pressure: number | null // hPa + wind_speed: number | null // km/h (konvertiert von mph) + wind_gust: number | null // km/h (konvertiert von mph) + wind_dir: number | null // Grad (0-360) + rain: number | null // mm + rain_rate: number | null // mm/h + received_at: string (ISO 8601) +} +``` + +### WeatherStats + +```typescript +{ + avg_temperature: number | null + min_temperature: number | null + max_temperature: number | null + avg_humidity: number | null + avg_pressure: number | null + avg_wind_speed: number | null + max_wind_gust: number | null + total_rain: number | null + data_points: number +} +``` + +### HealthResponse + +```typescript +{ + status: string // "ok" | "error" + database: string // "connected" | "disconnected" + timestamp: string (ISO 8601) +} +``` + +--- + +## Einheitenkonvertierung + +Die API konvertiert automatisch folgende Einheiten aus der Datenbank: + +| Wert | Datenbank | API-Ausgabe | +|------|-----------|-------------| +| Windgeschwindigkeit | mph | km/h (× 1.60934) | +| Windböen | mph | km/h (× 1.60934) | +| Temperatur | °C | °C (unverändert) | +| Luftdruck | hPa | hPa (unverändert) | +| Regen | mm | mm (unverändert) | + +--- + +## CORS + +Die API erlaubt CORS-Anfragen von allen Origins (`allow_origins=["*"]`). In Production sollte dies auf spezifische Domains eingeschränkt werden. + +--- + +## Fehlerbehandlung + +### HTTP Status Codes + +- `200 OK` - Erfolgreiche Anfrage +- `400 Bad Request` - Ungültige Parameter +- `404 Not Found` - Keine Daten gefunden +- `500 Internal Server Error` - Datenbankfehler + +### Fehler-Response + +```json +{ + "detail": "Keine Daten verfügbar" +} +``` + +--- + +## Interaktive Dokumentation + +FastAPI generiert automatisch eine interaktive API-Dokumentation: + +- **Swagger UI:** [http://localhost:8000/docs](http://localhost:8000/docs) +- **ReDoc:** [http://localhost:8000/redoc](http://localhost:8000/redoc) + +Dort können alle Endpunkte direkt getestet werden. + +--- + +## Beispiele + +### cURL + +```bash +# Aktuelle Wetterdaten abrufen +curl http://localhost:8000/weather/current + +# Letzte 48 Stunden +curl "http://localhost:8000/weather/history?hours=48" + +# Jahresübersicht (alle verfügbaren Daten) +curl "http://localhost:8000/weather/daily-aggregated?days=365" + +# Statistiken für letzte 7 Tage +curl "http://localhost:8000/weather/stats?hours=168" +``` + +### JavaScript (Fetch) + +```javascript +// Aktuelle Wetterdaten +const response = await fetch('http://localhost:8000/weather/current') +const data = await response.json() +console.log(`Temperatur: ${data.temperature}°C`) + +// Tägliche Aggregation für 365 Tage +const yearData = await fetch('http://localhost:8000/weather/daily-aggregated?days=365') +const year = await yearData.json() +console.log(`${year.length} Tage verfügbar`) +``` + +### Python (requests) + +```python +import requests + +# Aktuelle Daten +response = requests.get('http://localhost:8000/weather/current') +data = response.json() +print(f"Temperatur: {data['temperature']}°C") + +# Statistiken +stats = requests.get('http://localhost:8000/weather/stats?hours=24') +print(f"Durchschnittstemperatur: {stats.json()['avg_temperature']}°C") +``` + +--- + +## Entwicklung + +### Abhängigkeiten installieren + +```bash +pip install -r requirements.txt +``` + +### Server starten (Development mit Auto-Reload) + +```bash +uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +### Logging + +Die API verwendet Python's `logging`-Modul. Log-Level: `INFO` + +--- + +## Deployment + +Die API wird als Docker-Container deployed. Siehe `Dockerfile` und `docker-compose.yml` im Hauptverzeichnis. + +### Docker Image bauen + +```bash +docker build -t wetterstation-api ./api +``` + +### Container starten + +```bash +docker run -d \ + -p 8000:8000 \ + -e DB_HOST=db \ + -e DB_USER=wetterstation_user \ + -e DB_PASSWORD= \ + wetterstation-api +``` + +--- + +## Performance-Tipps + +1. **Aggregierte Endpunkte verwenden** für Langzeit-Visualisierungen (reduziert Datenmenge) +2. **Limit-Parameter** nutzen, um nur benötigte Datenmenge abzurufen +3. **Spezifische Endpunkte** verwenden (`/weather/temperature` statt `/weather/history` wenn nur Temperatur benötigt wird) +4. **Caching** auf Client-Seite implementieren für historische Daten + +--- + +## Lizenz + +Siehe Hauptprojekt-Repository. diff --git a/api/main.py b/api/main.py index bb0c4b5..68cc9cf 100644 --- a/api/main.py +++ b/api/main.py @@ -383,24 +383,44 @@ async def get_daily_aggregated_data( conn = get_db_connection() try: with conn.cursor() as cursor: - cursor.execute(""" - SELECT - 0 as id, - date_trunc('day', datetime) as datetime, - AVG(temperature) as temperature, - ROUND(AVG(humidity)) as humidity, - AVG(pressure) as pressure, - AVG(wind_speed * 1.60934) as wind_speed, - MAX(wind_gust * 1.60934) as wind_gust, - AVG(wind_dir) as wind_dir, - AVG(rain) as rain, - AVG(rain_rate) as rain_rate, - MAX(received_at) as received_at - FROM weather_data - WHERE datetime >= NOW() - make_interval(days => %s) - GROUP BY date_trunc('day', datetime) - ORDER BY datetime ASC - """, (days,)) + # Bei 365 Tagen: alle verfügbaren Daten zurückgeben + if days >= 365: + cursor.execute(""" + SELECT + 0 as id, + date_trunc('day', datetime) as datetime, + AVG(temperature) as temperature, + ROUND(AVG(humidity)) as humidity, + AVG(pressure) as pressure, + AVG(wind_speed * 1.60934) as wind_speed, + MAX(wind_gust * 1.60934) as wind_gust, + AVG(wind_dir) as wind_dir, + AVG(rain) as rain, + AVG(rain_rate) as rain_rate, + MAX(received_at) as received_at + FROM weather_data + GROUP BY date_trunc('day', datetime) + ORDER BY datetime ASC + """) + else: + cursor.execute(""" + SELECT + 0 as id, + date_trunc('day', datetime) as datetime, + AVG(temperature) as temperature, + ROUND(AVG(humidity)) as humidity, + AVG(pressure) as pressure, + AVG(wind_speed * 1.60934) as wind_speed, + MAX(wind_gust * 1.60934) as wind_gust, + AVG(wind_dir) as wind_dir, + AVG(rain) as rain, + AVG(rain_rate) as rain_rate, + MAX(received_at) as received_at + FROM weather_data + WHERE datetime >= NOW() - make_interval(days => %s) + GROUP BY date_trunc('day', datetime) + ORDER BY datetime ASC + """, (days,)) results = cursor.fetchall() return [dict(row) for row in results] @@ -440,15 +460,26 @@ async def get_weekly_rain_data( conn = get_db_connection() try: with conn.cursor() as cursor: - cursor.execute(""" - SELECT - date_trunc('week', datetime) as week_start, - SUM(rain) as total_rain - FROM weather_data - WHERE datetime >= NOW() - make_interval(days => %s) - GROUP BY date_trunc('week', datetime) - ORDER BY week_start ASC - """, (days,)) + # Bei 365 Tagen: alle verfügbaren Daten zurückgeben + if days >= 365: + cursor.execute(""" + SELECT + date_trunc('week', datetime) as week_start, + SUM(rain) as total_rain + FROM weather_data + GROUP BY date_trunc('week', datetime) + ORDER BY week_start ASC + """) + else: + cursor.execute(""" + SELECT + date_trunc('week', datetime) as week_start, + SUM(rain) as total_rain + FROM weather_data + WHERE datetime >= NOW() - make_interval(days => %s) + GROUP BY date_trunc('week', datetime) + ORDER BY week_start ASC + """, (days,)) results = cursor.fetchall() return [dict(row) for row in results] diff --git a/data/365.png b/data/365.png new file mode 100644 index 0000000..ac1dac8 Binary files /dev/null and b/data/365.png differ diff --git a/data/wview-archive.sdb b/data/wview-archive.sdb new file mode 100644 index 0000000..574146e Binary files /dev/null and b/data/wview-archive.sdb differ diff --git a/docker-compose_postgres.yml b/docker-compose_postgres.yml new file mode 100644 index 0000000..412785b --- /dev/null +++ b/docker-compose_postgres.yml @@ -0,0 +1,41 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: wetterstation_db + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + ports: + - "${DB_PORT}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + + pgadmin: + image: dpage/pgadmin4:latest + container_name: wetterstation_pgadmin + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@admin.com} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin} + PGADMIN_CONFIG_SERVER_MODE: 'False' + ports: + - "5050:80" + volumes: + - pgadmin_data:/var/lib/pgadmin + depends_on: + - postgres + +volumes: + postgres_data: + driver: local + pgadmin_data: + driver: local diff --git a/frontend/src/components/WeatherDashboard.css b/frontend/src/components/WeatherDashboard.css index 87f20cb..aad46da 100644 --- a/frontend/src/components/WeatherDashboard.css +++ b/frontend/src/components/WeatherDashboard.css @@ -1,6 +1,7 @@ .dashboard { width: 100%; - max-width: 1900px; +/* max-width: 1900px; */ + max-width: 795px; margin: 0 auto; } diff --git a/frontend/src/components/WeatherDashboard.jsx b/frontend/src/components/WeatherDashboard.jsx index eca3208..f1777e6 100644 --- a/frontend/src/components/WeatherDashboard.jsx +++ b/frontend/src/components/WeatherDashboard.jsx @@ -37,6 +37,19 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC } }, [timeRange]) + // Aggregations-Zusatz für Chart-Titel + const aggregationSuffix = useMemo(() => { + switch (timeRange) { + case '7d': + case '30d': + return ' (Stundenmittel)' + case '365d': + return ' (Tagesmittel)' + default: + return '' + } + }, [timeRange]) + // Gemeinsame Chart-Optionen (angepasst an Zeitraum) const getCommonOptions = () => { // X-Achsen-Konfiguration basierend auf Zeitraum @@ -46,20 +59,44 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC gridLineColor: 'rgba(0, 0, 0, 0.1)' } + // Zeitspanne für X-Achse berechnen (für festen Zeitrahmen) + const now = new Date().getTime() + let xAxisMin, xAxisMax + switch (timeRange) { case '24h': xAxisConfig.tickInterval = 4 * 3600 * 1000 // 4 Stunden xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' } + xAxisMin = now - 24 * 3600 * 1000 + xAxisMax = now break case '7d': + xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' } + xAxisMin = now - 7 * 24 * 3600 * 1000 + xAxisMax = now + break case '30d': xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' } + xAxisMin = now - 30 * 24 * 3600 * 1000 + xAxisMax = now break case '365d': - xAxisConfig.labels = { format: '{value:%b}', align: 'center' } + xAxisConfig.labels = { format: '{value:%b %Y}', align: 'center' } + // Bei 365d: Min/Max aus vorhandenen Daten berechnen + if (sortedData.length > 0) { + xAxisMin = new Date(sortedData[0].datetime).getTime() + xAxisMax = new Date(sortedData[sortedData.length - 1].datetime).getTime() + } else { + xAxisMin = null + xAxisMax = null + } break } + // Min/Max für X-Achse setzen + xAxisConfig.min = xAxisMin + xAxisConfig.max = xAxisMax + return { chart: { height: '50%', @@ -105,7 +142,7 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC // Temperatur Chart const temperatureOptions = useMemo(() => { - const temps = sortedData.map(item => item.temperature) + const temps = sortedData.filter(item => item.temperature != null).map(item => item.temperature) const min = Math.min(...temps) const max = Math.max(...temps) const range = max - min @@ -129,7 +166,7 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC }, series: [{ name: 'Temperatur', - data: sortedData.map(item => [new Date(item.datetime).getTime(), item.temperature]), + data: sortedData.filter(item => item.temperature != null).map(item => [new Date(item.datetime).getTime(), item.temperature]), color: 'rgb(255, 99, 132)', fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, @@ -140,12 +177,15 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC }, type: 'areaspline', threshold: null, + connectNulls: false, + gapSize: 2 * 24 * 3600 * 1000, + gapUnit: 'value', tooltip: { valueSuffix: ' °C' } }] } - }, [sortedData]) + }, [sortedData, aggregationSuffix]) // Luftfeuchtigkeit Chart const humidityOptions = useMemo(() => ({ @@ -158,7 +198,7 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC }, series: [{ name: 'Feuchte', - data: sortedData.map(item => [new Date(item.datetime).getTime(), item.humidity]), + data: sortedData.filter(item => item.humidity != null).map(item => [new Date(item.datetime).getTime(), item.humidity]), color: 'rgb(54, 162, 235)', fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, @@ -168,6 +208,9 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC ] }, type: 'area', + connectNulls: false, + gapSize: 2 * 24 * 3600 * 1000, + gapUnit: 'value', tooltip: { valueSuffix: ' %' } @@ -176,7 +219,7 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC // Luftdruck Chart const pressureOptions = useMemo(() => { - const pressures = sortedData.map(item => item.pressure) + const pressures = sortedData.filter(item => item.pressure != null).map(item => item.pressure) const min = Math.min(...pressures) const max = Math.max(...pressures) const range = max - min @@ -200,7 +243,7 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC }, series: [{ name: 'Luftdruck', - data: sortedData.map(item => [new Date(item.datetime).getTime(), item.pressure]), + data: sortedData.filter(item => item.pressure != null).map(item => [new Date(item.datetime).getTime(), item.pressure]), color: 'rgb(75, 192, 192)', fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, @@ -210,6 +253,9 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC ] }, type: 'area', + connectNulls: false, + gapSize: 2 * 24 * 3600 * 1000, + gapUnit: 'value', tooltip: { valueSuffix: ' hPa' } @@ -227,7 +273,7 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC yAxisTitle = 'Regen (mm) / Rate (mm/h)' series = [{ name: 'Regen', - data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain]), + data: sortedData.filter(item => item.rain != null).map(item => [new Date(item.datetime).getTime(), item.rain]), color: 'rgb(54, 162, 235)', fillColor: 'rgba(54, 162, 235, 0.3)', type: 'area', @@ -236,7 +282,7 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC } }, { name: 'Regenrate', - data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain_rate]), + data: sortedData.filter(item => item.rain_rate != null).map(item => [new Date(item.datetime).getTime(), item.rain_rate]), color: 'rgb(59, 130, 246)', dashStyle: 'Dash', type: 'line', @@ -249,7 +295,7 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC yAxisTitle = 'Regen (mm pro Tag)' series = [{ name: 'Regen', - data: rainData.map(item => [new Date(item.date).getTime(), item.total_rain || 0]), + data: rainData.filter(item => item.total_rain != null && item.total_rain > 0).map(item => [new Date(item.date).getTime(), item.total_rain]), color: 'rgb(54, 162, 235)', type: 'column', tooltip: { @@ -261,7 +307,7 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC yAxisTitle = 'Regen (mm pro Woche)' series = [{ name: 'Regen', - data: rainData.map(item => [new Date(item.week_start).getTime(), item.total_rain || 0]), + data: rainData.filter(item => item.total_rain != null && item.total_rain > 0).map(item => [new Date(item.week_start).getTime(), item.total_rain]), color: 'rgb(54, 162, 235)', type: 'column', tooltip: { @@ -281,48 +327,79 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC }, [sortedData, rainData, timeRange]) // Windgeschwindigkeit Chart - const windSpeedOptions = useMemo(() => ({ - ...getCommonOptions(), - plotOptions: { - series: { - marker: { - enabled: false + const windSpeedOptions = useMemo(() => { + // Bei 365d nur Windgeschwindigkeit, keine Böen + const series = timeRange === '365d' + ? [{ + name: 'Windgeschwindigkeit', + data: sortedData + .filter(item => item.wind_speed != null) + .map(item => [new Date(item.datetime).getTime(), item.wind_speed]), + color: 'rgb(153, 102, 255)', + fillColor: 'rgba(153, 102, 255, 0.1)', + type: 'area', + connectNulls: false, + gapSize: 2 * 24 * 3600 * 1000, + gapUnit: 'value', + tooltip: { + valueSuffix: ' km/h' + } + }] + : [{ + name: 'Windgeschwindigkeit', + data: sortedData + .filter(item => item.wind_speed != null) + .map(item => [new Date(item.datetime).getTime(), item.wind_speed]), + color: 'rgb(153, 102, 255)', + fillColor: 'rgba(153, 102, 255, 0.1)', + type: 'area', + connectNulls: false, + gapSize: 2 * 24 * 3600 * 1000, + gapUnit: 'value', + tooltip: { + valueSuffix: ' km/h' + } + }, { + name: 'Windböen', + data: sortedData + .filter(item => item.wind_gust != null) + .map(item => [new Date(item.datetime).getTime(), item.wind_gust]), + color: 'rgb(255, 159, 64)', + fillColor: 'rgba(255, 159, 64, 0.1)', + type: 'area', + connectNulls: false, + gapSize: 2 * 24 * 3600 * 1000, + gapUnit: 'value', + tooltip: { + valueSuffix: ' km/h' + } + }] + + return { + ...getCommonOptions(), + plotOptions: { + series: { + marker: { + enabled: false + }, + lineWidth: 2 }, - lineWidth: 2 - }, - line: { - step: 'left' // Keine Glättung - } - }, - yAxis: { - ...getCommonOptions().yAxis, - title: { - text: 'Windspeed (km/h)', - style: { - whiteSpace: 'nowrap' + line: { + step: 'left' // Keine Glättung } - } - }, - series: [{ - name: 'Windgeschwindigkeit', - data: sortedData.map(item => [new Date(item.datetime).getTime(), item.wind_speed]), - color: 'rgb(153, 102, 255)', - fillColor: 'rgba(153, 102, 255, 0.1)', - type: 'area', - tooltip: { - valueSuffix: ' km/h' - } - }, { - name: 'Windböen', - data: sortedData.map(item => [new Date(item.datetime).getTime(), item.wind_gust]), - color: 'rgb(255, 159, 64)', - fillColor: 'rgba(255, 159, 64, 0.1)', - type: 'area', - tooltip: { - valueSuffix: ' km/h' - } - }] - }), [sortedData]) + }, + yAxis: { + ...getCommonOptions().yAxis, + title: { + text: 'Windspeed (km/h)', + style: { + whiteSpace: 'nowrap' + } + } + }, + series + } + }, [sortedData, timeRange]) // Windrichtung Chart const windDirOptions = useMemo(() => ({ @@ -359,7 +436,7 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC }, series: [{ name: 'Windrichtung', - data: sortedData.map(item => [new Date(item.datetime).getTime(), item.wind_dir]), + data: sortedData.filter(item => item.wind_dir != null).map(item => [new Date(item.datetime).getTime(), item.wind_dir]), color: 'rgb(54, 162, 235)', type: 'scatter', tooltip: { @@ -465,7 +542,7 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC {/* Charts Grid */}
-

🌡️ Temperatur - Aktuell: {current.temperature?.toFixed(1) || '-'}°C

+

🌡️ Temperatur{aggregationSuffix} - Aktuell: {current.temperature?.toFixed(1) || '-'}°C

@@ -475,7 +552,7 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC
-

🌐 Luftdruck - Aktuell: {current.pressure?.toFixed(1) || '-'} hPa

+

🌐 Luftdruck{aggregationSuffix} - Aktuell: {current.pressure?.toFixed(1) || '-'} hPa

@@ -485,7 +562,7 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC
-

💧 Luftfeuchtigkeit - Aktuell: {current.humidity || '-'}%

+

💧 Luftfeuchtigkeit{aggregationSuffix} - Aktuell: {current.humidity || '-'}%

@@ -495,21 +572,21 @@ const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeC
-

🌧️ Regen - Aktuell: {current.rain?.toFixed(1) || '-'} mm

+

🌧️ Regen{aggregationSuffix} - Aktuell: {current.rain?.toFixed(1) || '-'} mm

-

🧭 Windrichtung - Aktuell: {current.wind_dir ?? '-'}°

+

🧭 Windrichtung{aggregationSuffix} - Aktuell: {current.wind_dir ?? '-'}°

-

💨 Windspeed - Aktuell: {current.wind_speed?.toFixed(1) || '-'} km/h

+

💨 Windspeed{aggregationSuffix} - Aktuell: {current.wind_speed?.toFixed(1) || '-'} km/h

diff --git a/migrate_sqlite_to_postgres.py b/migrate_sqlite_to_postgres.py new file mode 100755 index 0000000..fe803dc --- /dev/null +++ b/migrate_sqlite_to_postgres.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +Migration Tool: SQLite (wview) → PostgreSQL (wetterstation) +Migriert Wetterdaten vom 1.1.2025 bis heute +""" + +import sqlite3 +import psycopg +from datetime import datetime, timezone +import os +from pathlib import Path +from dotenv import load_dotenv +import sys + +# Umgebungsvariablen laden +env_path = Path(__file__).parent / '.env' +load_dotenv(dotenv_path=env_path) + +# Konfiguration +SQLITE_DB = "data/wview-archive.sdb" +START_DATE = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc) +END_DATE = datetime(2026, 2, 8, 0, 0, 0, tzinfo=timezone.utc) + +# PostgreSQL-Konfiguration +DB_HOST = os.getenv('DB_HOST', 'localhost') +DB_PORT = int(os.getenv('DB_PORT', 5432)) +DB_NAME = os.getenv('DB_NAME', 'wetterstation') +DB_USER = os.getenv('DB_USER') +DB_PASSWORD = os.getenv('DB_PASSWORD') + +# Soll die Tabelle vorher geleert werden? +TRUNCATE_TABLE = False # Auf False setzen, um vorhandene Daten zu behalten + +# Konvertierungsfunktionen +def fahrenheit_to_celsius(f): + """Fahrenheit → Celsius""" + if f is None: + return None + return (f - 32) * 5 / 9 + +def inches_hg_to_hpa(inhg): + """inches Hg → hPa""" + if inhg is None: + return None + return inhg * 33.8639 + +def mph_to_kmh(mph): + """mph → km/h""" + if mph is None: + return None + return mph * 1.60934 + +def inches_to_mm(inches): + """inches → mm""" + if inches is None: + return None + return inches * 25.4 + +def unix_to_datetime(timestamp): + """Unix timestamp → datetime""" + return datetime.fromtimestamp(timestamp, tz=timezone.utc) + + +def main(): + print("=" * 60) + print("SQLite → PostgreSQL Migration") + print("=" * 60) + print(f"Quelle: {SQLITE_DB}") + print(f"Zeitraum: {START_DATE.date()} bis {END_DATE.date()}") + print(f"Ziel: PostgreSQL ({DB_HOST}:{DB_PORT}/{DB_NAME})") + print("=" * 60) + print() + + # SQLite öffnen + try: + sqlite_conn = sqlite3.connect(SQLITE_DB) + sqlite_cursor = sqlite_conn.cursor() + print("✓ SQLite-Verbindung hergestellt") + except Exception as e: + print(f"✗ Fehler beim Öffnen der SQLite-Datenbank: {e}") + sys.exit(1) + + # PostgreSQL öffnen + try: + pg_conn = psycopg.connect( + host=DB_HOST, + port=DB_PORT, + dbname=DB_NAME, + user=DB_USER, + password=DB_PASSWORD + ) + pg_cursor = pg_conn.cursor() + print("✓ PostgreSQL-Verbindung hergestellt") + except Exception as e: + print(f"✗ Fehler beim Verbinden mit PostgreSQL: {e}") + sqlite_conn.close() + sys.exit(1) + + # Tabelle leeren falls gewünscht + if TRUNCATE_TABLE: + print("\nLeere PostgreSQL-Tabelle weather_data...") + try: + pg_cursor.execute("TRUNCATE TABLE weather_data RESTART IDENTITY CASCADE") + pg_conn.commit() + print("✓ Tabelle geleert") + except Exception as e: + print(f"✗ Fehler beim Leeren der Tabelle: {e}") + sqlite_conn.close() + pg_conn.close() + sys.exit(1) + + # Zeitraum in Unix timestamps umrechnen + start_ts = int(START_DATE.timestamp()) + end_ts = int(END_DATE.timestamp()) + + # Daten aus SQLite laden + print(f"\nLade Daten aus SQLite (Zeitraum: {start_ts} - {end_ts})...") + sqlite_cursor.execute(""" + SELECT + dateTime, + outTemp, + outHumidity, + barometer, + windSpeed, + windGust, + windDir, + rain, + rainRate + FROM archive + WHERE dateTime >= ? AND dateTime <= ? + ORDER BY dateTime ASC + """, (start_ts, end_ts)) + + rows = sqlite_cursor.fetchall() + print(f"✓ {len(rows)} Datensätze gefunden") + + if len(rows) == 0: + print("Keine Daten im angegebenen Zeitraum gefunden.") + sqlite_conn.close() + pg_conn.close() + return + + # Migration durchführen + print("\nMigriere Daten...") + inserted = 0 + skipped = 0 + errors = 0 + + for row in rows: + try: + (dateTime, outTemp, outHumidity, barometer, + windSpeed, windGust, windDir, rain, rainRate) = row + + # Konvertierungen + dt = unix_to_datetime(dateTime) + temp_c = fahrenheit_to_celsius(outTemp) + humidity = int(outHumidity) if outHumidity is not None else None + pressure_hpa = inches_hg_to_hpa(barometer) + wind_speed_kmh = mph_to_kmh(windSpeed) + wind_gust_kmh = mph_to_kmh(windGust) + rain_mm = inches_to_mm(rain) + rain_rate_mm = inches_to_mm(rainRate) + + # In PostgreSQL einfügen + pg_cursor.execute(""" + INSERT INTO weather_data + (datetime, temperature, humidity, pressure, + wind_speed, wind_gust, wind_dir, rain, rain_rate) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (datetime) DO NOTHING + """, (dt, temp_c, humidity, pressure_hpa, + wind_speed_kmh, wind_gust_kmh, windDir, rain_mm, rain_rate_mm)) + + if pg_cursor.rowcount > 0: + inserted += 1 + if inserted % 1000 == 0: + pg_conn.commit() + print(f" {inserted} Datensätze eingefügt...") + else: + skipped += 1 + + except Exception as e: + errors += 1 + if errors <= 5: # Zeige nur die ersten 5 Fehler + print(f" Fehler bei Datensatz {dateTime}: {e}") + + # Commit verbleibende Daten + pg_conn.commit() + + # Zusammenfassung + print("\n" + "=" * 60) + print("Migration abgeschlossen!") + print("=" * 60) + print(f"Eingefügt: {inserted} Datensätze") + print(f"Übersprungen: {skipped} Datensätze (bereits vorhanden)") + print(f"Fehler: {errors} Datensätze") + print("=" * 60) + + # Zeitraum der migrierten Daten anzeigen + if inserted > 0: + pg_cursor.execute(""" + SELECT MIN(datetime), MAX(datetime), COUNT(*) + FROM weather_data + WHERE datetime >= %s AND datetime <= %s + """, (START_DATE, END_DATE)) + min_dt, max_dt, count = pg_cursor.fetchone() + print(f"\nDaten in PostgreSQL:") + print(f" Von: {min_dt}") + print(f" Bis: {max_dt}") + print(f" Gesamt: {count} Datensätze") + + # Verbindungen schließen + sqlite_conn.close() + pg_conn.close() + print("\n✓ Fertig!") + + +if __name__ == "__main__": + main()