Weather Station / ECOWITT / DNT
0

Configure Feed

Select the types of activity you want to include in your feed.

Forecast Analysis System

+933 -22
+95
README.md
··· 296 296 - Uses free 5 Day / 3 Hour Forecast API (aggregated to daily) 297 297 - Requires `OPENWEATHER_API_KEY` in `.env` (free API key from openweathermap.org) 298 298 - Station selection dropdown grouped by Austrian states (Bundesländer) 299 + - Uses `FORECAST_STATION_ID` as default station if no station selected 299 300 - Last selected station is saved in localStorage 301 + - **Analyse (Analysis)**: Forecast accuracy analysis comparing predictions with actual weather data. 302 + - Compares stored forecasts from all 4 sources with real-time measurements 303 + - Shows Mean Absolute Error (MAE) and Root Mean Square Error (RMSE) for temperature, precipitation, and wind 304 + - Daily comparison details with error highlighting 305 + - Configurable time range (7-60 days) and station selection 306 + - Automatically stores forecasts daily via server background poller (runs on startup + every 24h) 307 + - Station ID configured via `FORECAST_STATION_ID` environment variable (default: 11035 - Wien) 308 + - Same station ID is used for all forecast sources (Geosphere, Meteoblue, Open-Meteo, OpenWeatherMap) 300 309 - Color-coded display: red for max temperature, blue for min temperature and precipitation 301 310 302 311 ### Backend Realtime Processing ··· 487 496 488 497 ```ts 489 498 const rt = await fetch('/api/rt/last').then(r => r.json()); 499 + ``` 500 + 501 + ### Forecast Storage & Analysis 502 + 503 + Forecasts are automatically stored daily by the server background poller (configured via `FORECAST_STATION_ID` in `.env`). 504 + 505 + - Manually store forecasts for a station (POST) 506 + 507 + ```bash 508 + curl -X POST 'http://localhost:3000/api/forecast/store' \ 509 + -H 'Content-Type: application/json' \ 510 + -d '{"stationId": "11035"}' 511 + ``` 512 + 513 + - Compare forecasts with actual data (GET) 514 + 515 + ```bash 516 + curl 'http://localhost:3000/api/forecast/compare?stationId=11035&days=30' 517 + curl 'http://localhost:3000/api/forecast/compare?stationId=11230&days=7' 518 + ``` 519 + 520 + Response format for comparison: 521 + 522 + ```json 523 + { 524 + "stationId": "11035", 525 + "days": 30, 526 + "data": { 527 + "dailyComparisons": [ 528 + { 529 + "date": "2025-01-15", 530 + "actual": { 531 + "tempMin": -2.3, 532 + "tempMax": 4.1, 533 + "precipitation": 0.0, 534 + "windSpeed": 12.5 535 + }, 536 + "forecasts": { 537 + "geosphere": { 538 + "tempMin": -1.8, 539 + "tempMax": 3.9, 540 + "precipitation": 0.2, 541 + "windSpeed": 11.2 542 + }, 543 + "openweather": { 544 + "tempMin": -2.1, 545 + "tempMax": 4.3, 546 + "precipitation": 0.0, 547 + "windSpeed": 13.1 548 + } 549 + }, 550 + "errors": { 551 + "geosphere": { 552 + "tempMinError": 0.5, 553 + "tempMaxError": 0.2, 554 + "precipitationError": 0.2, 555 + "windSpeedError": 1.3 556 + }, 557 + "openweather": { 558 + "tempMinError": 0.2, 559 + "tempMaxError": 0.2, 560 + "precipitationError": 0.0, 561 + "windSpeedError": 0.6 562 + } 563 + } 564 + } 565 + ], 566 + "accuracyStats": { 567 + "geosphere": { 568 + "sampleSize": 25, 569 + "tempMin": { "mae": 0.8, "rmse": 1.1 }, 570 + "tempMax": { "mae": 0.7, "rmse": 0.9 }, 571 + "precipitation": { "mae": 0.3, "rmse": 0.5 }, 572 + "windSpeed": { "mae": 2.1, "rmse": 2.8 } 573 + }, 574 + "openweather": { 575 + "sampleSize": 25, 576 + "tempMin": { "mae": 0.6, "rmse": 0.8 }, 577 + "tempMax": { "mae": 0.5, "rmse": 0.7 }, 578 + "precipitation": { "mae": 0.2, "rmse": 0.4 }, 579 + "windSpeed": { "mae": 1.8, "rmse": 2.3 } 580 + } 581 + } 582 + }, 583 + "generated": "2025-01-16T10:30:00.000Z" 584 + } 490 585 ``` 491 586 492 587 ### Data (CSV/DuckDB-backed)
+6
env.example
··· 14 14 ## Meteoblue API Key for 7-14 day forecast (FREE - get key at https://www.meteoblue.com/en/weather-api) 15 15 ## Register for free, confirm non-commercial use, and get instant access 16 16 METEOBLUE_API_KEY=your_api_key_here 17 + 18 + ## Forecast Station ID for daily automatic forecast storage (default: 11035 - Wien Hohe Warte) 19 + ## This station is used for all forecast sources (Geosphere, Meteoblue, Open-Meteo, OpenWeatherMap) 20 + ## Find station IDs at: https://dataset.api.hub.geosphere.at/v1/station/current/tawes-v1-10min/metadata 21 + ## Popular stations: 11035 (Wien), 11120 (Innsbruck), 11320 (Graz), 11150 (Salzburg), 11240 (Klagenfurt) 22 + FORECAST_STATION_ID=11035
+12
src/app/api/config/forecast-station/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + 3 + export const runtime = "nodejs"; 4 + 5 + /** 6 + * API route to get the default forecast station ID from environment 7 + * GET /api/config/forecast-station 8 + */ 9 + export async function GET() { 10 + const stationId = process.env.FORECAST_STATION_ID || "11035"; 11 + return NextResponse.json({ stationId }); 12 + }
+203
src/app/api/forecast/compare/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + import { getDuckConn } from "@/lib/db/duckdb"; 3 + 4 + export const runtime = "nodejs"; 5 + 6 + /** 7 + * API route to compare stored forecasts with actual weather data 8 + * GET /api/forecast/compare?stationId=11035&days=30 9 + * 10 + * Returns accuracy analysis for each forecast source 11 + */ 12 + export async function GET(req: Request) { 13 + try { 14 + const { searchParams } = new URL(req.url); 15 + const stationId = searchParams.get("stationId"); 16 + const days = parseInt(searchParams.get("days") || "30"); 17 + 18 + if (!stationId) { 19 + return NextResponse.json({ error: "stationId parameter is required" }, { status: 400 }); 20 + } 21 + 22 + const conn = await getDuckConn(); 23 + 24 + // Get comparison data for the last N days 25 + const comparisonData = await getForecastComparison(conn, stationId, days); 26 + 27 + return NextResponse.json({ 28 + stationId, 29 + days, 30 + data: comparisonData, 31 + generated: new Date().toISOString() 32 + }); 33 + 34 + } catch (error: any) { 35 + console.error("Forecast comparison error:", error); 36 + return NextResponse.json({ error: error.message }, { status: 500 }); 37 + } 38 + } 39 + 40 + /** 41 + * Get forecast vs actual data comparison 42 + */ 43 + async function getForecastComparison(conn: any, stationId: string, days: number) { 44 + // Get actual weather data from the main table 45 + const actualQuery = ` 46 + SELECT 47 + DATE(time) as date, 48 + MIN(CAST(CASE WHEN tempf LIKE '%[^0-9.]%' THEN NULL ELSE tempf END AS DOUBLE)) as temp_min_f, 49 + MAX(CAST(CASE WHEN tempf LIKE '%[^0-9.]%' THEN NULL ELSE tempf END AS DOUBLE)) as temp_max_f, 50 + SUM(CAST(CASE WHEN rain_rate_in LIKE '%[^0-9.]%' THEN NULL ELSE rain_rate_in END AS DOUBLE)) as precipitation_in, 51 + AVG(CAST(CASE WHEN windspeedmph LIKE '%[^0-9.]%' THEN NULL ELSE windspeedmph END AS DOUBLE)) as wind_speed_mph 52 + FROM weather_data 53 + WHERE station_id = ? 54 + AND DATE(time) >= DATE('now', '-${days} days') 55 + AND DATE(time) < DATE('now') 56 + GROUP BY DATE(time) 57 + ORDER BY date DESC 58 + `; 59 + 60 + const actualData = await conn.all(actualQuery, [stationId]); 61 + 62 + // Convert units to match forecast units (°C, mm, km/h) 63 + const actualDataConverted = actualData.map((row: any) => ({ 64 + date: row.date, 65 + tempMin: row.temp_min_f !== null ? (row.temp_min_f - 32) * 5/9 : null, 66 + tempMax: row.temp_max_f !== null ? (row.temp_max_f - 32) * 5/9 : null, 67 + precipitation: row.precipitation_in !== null ? row.precipitation_in * 25.4 : null, 68 + windSpeed: row.wind_speed_mph !== null ? row.wind_speed_mph * 1.60934 : null 69 + })); 70 + 71 + // Get stored forecasts for comparison 72 + const forecastQuery = ` 73 + SELECT 74 + forecast_date, 75 + source, 76 + temp_min, 77 + temp_max, 78 + precipitation, 79 + wind_speed 80 + FROM forecasts 81 + WHERE station_id = ? 82 + AND forecast_date >= DATE('now', '-${days} days') 83 + AND forecast_date < DATE('now') 84 + ORDER BY forecast_date DESC, source 85 + `; 86 + 87 + const forecastData = await conn.all(forecastQuery, [stationId]); 88 + 89 + // Group forecasts by date and source 90 + const forecastsByDate: Record<string, Record<string, any>> = {}; 91 + 92 + forecastData.forEach((row: any) => { 93 + const date = row.forecast_date; 94 + if (!forecastsByDate[date]) { 95 + forecastsByDate[date] = {}; 96 + } 97 + forecastsByDate[date][row.source] = row; 98 + }); 99 + 100 + // Compare actual vs forecast data 101 + const comparisons = []; 102 + 103 + for (const actual of actualDataConverted) { 104 + const date = actual.date; 105 + const forecasts = forecastsByDate[date]; 106 + 107 + if (forecasts) { 108 + const comparison: any = { 109 + date, 110 + actual: { 111 + tempMin: actual.tempMin, 112 + tempMax: actual.tempMax, 113 + precipitation: actual.precipitation, 114 + windSpeed: actual.windSpeed 115 + }, 116 + forecasts: {}, 117 + errors: {} 118 + }; 119 + 120 + // Compare each forecast source 121 + ['geosphere', 'openweather', 'meteoblue', 'openmeteo'].forEach(source => { 122 + if (forecasts[source]) { 123 + const forecast = forecasts[source]; 124 + comparison.forecasts[source] = { 125 + tempMin: forecast.temp_min, 126 + tempMax: forecast.temp_max, 127 + precipitation: forecast.precipitation, 128 + windSpeed: forecast.wind_speed 129 + }; 130 + 131 + // Calculate errors 132 + comparison.errors[source] = { 133 + tempMinError: calculateError(actual.tempMin, forecast.temp_min), 134 + tempMaxError: calculateError(actual.tempMax, forecast.temp_max), 135 + precipitationError: calculateError(actual.precipitation, forecast.precipitation), 136 + windSpeedError: calculateError(actual.windSpeed, forecast.wind_speed) 137 + }; 138 + } 139 + }); 140 + 141 + comparisons.push(comparison); 142 + } 143 + } 144 + 145 + // Calculate overall accuracy statistics 146 + const accuracyStats = calculateAccuracyStats(comparisons); 147 + 148 + return { 149 + dailyComparisons: comparisons, 150 + accuracyStats 151 + }; 152 + } 153 + 154 + /** 155 + * Calculate error between actual and forecast values 156 + */ 157 + function calculateError(actual: number | null, forecast: number | null): number | null { 158 + if (actual === null || forecast === null) return null; 159 + return Math.abs(actual - forecast); 160 + } 161 + 162 + /** 163 + * Calculate overall accuracy statistics for each forecast source 164 + */ 165 + function calculateAccuracyStats(comparisons: any[]) { 166 + const sources = ['geosphere', 'openweather', 'meteoblue', 'openmeteo']; 167 + const stats: Record<string, any> = {}; 168 + 169 + sources.forEach(source => { 170 + const errors = comparisons 171 + .map(c => c.errors[source]) 172 + .filter(e => e !== undefined); 173 + 174 + if (errors.length > 0) { 175 + const tempMinErrors = errors.map(e => e.tempMinError).filter(e => e !== null); 176 + const tempMaxErrors = errors.map(e => e.tempMaxError).filter(e => e !== null); 177 + const precipitationErrors = errors.map(e => e.precipitationError).filter(e => e !== null); 178 + const windSpeedErrors = errors.map(e => e.windSpeedError).filter(e => e !== null); 179 + 180 + stats[source] = { 181 + sampleSize: errors.length, 182 + tempMin: { 183 + mae: tempMinErrors.length > 0 ? tempMinErrors.reduce((sum, e) => sum + e, 0) / tempMinErrors.length : null, 184 + rmse: tempMinErrors.length > 0 ? Math.sqrt(tempMinErrors.reduce((sum, e) => sum + e*e, 0) / tempMinErrors.length) : null 185 + }, 186 + tempMax: { 187 + mae: tempMaxErrors.length > 0 ? tempMaxErrors.reduce((sum, e) => sum + e, 0) / tempMaxErrors.length : null, 188 + rmse: tempMaxErrors.length > 0 ? Math.sqrt(tempMaxErrors.reduce((sum, e) => sum + e*e, 0) / tempMaxErrors.length) : null 189 + }, 190 + precipitation: { 191 + mae: precipitationErrors.length > 0 ? precipitationErrors.reduce((sum, e) => sum + e, 0) / precipitationErrors.length : null, 192 + rmse: precipitationErrors.length > 0 ? Math.sqrt(precipitationErrors.reduce((sum, e) => sum + e*e, 0) / precipitationErrors.length) : null 193 + }, 194 + windSpeed: { 195 + mae: windSpeedErrors.length > 0 ? windSpeedErrors.reduce((sum, e) => sum + e, 0) / windSpeedErrors.length : null, 196 + rmse: windSpeedErrors.length > 0 ? Math.sqrt(windSpeedErrors.reduce((sum, e) => sum + e*e, 0) / windSpeedErrors.length) : null 197 + } 198 + }; 199 + } 200 + }); 201 + 202 + return stats; 203 + }
+170
src/app/api/forecast/store/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + import { getDuckConn } from "@/lib/db/duckdb"; 3 + 4 + export const runtime = "nodejs"; 5 + 6 + /** 7 + * API route to store forecast data from all sources daily 8 + * POST /api/forecast/store 9 + * Body: { stationId: string } 10 + * 11 + * This endpoint fetches forecasts from all 4 sources and stores them in DuckDB 12 + * for later comparison with actual weather data 13 + */ 14 + export async function POST(req: Request) { 15 + try { 16 + const { stationId } = await req.json(); 17 + 18 + if (!stationId) { 19 + return NextResponse.json({ error: "stationId is required" }, { status: 400 }); 20 + } 21 + 22 + // Fetch forecasts from all 4 sources 23 + const forecastPromises = [ 24 + fetchForecastData('forecast', stationId), 25 + fetchForecastData('openweather', stationId), 26 + fetchForecastData('meteoblue', stationId), 27 + fetchForecastData('openmeteo', stationId) 28 + ]; 29 + 30 + const [geosphereData, openweatherData, meteoblueData, openmeteoData] = await Promise.allSettled(forecastPromises); 31 + 32 + // Store in DuckDB 33 + const conn = await getDuckConn(); 34 + const storageDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD 35 + 36 + // Create forecast table if not exists 37 + await conn.run(` 38 + CREATE TABLE IF NOT EXISTS forecasts ( 39 + id INTEGER PRIMARY KEY, 40 + storage_date DATE NOT NULL, 41 + station_id VARCHAR(50) NOT NULL, 42 + forecast_date DATE NOT NULL, 43 + source VARCHAR(20) NOT NULL, 44 + temp_min DOUBLE, 45 + temp_max DOUBLE, 46 + precipitation DOUBLE, 47 + wind_speed DOUBLE, 48 + wind_gust DOUBLE, 49 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 50 + UNIQUE(storage_date, station_id, forecast_date, source) 51 + ) 52 + `); 53 + 54 + // Insert forecast data from each source 55 + const insertPromises = []; 56 + 57 + // Process Geosphere forecast 58 + if (geosphereData.status === 'fulfilled' && geosphereData.value.forecast) { 59 + const dailyData = aggregateHourlyToDaily(geosphereData.value.forecast); 60 + for (const day of dailyData) { 61 + insertPromises.push( 62 + conn.run(` 63 + INSERT OR REPLACE INTO forecasts 64 + (storage_date, station_id, forecast_date, source, temp_min, temp_max, precipitation, wind_speed) 65 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 66 + `, [storageDate, stationId, day.date, 'geosphere', day.tempMin, day.tempMax, day.precipitation, day.windSpeed]) 67 + ); 68 + } 69 + } 70 + 71 + // Process OpenWeatherMap forecast 72 + if (openweatherData.status === 'fulfilled' && openweatherData.value.forecast) { 73 + for (const day of openweatherData.value.forecast) { 74 + insertPromises.push( 75 + conn.run(` 76 + INSERT OR REPLACE INTO forecasts 77 + (storage_date, station_id, forecast_date, source, temp_min, temp_max, precipitation, wind_speed, wind_gust) 78 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 79 + `, [storageDate, stationId, day.date, 'openweather', day.tempMin, day.tempMax, day.precipitation, day.windSpeed, day.windGust]) 80 + ); 81 + } 82 + } 83 + 84 + // Process Meteoblue forecast 85 + if (meteoblueData.status === 'fulfilled' && meteoblueData.value.forecast) { 86 + for (const day of meteoblueData.value.forecast) { 87 + insertPromises.push( 88 + conn.run(` 89 + INSERT OR REPLACE INTO forecasts 90 + (storage_date, station_id, forecast_date, source, temp_min, temp_max, precipitation, wind_speed, wind_gust) 91 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 92 + `, [storageDate, stationId, day.date, 'meteoblue', day.tempMin, day.tempMax, day.precipitation, day.windSpeed, day.windGust]) 93 + ); 94 + } 95 + } 96 + 97 + // Process Open-Meteo forecast 98 + if (openmeteoData.status === 'fulfilled' && openmeteoData.value.forecast) { 99 + for (const day of openmeteoData.value.forecast) { 100 + insertPromises.push( 101 + conn.run(` 102 + INSERT OR REPLACE INTO forecasts 103 + (storage_date, station_id, forecast_date, source, temp_min, temp_max, precipitation, wind_speed, wind_gust) 104 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 105 + `, [storageDate, stationId, day.date, 'openmeteo', day.tempMin, day.tempMax, day.precipitation, day.windSpeed, day.windGust]) 106 + ); 107 + } 108 + } 109 + 110 + await Promise.all(insertPromises); 111 + 112 + return NextResponse.json({ 113 + success: true, 114 + message: `Stored forecasts for station ${stationId} on ${storageDate}`, 115 + sources: { 116 + geosphere: geosphereData.status === 'fulfilled', 117 + openweather: openweatherData.status === 'fulfilled', 118 + meteoblue: meteoblueData.status === 'fulfilled', 119 + openmeteo: openmeteoData.status === 'fulfilled' 120 + } 121 + }); 122 + 123 + } catch (error: any) { 124 + console.error("Forecast storage error:", error); 125 + return NextResponse.json({ error: error.message }, { status: 500 }); 126 + } 127 + } 128 + 129 + /** 130 + * Fetch forecast data from existing forecast API 131 + */ 132 + async function fetchForecastData(action: string, stationId: string) { 133 + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; 134 + const response = await fetch(`${baseUrl}/api/forecast?action=${action}&stationId=${stationId}`); 135 + 136 + if (!response.ok) { 137 + throw new Error(`Failed to fetch ${action} forecast: ${response.statusText}`); 138 + } 139 + 140 + return response.json(); 141 + } 142 + 143 + /** 144 + * Aggregate hourly forecast data to daily values (for Geosphere) 145 + */ 146 + function aggregateHourlyToDaily(hourlyData: any[]): any[] { 147 + const dailyMap: Record<string, any[]> = {}; 148 + 149 + hourlyData.forEach(item => { 150 + const date = new Date(item.time).toISOString().split('T')[0]; 151 + if (!dailyMap[date]) { 152 + dailyMap[date] = []; 153 + } 154 + dailyMap[date].push(item); 155 + }); 156 + 157 + return Object.entries(dailyMap).map(([date, items]) => { 158 + const temps = items.map(i => i.temperature).filter(t => t !== null); 159 + const precipitations = items.map(i => i.precipitation).filter(p => p !== null); 160 + const windSpeeds = items.map(i => i.windSpeed).filter(w => w !== null); 161 + 162 + return { 163 + date, 164 + tempMin: temps.length > 0 ? Math.min(...temps) : null, 165 + tempMax: temps.length > 0 ? Math.max(...temps) : null, 166 + precipitation: precipitations.length > 0 ? precipitations.reduce((sum, p) => sum + p, 0) : 0, 167 + windSpeed: windSpeeds.length > 0 ? windSpeeds.reduce((sum, w) => sum + w, 0) / windSpeeds.length : null 168 + }; 169 + }); 170 + }
+12 -3
src/app/page.tsx
··· 6 6 import Gauges from "@/components/Gauges"; 7 7 import Statistics from "@/components/Statistics"; 8 8 import Forecast from "@/components/Forecast"; 9 + import ForecastAnalysis from "@/components/ForecastAnalysis"; 9 10 import { useTranslation } from "react-i18next"; 10 11 import LanguageSwitcher from "@/components/LanguageSwitcher"; 11 12 import { RealtimeProvider } from "@/contexts/RealtimeContext"; ··· 15 16 * It provides a tabbed interface to switch between different views: 16 17 * - Realtime: A list of current sensor readings. 17 18 * - Graphics: A set of gauges and visual displays for current data. 19 + * - Forecast: 7-day weather forecast from multiple APIs. 20 + * - Analysis: Forecast accuracy analysis comparing predictions with actual data. 18 21 * - Saved: A dashboard for viewing historical data with charts. 19 22 * - Statistics: Statistical analysis of historical data. 20 - * - Forecast: 7-day weather forecast from Geosphere API. 21 23 * 22 24 * @returns The Home page component. 23 25 */ 24 26 export default function Home() { 25 27 const { t } = useTranslation(); 26 - const [tab, setTab] = useState<"rt" | "gfx" | "stored" | "stats" | "forecast">("rt"); 28 + const [tab, setTab] = useState<"rt" | "gfx" | "stored" | "stats" | "forecast" | "analysis">("rt"); 27 29 return ( 28 30 <RealtimeProvider> 29 31 <div className="min-h-screen w-full bg-gray-50 dark:bg-neutral-950 text-gray-900 dark:text-gray-100 p-4 sm:p-6"> ··· 48 50 {t("tabs.forecast", "Forecast")} 49 51 </button> 50 52 <button 53 + className={`px-3 py-2 text-sm font-medium rounded-t ${tab === "analysis" ? "bg-white dark:bg-neutral-900 border border-b-0 border-gray-200 dark:border-neutral-800" : "text-gray-600 hover:text-gray-900"}`} 54 + onClick={() => setTab("analysis")} 55 + > 56 + {t("tabs.analysis", "Analysis")} 57 + </button> 58 + <button 51 59 className={`px-3 py-2 text-sm font-medium rounded-t ${tab === "stored" ? "bg-white dark:bg-neutral-900 border border-b-0 border-gray-200 dark:border-neutral-800" : "text-gray-600 hover:text-gray-900"}`} 52 60 onClick={() => setTab("stored")} 53 61 > ··· 65 73 <div className="rounded-b border border-gray-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4"> 66 74 {tab === "rt" && <Realtime />} 67 75 {tab === "gfx" && <Gauges />} 76 + {tab === "forecast" && <Forecast />} 77 + {tab === "analysis" && <ForecastAnalysis />} 68 78 {tab === "stored" && <Dashboard />} 69 79 {tab === "stats" && <Statistics />} 70 - {tab === "forecast" && <Forecast />} 71 80 </div> 72 81 </div> 73 82 </div>
+20 -5
src/components/Forecast.tsx
··· 156 156 const data = await response.json(); 157 157 setStations(data.stations); 158 158 159 - // Load last selected station from localStorage 159 + // Load last selected station from localStorage, or use default from env 160 160 const lastSelected = localStorage.getItem("forecastStation"); 161 - if (lastSelected && data.stations) { 162 - // Verify the station still exists 161 + let stationToUse = lastSelected; 162 + 163 + // If no station in localStorage, fetch default from server 164 + if (!stationToUse) { 165 + try { 166 + const configResponse = await fetch("/api/config/forecast-station"); 167 + if (configResponse.ok) { 168 + const configData = await configResponse.json(); 169 + stationToUse = configData.stationId; 170 + } 171 + } catch (err) { 172 + console.error("Error loading default station:", err); 173 + } 174 + } 175 + 176 + if (stationToUse && data.stations) { 177 + // Verify the station exists 163 178 let stationExists = false; 164 179 for (const state in data.stations) { 165 - if (data.stations[state].some((station: Station) => station.id === lastSelected)) { 180 + if (data.stations[state].some((station: Station) => station.id === stationToUse)) { 166 181 stationExists = true; 167 182 break; 168 183 } 169 184 } 170 185 if (stationExists) { 171 - setSelectedStation(lastSelected); 186 + setSelectedStation(stationToUse); 172 187 } 173 188 } 174 189 } catch (err) {
+272
src/components/ForecastAnalysis.tsx
··· 1 + "use client"; 2 + 3 + import { useState, useEffect } from "react"; 4 + import { useTranslation } from "react-i18next"; 5 + 6 + interface ForecastAccuracyData { 7 + stationId: string; 8 + days: number; 9 + data: { 10 + dailyComparisons: DailyComparison[]; 11 + accuracyStats: AccuracyStats; 12 + }; 13 + generated: string; 14 + } 15 + 16 + interface DailyComparison { 17 + date: string; 18 + actual: { 19 + tempMin: number | null; 20 + tempMax: number | null; 21 + precipitation: number | null; 22 + windSpeed: number | null; 23 + }; 24 + forecasts: Record<string, { 25 + tempMin: number | null; 26 + tempMax: number | null; 27 + precipitation: number | null; 28 + windSpeed: number | null; 29 + }>; 30 + errors: Record<string, { 31 + tempMinError: number | null; 32 + tempMaxError: number | null; 33 + precipitationError: number | null; 34 + windSpeedError: number | null; 35 + }>; 36 + } 37 + 38 + interface AccuracyStats { 39 + [source: string]: { 40 + sampleSize: number; 41 + tempMin: { mae: number | null; rmse: number | null }; 42 + tempMax: { mae: number | null; rmse: number | null }; 43 + precipitation: { mae: number | null; rmse: number | null }; 44 + windSpeed: { mae: number | null; rmse: number | null }; 45 + }; 46 + } 47 + 48 + export default function ForecastAnalysis() { 49 + const { t } = useTranslation(); 50 + const [stationId, setStationId] = useState(""); 51 + const [days, setDays] = useState(30); 52 + const [data, setData] = useState<ForecastAccuracyData | null>(null); 53 + const [loading, setLoading] = useState(false); 54 + const [error, setError] = useState<string | null>(null); 55 + const [stations, setStations] = useState<Record<string, any[]>>({}); 56 + 57 + useEffect(() => { 58 + fetchStations(); 59 + loadDefaultStation(); 60 + }, []); 61 + 62 + const loadDefaultStation = async () => { 63 + try { 64 + const response = await fetch("/api/config/forecast-station"); 65 + if (response.ok) { 66 + const data = await response.json(); 67 + setStationId(data.stationId); 68 + } 69 + } catch (err) { 70 + console.error("Failed to load default station:", err); 71 + setStationId("11035"); // Fallback: Wien Hohe Warte 72 + } 73 + }; 74 + 75 + useEffect(() => { 76 + if (stationId) { 77 + fetchAnalysis(); 78 + } 79 + }, [stationId, days]); 80 + 81 + const fetchStations = async () => { 82 + try { 83 + const response = await fetch("/api/forecast?action=stations"); 84 + const stationsData = await response.json(); 85 + setStations(stationsData.stations || {}); 86 + } catch (err) { 87 + console.error("Failed to fetch stations:", err); 88 + } 89 + }; 90 + 91 + const fetchAnalysis = async () => { 92 + setLoading(true); 93 + setError(null); 94 + 95 + try { 96 + const response = await fetch(`/api/forecast/compare?stationId=${stationId}&days=${days}`); 97 + if (!response.ok) { 98 + throw new Error(`HTTP error! status: ${response.status}`); 99 + } 100 + const analysisData = await response.json(); 101 + setData(analysisData); 102 + } catch (err: any) { 103 + setError(err.message); 104 + } finally { 105 + setLoading(false); 106 + } 107 + }; 108 + 109 + const getSourceName = (source: string) => { 110 + const names: Record<string, string> = { 111 + geosphere: "Geosphere 🇦🇹", 112 + openweather: "OpenWeatherMap 🌍", 113 + meteoblue: "Meteoblue 🇨🇭", 114 + openmeteo: "Open-Meteo 🌍" 115 + }; 116 + return names[source] || source; 117 + }; 118 + 119 + const formatError = (error: number | null) => { 120 + if (error === null) return "N/A"; 121 + return error.toFixed(1); 122 + }; 123 + 124 + if (loading) return <div className="p-6">Lade Analyse-Daten...</div>; 125 + if (error) return <div className="p-6 text-red-500">Fehler: {error}</div>; 126 + if (!data) return <div className="p-6">Keine Daten verfügbar</div>; 127 + 128 + return ( 129 + <div className="p-6 space-y-6"> 130 + {/* Controls */} 131 + <div className="flex flex-wrap gap-4 items-end bg-white p-4 rounded-lg shadow"> 132 + <div> 133 + <label className="block text-sm font-medium mb-1">Station</label> 134 + <select 135 + value={stationId} 136 + onChange={(e) => setStationId(e.target.value)} 137 + className="border rounded px-3 py-2" 138 + > 139 + {Object.entries(stations).map(([state, stateStations]) => ( 140 + <optgroup key={state} label={state}> 141 + {stateStations.map((station: any) => ( 142 + <option key={station.id} value={station.id}> 143 + {station.name} 144 + </option> 145 + ))} 146 + </optgroup> 147 + ))} 148 + </select> 149 + </div> 150 + 151 + <div> 152 + <label className="block text-sm font-medium mb-1">Tage</label> 153 + <select 154 + value={days} 155 + onChange={(e) => setDays(parseInt(e.target.value))} 156 + className="border rounded px-3 py-2" 157 + > 158 + <option value={7}>7 Tage</option> 159 + <option value={14}>14 Tage</option> 160 + <option value={30}>30 Tage</option> 161 + <option value={60}>60 Tage</option> 162 + </select> 163 + </div> 164 + 165 + <button 166 + onClick={fetchAnalysis} 167 + className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600" 168 + disabled={loading} 169 + > 170 + {loading ? "Lädt..." : "Aktualisieren"} 171 + </button> 172 + </div> 173 + 174 + {/* Summary Stats */} 175 + <div className="bg-white p-6 rounded-lg shadow"> 176 + <h2 className="text-xl font-bold mb-4">Vorhersage-Genauigkeit (MAE)</h2> 177 + <div className="overflow-x-auto"> 178 + <table className="w-full border-collapse"> 179 + <thead> 180 + <tr className="border-b"> 181 + <th className="text-left p-2">Quelle</th> 182 + <th className="text-left p-2">Samples</th> 183 + <th className="text-left p-2">Temp Min (°C)</th> 184 + <th className="text-left p-2">Temp Max (°C)</th> 185 + <th className="text-left p-2">Niederschlag (mm)</th> 186 + <th className="text-left p-2">Wind (km/h)</th> 187 + </tr> 188 + </thead> 189 + <tbody> 190 + {Object.entries(data.data.accuracyStats).map(([source, stats]) => ( 191 + <tr key={source} className="border-b hover:bg-gray-50"> 192 + <td className="p-2 font-medium">{getSourceName(source)}</td> 193 + <td className="p-2">{stats.sampleSize}</td> 194 + <td className="p-2">{formatError(stats.tempMin.mae)}</td> 195 + <td className="p-2">{formatError(stats.tempMax.mae)}</td> 196 + <td className="p-2">{formatError(stats.precipitation.mae)}</td> 197 + <td className="p-2">{formatError(stats.windSpeed.mae)}</td> 198 + </tr> 199 + ))} 200 + </tbody> 201 + </table> 202 + </div> 203 + </div> 204 + 205 + {/* Detailed Daily Comparisons */} 206 + <div className="bg-white p-6 rounded-lg shadow"> 207 + <h2 className="text-xl font-bold mb-4">Tägliche Vergleiche (letzten 10 Tage)</h2> 208 + <div className="space-y-4 max-h-96 overflow-y-auto"> 209 + {data.data.dailyComparisons.slice(0, 10).map((comparison, index) => ( 210 + <div key={index} className="border rounded p-4"> 211 + <h3 className="font-semibold mb-2">{new Date(comparison.date).toLocaleDateString('de-DE')}</h3> 212 + 213 + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> 214 + {Object.entries(comparison.forecasts).map(([source, forecast]) => { 215 + const errors = comparison.errors[source]; 216 + return ( 217 + <div key={source} className="border rounded p-3"> 218 + <h4 className="font-medium text-sm mb-2">{getSourceName(source)}</h4> 219 + 220 + <div className="space-y-1 text-xs"> 221 + <div> 222 + <span className="text-gray-600">Temp Min: </span> 223 + <span className={errors.tempMinError && errors.tempMinError > 2 ? "text-red-500" : ""}> 224 + {forecast.tempMin?.toFixed(1) ?? "N/A"} vs {comparison.actual.tempMin?.toFixed(1) ?? "N/A"} 225 + {errors.tempMinError && ` (${errors.tempMinError.toFixed(1)}°C)`} 226 + </span> 227 + </div> 228 + 229 + <div> 230 + <span className="text-gray-600">Temp Max: </span> 231 + <span className={errors.tempMaxError && errors.tempMaxError > 2 ? "text-red-500" : ""}> 232 + {forecast.tempMax?.toFixed(1) ?? "N/A"} vs {comparison.actual.tempMax?.toFixed(1) ?? "N/A"} 233 + {errors.tempMaxError && ` (${errors.tempMaxError.toFixed(1)}°C)`} 234 + </span> 235 + </div> 236 + 237 + <div> 238 + <span className="text-gray-600">Regen: </span> 239 + <span className={errors.precipitationError && errors.precipitationError > 1 ? "text-red-500" : ""}> 240 + {forecast.precipitation?.toFixed(1) ?? "0"} vs {comparison.actual.precipitation?.toFixed(1) ?? "0"} 241 + {errors.precipitationError && ` (${errors.precipitationError.toFixed(1)}mm)`} 242 + </span> 243 + </div> 244 + 245 + <div> 246 + <span className="text-gray-600">Wind: </span> 247 + <span className={errors.windSpeedError && errors.windSpeedError > 5 ? "text-red-500" : ""}> 248 + {forecast.windSpeed?.toFixed(1) ?? "N/A"} vs {comparison.actual.windSpeed?.toFixed(1) ?? "N/A"} 249 + {errors.windSpeedError && ` (${errors.windSpeedError.toFixed(1)}km/h)`} 250 + </span> 251 + </div> 252 + </div> 253 + </div> 254 + ); 255 + })} 256 + </div> 257 + </div> 258 + ))} 259 + </div> 260 + </div> 261 + 262 + {/* Info */} 263 + <div className="bg-blue-50 p-4 rounded-lg"> 264 + <p className="text-sm text-blue-800"> 265 + <strong>Info:</strong> Diese Analyse vergleicht gespeicherte Vorhersagen mit den tatsächlichen Wetterdaten. 266 + MAE = Mean Absolute Error (mittlerer absoluter Fehler). Niedrigere Werte bedeuten bessere Genauigkeit. 267 + Die Farben zeigen Abweichungen: Rot bei größeren Fehlern. 268 + </p> 269 + </div> 270 + </div> 271 + ); 272 + }
+127
src/instrumentation.ts
··· 6 6 var __rtPoller: NodeJS.Timer | undefined; 7 7 // eslint-disable-next-line no-var 8 8 var __statsPoller: NodeJS.Timer | undefined; 9 + // eslint-disable-next-line no-var 10 + var __forecastPoller: NodeJS.Timer | undefined; 9 11 } 10 12 11 13 /** ··· 83 85 } 84 86 }, statsIntervalMs); 85 87 } 88 + 89 + // Schedule daily forecast storage (once per day at startup + every 24h) 90 + const forecastIntervalMs = 24 * 60 * 60 * 1000; // 24h 91 + if (!global.__forecastPoller) { 92 + const stationId = process.env.FORECAST_STATION_ID || "11035"; // Default: Wien Hohe Warte 93 + console.log(`[forecast] Daily forecast storage enabled for station ${stationId} (every ${forecastIntervalMs} ms)`); 94 + 95 + // Store forecasts on startup 96 + (async () => { 97 + try { 98 + await storeForecastForStation(stationId); 99 + console.log(`[forecast] Stored forecasts for station ${stationId} on startup`); 100 + } catch (e) { 101 + console.error("[forecast] Startup storage failed:", e); 102 + } 103 + })(); 104 + 105 + global.__forecastPoller = setInterval(async () => { 106 + try { 107 + await storeForecastForStation(stationId); 108 + console.log(`[forecast] Stored forecasts for station ${stationId}`); 109 + } catch (e) { 110 + console.error("[forecast] Background storage failed:", e); 111 + } 112 + }, forecastIntervalMs); 113 + } 114 + } 115 + 116 + /** 117 + * Store forecasts for a single station by calling the internal store API 118 + */ 119 + async function storeForecastForStation(stationId: string) { 120 + try { 121 + const { getDuckConn } = await import("@/lib/db/duckdb"); 122 + const conn = await getDuckConn(); 123 + const storageDate = new Date().toISOString().split('T')[0]; 124 + 125 + // Create forecast table if not exists 126 + await conn.run(` 127 + CREATE TABLE IF NOT EXISTS forecasts ( 128 + id INTEGER PRIMARY KEY, 129 + storage_date DATE NOT NULL, 130 + station_id VARCHAR(50) NOT NULL, 131 + forecast_date DATE NOT NULL, 132 + source VARCHAR(20) NOT NULL, 133 + temp_min DOUBLE, 134 + temp_max DOUBLE, 135 + precipitation DOUBLE, 136 + wind_speed DOUBLE, 137 + wind_gust DOUBLE, 138 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 139 + UNIQUE(storage_date, station_id, forecast_date, source) 140 + ) 141 + `); 142 + 143 + // Fetch forecasts from all 4 sources 144 + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; 145 + const sources = ['forecast', 'openweather', 'meteoblue', 'openmeteo']; 146 + 147 + for (const source of sources) { 148 + try { 149 + const response = await fetch(`${baseUrl}/api/forecast?action=${source}&stationId=${stationId}`); 150 + if (!response.ok) continue; 151 + 152 + const data = await response.json(); 153 + const forecastData = data.forecast || []; 154 + 155 + // Process based on source format 156 + if (source === 'forecast') { 157 + // Geosphere: aggregate hourly to daily 158 + const dailyData = aggregateHourlyToDaily(forecastData); 159 + for (const day of dailyData) { 160 + await conn.run(` 161 + INSERT OR REPLACE INTO forecasts 162 + (storage_date, station_id, forecast_date, source, temp_min, temp_max, precipitation, wind_speed) 163 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 164 + `, [storageDate, stationId, day.date, 'geosphere', day.tempMin, day.tempMax, day.precipitation, day.windSpeed]); 165 + } 166 + } else { 167 + // Other sources: already daily format 168 + for (const day of forecastData) { 169 + await conn.run(` 170 + INSERT OR REPLACE INTO forecasts 171 + (storage_date, station_id, forecast_date, source, temp_min, temp_max, precipitation, wind_speed, wind_gust) 172 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 173 + `, [storageDate, stationId, day.date, source, day.tempMin, day.tempMax, day.precipitation, day.windSpeed, day.windGust]); 174 + } 175 + } 176 + } catch (e) { 177 + console.error(`[forecast] Failed to store ${source}:`, e); 178 + } 179 + } 180 + } catch (e) { 181 + console.error("[forecast] Storage failed:", e); 182 + throw e; 183 + } 184 + } 185 + 186 + /** 187 + * Aggregate hourly forecast data to daily values (for Geosphere) 188 + */ 189 + function aggregateHourlyToDaily(hourlyData: any[]): any[] { 190 + const dailyMap: Record<string, any[]> = {}; 191 + 192 + hourlyData.forEach(item => { 193 + const date = new Date(item.time).toISOString().split('T')[0]; 194 + if (!dailyMap[date]) { 195 + dailyMap[date] = []; 196 + } 197 + dailyMap[date].push(item); 198 + }); 199 + 200 + return Object.entries(dailyMap).map(([date, items]) => { 201 + const temps = items.map(i => i.temperature).filter(t => t !== null); 202 + const precipitations = items.map(i => i.precipitation).filter(p => p !== null); 203 + const windSpeeds = items.map(i => i.windSpeed).filter(w => w !== null); 204 + 205 + return { 206 + date, 207 + tempMin: temps.length > 0 ? Math.min(...temps) : null, 208 + tempMax: temps.length > 0 ? Math.max(...temps) : null, 209 + precipitation: precipitations.length > 0 ? precipitations.reduce((sum, p) => sum + p, 0) : 0, 210 + windSpeed: windSpeeds.length > 0 ? windSpeeds.reduce((sum, w) => sum + w, 0) / windSpeeds.length : null 211 + }; 212 + }); 86 213 }
+2 -1
src/locales/de/common.json
··· 4 4 "graphics": "Grafik", 5 5 "saved": "Gespeicherte Daten", 6 6 "statistics": "Statistik", 7 - "forecast": "Prognose" 7 + "forecast": "Prognose", 8 + "analysis": "Analyse" 8 9 }, 9 10 "statuses": { 10 11 "lastUpdate": "Letzte Aktualisierung:",
+2 -1
src/locales/en/common.json
··· 4 4 "graphics": "Graphics", 5 5 "saved": "Saved Data", 6 6 "statistics": "Statistics", 7 - "forecast": "Forecast" 7 + "forecast": "Forecast", 8 + "analysis": "Analysis" 8 9 }, 9 10 "statuses": { 10 11 "lastUpdate": "Last update:",
+12 -12
temp-minmax-data.json
··· 3 3 "sensors": { 4 4 "indoor": { 5 5 "min": 21.3, 6 - "max": 21.6, 6 + "max": 21.7, 7 7 "minTime": "2025-11-02T13:47:07.767Z", 8 - "maxTime": "2025-11-02T16:34:23.366Z" 8 + "maxTime": "2025-11-02T16:49:23.425Z" 9 9 }, 10 10 "outdoor": { 11 - "min": 13.5, 11 + "min": 13.3, 12 12 "max": 17.3, 13 - "minTime": "2025-11-02T16:39:23.373Z", 13 + "minTime": "2025-11-02T16:59:23.793Z", 14 14 "maxTime": "2025-11-02T13:19:45.681Z" 15 15 }, 16 16 "temp_and_humidity_ch1": { ··· 21 21 }, 22 22 "temp_and_humidity_ch2": { 23 23 "min": 20.5, 24 - "max": 20.8, 24 + "max": 20.9, 25 25 "minTime": "2025-11-02T15:11:36.657Z", 26 - "maxTime": "2025-11-02T14:21:49.969Z" 26 + "maxTime": "2025-11-02T16:44:23.032Z" 27 27 }, 28 28 "temp_and_humidity_ch3": { 29 29 "min": 15.4, ··· 44 44 "maxTime": "2025-11-02T13:19:45.681Z" 45 45 }, 46 46 "temp_and_humidity_ch7": { 47 - "min": 13.3, 47 + "min": 13.1, 48 48 "max": 15.7, 49 - "minTime": "2025-11-02T16:34:23.366Z", 49 + "minTime": "2025-11-02T16:54:23.232Z", 50 50 "maxTime": "2025-11-02T13:19:45.681Z" 51 51 }, 52 52 "temp_and_humidity_ch8": { ··· 88 88 "maxTime": "2025-11-02T14:41:49.852Z" 89 89 }, 90 90 "temp_and_humidity_ch5": { 91 - "min": 69, 91 + "min": 68, 92 92 "max": 69, 93 - "minTime": "2025-11-02T13:19:45.681Z", 93 + "minTime": "2025-11-02T16:54:23.232Z", 94 94 "maxTime": "2025-11-02T13:19:45.681Z" 95 95 }, 96 96 "temp_and_humidity_ch6": { ··· 101 101 }, 102 102 "temp_and_humidity_ch7": { 103 103 "min": 94, 104 - "max": 97, 104 + "max": 98, 105 105 "minTime": "2025-11-02T13:38:27.577Z", 106 - "maxTime": "2025-11-02T14:51:36.541Z" 106 + "maxTime": "2025-11-02T16:59:23.793Z" 107 107 }, 108 108 "temp_and_humidity_ch8": { 109 109 "min": 68,