@cfasim-ui/shared 0.9.3 → 0.10.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/shared",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Shared utilities for cfasim-ui",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { ModelOutput } from "./ModelOutput.js";
2
+ import { ModelOutput, DATE_MISSING, isoDate } from "./ModelOutput.js";
3
3
  import type {
4
4
  ModelOutputWire,
5
5
  ModelOutputsWire,
@@ -207,3 +207,20 @@ describe("transfer protocol with ModelOutputs", () => {
207
207
  expect(unwrapResponse(response)).toBe("hello");
208
208
  });
209
209
  });
210
+
211
+ describe("date columns", () => {
212
+ it("decodes days since the epoch to ISO strings and CSV", () => {
213
+ const output = new ModelOutput(
214
+ 3,
215
+ [{ name: "when", type: "date" }],
216
+ [new Uint32Array([0, 20458, DATE_MISSING])],
217
+ );
218
+ expect(output.dates("when")).toEqual(["1970-01-01", "2026-01-05", null]);
219
+ expect(isoDate(20465)).toBe("2026-01-12");
220
+ expect(isoDate(0)).toBe("1970-01-01");
221
+ expect(isoDate(2_932_897)).toBe("10000-01-01");
222
+ expect(isoDate(4_000_000_000)).toBeNull();
223
+ expect(modelOutputToCSV(output)).toBe("when\n1970-01-01\n2026-01-05\n");
224
+ expect(() => output.dates("nope")).toThrow();
225
+ });
226
+ });
@@ -1,4 +1,8 @@
1
- export type ColumnType = "i32" | "u32" | "f64" | "bool" | "enum";
1
+ export type ColumnType = "i32" | "u32" | "f64" | "bool" | "enum" | "date";
2
+
3
+ /** Value of a `date` column with no date (`DATE_MISSING` on the Python side). */
4
+ export const DATE_MISSING = 0xffffffff;
5
+ const DAY_MS = 86_400_000;
2
6
 
