Weather Station / ECOWITT / DNT
0

Configure Feed

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

at stats2.0 23 kB View raw
1import path from "path"; 2import { promises as fs } from "fs"; 3import { getDuckConn } from "@/lib/db/duckdb"; 4import { ensureMainParquetsInRange } from "@/lib/db/ingest"; 5import { discoverMainColumns, sqlNum, speedExprFor } from "@/lib/data/columns"; 6import type { StatisticsPayload, YearStats, MonthStats } from "@/types/statistics"; 7 8const DATA_DIR = path.join(process.cwd(), "data"); 9const STATS_PATH = path.join(DATA_DIR, "statistics.json"); 10 11async function ensureDataDir() { 12 await fs.mkdir(DATA_DIR, { recursive: true }); 13} 14 15export async function getDailySeries(year?: number) { 16 const parquetFiles = await ensureMainParquetsInRange(); 17 const rows = await queryDailyAggregates(parquetFiles); 18 if (!year) return rows; 19 const y = String(year); 20 return rows.filter((r: any) => typeof r.day === 'string' && r.day.startsWith(y)); 21} 22 23/** Daily aggregate row shape returned by range queries */ 24export interface DailyAggregateRow { 25 day: string; // YYYY-MM-DD 26 tmax: number | null; 27 tmin: number | null; 28 tavg: number | null; 29 rain_day: number | null; 30 wind_max: number | null; 31 gust_max: number | null; 32 wind_avg: number | null; 33 tfmax?: number | null; 34 tfmin?: number | null; 35} 36 37async function queryDailyAggregates(parquetFiles: string[]) { 38 if (!parquetFiles.length) return [] as any[]; 39 const conn = await getDuckConn(); 40 const qp = parquetFiles.map((p) => p.replace(/\\/g, "/")); 41 const cols = await discoverMainColumns(qp); 42 if (!cols.temp) throw new Error("Could not detect outdoor temperature column in main dataset"); 43 44 // Read all Parquets in one scan with union_by_name to avoid schema/order mismatches across months 45 const arr = '[' + qp.map((p) => `'${p}'`).join(',') + ']'; 46 47 // Build COALESCE over all candidate temperature columns for robustness across months 48 const mergedTempCandidates = [ 49 ...(cols.tempCandidates || []), 50 ...(cols.dewCandidates || []), 51 ...(cols.feelsLikeCandidates || []), 52 ]; 53 if (cols.temp && !mergedTempCandidates.includes(cols.temp)) mergedTempCandidates.unshift(cols.temp); 54 if (cols.dew && !mergedTempCandidates.includes(cols.dew)) mergedTempCandidates.push(cols.dew); 55 if (cols.feelsLike && !mergedTempCandidates.includes(cols.feelsLike)) mergedTempCandidates.push(cols.feelsLike); 56 const tempExprList = (mergedTempCandidates.length ? mergedTempCandidates : (cols.temp ? [cols.temp] : [])) 57 .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 58 const tExpr = tempExprList.length ? `COALESCE(${tempExprList.join(', ')})` : 'NULL'; 59 // Feels-like expression (optional) 60 const feelsList = (cols.feelsLikeCandidates && cols.feelsLikeCandidates.length ? cols.feelsLikeCandidates : (cols.feelsLike ? [cols.feelsLike] : [])) 61 .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 62 const feelsExpr = feelsList.length ? `COALESCE(${feelsList.join(', ')})` : 'NULL'; 63 // Build rain expressions per family to support fallback (daily cumulative vs hourly/generic sums) 64 const rainDailyExprList = (cols.dailyRainCandidates.length ? cols.dailyRainCandidates : []) 65 .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 66 const rainHourlyExprList = (cols.hourlyRainCandidates.length ? cols.hourlyRainCandidates : []) 67 .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 68 const rainGenericExprList = (cols.genericRainCandidates.length ? cols.genericRainCandidates : []) 69 .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 70 const rainDailyExpr = rainDailyExprList.length ? `COALESCE(${rainDailyExprList.join(', ')})` : 'NULL'; 71 const rainHourlyExpr = rainHourlyExprList.length ? `COALESCE(${rainHourlyExprList.join(', ')})` : 'NULL'; 72 const rainGenericExpr = rainGenericExprList.length ? `COALESCE(${rainGenericExprList.join(', ')})` : 'NULL'; 73 const windExprList = (cols.windCandidates && cols.windCandidates.length ? cols.windCandidates : (cols.wind ? [cols.wind] : [])) 74 .map((c) => speedExprFor(c)); 75 const gustExprList = (cols.gustCandidates && cols.gustCandidates.length ? cols.gustCandidates : (cols.gust ? [cols.gust] : [])) 76 .map((c) => speedExprFor(c)); 77 const windExpr = windExprList.length ? `COALESCE(${windExprList.join(', ')})` : 'NULL'; 78 const gustExpr = gustExprList.length ? `COALESCE(${gustExprList.join(', ')})` : 'NULL'; 79 const rainAgg = cols.rainMode === "daily" ? "max(rain_day)" : "sum(rain_day)"; 80 81 const sql = ` 82 WITH src AS ( 83 SELECT * FROM read_parquet(${arr}, union_by_name=true) 84 ), 85 casted AS ( 86 SELECT ts, 87 ${tExpr} AS t, 88 ${feelsExpr} AS tf, 89 ${rainDailyExpr} AS rain_d, 90 ${rainHourlyExpr} AS rain_h, 91 ${rainGenericExpr} AS rain_g, 92 ${windExpr} AS wind, 93 ${gustExpr} AS gust 94 FROM src 95 WHERE ts IS NOT NULL 96 ), 97 daily AS ( 98 SELECT 99 date_trunc('day', ts) AS d, 100 max(t) AS tmax, 101 min(t) AS tmin, 102 avg(t) AS tavg, 103 max(tf) AS tfmax, 104 min(tf) AS tfmin, 105 max(rain_d) AS rdaily, 106 sum(rain_h) AS rhourly, 107 sum(rain_g) AS rgeneric, 108 max(wind) AS wind_max, 109 max(gust) AS gust_max, 110 avg(wind) AS wind_avg 111 FROM casted 112 GROUP BY 1 113 ) 114 SELECT strftime(d, '%Y-%m-%d') AS day, 115 tmax, tmin, tavg, 116 tfmax, tfmin, 117 COALESCE(rdaily, rhourly, rgeneric) AS rain_day, 118 wind_max, gust_max, wind_avg 119 FROM daily 120 ORDER BY day; 121 `; 122 123 const reader = await conn.runAndReadAll(sql); 124 return reader.getRowObjects(); 125} 126 127/** Query daily aggregates for a specific time range (inclusive) */ 128export async function queryDailyAggregatesInRange( 129 parquetFiles: string[], 130 start?: Date, 131 end?: Date 132): Promise<DailyAggregateRow[]> { 133 if (!parquetFiles.length) return [] as any[]; 134 const conn = await getDuckConn(); 135 const qp = parquetFiles.map((p) => p.replace(/\\/g, "/")); 136 const cols = await discoverMainColumns(qp); 137 if (!cols.temp) throw new Error("Could not detect outdoor temperature column in main dataset"); 138 139 const arr = '[' + qp.map((p) => `'${p}'`).join(',') + ']'; 140 141 const mergedTempCandidates = [ 142 ...(cols.tempCandidates || []), 143 ...(cols.dewCandidates || []), 144 ...(cols.feelsLikeCandidates || []), 145 ]; 146 if (cols.temp && !mergedTempCandidates.includes(cols.temp)) mergedTempCandidates.unshift(cols.temp); 147 if (cols.dew && !mergedTempCandidates.includes(cols.dew)) mergedTempCandidates.push(cols.dew); 148 if (cols.feelsLike && !mergedTempCandidates.includes(cols.feelsLike)) mergedTempCandidates.push(cols.feelsLike); 149 const tempExprList = (mergedTempCandidates.length ? mergedTempCandidates : (cols.temp ? [cols.temp] : [])) 150 .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 151 const tExpr = tempExprList.length ? `COALESCE(${tempExprList.join(', ')})` : 'NULL'; 152 153 const rainDailyExprList = (cols.dailyRainCandidates.length ? cols.dailyRainCandidates : []) 154 .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 155 const rainHourlyExprList = (cols.hourlyRainCandidates.length ? cols.hourlyRainCandidates : []) 156 .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 157 const rainGenericExprList = (cols.genericRainCandidates.length ? cols.genericRainCandidates : []) 158 .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 159 const rainDailyExpr = rainDailyExprList.length ? `COALESCE(${rainDailyExprList.join(', ')})` : 'NULL'; 160 const rainHourlyExpr = rainHourlyExprList.length ? `COALESCE(${rainHourlyExprList.join(', ')})` : 'NULL'; 161 const rainGenericExpr = rainGenericExprList.length ? `COALESCE(${rainGenericExprList.join(', ')})` : 'NULL'; 162 const windExprList = (cols.windCandidates && cols.windCandidates.length ? cols.windCandidates : (cols.wind ? [cols.wind] : [])) 163 .map((c) => speedExprFor(c)); 164 const gustExprList = (cols.gustCandidates && cols.gustCandidates.length ? cols.gustCandidates : (cols.gust ? [cols.gust] : [])) 165 .map((c) => speedExprFor(c)); 166 const windExpr = windExprList.length ? `COALESCE(${windExprList.join(', ')})` : 'NULL'; 167 const gustExpr = gustExprList.length ? `COALESCE(${gustExprList.join(', ')})` : 'NULL'; 168 169 const whereStart = start ? `ts >= strptime('${formatDuck(start)}', ['%Y-%m-%d %H:%M'])` : '1=1'; 170 const whereEnd = end ? `ts <= strptime('${formatDuck(end)}', ['%Y-%m-%d %H:%M'])` : '1=1'; 171 172 const sql = ` 173 WITH src AS ( 174 SELECT * FROM read_parquet(${arr}, union_by_name=true) 175 ), 176 casted AS ( 177 SELECT ts, 178 ${tExpr} AS t, 179 ${rainDailyExpr} AS rain_d, 180 ${rainHourlyExpr} AS rain_h, 181 ${rainGenericExpr} AS rain_g, 182 ${windExpr} AS wind, 183 ${gustExpr} AS gust 184 FROM src 185 WHERE ts IS NOT NULL AND ${whereStart} AND ${whereEnd} 186 ), 187 daily AS ( 188 SELECT 189 date_trunc('day', ts) AS d, 190 max(t) AS tmax, 191 min(t) AS tmin, 192 avg(t) AS tavg, 193 max(rain_d) AS rdaily, 194 sum(rain_h) AS rhourly, 195 sum(rain_g) AS rgeneric, 196 max(wind) AS wind_max, 197 max(gust) AS gust_max, 198 avg(wind) AS wind_avg 199 FROM casted 200 GROUP BY 1 201 ) 202 SELECT strftime(d, '%Y-%m-%d') AS day, 203 tmax, tmin, tavg, 204 COALESCE(rdaily, rhourly, rgeneric) AS rain_day, 205 wind_max, gust_max, wind_avg 206 FROM daily 207 ORDER BY day; 208 `; 209 210 const reader = await conn.runAndReadAll(sql); 211 return reader.getRowObjects() as unknown as DailyAggregateRow[]; 212} 213 214function formatDuck(d: Date) { 215 const pad2 = (n: number) => (n < 10 ? `0${n}` : String(n)); 216 const yyyy = d.getFullYear(); 217 const mm = pad2(d.getMonth() + 1); 218 const dd = pad2(d.getDate()); 219 const hh = pad2(d.getHours()); 220 const mi = pad2(d.getMinutes()); 221 return `${yyyy}-${mm}-${dd} ${hh}:${mi}`; 222} 223 224export function computeStatsFromDaily(rows: DailyAggregateRow[]): { 225 temp: YearStats["temperature"]; rain: YearStats["precipitation"]; wind: YearStats["wind"]; feels?: { max: number | null; maxDate: string | null; min: number | null; minDate: string | null }; rainDays: number; 226} { 227 const toNum = (v: any): number | null => { 228 if (v == null) return null; 229 if (typeof v === 'number') return Number.isFinite(v) ? v : null; 230 const s = String(v).trim().replace('−', '-').replace(',', '.').replace(/[^0-9+\-\.]/g, ''); 231 const n = Number(s); 232 return Number.isFinite(n) ? n : null; 233 }; 234 235 let tMax = Number.NEGATIVE_INFINITY; let tMaxDate: string | null = null; 236 let tMin = Number.POSITIVE_INFINITY; let tMinDate: string | null = null; 237 let tAvgSum = 0; let tAvgCnt = 0; 238 const over30: { date: string; value: number }[] = []; 239 const over25: { date: string; value: number }[] = []; 240 const over20: { date: string; value: number }[] = []; 241 const under0: { date: string; value: number }[] = []; 242 const under10: { date: string; value: number }[] = []; 243 244 let rainTotal = 0; let rainCnt = 0; let rainMax = Number.NEGATIVE_INFINITY; let rainMaxDate: string | null = null; 245 let rainMin = Number.POSITIVE_INFINITY; let rainMinDate: string | null = null; 246 const rainOver20: { date: string; value: number }[] = []; 247 const rainOver30: { date: string; value: number }[] = []; 248 let rainDays = 0; 249 250 let windMax = Number.NEGATIVE_INFINITY; let windMaxDate: string | null = null; 251 let gustMax = Number.NEGATIVE_INFINITY; let gustMaxDate: string | null = null; 252 let windAvgSum = 0; let windAvgCnt = 0; 253 254 let feltMax = Number.NEGATIVE_INFINITY; let feltMaxDate: string | null = null; 255 let feltMin = Number.POSITIVE_INFINITY; let feltMinDate: string | null = null; 256 257 for (const r of rows) { 258 const d = r.day; 259 const tx = toNum(r.tmax); 260 const tn = toNum(r.tmin); 261 const ta = toNum(r.tavg); 262 if (tx !== null) { 263 if (tx > tMax) { tMax = tx; tMaxDate = d; } 264 if (tx > 30) over30.push({ date: d, value: tx }); 265 if (tx > 25) over25.push({ date: d, value: tx }); 266 if (tx > 20) over20.push({ date: d, value: tx }); 267 } 268 if (tn !== null) { 269 if (tn < tMin) { tMin = tn; tMinDate = d; } 270 if (tn < 0) under0.push({ date: d, value: tn }); 271 if (tn <= -10) under10.push({ date: d, value: tn }); 272 } 273 if (ta !== null) { tAvgSum += ta; tAvgCnt++; } 274 275 const rd = toNum(r.rain_day); 276 if (rd !== null && Number.isFinite(rd)) { 277 rainCnt++; 278 if (rd > 0) rainDays++; 279 rainTotal += rd; 280 if (rd > rainMax) { rainMax = rd; rainMaxDate = d; } 281 if (rd < rainMin) { rainMin = rd; rainMinDate = d; } 282 if (rd >= 20) rainOver20.push({ date: d, value: rd }); 283 if (rd >= 30) rainOver30.push({ date: d, value: rd }); 284 } 285 286 const wmx = toNum(r.wind_max); 287 const gmx = toNum(r.gust_max); 288 const wav = toNum(r.wind_avg); 289 if (wmx !== null && wmx > windMax) { windMax = wmx; windMaxDate = d; } 290 if (gmx !== null && gmx > gustMax) { gustMax = gmx; gustMaxDate = d; } 291 if (wav !== null) { windAvgSum += wav; windAvgCnt++; } 292 293 const fmx = toNum((r as any).tfmax); 294 const fmn = toNum((r as any).tfmin); 295 if (fmx !== null && fmx > feltMax) { feltMax = fmx; feltMaxDate = d; } 296 if (fmn !== null && fmn < feltMin) { feltMin = fmn; feltMinDate = d; } 297 } 298 299 const temp = { 300 max: Number.isFinite(tMax) ? tMax : null, 301 maxDate: tMaxDate, 302 min: Number.isFinite(tMin) ? tMin : null, 303 minDate: tMinDate, 304 avg: tAvgCnt > 0 ? tAvgSum / tAvgCnt : null, 305 over30: { count: over30.length, items: over30 }, 306 over25: { count: over25.length, items: over25 }, 307 over20: { count: over20.length, items: over20 }, 308 under0: { count: under0.length, items: under0 }, 309 under10: { count: under10.length, items: under10 }, 310 } as YearStats["temperature"]; 311 312 const rain = { 313 total: rainCnt > 0 && Number.isFinite(rainTotal) ? rainTotal : null, 314 maxDay: rainCnt > 0 && Number.isFinite(rainMax) ? rainMax : null, 315 maxDayDate: rainCnt > 0 ? rainMaxDate : null, 316 minDay: rainCnt > 0 && Number.isFinite(rainMin) ? rainMin : null, 317 minDayDate: rainCnt > 0 ? rainMinDate : null, 318 over20mm: { count: rainOver20.length, items: rainOver20 }, 319 over30mm: { count: rainOver30.length, items: rainOver30 }, 320 } as YearStats["precipitation"]; 321 322 const wind = { 323 max: Number.isFinite(windMax) ? windMax : null, 324 maxDate: windMaxDate, 325 gustMax: Number.isFinite(gustMax) ? gustMax : null, 326 gustMaxDate: gustMaxDate, 327 avg: windAvgCnt > 0 ? windAvgSum / windAvgCnt : null, 328 } as YearStats["wind"]; 329 330 const feels = (Number.isFinite(feltMax) || Number.isFinite(feltMin)) ? { 331 max: Number.isFinite(feltMax) ? feltMax : null, 332 maxDate: feltMaxDate, 333 min: Number.isFinite(feltMin) ? feltMin : null, 334 minDate: feltMinDate, 335 } : undefined; 336 337 return { temp, rain, wind, feels, rainDays }; 338} 339 340function buildYearAndMonthStats(rows: any[]): StatisticsPayload { 341 interface DayRow { 342 day: string; // YYYY-MM-DD 343 tmax: number | null; 344 tmin: number | null; 345 tavg: number | null; 346 rain_day: number | null; 347 wind_max: number | null; 348 gust_max: number | null; 349 wind_avg: number | null; 350 } 351 const days: DayRow[] = rows.map((r: any) => ({ 352 day: String(r.day), 353 tmax: r.tmax ?? null, 354 tmin: r.tmin ?? null, 355 tavg: r.tavg ?? null, 356 rain_day: r.rain_day ?? null, 357 wind_max: r.wind_max ?? null, 358 gust_max: r.gust_max ?? null, 359 wind_avg: r.wind_avg ?? null, 360 })); 361 362 const byYear = new Map<number, DayRow[]>(); 363 for (const d of days) { 364 if (!d.day || typeof d.day !== "string" || d.day.length < 10) continue; 365 const y = Number(d.day.slice(0, 4)); 366 if (!byYear.has(y)) byYear.set(y, []); 367 byYear.get(y)!.push(d); 368 } 369 370 const years: YearStats[] = []; 371 372 const toNum = (v: any): number | null => { 373 if (v == null) return null; 374 if (typeof v === 'number') return Number.isFinite(v) ? v : null; 375 const s = String(v).trim().replace('−', '-').replace(',', '.').replace(/[^0-9+\-\.]/g, ''); 376 const n = Number(s); 377 return Number.isFinite(n) ? n : null; 378 }; 379 380 const computeBlock = (rows: DayRow[]): { temp: YearStats["temperature"], rain: YearStats["precipitation"], wind: YearStats["wind"] } => { 381 let tMax = Number.NEGATIVE_INFINITY; 382 let tMaxDate: string | null = null; 383 let tMin = Number.POSITIVE_INFINITY; 384 let tMinDate: string | null = null; 385 let tAvgSum = 0; 386 let tAvgCnt = 0; 387 const over30: { date: string; value: number }[] = []; 388 const over25: { date: string; value: number }[] = []; 389 const over20: { date: string; value: number }[] = []; 390 const under0: { date: string; value: number }[] = []; 391 const under10: { date: string; value: number }[] = []; 392 393 let rainTotal = 0; 394 let rainCnt = 0; 395 let rainMax = Number.NEGATIVE_INFINITY; let rainMaxDate: string | null = null; 396 let rainMin = Number.POSITIVE_INFINITY; let rainMinDate: string | null = null; 397 const rainOver20: { date: string; value: number }[] = []; 398 const rainOver30: { date: string; value: number }[] = []; 399 400 let windMax = Number.NEGATIVE_INFINITY; let windMaxDate: string | null = null; 401 let gustMax = Number.NEGATIVE_INFINITY; let gustMaxDate: string | null = null; 402 let windAvgSum = 0; let windAvgCnt = 0; 403 404 for (const r of rows) { 405 const d = r.day; 406 const tx = toNum(r.tmax); 407 const tn = toNum(r.tmin); 408 const ta = toNum(r.tavg); 409 410 if (tx !== null) { 411 if (tx > tMax) { tMax = tx; tMaxDate = d; } 412 if (tx > 30) over30.push({ date: d, value: tx }); 413 if (tx > 25) over25.push({ date: d, value: tx }); 414 if (tx > 20) over20.push({ date: d, value: tx }); 415 } 416 if (tn !== null) { 417 if (tn < tMin) { tMin = tn; tMinDate = d; } 418 if (tn < 0) under0.push({ date: d, value: tn }); 419 if (tn <= -10) under10.push({ date: d, value: tn }); 420 } 421 if (ta !== null) { tAvgSum += ta; tAvgCnt++; } 422 423 const rd = toNum(r.rain_day); 424 if (rd !== null && Number.isFinite(rd)) { 425 rainCnt++; 426 rainTotal += rd; 427 if (rd > rainMax) { rainMax = rd; rainMaxDate = d; } 428 if (rd < rainMin) { rainMin = rd; rainMinDate = d; } 429 if (rd >= 20) rainOver20.push({ date: d, value: rd }); 430 if (rd >= 30) rainOver30.push({ date: d, value: rd }); 431 } 432 433 const wmx = toNum(r.wind_max); 434 const gmx = toNum(r.gust_max); 435 const wav = toNum(r.wind_avg); 436 if (wmx !== null && wmx > windMax) { windMax = wmx; windMaxDate = d; } 437 if (gmx !== null && gmx > gustMax) { gustMax = gmx; gustMaxDate = d; } 438 if (wav !== null) { windAvgSum += wav; windAvgCnt++; } 439 } 440 441 const temp = { 442 max: Number.isFinite(tMax) ? tMax : null, 443 maxDate: tMaxDate, 444 min: Number.isFinite(tMin) ? tMin : null, 445 minDate: tMinDate, 446 avg: tAvgCnt > 0 ? tAvgSum / tAvgCnt : null, 447 over30: { count: over30.length, items: over30 }, 448 over25: { count: over25.length, items: over25 }, 449 over20: { count: over20.length, items: over20 }, 450 under0: { count: under0.length, items: under0 }, 451 under10: { count: under10.length, items: under10 }, 452 } as YearStats["temperature"]; 453 454 const rain = { 455 total: rainCnt > 0 && Number.isFinite(rainTotal) ? rainTotal : null, 456 maxDay: rainCnt > 0 && Number.isFinite(rainMax) ? rainMax : null, 457 maxDayDate: rainCnt > 0 ? rainMaxDate : null, 458 minDay: rainCnt > 0 && Number.isFinite(rainMin) ? rainMin : null, 459 minDayDate: rainCnt > 0 ? rainMinDate : null, 460 over20mm: { count: rainOver20.length, items: rainOver20 }, 461 over30mm: { count: rainOver30.length, items: rainOver30 }, 462 } as YearStats["precipitation"]; 463 464 const wind = { 465 max: Number.isFinite(windMax) ? windMax : null, 466 maxDate: windMaxDate, 467 gustMax: Number.isFinite(gustMax) ? gustMax : null, 468 gustMaxDate: gustMaxDate, 469 avg: windAvgCnt > 0 ? windAvgSum / windAvgCnt : null, 470 } as YearStats["wind"]; 471 472 return { temp, rain, wind }; 473 }; 474 475 for (const [year, list] of Array.from(byYear.entries()).sort((a, b) => b[0] - a[0])) { 476 const { temp, rain, wind } = computeBlock(list); 477 478 // Months 479 const monthsMap = new Map<number, DayRow[]>(); 480 for (const r of list) { 481 const m = Number(r.day.slice(5, 7)); 482 if (!monthsMap.has(m)) monthsMap.set(m, []); 483 monthsMap.get(m)!.push(r); 484 } 485 const months: MonthStats[] = []; 486 for (const [month, rowsM] of Array.from(monthsMap.entries()).sort((a, b) => a[0] - b[0])) { 487 const { temp: tempM, rain: rainM, wind: windM } = computeBlock(rowsM); 488 months.push({ year, month, temperature: tempM, precipitation: rainM, wind: windM }); 489 } 490 491 years.push({ year, temperature: temp, precipitation: rain, wind, months }); 492 } 493 494 return { updatedAt: new Date().toISOString(), years }; 495} 496 497export async function computeStatistics(): Promise<StatisticsPayload> { 498 const parquetFiles = await ensureMainParquetsInRange(); 499 if (!parquetFiles.length) throw new Error("No main Parquet files found to compute statistics"); 500 const rows = await queryDailyAggregates(parquetFiles); 501 return buildYearAndMonthStats(rows); 502} 503 504export async function readStatistics(): Promise<StatisticsPayload | null> { 505 try { 506 const txt = await fs.readFile(STATS_PATH, "utf8"); 507 return JSON.parse(txt) as StatisticsPayload; 508 } catch { 509 return null; 510 } 511} 512 513export async function writeStatistics(stats: StatisticsPayload): Promise<void> { 514 await ensureDataDir(); 515 await fs.writeFile(STATS_PATH, JSON.stringify(stats, null, 2), "utf8"); 516} 517 518export async function updateStatistics(): Promise<StatisticsPayload> { 519 const stats = await computeStatistics(); 520 await writeStatistics(stats); 521 return stats; 522} 523 524export async function updateStatisticsIfNeeded(maxAgeMs = 24 * 60 * 60 * 1000): Promise<StatisticsPayload> { 525 const existing = await readStatistics(); 526 if (existing && existing.updatedAt) { 527 const age = Date.now() - new Date(existing.updatedAt).getTime(); 528 if (age < maxAgeMs) return existing; 529 } 530 return updateStatistics(); 531} 532 533export async function getStatisticsMeta() { 534 const parquetFiles = await ensureMainParquetsInRange(); 535 const qp = parquetFiles.map((p) => p.replace(/\\/g, "/")); 536 const cols = await discoverMainColumns(qp); 537 return { 538 parquetCount: qp.length, 539 columns: { 540 temperature: cols.temp, 541 rain: cols.rainDay, 542 rainMode: cols.rainMode, 543 wind: cols.wind, 544 gust: cols.gust, 545 }, 546 }; 547} 548 549export async function getDailyDebug(year?: number) { 550 const parquetFiles = await ensureMainParquetsInRange(); 551 const rows = await queryDailyAggregates(parquetFiles); 552 const list = year ? rows.filter((r: any) => typeof r.day === 'string' && r.day.startsWith(String(year))) : rows; 553 const totalDays = list.length; 554 let daysWithTemp = 0; 555 let daysWithRain = 0; 556 let tminOverall = Number.POSITIVE_INFINITY; 557 let tminDate: string | null = null; 558 for (const r of list) { 559 const tmin = Number.isFinite(r.tmin as any) ? Number(r.tmin) : null; 560 const tmax = Number.isFinite(r.tmax as any) ? Number(r.tmax) : null; 561 const rain = Number.isFinite(r.rain_day as any) ? Number(r.rain_day) : null; 562 if (tmin !== null || tmax !== null) daysWithTemp++; 563 if (rain !== null) daysWithRain++; 564 if (tmin !== null && tmin < tminOverall) { tminOverall = tmin; tminDate = r.day; } 565 } 566 const first = list.slice(0, 5); 567 const last = list.slice(Math.max(0, list.length - 5)); 568 return { 569 year: year || null, 570 totalDays, 571 daysWithTemp, 572 daysWithRain, 573 tminOverall: Number.isFinite(tminOverall) ? tminOverall : null, 574 tminDate, 575 sample: { first, last }, 576 }; 577}