@oqtopus-team/pi-qdash 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/extensions/lib/forum-images.ts +82 -0
- package/extensions/lib/investigation-context.ts +169 -0
- package/extensions/lib/timeseries-analysis.ts +609 -0
- package/extensions/lib/write-gate.ts +1 -0
- package/extensions/qdash.ts +222 -52
- package/extensions/tools/timeseries.ts +211 -0
- package/package.json +7 -3
- package/skills/qdash/SKILL.md +48 -7
- package/skills/qdash-calibration-agent/SKILL.md +4 -1
- package/skills/qdash-timeseries-diagnosis/SKILL.md +155 -0
- package/skills/qdash-two-qubit-calibration-diagnosis/SKILL.md +4 -3
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type NumericTimeseriesPoint = { at: string | number | Date; value: number };
|
|
5
|
+
export type NumericTimeseries = {
|
|
6
|
+
label: string;
|
|
7
|
+
unit?: string;
|
|
8
|
+
source: "qdash" | "csv";
|
|
9
|
+
points: NumericTimeseriesPoint[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type CsvTimeFormat =
|
|
13
|
+
| "iso"
|
|
14
|
+
| "yyyy/M/d H:mm"
|
|
15
|
+
| "yyyy-MM-dd HH:mm:ss"
|
|
16
|
+
| "unix-seconds"
|
|
17
|
+
| "unix-milliseconds";
|
|
18
|
+
|
|
19
|
+
export type CsvValueColumn = {
|
|
20
|
+
column: string;
|
|
21
|
+
label?: string;
|
|
22
|
+
unit?: string;
|
|
23
|
+
scale?: number;
|
|
24
|
+
offset?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ExternalCsvSpec = {
|
|
28
|
+
path: string;
|
|
29
|
+
timeColumn: string;
|
|
30
|
+
valueColumns: CsvValueColumn[];
|
|
31
|
+
timeFormat?: CsvTimeFormat;
|
|
32
|
+
timezoneOffsetMinutes?: number;
|
|
33
|
+
delimiter?: string;
|
|
34
|
+
skipRows?: number;
|
|
35
|
+
filters?: Record<string, string[]>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type CsvInspection = {
|
|
39
|
+
path: string;
|
|
40
|
+
headers: string[];
|
|
41
|
+
dataRows: number;
|
|
42
|
+
scannedRows: number;
|
|
43
|
+
columns: Array<{
|
|
44
|
+
column: string;
|
|
45
|
+
nonEmpty: number;
|
|
46
|
+
numeric: number;
|
|
47
|
+
numericFraction: number;
|
|
48
|
+
min?: number;
|
|
49
|
+
max?: number;
|
|
50
|
+
samples: string[];
|
|
51
|
+
}>;
|
|
52
|
+
time?: {
|
|
53
|
+
column: string;
|
|
54
|
+
valid: number;
|
|
55
|
+
invalid: number;
|
|
56
|
+
firstAt?: string;
|
|
57
|
+
lastAt?: string;
|
|
58
|
+
medianCadenceMinutes?: number;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type TimeseriesTransformOptions = {
|
|
63
|
+
startAt?: string;
|
|
64
|
+
endAt?: string;
|
|
65
|
+
resampleMinutes?: number;
|
|
66
|
+
smoothingWindowMinutes?: number;
|
|
67
|
+
detrend?: "none" | "linear";
|
|
68
|
+
normalize?: "none" | "zscore";
|
|
69
|
+
maxInterpolationGapMinutes?: number;
|
|
70
|
+
periodSearch?: { minMinutes: number; maxMinutes: number; stepMinutes?: number };
|
|
71
|
+
includeAlignedData?: boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type SeriesStatistics = {
|
|
75
|
+
label: string;
|
|
76
|
+
source: "qdash" | "csv";
|
|
77
|
+
unit?: string;
|
|
78
|
+
inputPoints: number;
|
|
79
|
+
firstAt: string;
|
|
80
|
+
lastAt: string;
|
|
81
|
+
min: number;
|
|
82
|
+
max: number;
|
|
83
|
+
mean: number;
|
|
84
|
+
standardDeviation: number;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type PairwiseComparison = {
|
|
88
|
+
left: string;
|
|
89
|
+
right: string;
|
|
90
|
+
correlation: number | null;
|
|
91
|
+
phaseDifferenceDegrees?: number;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type PeriodicFit = {
|
|
95
|
+
label: string;
|
|
96
|
+
periodMinutes: number;
|
|
97
|
+
phaseDegrees: number;
|
|
98
|
+
periodicExplainedFraction: number;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type TimeseriesComparison = {
|
|
102
|
+
overlap: {
|
|
103
|
+
startAt: string;
|
|
104
|
+
endAt: string;
|
|
105
|
+
durationMinutes: number;
|
|
106
|
+
alignedPoints: number;
|
|
107
|
+
resampleMinutes: number;
|
|
108
|
+
observedCycles?: number;
|
|
109
|
+
};
|
|
110
|
+
transforms: {
|
|
111
|
+
smoothingWindowMinutes: number;
|
|
112
|
+
detrend: "none" | "linear";
|
|
113
|
+
normalize: "none" | "zscore";
|
|
114
|
+
maxInterpolationGapMinutes: number;
|
|
115
|
+
};
|
|
116
|
+
series: SeriesStatistics[];
|
|
117
|
+
pairs: PairwiseComparison[];
|
|
118
|
+
periodicFits?: PeriodicFit[];
|
|
119
|
+
warnings: string[];
|
|
120
|
+
alignedData?: Array<{ at: string; values: Record<string, number> }>;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const MAX_CSV_BYTES = 10 * 1024 * 1024;
|
|
124
|
+
const MAX_GRID_POINTS = 100_000;
|
|
125
|
+
|
|
126
|
+
function parseCsv(text: string, delimiter: string): string[][] {
|
|
127
|
+
if (delimiter.length !== 1) throw new Error("CSV delimiter must be exactly one character");
|
|
128
|
+
const rows: string[][] = [];
|
|
129
|
+
let row: string[] = [];
|
|
130
|
+
let field = "";
|
|
131
|
+
let quoted = false;
|
|
132
|
+
for (let index = 0; index < text.length; index++) {
|
|
133
|
+
const character = text[index];
|
|
134
|
+
if (quoted) {
|
|
135
|
+
if (character === '"' && text[index + 1] === '"') {
|
|
136
|
+
field += '"';
|
|
137
|
+
index++;
|
|
138
|
+
} else if (character === '"') quoted = false;
|
|
139
|
+
else field += character;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (character === '"') quoted = true;
|
|
143
|
+
else if (character === delimiter) {
|
|
144
|
+
row.push(field);
|
|
145
|
+
field = "";
|
|
146
|
+
} else if (character === "\n") {
|
|
147
|
+
row.push(field.replace(/\r$/, ""));
|
|
148
|
+
rows.push(row);
|
|
149
|
+
row = [];
|
|
150
|
+
field = "";
|
|
151
|
+
} else field += character;
|
|
152
|
+
}
|
|
153
|
+
if (quoted) throw new Error("CSV contains an unterminated quoted field");
|
|
154
|
+
if (field.length > 0 || row.length > 0) {
|
|
155
|
+
row.push(field.replace(/\r$/, ""));
|
|
156
|
+
rows.push(row);
|
|
157
|
+
}
|
|
158
|
+
return rows;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function timestampFromParts(parts: number[], timezoneOffsetMinutes: number): number {
|
|
162
|
+
const [year, month, day, hour = 0, minute = 0, second = 0] = parts;
|
|
163
|
+
return Date.UTC(year, month - 1, day, hour, minute, second) - timezoneOffsetMinutes * 60_000;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseCsvTimestamp(value: string, format: CsvTimeFormat, timezoneOffsetMinutes?: number): number {
|
|
167
|
+
const trimmed = value.trim();
|
|
168
|
+
if (format === "unix-seconds" || format === "unix-milliseconds") {
|
|
169
|
+
const numeric = Number(trimmed);
|
|
170
|
+
if (!Number.isFinite(numeric)) throw new Error(`Invalid ${format} timestamp: ${value}`);
|
|
171
|
+
return format === "unix-seconds" ? numeric * 1000 : numeric;
|
|
172
|
+
}
|
|
173
|
+
if (format === "iso") {
|
|
174
|
+
const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(trimmed);
|
|
175
|
+
if (!hasZone && timezoneOffsetMinutes === undefined) {
|
|
176
|
+
throw new Error("Naive ISO timestamps require timezoneOffsetMinutes");
|
|
177
|
+
}
|
|
178
|
+
const zoned = hasZone
|
|
179
|
+
? trimmed
|
|
180
|
+
: `${trimmed}${formatOffset(timezoneOffsetMinutes ?? 0)}`;
|
|
181
|
+
const parsed = Date.parse(zoned);
|
|
182
|
+
if (!Number.isFinite(parsed)) throw new Error(`Invalid ISO timestamp: ${value}`);
|
|
183
|
+
return parsed;
|
|
184
|
+
}
|
|
185
|
+
if (timezoneOffsetMinutes === undefined) {
|
|
186
|
+
throw new Error(`${format} timestamps require timezoneOffsetMinutes`);
|
|
187
|
+
}
|
|
188
|
+
const expression = format === "yyyy/M/d H:mm"
|
|
189
|
+
? /^(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2})$/
|
|
190
|
+
: /^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})$/;
|
|
191
|
+
const match = trimmed.match(expression);
|
|
192
|
+
if (!match) throw new Error(`Timestamp does not match ${format}: ${value}`);
|
|
193
|
+
return timestampFromParts(match.slice(1).map(Number), timezoneOffsetMinutes);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function formatOffset(minutes: number): string {
|
|
197
|
+
const sign = minutes >= 0 ? "+" : "-";
|
|
198
|
+
const absolute = Math.abs(minutes);
|
|
199
|
+
return `${sign}${String(Math.floor(absolute / 60)).padStart(2, "0")}:${String(absolute % 60).padStart(2, "0")}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function csvRows(pathInput: string, cwd: string, delimiter = ",", skipRows = 0): { path: string; rows: string[][]; headers: string[] } {
|
|
203
|
+
const rawPath = pathInput.startsWith("@") ? pathInput.slice(1) : pathInput;
|
|
204
|
+
const path = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
|
|
205
|
+
const size = statSync(path).size;
|
|
206
|
+
if (size > MAX_CSV_BYTES) throw new Error(`CSV exceeds 10 MB limit: ${path}`);
|
|
207
|
+
if (!Number.isInteger(skipRows) || skipRows < 0) throw new Error("skipRows must be a non-negative integer");
|
|
208
|
+
const rows = parseCsv(readFileSync(path, "utf8"), delimiter).slice(skipRows);
|
|
209
|
+
if (rows.length < 2) throw new Error(`CSV has no data rows: ${path}`);
|
|
210
|
+
return { path, rows, headers: rows[0].map((header) => header.trim().replace(/^\uFEFF/, "")) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function findHeader(headers: string[], requested: string): number {
|
|
214
|
+
const exact = headers.indexOf(requested);
|
|
215
|
+
if (exact >= 0) return exact;
|
|
216
|
+
const normalized = requested.trim().replace(/^#/, "");
|
|
217
|
+
return headers.findIndex((header) => header.replace(/^#/, "") === normalized);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function inspectExternalCsv(spec: {
|
|
221
|
+
path: string;
|
|
222
|
+
delimiter?: string;
|
|
223
|
+
skipRows?: number;
|
|
224
|
+
timeColumn?: string;
|
|
225
|
+
timeFormat?: CsvTimeFormat;
|
|
226
|
+
timezoneOffsetMinutes?: number;
|
|
227
|
+
scanRows?: number;
|
|
228
|
+
}, cwd: string): CsvInspection {
|
|
229
|
+
const { path, rows, headers } = csvRows(spec.path, cwd, spec.delimiter, spec.skipRows);
|
|
230
|
+
const scanLimit = spec.scanRows ?? 10_000;
|
|
231
|
+
if (!Number.isInteger(scanLimit) || scanLimit < 1 || scanLimit > 100_000) throw new Error("scanRows must be an integer from 1 to 100000");
|
|
232
|
+
const data = rows.slice(1, scanLimit + 1).filter((row) => !row.every((field) => field.trim() === ""));
|
|
233
|
+
const columns = headers.map((column, index) => {
|
|
234
|
+
const values = data.map((row) => (row[index] ?? "").trim()).filter(Boolean);
|
|
235
|
+
const numericValues = values.map(Number).filter(Number.isFinite);
|
|
236
|
+
return {
|
|
237
|
+
column,
|
|
238
|
+
nonEmpty: values.length,
|
|
239
|
+
numeric: numericValues.length,
|
|
240
|
+
numericFraction: values.length === 0 ? 0 : numericValues.length / values.length,
|
|
241
|
+
min: numericValues.length === 0 ? undefined : Math.min(...numericValues),
|
|
242
|
+
max: numericValues.length === 0 ? undefined : Math.max(...numericValues),
|
|
243
|
+
samples: [...new Set(values)].slice(0, 3),
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
const inspection: CsvInspection = { path, headers, dataRows: rows.length - 1, scannedRows: data.length, columns };
|
|
247
|
+
if (spec.timeColumn) {
|
|
248
|
+
const index = findHeader(headers, spec.timeColumn);
|
|
249
|
+
if (index < 0) throw new Error(`CSV time column not found: ${spec.timeColumn}`);
|
|
250
|
+
const format = spec.timeFormat ?? "iso";
|
|
251
|
+
const timestamps: number[] = [];
|
|
252
|
+
let invalid = 0;
|
|
253
|
+
for (const row of data) {
|
|
254
|
+
try {
|
|
255
|
+
timestamps.push(parseCsvTimestamp(row[index] ?? "", format, spec.timezoneOffsetMinutes));
|
|
256
|
+
} catch {
|
|
257
|
+
invalid++;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
timestamps.sort((a, b) => a - b);
|
|
261
|
+
const intervals = timestamps.slice(1).map((value, item) => (value - timestamps[item]) / 60_000).filter((value) => value > 0).sort((a, b) => a - b);
|
|
262
|
+
inspection.time = {
|
|
263
|
+
column: headers[index],
|
|
264
|
+
valid: timestamps.length,
|
|
265
|
+
invalid,
|
|
266
|
+
firstAt: timestamps.length ? new Date(timestamps[0]).toISOString() : undefined,
|
|
267
|
+
lastAt: timestamps.length ? new Date(timestamps.at(-1)!).toISOString() : undefined,
|
|
268
|
+
medianCadenceMinutes: intervals.length ? intervals[Math.floor(intervals.length / 2)] : undefined,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return inspection;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function csvInspectionText(inspection: CsvInspection): string {
|
|
275
|
+
const lines = [
|
|
276
|
+
`CSV ${inspection.path}`,
|
|
277
|
+
`rows ${inspection.dataRows} (scanned ${inspection.scannedRows})`,
|
|
278
|
+
`headers ${inspection.headers.join(", ")}`,
|
|
279
|
+
"",
|
|
280
|
+
"Columns",
|
|
281
|
+
...inspection.columns.map((column) => `- ${column.column}: non-empty=${column.nonEmpty}, numeric=${column.numeric} (${(column.numericFraction * 100).toFixed(1)}%)${column.min === undefined ? "" : `, range=${column.min}..${column.max}`}, samples=${column.samples.map((sample) => JSON.stringify(sample)).join(", ")}`),
|
|
282
|
+
];
|
|
283
|
+
if (inspection.time) lines.push("", `Time ${inspection.time.column}: valid=${inspection.time.valid}, invalid=${inspection.time.invalid}, range=${inspection.time.firstAt ?? "n/a"}..${inspection.time.lastAt ?? "n/a"}, median cadence=${inspection.time.medianCadenceMinutes ?? "n/a"} min`);
|
|
284
|
+
return lines.join("\n");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function loadExternalCsvSeries(spec: ExternalCsvSpec, cwd: string): NumericTimeseries[] {
|
|
288
|
+
const { rows, headers } = csvRows(spec.path, cwd, spec.delimiter, spec.skipRows);
|
|
289
|
+
const timeFormat = spec.timeFormat ?? "iso";
|
|
290
|
+
const supportedFormats: CsvTimeFormat[] = ["iso", "yyyy/M/d H:mm", "yyyy-MM-dd HH:mm:ss", "unix-seconds", "unix-milliseconds"];
|
|
291
|
+
if (!supportedFormats.includes(timeFormat)) throw new Error(`Unsupported CSV timeFormat: ${String(timeFormat)}`);
|
|
292
|
+
const findHeaderIndex = (requested: string) => findHeader(headers, requested);
|
|
293
|
+
const timeIndex = findHeaderIndex(spec.timeColumn);
|
|
294
|
+
if (timeIndex < 0) throw new Error(`CSV time column not found: ${spec.timeColumn}`);
|
|
295
|
+
const columns = spec.valueColumns.map((column) => {
|
|
296
|
+
const index = findHeaderIndex(column.column);
|
|
297
|
+
if (index < 0) throw new Error(`CSV value column not found: ${column.column}`);
|
|
298
|
+
return { ...column, index };
|
|
299
|
+
});
|
|
300
|
+
const filters = Object.entries(spec.filters ?? {}).map(([column, accepted]) => {
|
|
301
|
+
const index = findHeaderIndex(column);
|
|
302
|
+
if (index < 0) throw new Error(`CSV filter column not found: ${column}`);
|
|
303
|
+
if (accepted.length === 0) throw new Error(`CSV filter ${column} must contain at least one accepted value`);
|
|
304
|
+
return { index, accepted: new Set(accepted) };
|
|
305
|
+
});
|
|
306
|
+
const output = columns.map((column) => ({
|
|
307
|
+
label: column.label ?? column.column,
|
|
308
|
+
unit: column.unit,
|
|
309
|
+
source: "csv" as const,
|
|
310
|
+
points: [] as NumericTimeseriesPoint[],
|
|
311
|
+
}));
|
|
312
|
+
for (let rowIndex = 1; rowIndex < rows.length; rowIndex++) {
|
|
313
|
+
const row = rows[rowIndex];
|
|
314
|
+
if (row.every((field) => field.trim() === "")) continue;
|
|
315
|
+
if (filters.some((filter) => !filter.accepted.has((row[filter.index] ?? "").trim()))) continue;
|
|
316
|
+
let at: number;
|
|
317
|
+
try {
|
|
318
|
+
at = parseCsvTimestamp(row[timeIndex] ?? "", timeFormat, spec.timezoneOffsetMinutes);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
throw new Error(`CSV row ${rowIndex + 1}: ${error instanceof Error ? error.message : String(error)}`);
|
|
321
|
+
}
|
|
322
|
+
columns.forEach((column, columnIndex) => {
|
|
323
|
+
const value = Number((row[column.index] ?? "").trim());
|
|
324
|
+
if (!Number.isFinite(value)) return;
|
|
325
|
+
output[columnIndex].points.push({
|
|
326
|
+
at,
|
|
327
|
+
value: value * (column.scale ?? 1) + (column.offset ?? 0),
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return output;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function epoch(value: string | number | Date): number {
|
|
335
|
+
const parsed = typeof value === "number" ? value : value instanceof Date ? value.getTime() : Date.parse(value);
|
|
336
|
+
if (!Number.isFinite(parsed)) throw new Error(`Invalid timeseries timestamp: ${String(value)}`);
|
|
337
|
+
return parsed;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function preparedPoints(series: NumericTimeseries, start?: number, end?: number): Array<{ at: number; value: number }> {
|
|
341
|
+
const byTime = new Map<number, number>();
|
|
342
|
+
for (const point of series.points) {
|
|
343
|
+
if (!Number.isFinite(point.value)) continue;
|
|
344
|
+
const at = epoch(point.at);
|
|
345
|
+
if ((start !== undefined && at < start) || (end !== undefined && at > end)) continue;
|
|
346
|
+
byTime.set(at, point.value);
|
|
347
|
+
}
|
|
348
|
+
return [...byTime.entries()].map(([at, value]) => ({ at, value })).sort((a, b) => a.at - b.at);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function interpolate(points: Array<{ at: number; value: number }>, at: number, maxGapMs: number): number | undefined {
|
|
352
|
+
let low = 0;
|
|
353
|
+
let high = points.length - 1;
|
|
354
|
+
while (low <= high) {
|
|
355
|
+
const middle = Math.floor((low + high) / 2);
|
|
356
|
+
if (points[middle].at < at) low = middle + 1;
|
|
357
|
+
else if (points[middle].at > at) high = middle - 1;
|
|
358
|
+
else return points[middle].value;
|
|
359
|
+
}
|
|
360
|
+
const right = points[low];
|
|
361
|
+
const left = points[low - 1];
|
|
362
|
+
if (!left || !right || right.at - left.at > maxGapMs) return undefined;
|
|
363
|
+
return left.value + (right.value - left.value) * ((at - left.at) / (right.at - left.at));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function centeredMean(values: number[], width: number): number[] {
|
|
367
|
+
if (width <= 1) return [...values];
|
|
368
|
+
const before = Math.floor(width / 2);
|
|
369
|
+
const after = width - before - 1;
|
|
370
|
+
return values.map((_value, index) => {
|
|
371
|
+
const slice = values.slice(Math.max(0, index - before), Math.min(values.length, index + after + 1));
|
|
372
|
+
return slice.reduce((sum, value) => sum + value, 0) / slice.length;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function linearDetrend(values: number[]): number[] {
|
|
377
|
+
const count = values.length;
|
|
378
|
+
const meanX = (count - 1) / 2;
|
|
379
|
+
const meanY = values.reduce((sum, value) => sum + value, 0) / count;
|
|
380
|
+
let numerator = 0;
|
|
381
|
+
let denominator = 0;
|
|
382
|
+
values.forEach((value, index) => {
|
|
383
|
+
numerator += (index - meanX) * (value - meanY);
|
|
384
|
+
denominator += (index - meanX) ** 2;
|
|
385
|
+
});
|
|
386
|
+
const slope = denominator === 0 ? 0 : numerator / denominator;
|
|
387
|
+
return values.map((value, index) => value - (meanY + slope * (index - meanX)));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function zscore(values: number[]): number[] {
|
|
391
|
+
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
392
|
+
const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / Math.max(1, values.length - 1);
|
|
393
|
+
const deviation = Math.sqrt(variance);
|
|
394
|
+
return deviation === 0 ? values.map(() => 0) : values.map((value) => (value - mean) / deviation);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function pearson(left: number[], right: number[]): number | null {
|
|
398
|
+
if (left.length < 3 || right.length !== left.length) return null;
|
|
399
|
+
const leftMean = left.reduce((sum, value) => sum + value, 0) / left.length;
|
|
400
|
+
const rightMean = right.reduce((sum, value) => sum + value, 0) / right.length;
|
|
401
|
+
let numerator = 0;
|
|
402
|
+
let leftSquare = 0;
|
|
403
|
+
let rightSquare = 0;
|
|
404
|
+
left.forEach((value, index) => {
|
|
405
|
+
const a = value - leftMean;
|
|
406
|
+
const b = right[index] - rightMean;
|
|
407
|
+
numerator += a * b;
|
|
408
|
+
leftSquare += a * a;
|
|
409
|
+
rightSquare += b * b;
|
|
410
|
+
});
|
|
411
|
+
const denominator = Math.sqrt(leftSquare * rightSquare);
|
|
412
|
+
return denominator === 0 ? null : numerator / denominator;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function solve(matrix: number[][], vector: number[]): number[] | undefined {
|
|
416
|
+
const augmented = matrix.map((row, index) => [...row, vector[index]]);
|
|
417
|
+
for (let column = 0; column < matrix.length; column++) {
|
|
418
|
+
let pivot = column;
|
|
419
|
+
for (let row = column + 1; row < matrix.length; row++) {
|
|
420
|
+
if (Math.abs(augmented[row][column]) > Math.abs(augmented[pivot][column])) pivot = row;
|
|
421
|
+
}
|
|
422
|
+
if (Math.abs(augmented[pivot][column]) < 1e-12) return undefined;
|
|
423
|
+
[augmented[column], augmented[pivot]] = [augmented[pivot], augmented[column]];
|
|
424
|
+
const divisor = augmented[column][column];
|
|
425
|
+
for (let index = column; index <= matrix.length; index++) augmented[column][index] /= divisor;
|
|
426
|
+
for (let row = 0; row < matrix.length; row++) {
|
|
427
|
+
if (row === column) continue;
|
|
428
|
+
const factor = augmented[row][column];
|
|
429
|
+
for (let index = column; index <= matrix.length; index++) augmented[row][index] -= factor * augmented[column][index];
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return augmented.map((row) => row[matrix.length]);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function sinusoidFit(values: number[], periodSamples: number): { explained: number; phase: number } | undefined {
|
|
436
|
+
const rows = values.map((_value, index) => [1, index, Math.sin(2 * Math.PI * index / periodSamples), Math.cos(2 * Math.PI * index / periodSamples)]);
|
|
437
|
+
const normal = Array.from({ length: 4 }, (_unused, row) => Array.from({ length: 4 }, (_unused2, column) => rows.reduce((sum, item) => sum + item[row] * item[column], 0)));
|
|
438
|
+
const target = Array.from({ length: 4 }, (_unused, column) => rows.reduce((sum, item, index) => sum + item[column] * values[index], 0));
|
|
439
|
+
const coefficients = solve(normal, target);
|
|
440
|
+
if (!coefficients) return undefined;
|
|
441
|
+
const baseline = linearDetrend(values);
|
|
442
|
+
const residual = values.map((value, index) => value - rows[index].reduce((sum, item, column) => sum + item * coefficients[column], 0));
|
|
443
|
+
const baselineSquares = baseline.reduce((sum, value) => sum + value ** 2, 0);
|
|
444
|
+
const residualSquares = residual.reduce((sum, value) => sum + value ** 2, 0);
|
|
445
|
+
return {
|
|
446
|
+
explained: baselineSquares === 0 ? 0 : Math.max(0, Math.min(1, 1 - residualSquares / baselineSquares)),
|
|
447
|
+
phase: Math.atan2(coefficients[3], coefficients[2]),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function descriptive(series: NumericTimeseries, points: Array<{ at: number; value: number }>): SeriesStatistics {
|
|
452
|
+
const values = points.map((point) => point.value);
|
|
453
|
+
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
454
|
+
return {
|
|
455
|
+
label: series.label,
|
|
456
|
+
source: series.source,
|
|
457
|
+
unit: series.unit,
|
|
458
|
+
inputPoints: values.length,
|
|
459
|
+
firstAt: new Date(points[0].at).toISOString(),
|
|
460
|
+
lastAt: new Date(points.at(-1)!.at).toISOString(),
|
|
461
|
+
min: Math.min(...values),
|
|
462
|
+
max: Math.max(...values),
|
|
463
|
+
mean,
|
|
464
|
+
standardDeviation: Math.sqrt(values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / Math.max(1, values.length - 1)),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function compareTimeseries(inputSeries: NumericTimeseries[], options: TimeseriesTransformOptions = {}): TimeseriesComparison {
|
|
469
|
+
if (inputSeries.length < 2) throw new Error("At least two timeseries are required");
|
|
470
|
+
const labels = inputSeries.map((series) => series.label);
|
|
471
|
+
if (new Set(labels).size !== labels.length) throw new Error("Timeseries labels must be unique");
|
|
472
|
+
const requestedStart = options.startAt ? epoch(options.startAt) : undefined;
|
|
473
|
+
const requestedEnd = options.endAt ? epoch(options.endAt) : undefined;
|
|
474
|
+
const prepared = inputSeries.map((series) => preparedPoints(series, requestedStart, requestedEnd));
|
|
475
|
+
prepared.forEach((points, index) => {
|
|
476
|
+
if (points.length < 2) throw new Error(`Timeseries ${inputSeries[index].label} has fewer than two usable points`);
|
|
477
|
+
});
|
|
478
|
+
const start = Math.max(...prepared.map((points) => points[0].at));
|
|
479
|
+
const end = Math.min(...prepared.map((points) => points.at(-1)!.at));
|
|
480
|
+
if (start >= end) throw new Error("Timeseries have no overlapping time range");
|
|
481
|
+
const stepMinutes = options.resampleMinutes ?? 1;
|
|
482
|
+
if (!(stepMinutes > 0)) throw new Error("resampleMinutes must be greater than zero");
|
|
483
|
+
const stepMs = stepMinutes * 60_000;
|
|
484
|
+
const sourceCadences = prepared.map((points) => {
|
|
485
|
+
const intervals = points.slice(1).map((point, index) => (point.at - points[index].at) / 60_000).filter((value) => value > 0).sort((a, b) => a - b);
|
|
486
|
+
return intervals.length === 0 ? stepMinutes : intervals[Math.floor(intervals.length / 2)];
|
|
487
|
+
});
|
|
488
|
+
const maxGapMinutes = options.maxInterpolationGapMinutes ?? Math.max(stepMinutes * 5, ...sourceCadences.map((cadence) => cadence * 3));
|
|
489
|
+
if (!(maxGapMinutes > 0)) throw new Error("maxInterpolationGapMinutes must be greater than zero");
|
|
490
|
+
const gridCount = Math.floor((end - start) / stepMs) + 1;
|
|
491
|
+
if (gridCount > MAX_GRID_POINTS) throw new Error(`Aligned grid exceeds ${MAX_GRID_POINTS} points; increase resampleMinutes or shorten the range`);
|
|
492
|
+
const alignedAt: number[] = [];
|
|
493
|
+
const rawAligned = inputSeries.map(() => [] as number[]);
|
|
494
|
+
for (let at = start; at <= end; at += stepMs) {
|
|
495
|
+
const values = prepared.map((points) => interpolate(points, at, maxGapMinutes * 60_000));
|
|
496
|
+
if (values.some((value) => value === undefined)) continue;
|
|
497
|
+
alignedAt.push(at);
|
|
498
|
+
values.forEach((value, index) => rawAligned[index].push(value!));
|
|
499
|
+
}
|
|
500
|
+
if (alignedAt.length < 3) throw new Error("Fewer than three aligned points remain after interpolation gap filtering");
|
|
501
|
+
const smoothingMinutes = options.smoothingWindowMinutes ?? 0;
|
|
502
|
+
if (smoothingMinutes < 0) throw new Error("smoothingWindowMinutes must not be negative");
|
|
503
|
+
const smoothingWidth = Math.max(1, Math.round(smoothingMinutes / stepMinutes));
|
|
504
|
+
const transformed = rawAligned.map((values) => {
|
|
505
|
+
let output = centeredMean(values, smoothingWidth);
|
|
506
|
+
if ((options.detrend ?? "none") === "linear") output = linearDetrend(output);
|
|
507
|
+
if ((options.normalize ?? "none") === "zscore") output = zscore(output);
|
|
508
|
+
return output;
|
|
509
|
+
});
|
|
510
|
+
const pairs: PairwiseComparison[] = [];
|
|
511
|
+
for (let left = 0; left < inputSeries.length; left++) {
|
|
512
|
+
for (let right = left + 1; right < inputSeries.length; right++) {
|
|
513
|
+
pairs.push({ left: labels[left], right: labels[right], correlation: pearson(transformed[left], transformed[right]) });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
let periodicFits: PeriodicFit[] | undefined;
|
|
517
|
+
let observedCycles: number | undefined;
|
|
518
|
+
const warnings: string[] = [];
|
|
519
|
+
if (options.periodSearch) {
|
|
520
|
+
const { minMinutes, maxMinutes, stepMinutes: searchStep = Math.max(0.1, stepMinutes / 10) } = options.periodSearch;
|
|
521
|
+
if (!(minMinutes > 0 && maxMinutes > minMinutes && searchStep > 0)) throw new Error("periodSearch must have 0 < minMinutes < maxMinutes and a positive stepMinutes");
|
|
522
|
+
let bestPeriod = minMinutes;
|
|
523
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
524
|
+
for (let period = minMinutes; period <= maxMinutes + searchStep / 2; period += searchStep) {
|
|
525
|
+
const fits = transformed.map((values) => sinusoidFit(values, period / stepMinutes));
|
|
526
|
+
const score = fits.reduce((sum, fit) => sum + (fit?.explained ?? 0), 0);
|
|
527
|
+
if (score > bestScore) {
|
|
528
|
+
bestScore = score;
|
|
529
|
+
bestPeriod = period;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
periodicFits = transformed.map((values, index) => {
|
|
533
|
+
const fit = sinusoidFit(values, bestPeriod / stepMinutes)!;
|
|
534
|
+
return {
|
|
535
|
+
label: labels[index],
|
|
536
|
+
periodMinutes: bestPeriod,
|
|
537
|
+
phaseDegrees: ((fit.phase * 180 / Math.PI) % 360 + 360) % 360,
|
|
538
|
+
periodicExplainedFraction: fit.explained,
|
|
539
|
+
};
|
|
540
|
+
});
|
|
541
|
+
for (const pair of pairs) {
|
|
542
|
+
const left = periodicFits.find((fit) => fit.label === pair.left)!;
|
|
543
|
+
const right = periodicFits.find((fit) => fit.label === pair.right)!;
|
|
544
|
+
let difference = left.phaseDegrees - right.phaseDegrees;
|
|
545
|
+
while (difference > 180) difference -= 360;
|
|
546
|
+
while (difference <= -180) difference += 360;
|
|
547
|
+
pair.phaseDifferenceDegrees = difference;
|
|
548
|
+
}
|
|
549
|
+
observedCycles = ((alignedAt.at(-1)! - alignedAt[0]) / 60_000) / bestPeriod;
|
|
550
|
+
if (observedCycles < 5) warnings.push(`Only ${observedCycles.toFixed(1)} cycles of the selected period are observed; periodic inference is preliminary.`);
|
|
551
|
+
}
|
|
552
|
+
warnings.push("Correlation and shared periodicity do not by themselves establish a causal direction or exclude a common driver.");
|
|
553
|
+
if (smoothingMinutes > 0) warnings.push("Smoothing and interpolation introduce autocorrelation; aligned points are not independent observations.");
|
|
554
|
+
const result: TimeseriesComparison = {
|
|
555
|
+
overlap: {
|
|
556
|
+
startAt: new Date(alignedAt[0]).toISOString(),
|
|
557
|
+
endAt: new Date(alignedAt.at(-1)!).toISOString(),
|
|
558
|
+
durationMinutes: (alignedAt.at(-1)! - alignedAt[0]) / 60_000,
|
|
559
|
+
alignedPoints: alignedAt.length,
|
|
560
|
+
resampleMinutes: stepMinutes,
|
|
561
|
+
observedCycles,
|
|
562
|
+
},
|
|
563
|
+
transforms: {
|
|
564
|
+
smoothingWindowMinutes: smoothingMinutes,
|
|
565
|
+
detrend: options.detrend ?? "none",
|
|
566
|
+
normalize: options.normalize ?? "none",
|
|
567
|
+
maxInterpolationGapMinutes: maxGapMinutes,
|
|
568
|
+
},
|
|
569
|
+
series: inputSeries.map((series, index) => descriptive(series, prepared[index])),
|
|
570
|
+
pairs,
|
|
571
|
+
periodicFits,
|
|
572
|
+
warnings,
|
|
573
|
+
};
|
|
574
|
+
if (options.includeAlignedData) {
|
|
575
|
+
if (alignedAt.length > 5_000) throw new Error("includeAlignedData is limited to 5,000 aligned rows; shorten the range or increase resampleMinutes");
|
|
576
|
+
result.alignedData = alignedAt.map((at, row) => ({
|
|
577
|
+
at: new Date(at).toISOString(),
|
|
578
|
+
values: Object.fromEntries(labels.map((label, column) => [label, transformed[column][row]])),
|
|
579
|
+
}));
|
|
580
|
+
}
|
|
581
|
+
return result;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function number(value: number | null | undefined, digits = 3): string {
|
|
585
|
+
return value === null || value === undefined || !Number.isFinite(value) ? "n/a" : value.toFixed(digits);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function timeseriesComparisonText(comparison: TimeseriesComparison): string {
|
|
589
|
+
const lines = [
|
|
590
|
+
"Timeseries comparison",
|
|
591
|
+
`overlap ${comparison.overlap.startAt} → ${comparison.overlap.endAt}`,
|
|
592
|
+
`aligned ${comparison.overlap.alignedPoints} points every ${comparison.overlap.resampleMinutes} min`,
|
|
593
|
+
`transform smooth=${comparison.transforms.smoothingWindowMinutes} min detrend=${comparison.transforms.detrend} normalize=${comparison.transforms.normalize}`,
|
|
594
|
+
"",
|
|
595
|
+
"Series",
|
|
596
|
+
...comparison.series.map((series) => `- ${series.label} (${series.source}): n=${series.inputPoints}, min=${number(series.min)}${series.unit ? ` ${series.unit}` : ""}, max=${number(series.max)}${series.unit ? ` ${series.unit}` : ""}`),
|
|
597
|
+
"",
|
|
598
|
+
"Pairwise correlation",
|
|
599
|
+
...comparison.pairs.map((pair) => `- ${pair.left} vs ${pair.right}: r=${number(pair.correlation)}${pair.phaseDifferenceDegrees === undefined ? "" : `, phase=${number(pair.phaseDifferenceDegrees, 1)}°`}`),
|
|
600
|
+
];
|
|
601
|
+
if (comparison.periodicFits) {
|
|
602
|
+
lines.push("", "Shared-period fit");
|
|
603
|
+
for (const fit of comparison.periodicFits) {
|
|
604
|
+
lines.push(`- ${fit.label}: period=${number(fit.periodMinutes, 1)} min, phase=${number(fit.phaseDegrees, 1)}°, explained=${number(fit.periodicExplainedFraction * 100, 1)}%`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
lines.push("", "Warnings", ...comparison.warnings.map((warning) => `- ${warning}`));
|
|
608
|
+
return lines.join("\n");
|
|
609
|
+
}
|