Weather Station / ECOWITT / DNT
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}