3
7
  export interface ColumnDescriptor {
4
8
  name: string;
@@ -20,17 +24,27 @@ export interface ModelOutputsWire {
20
24
  outputs: Record<string, ModelOutputWire>;
21
25
  }
22
26
 
23
- const COLUMN_TYPE_TO_CTOR: Record<
27
+ export const COLUMN_TYPE_TO_CTOR: Record<
24
28
  ColumnType,
25
- new (buffer: ArrayBuffer) => TypedColumn
29
+ { new (buffer: ArrayBuffer): TypedColumn; BYTES_PER_ELEMENT: number }
26
30
  > = {
27
31
  f64: Float64Array,
28
32
  i32: Int32Array,
29
33
  u32: Uint32Array,
30
34
  bool: Uint8Array,
31
35
  enum: Uint32Array,
36
+ date: Uint32Array,
32
37
  };
33
38
 
39
+ /** ISO `yyyy-mm-dd` for a `date` column value (days since 1970-01-01). */
40
+ export function isoDate(days: number): string | null {
41
+ const date = new Date(days * DAY_MS);
42
+ if (days === DATE_MISSING || Number.isNaN(date.getTime())) return null;
43
+ // Not toISOString, which writes years past 9999 as "+010000".
44
+ const pad = (n: number, width = 2) => String(n).padStart(width, "0");
45
+ return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
46
+ }
47
+
34
48
  export class ModelOutput {
35
49
  readonly length: number;
36
50
  readonly columns: readonly ColumnDescriptor[];
@@ -66,6 +80,14 @@ export class ModelOutput {
66
80
  return desc.enumLabels[index];
67
81
  }
68
82
 
83
+ /** A `date` column as ISO strings (`null` where the date is missing). */
84
+ dates(columnName: string): (string | null)[] {
85
+ if (this.descriptor(columnName).type !== "date") {
86
+ throw new Error(`Column ${columnName} is not a date`);
87
+ }
88
+ return Array.from(this.column(columnName), isoDate);
89
+ }
90
+
69
91
  get names(): string[] {
70
92
  return this.columns.map((c) => c.name);
71
93
  }
package/src/csv.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ModelOutput } from "./ModelOutput.js";
2
+ import { isoDate } from "./ModelOutput.js";
2
3
 
3
4
  export function modelOutputToCSV(output: ModelOutput): string {
4
5
  const header = output.names.join(",");
@@ -10,6 +11,8 @@ export function modelOutputToCSV(output: ModelOutput): string {
10
11
  const val = output.buffers[c][r];
11
12
  if (desc.type === "enum") {
12
13
  cells.push(desc.enumLabels![val as number]);
14
+ } else if (desc.type === "date") {
15
+ cells.push(isoDate(val as number) ?? "");
13
16
  } else if (desc.type === "bool") {
14
17
  cells.push(val ? "true" : "false");
15
18
  } else {
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { decodeModelOutputs } from "./decodeModelOutputs.js";
5
+
6
+ // Shared protocol fixtures, written by the Python reference implementation.
7
+ // (__dirname rather than import.meta.url: happy-dom gives the latter an http scheme.)
8
+ const FIXTURES = resolve(__dirname, "../../../cfasim-model-py/tests/fixtures");
9
+
10
+ function fixtureBuffer(name: string): ArrayBuffer {
11
+ const bytes = readFileSync(resolve(FIXTURES, name));
12
+ return bytes.buffer.slice(
13
+ bytes.byteOffset,
14
+ bytes.byteOffset + bytes.byteLength,
15
+ ) as ArrayBuffer;
16
+ }
17
+
18
+ const expected = JSON.parse(
19
+ readFileSync(resolve(FIXTURES, "sample.columns.json"), "utf8"),
20
+ ) as Record<string, Record<string, number[]>>;
21
+
22
+ describe("decodeModelOutputs", () => {
23
+ it("decodes the shared fixture into ModelOutput tables", () => {
24
+ const outputs = decodeModelOutputs(fixtureBuffer("sample.cfasim"));
25
+ expect(Object.keys(outputs).sort()).toEqual(["empty", "series"]);
26
+ for (const [table, columns] of Object.entries(expected)) {
27
+ for (const [name, values] of Object.entries(columns)) {
28
+ expect(Array.from(outputs[table].column(name))).toEqual(values);
29
+ }
30
+ }
31
+ expect(outputs.series.column("time")).toBeInstanceOf(Float64Array);
32
+ expect(outputs.series.column("delta")).toBeInstanceOf(Int32Array);
33
+ expect(outputs.series.column("count")).toBeInstanceOf(Uint32Array);
34
+ expect(outputs.series.column("flag")).toBeInstanceOf(Uint8Array);
35
+ const status = outputs.series.column("status");
36
+ expect(outputs.series.label("status", status[1])).toBe("R");
37
+ expect(outputs.empty.length).toBe(0);
38
+ });
39
+
40
+ it("matches the committed header", () => {
41
+ const buf = fixtureBuffer("sample.cfasim");
42
+ const headerLength = new DataView(buf).getUint32(0, true);
43
+ const header = JSON.parse(
44
+ new TextDecoder().decode(new Uint8Array(buf, 4, headerLength)),
45
+ );
46
+ const dataStart = 4 + headerLength;
47
+ const committed = JSON.parse(
48
+ readFileSync(resolve(FIXTURES, "sample.header.json"), "utf8"),
49
+ );
50
+ expect(header).toEqual(committed);
51
+ expect(dataStart % 8).toBe(0);
52
+ });
53
+
54
+ it("rejects truncated and malformed files", () => {
55
+ const buf = fixtureBuffer("sample.cfasim");
56
+ expect(() => decodeModelOutputs(buf.slice(0, buf.byteLength - 4))).toThrow(
57
+ /truncated/,
58
+ );
59
+ expect(() => decodeModelOutputs(buf.slice(0, 2))).toThrow(/truncated/);
60
+ const bad = new Uint8Array(buf.slice(0));
61
+ const text = new TextDecoder().decode(bad.subarray(4, 24));
62
+ const idx = 4 + text.indexOf('"version":1') + '"version":'.length;
63
+ bad[idx] = "2".charCodeAt(0);
64
+ expect(() => decodeModelOutputs(bad.buffer as ArrayBuffer)).toThrow(
65
+ /version/,
66
+ );
67
+ });
68
+ });
@@ -0,0 +1,80 @@
1
+ import type { ColumnDescriptor } from "./ModelOutput.js";
2
+ import { COLUMN_TYPE_TO_CTOR, ModelOutput } from "./ModelOutput.js";
3
+
4
+ /**
5
+ * Header of a `.cfasim` outputs file. The file is a little-endian u32 header
6
+ * length, a UTF-8 JSON header (space-padded to an 8-byte boundary), then the
7
+ * raw column buffers; `byteOffsets` are relative to the start of the buffers.
8
+ */
9
+ export interface ModelOutputsFileTable {
10
+ length: number;
11
+ columns: ColumnDescriptor[];
12
+ byteOffsets: number[];
13
+ /** Unpadded byte length per column. */
14
+ byteLengths: number[];
15
+ }
16
+
17
+ export interface ModelOutputsFileHeader {
18
+ version: number;
19
+ outputs: Record<string, ModelOutputsFileTable>;
20
+ }
21
+
22
+ function readModelOutputsHeader(buffer: ArrayBuffer): {
23
+ header: ModelOutputsFileHeader;
24
+ dataStart: number;
25
+ } {
26
+ if (buffer.byteLength < 4) {
27
+ throw new Error("outputs file is truncated (no header length)");
28
+ }
29
+ const headerLength = new DataView(buffer).getUint32(0, true);
30
+ const dataStart = 4 + headerLength;
31
+ if (dataStart > buffer.byteLength) {
32
+ throw new Error("outputs file is truncated (header)");
33
+ }
34
+ const text = new TextDecoder().decode(
35
+ new Uint8Array(buffer, 4, headerLength),
36
+ );
37
+ const header = JSON.parse(text) as ModelOutputsFileHeader;
38
+ if (header.version !== 1) {
39
+ throw new Error(`unsupported outputs file version ${header.version}`);
40
+ }
41
+ return { header, dataStart };
42
+ }
43
+
44
+ /**
45
+ * Decode a `.cfasim` outputs file (as served by `GET /api/runs/{id}/outputs`
46
+ * or read from disk) into named `ModelOutput` tables. Column buffers are
47
+ * copied out of `buffer`, so the file bytes can be released afterwards.
48
+ */
49
+ export function decodeModelOutputs(
50
+ buffer: ArrayBuffer,
51
+ ): Record<string, ModelOutput> {
52
+ const { header, dataStart } = readModelOutputsHeader(buffer);
53
+ const result: Record<string, ModelOutput> = {};
54
+ for (const [name, table] of Object.entries(header.outputs)) {
55
+ const buffers = table.columns.map((col, i) => {
56
+ const bpe = COLUMN_TYPE_TO_CTOR[col.type]?.BYTES_PER_ELEMENT;
57
+ if (bpe === undefined) {
58
+ throw new Error(`column ${col.name} has unknown type ${col.type}`);
59
+ }
60
+ const start = dataStart + table.byteOffsets[i];
61
+ const bytes = table.byteLengths[i];
62
+ if (bytes !== table.length * bpe) {
63
+ throw new Error(
64
+ `column ${col.name} has ${bytes} bytes for ${table.length} rows`,
65
+ );
66
+ }
67
+ if (start + bytes > buffer.byteLength) {
68
+ throw new Error(`outputs file is truncated (column ${col.name})`);
69
+ }
70
+ return buffer.slice(start, start + bytes);
71
+ });
72
+ result[name] = ModelOutput.fromWire({
73
+ __modelOutput: true,
74
+ length: table.length,
75
+ columns: table.columns,
76
+ buffers,
77
+ });
78
+ }
79
+ return result;
80
+ }
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ export {
5
5
  unwrapResponse,
6
6
  } from "./transferUtils.js";
7
7
  export type { TransferableResponse } from "./transferUtils.js";
8
- export { ModelOutput } from "./ModelOutput.js";
8
+ export { ModelOutput, isoDate, DATE_MISSING } from "./ModelOutput.js";
9
9
  export type {
10
10
  ColumnDescriptor,
11
11
  ColumnType,
@@ -14,6 +14,11 @@ export type {
14
14
  ModelOutputsWire,
15
15
  } from "./ModelOutput.js";
16
16
  export { modelOutputToCSV } from "./csv.js";
17
+ export { decodeModelOutputs } from "./decodeModelOutputs.js";
18
+ export type {
19
+ ModelOutputsFileHeader,
20
+ ModelOutputsFileTable,
21
+ } from "./decodeModelOutputs.js";
17
22
  export { describeWorkerError, MESSAGEERROR_TEXT } from "./workerError.js";
18
23
  export { formatNumber, isNumberFormat } from "./formatNumber.js";
19
24
  export type { NumberFormat, NumberFormatPreset } from "./formatNumber.js";