/* tinycast static-Space engine. * * In the browser it overrides window.fetch for the app's own /api/* routes and * answers them client-side: weights come from weights.bin/meta.json in the * Space, the raw ETTh1 CSV comes straight from GitHub (CORS: *), and the * linear models run as plain matrix-vector products below. Under Node it just * exports the math so the Python test suite can check JS/PyTorch parity. */ (function (root) { "use strict"; // ---------- pure forecasting math (mirrors tinycast/models.py) ---------- function movingAverage(col, kernel) { const L = col.length; const left = Math.floor(kernel / 2); const right = kernel - 1 - left; const padded = new Float64Array(L + kernel - 1); for (let i = 0; i < left; i++) padded[i] = col[0]; for (let i = 0; i < L; i++) padded[left + i] = col[i]; for (let i = 0; i < right; i++) padded[left + L + i] = col[L - 1]; const out = new Float64Array(L); let sum = 0; for (let i = 0; i < kernel; i++) sum += padded[i]; out[0] = sum / kernel; for (let i = 1; i < L; i++) { sum += padded[i + kernel - 1] - padded[i - 1]; out[i] = sum / kernel; } return out; } function matVec(w, rows, cols, v, b) { const out = new Float64Array(rows); for (let r = 0; r < rows; r++) { let s = b ? b[r] : 0; const off = r * cols; for (let c = 0; c < cols; c++) s += w[off + c] * v[c]; out[r] = s; } return out; } function nlinearForward(t, col, H) { const L = col.length; const last = col[L - 1]; const centered = new Float64Array(L); for (let i = 0; i < L; i++) centered[i] = col[i] - last; const out = matVec(t.w.data, H, L, centered, t.b.data); for (let h = 0; h < H; h++) out[h] += last; return out; } function dlinearForward(t, col, H, kernel) { const L = col.length; const trend = movingAverage(col, kernel); const seasonal = new Float64Array(L); for (let i = 0; i < L; i++) seasonal[i] = col[i] - trend[i]; const out = matVec(t.wt.data, H, L, trend, t.bt.data); const s = matVec(t.ws.data, H, L, seasonal, t.bs.data); for (let h = 0; h < H; h++) out[h] += s[h]; return out; } function persistence(col, H) { return new Float64Array(H).fill(col[col.length - 1]); } function seasonalNaive(col, H, period) { period = period || 24; const out = new Float64Array(H); const start = col.length - period; for (let h = 0; h < H; h++) out[h] = col[start + (h % period)]; return out; } const math = { movingAverage, matVec, nlinearForward, dlinearForward, persistence, seasonalNaive }; if (typeof module !== "undefined" && module.exports) { module.exports = math; // Node: parity tests only return; } // ---------- browser: data loading + fetch override ---------- const realFetch = root.fetch.bind(root); let enginePromise = null; async function loadEngine() { const meta = await (await realFetch("meta.json")).json(); const buf = await (await realFetch("weights.bin")).arrayBuffer(); const tensors = {}; for (const [model, parts] of Object.entries(meta.tensors)) { tensors[model] = {}; for (const [name, t] of Object.entries(parts)) { const size = t.shape.reduce((a, b) => a * b, 1); tensors[model][name] = { data: new Float32Array(buf, t.offset * 4, size), shape: t.shape }; } } const csv = await (await realFetch(meta.data_url)).text(); const lines = csv.trim().split("\n"); const header = lines[0].split(","); const otCol = header.indexOf("OT"); const mean = meta.scaler_mean[meta.target_index]; const std = meta.scaler_std[meta.target_index]; const dates = []; const ot = new Float64Array(meta.test_end_row - meta.test_start_row); for (let r = meta.test_start_row; r < meta.test_end_row; r++) { const cells = lines[r + 1].split(","); dates.push(cells[0]); ot[r - meta.test_start_row] = (parseFloat(cells[otCol]) - mean) / std; } return { meta, tensors, ot, dates, mean, std }; } function engine() { if (!enginePromise) enginePromise = loadEngine(); return enginePromise; } async function handle(url) { const e = await engine(); const { meta } = e; const L = meta.seq_len; const nWindows = (h) => e.ot.length - L - h + 1; const u = new URL(url, location.href); if (u.pathname.endsWith("/api/meta") || u.pathname.endsWith("api/meta")) { const n = {}; for (const h of meta.horizons) n[String(h)] = nWindows(h); return { horizons: meta.horizons, n_windows: n, models: ["persistence", "seasonal_naive", "nlinear", "dlinear"], results: meta.results, }; } const index = parseInt(u.searchParams.get("index") || "0", 10); const H = parseInt(u.searchParams.get("horizon") || "96", 10); if (!meta.horizons.includes(H)) throw new Error("horizon not exported: " + H); if (index < 0 || index >= nWindows(H)) throw new Error("index out of range"); const col = e.ot.subarray(index, index + L); const actual = e.ot.subarray(index + L, index + L + H); const toC = (a) => Array.from(a, (z) => Math.round((z * e.std + e.mean) * 1000) / 1000); const forecasts = { persistence: persistence(col, H), seasonal_naive: seasonalNaive(col, H), nlinear: nlinearForward(e.tensors["nlinear_" + H], col, H), dlinear: dlinearForward(e.tensors["dlinear_" + H], col, H, meta.moving_avg), }; const mae = {}; for (const [k, f] of Object.entries(forecasts)) { let s = 0; for (let h = 0; h < H; h++) s += Math.abs(f[h] - actual[h]); mae[k] = Math.round((s / H) * e.std * 1000) / 1000; } const out = {}; for (const [k, f] of Object.entries(forecasts)) out[k] = toC(f); return { index, n_windows: nWindows(H), horizon: H, t0: e.dates[index + L], history_ot: toC(col), actual_ot: toC(actual), forecasts: out, window_mae_c: mae, }; } root.fetch = function (url, opts) { const u = String(url); if (u.startsWith("api/") || u.startsWith("/api/")) { return handle(u).then( (data) => new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json" } }), (err) => new Response(String(err && err.message), { status: 500 }) ); } return realFetch(url, opts); }; })(typeof window !== "undefined" ? window : globalThis);