Files
SystemSimulationApp/frontend/src/resultCsvExport.ts
T
lujingze 3bc4be3c06 优化原生结果编码传输与浏览器缓存,记录八路性能基线
原生结果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。
2026-09-11 15:09:15 +00:00

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);
}
});
}