Weather Station / ECOWITT / DNT
1import { promises as fs } from "fs";
2import path from "path";
3import { parseTimestamp, floorToResolution, keyForResolution, type Resolution } from "@/lib/time";
4
5/**
6 * Represents a row of data from a CSV file.
7 * The `time` property is always a string, while other properties can be strings, numbers, or null.
8 */
9export type Row = { [key: string]: string | number | null } & { time: string };
10
11/**
12 * Reads a CSV file from a relative path.
13 * @param {string} relPath - The relative path to the CSV file.
14 * @returns {Promise<string>} A promise that resolves with the content of the file as a string.
15 */
16export async function readCsvFile(relPath: string): Promise<string> {
17 const base = process.cwd();
18 const abs = path.join(base, relPath);
19 return fs.readFile(abs, "utf8");
20}
21
22/**
23 * Parses a CSV string into a header array and an array of row objects.
24 * @param {string} content - The CSV content as a string.
25 * @returns {{ header: string[]; rows: Row[] }} An object containing the header and rows.
26 */
27export function parseCsv(content: string): { header: string[]; rows: Row[] } {
28 const lines = content.split(/\r?\n/).filter((l) => l.trim().length > 0);
29 if (lines.length === 0) return { header: [], rows: [] };
30 // Strip potential UTF-8 BOM
31 if (lines[0].charCodeAt(0) === 0xfeff) {
32 lines[0] = lines[0].slice(1);
33 }
34 const header = lines[0].split(",").map((s) => s.trim());
35 const rows: Row[] = [];
36 for (let i = 1; i < lines.length; i++) {
37 const cols = lines[i].split(",");
38 if (cols.length === 0) continue;
39 const row: Row = { time: "" } as Row;
40 for (let c = 0; c < header.length; c++) {
41 const key = header[c];
42 const valRaw = cols[c] ?? "";
43 const val = valRaw.trim();
44 // Always treat first column as time; header may vary or include BOM
45 if (c === 0) {
46 row.time = val;
47 continue;
48 }
49 if (val === "--" || val === "") row[key] = null;
50 else if (!isNaN(Number(val))) row[key] = Number(val);
51 else row[key] = val;
52 }
53 if (row.time) rows.push(row);
54 }
55 return { header, rows };
56}
57
58/**
59 * Aggregates rows of data by a given time resolution.
60 * @param {Row[]} rows - The array of rows to aggregate.
61 * @param {Resolution} resolution - The time resolution to group by (e.g., "minute", "hour", "day").
62 * @param {Date} [start] - An optional start date to filter the rows.
63 * @param {Date} [end] - An optional end date to filter the rows.
64 * @returns {Array<Row & { key: string }>} An array of aggregated rows, with an added `key` property for the time bucket.
65 */
66export function aggregateRows(rows: Row[], resolution: Resolution, start?: Date, end?: Date): Array<Row & { key: string }> {
67 // Group by floored time
68 const map = new Map<string, { t: Date; acc: Record<string, number>; cnt: Record<string, number> }>();
69 for (const r of rows) {
70 const dt = parseTimestamp(r.time);
71 if (!dt) continue;
72 if (start && dt < start) continue;
73 if (end && dt > end) continue;
74 const bucket = floorToResolution(dt, resolution);
75 const k = keyForResolution(bucket, resolution);
76 let entry = map.get(k);
77 if (!entry) {
78 entry = { t: bucket, acc: {}, cnt: {} };
79 map.set(k, entry);
80 }
81 for (const [k2, v] of Object.entries(r)) {
82 if (k2 === "time") continue;
83 if (typeof v === "number") {
84 entry.acc[k2] = (entry.acc[k2] ?? 0) + v;
85 entry.cnt[k2] = (entry.cnt[k2] ?? 0) + 1;
86 }
87 }
88 }
89 // Build aggregated rows (averages)
90 const out: (Row & { key: string })[] = [];
91 for (const [k, e] of Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
92 const row: Row & { key: string } = { key: k, time: k } as any;
93 for (const [col, sum] of Object.entries(e.acc)) {
94 const n = e.cnt[col] ?? 1;
95 row[col] = sum / n;
96 }
97 out.push(row);
98 }
99 return out;
100}
101
102/**
103 * Infers the keys for temperature, humidity, dew point, and heat index from a CSV header.
104 * @param {string[]} header - The array of header strings.
105 * @returns {{ temp: string[]; hum: string[]; dew: string[]; heat: string[] }} An object containing arrays of keys for each metric.
106 */
107export function inferAllsensorKeys(header: string[]): { temp: string[]; hum: string[]; dew: string[]; heat: string[] } {
108 const temp: string[] = [];
109 const hum: string[] = [];
110 const dew: string[] = [];
111 const heat: string[] = [];
112 for (const h of header) {
113 if (/^CH\d+ Temperature/.test(h)) temp.push(h);
114 else if (/^CH\d+ Luftfeuchtigkeit/.test(h) || /^WN35CH\d+hum/.test(h)) hum.push(h);
115 else if (/^CH\d+ Taupunkt/.test(h)) dew.push(h);
116 else if (/^CH\d+ Wärmeindex/.test(h)) heat.push(h);
117 }
118 return { temp, hum, dew, heat };
119}