原生结果series通过字节索引直传,C端使用Ryu精确回读编码和64 KiB批量写出;网页采用Float64缓存和CSV工作线程,减少结果处理与保存等待。 补充八路AME曲线核查、全流程分阶段计时、独立编码基准和复现工具,固定后续优化采用修正八路及rtol=1e-8。C写出1.1808→0.1638 s,点击到可查看8.0100→6.9756 s。 验证:最终10项编码专项、29项相关后端回归通过;8份原生结果逐位一致,16次网页结果/CSV/刷新恢复通过。前端构建及缓存/CSV专项在本轮结果处理工作中通过。环境、原始大结果与临时构建不纳入Git。
129 lines
5.5 KiB
TypeScript
129 lines
5.5 KiB
TypeScript
export type ResultCsvInput = {
|
|
variables: readonly { key: string }[];
|
|
series: Readonly<Record<string, readonly number[]>>;
|
|
};
|
|
|
|
export type ResultCsvWorkerMessage =
|
|
| { type: "start"; keys: string[]; rowCount: number }
|
|
| { type: "chunk"; offset: number; values: Float64Array }
|
|
| { type: "finish" };
|
|
export type ResultCsvWorkerResponse =
|
|
| { type: "complete"; blob: Blob }
|
|
| { type: "error"; message: string };
|
|
|
|
function csvColumns(input: ResultCsvInput) {
|
|
const times = input.series.time;
|
|
if (!Array.isArray(times) || !times.length) {
|
|
throw new Error("Simulation results must contain a non-empty time series.");
|
|
}
|
|
const variableKeys = input.variables.map((variable) => variable.key);
|
|
if (!variableKeys.length) {
|
|
throw new Error("Simulation results do not contain exportable variables.");
|
|
}
|
|
if (new Set(variableKeys).size !== variableKeys.length) {
|
|
throw new Error("Simulation result metadata contains duplicate variable keys.");
|
|
}
|
|
const keys = ["time", ...variableKeys];
|
|
const expectedKeys = new Set(keys);
|
|
const missing = [...expectedKeys].filter((key) => !Object.hasOwn(input.series, key)).sort();
|
|
const unknown = Object.keys(input.series).filter((key) => !expectedKeys.has(key)).sort();
|
|
if (missing.length || unknown.length) {
|
|
const details = [missing.length ? `missing ${missing.join(", ")}` : "", unknown.length ? `unmapped ${unknown.join(", ")}` : ""].filter(Boolean);
|
|
throw new Error(`Simulation result columns do not match metadata: ${details.join("; ")}.`);
|
|
}
|
|
for (const key of keys) {
|
|
if (!Array.isArray(input.series[key]) || input.series[key].length !== times.length) {
|
|
throw new Error(`Simulation result column '${key}' has an inconsistent length.`);
|
|
}
|
|
}
|
|
return { keys, rowCount: times.length };
|
|
}
|
|
|
|
export function resultCsvFilename(projectName: string): string {
|
|
const stem = projectName.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").replace(/^[ .]+|[ .]+$/g, "");
|
|
return `${Array.from(stem).slice(0, 80).join("") || "simulation"}-results.csv`;
|
|
}
|
|
|
|
/** Transfer at most 1 MiB per task; never stringify or clone the full result. */
|
|
export function exportResultCsv(input: ResultCsvInput, signal?: AbortSignal): Promise<Blob> {
|
|
return new Promise((resolve, reject) => {
|
|
if (signal?.aborted) {
|
|
reject(new DOMException("CSV export was cancelled.", "AbortError"));
|
|
return;
|
|
}
|
|
let worker: Worker | undefined;
|
|
let settled = false;
|
|
let yieldTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let resumeYield: (() => void) | undefined;
|
|
const finish = (error?: unknown, blob?: Blob) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
signal?.removeEventListener("abort", abort);
|
|
if (yieldTimer !== undefined) clearTimeout(yieldTimer);
|
|
resumeYield?.();
|
|
worker?.terminate();
|
|
if (error !== undefined) reject(error);
|
|
else resolve(blob!);
|
|
};
|
|
const abort = () => finish(new DOMException("CSV export was cancelled.", "AbortError"));
|
|
signal?.addEventListener("abort", abort, { once: true });
|
|
const yieldMainThread = () => new Promise<void>((resume) => {
|
|
resumeYield = resume;
|
|
yieldTimer = setTimeout(() => {
|
|
yieldTimer = undefined;
|
|
resumeYield = undefined;
|
|
resume();
|
|
}, 0);
|
|
});
|
|
try {
|
|
const { keys, rowCount } = csvColumns(input);
|
|
worker = new Worker(new URL("./resultCsv.worker.ts", import.meta.url), { type: "module" });
|
|
worker.onmessage = ({ data }: MessageEvent<ResultCsvWorkerResponse>) => {
|
|
if (data.type === "complete" && data.blob instanceof Blob) finish(undefined, data.blob);
|
|
else finish(new Error(data.type === "error" ? data.message : "CSV worker returned an invalid response."));
|
|
};
|
|
worker.onerror = (event) => {
|
|
event.preventDefault();
|
|
finish(new Error(event.message || "CSV worker failed."));
|
|
};
|
|
worker.onmessageerror = () => finish(new Error("CSV worker response could not be read."));
|
|
const transfer = async () => {
|
|
// Let the pending indicator render before copying any numeric payload.
|
|
await yieldMainThread();
|
|
if (settled) return;
|
|
worker!.postMessage({ type: "start", keys, rowCount } satisfies ResultCsvWorkerMessage);
|
|
const total = keys.length * rowCount;
|
|
let column = 0;
|
|
let row = 0;
|
|
for (let offset = 0; offset < total;) {
|
|
if (settled) return;
|
|
const batch = new Float64Array(Math.min(128 * 1024, total - offset));
|
|
let filled = 0;
|
|
while (filled < batch.length) {
|
|
const source = input.series[keys[column]];
|
|
const count = Math.min(rowCount - row, batch.length - filled);
|
|
for (let index = 0; index < count; index++) {
|
|
const value = source[row + index];
|
|
if (typeof value !== "number") {
|
|
throw new Error(`Simulation result column '${keys[column]}' contains non-numeric values.`);
|
|
}
|
|
batch[filled + index] = value;
|
|
}
|
|
row += count;
|
|
filled += count;
|
|
if (row === rowCount) { column++; row = 0; }
|
|
}
|
|
const transferredCount = batch.length;
|
|
worker!.postMessage({ type: "chunk", offset, values: batch } satisfies ResultCsvWorkerMessage, [batch.buffer]);
|
|
offset += transferredCount;
|
|
if (offset < total) await yieldMainThread();
|
|
}
|
|
if (!settled) worker!.postMessage({ type: "finish" } satisfies ResultCsvWorkerMessage);
|
|
};
|
|
void transfer().catch(finish);
|
|
} catch (error) {
|
|
finish(error);
|
|
}
|
|
});
|
|
}
|