From ceb4647093553c01afc462c7caa6a0ef240be69b Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Mon, 8 Sep 2025 16:21:30 +0200 Subject: [PATCH 1/4] Add UTC option on timeseries axis --- apps/class-solid/src/components/Analysis.tsx | 13 ++++++++----- packages/class/src/class.ts | 7 +++++++ packages/class/src/config.ts | 10 +++++++++- packages/class/src/config_utils.test.ts | 3 +++ packages/class/src/config_utils.ts | 2 +- packages/class/src/output.ts | 5 +++++ packages/class/src/validate.test.ts | 1 + 7 files changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 70c91e95..42e7d60a 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -185,11 +185,14 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) { setResetPlot(analysis.id); }; - const formatX = () => - analysis.xVariable === "t" ? formatSeconds : d3.format(".4"); - const formatY = () => - analysis.yVariable === "t" ? formatSeconds : d3.format(".4"); - + // Define axis format + const formatters: Record string> = { + t: formatSeconds, + utcTime: (t) => d3.utcFormat("%H:%M")(new Date(t)), + default: d3.format(".4"), + }; + const formatX = () => formatters[analysis.xVariable] ?? formatters.default; + const formatY = () => formatters[analysis.yVariable] ?? formatters.default; return ( <> {/* TODO: get label for yVariable from model config */} diff --git a/packages/class/src/class.ts b/packages/class/src/class.ts index 9fd6142e..5782516b 100644 --- a/packages/class/src/class.ts +++ b/packages/class/src/class.ts @@ -270,6 +270,13 @@ export class CLASS { return this.ml?.qt || 999; } + get utcTime() { + // export time in milliseconds since epoch so bounds calculation and + // rendering can happen on app side + const t0 = new Date(this._cfg.t0).getTime(); + return t0 + this.t * 1000; + } + /** * Retrieve a value by name, treating nested state (wind, ml) as if it's flat. * diff --git a/packages/class/src/config.ts b/packages/class/src/config.ts index 11a945d4..5423eca8 100644 --- a/packages/class/src/config.ts +++ b/packages/class/src/config.ts @@ -28,6 +28,13 @@ const untypedSchema = { default: "", "ui:widget": "textarea", }, + // Start date / time in utc + t0: { + type: "string", + title: "Start date and time (ISO 8601)", + default: new Date().toISOString(), + "ui:group": "Time Control", + }, dt: { type: "integer", unit: "s", @@ -63,7 +70,7 @@ const untypedSchema = { default: false, }, }, - required: ["name", "dt", "runtime"], + required: ["name", "t0", "dt", "runtime"], allOf: [ { if: { @@ -479,6 +486,7 @@ const untypedSchema = { type GeneralConfig = { name: string; description?: string; + t0: string; dt: number; runtime: number; }; diff --git a/packages/class/src/config_utils.test.ts b/packages/class/src/config_utils.test.ts index d820e091..4495c0a4 100644 --- a/packages/class/src/config_utils.test.ts +++ b/packages/class/src/config_utils.test.ts @@ -7,6 +7,7 @@ describe("pruneConfig()", () => { const preset = { name: "Default", description: "Default configuration", + t0: "2025-09-08T14:30:00Z", theta_0: 323, h_0: 200, dtheta_0: 1, @@ -26,6 +27,7 @@ describe("pruneConfig()", () => { const reference = { name: "Higher and Hotter", description: "Higher h_0", + t0: "2025-09-08T14:30:00Z", h_0: 211, theta_0: 323, dtheta_0: 1, @@ -45,6 +47,7 @@ describe("pruneConfig()", () => { const permutation = { name: "Higher", description: "", + t0: "2025-09-08T14:30:00Z", h_0: 222, theta_0: 323, dtheta_0: 1, diff --git a/packages/class/src/config_utils.ts b/packages/class/src/config_utils.ts index 7a6ce861..e5e185b7 100644 --- a/packages/class/src/config_utils.ts +++ b/packages/class/src/config_utils.ts @@ -73,7 +73,7 @@ export function pruneConfig( for (const key in config) { const k = key as keyof typeof config; const k2 = key as keyof typeof config; - if (typeof config[k] === "string") { + if (k === "name" || k === "description") { // Do not prune name and description continue; } diff --git a/packages/class/src/output.ts b/packages/class/src/output.ts index 7fa5e587..5182789d 100644 --- a/packages/class/src/output.ts +++ b/packages/class/src/output.ts @@ -10,6 +10,11 @@ export const outputVariables = { unit: "s", symbol: "t", }, + utcTime: { + title: "Time (UTC)", + unit: "-", + symbol: "UTC", + }, h: { title: "ABL height", unit: "m", diff --git a/packages/class/src/validate.test.ts b/packages/class/src/validate.test.ts index 40fcde2e..f9dc3910 100644 --- a/packages/class/src/validate.test.ts +++ b/packages/class/src/validate.test.ts @@ -50,6 +50,7 @@ describe("parse", () => { const output = parse(input); const expected = { + t0: output.t0, // dynamic default h: 200, theta: 288, dtheta: 1, From 582f28c4edaf22ca3faee66ec2d7ab8ad45f3349 Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Mon, 8 Sep 2025 16:51:46 +0200 Subject: [PATCH 2/4] Make time slider use UTC time by default + make UTC default in timeseries plot --- apps/class-solid/src/components/Analysis.tsx | 16 ++++++++++------ apps/class-solid/src/lib/store.ts | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 42e7d60a..8723d254 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -119,7 +119,7 @@ const flatObservations: () => Observation[] = createMemo(() => { }); const _allTimes = () => - new Set(flatExperiments().flatMap((e) => e.output?.t ?? [])); + new Set(flatExperiments().flatMap((e) => e.output?.utcTime ?? [])); const uniqueTimes = () => [...new Set(_allTimes())].sort((a, b) => a - b); // TODO: could memoize all reactive elements here, would it make a difference? @@ -188,7 +188,7 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) { // Define axis format const formatters: Record string> = { t: formatSeconds, - utcTime: (t) => d3.utcFormat("%H:%M")(new Date(t)), + utcTime: formatUTC, default: d3.format(".4"), }; const formatX = () => formatters[analysis.xVariable] ?? formatters.default; @@ -267,7 +267,7 @@ export function VerticalProfilePlot({ const profileData = () => flatExperiments().map((e) => { const { config, output, ...formatting } = e; - const t = output?.t.indexOf(uniqueTimes()[analysis.time]); + const t = output?.utcTime.indexOf(uniqueTimes()[analysis.time]); if (config.sw_ml && output && t !== undefined && t !== -1) { const outputAtTime = getOutputAtTime(output, t); return { ...formatting, data: generateProfiles(config, outputAtTime) }; @@ -410,6 +410,10 @@ function formatSeconds(seconds: number): string { return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; } +function formatUTC(milliseconds: number): string { + return d3.utcFormat("%H:%M")(new Date(milliseconds)); +} + function TimeSlider( time: Accessor, timeOptions: Accessor, @@ -432,12 +436,12 @@ function TimeSlider( class="w-full max-w-md" >
-

Time:

+

Time (UTC):

-

{formatSeconds(timeOptions()[time()])}

+

{formatUTC(timeOptions()[time()])}

); @@ -471,7 +475,7 @@ export function ThermodynamicPlot({ analysis }: { analysis: SkewTAnalysis }) { const profileData = () => flatExperiments().map((e) => { const { config, output, ...formatting } = e; - const t = output?.t.indexOf(uniqueTimes()[analysis.time]); + const t = output?.utcTime.indexOf(uniqueTimes()[analysis.time]); if (config.sw_ml && output && t !== undefined && t !== -1) { const outputAtTime = getOutputAtTime(output, t); return { ...formatting, data: generateProfiles(config, outputAtTime) }; diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index 2e025559..13ff8ba9 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -279,7 +279,7 @@ export function addAnalysis(name: string) { description: "", type: "timeseries", name: "Timeseries", - xVariable: "t", + xVariable: "utcTime", yVariable: "h", } as TimeseriesAnalysis; break; From de73f838db1adac2152c7d5b50e338784b44f676 Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Mon, 8 Sep 2025 17:12:04 +0200 Subject: [PATCH 3/4] Adjust granularity for UTC time --- apps/class-solid/src/components/Analysis.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 8723d254..516b7660 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -140,9 +140,15 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) { e.output ? e.output[analysis.yVariable as OutputVariableKey] : [], ); - const granularity = () => (analysis.xVariable === "t" ? 600 : undefined); - const xLim = () => getNiceAxisLimits(allX(), 0, granularity()); - const yLim = () => getNiceAxisLimits(allY()); + const granularities: Record = { + t: 600, // 10 minutes in seconds + utcTime: 60_000, // 1 minute in milliseconds + default: undefined, + }; + + const roundTo = (v: string) => granularities[v] ?? granularities.default; + const xLim = () => getNiceAxisLimits(allX(), 0, roundTo(analysis.xVariable)); + const yLim = () => getNiceAxisLimits(allY(), 0, roundTo(analysis.yVariable)); const chartData = () => flatExperiments().map((e) => { From a296609c7bdded42c054bd6f3a1f7af36ab6d102 Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Mon, 8 Sep 2025 17:12:25 +0200 Subject: [PATCH 4/4] Include final time in output --- packages/class/src/runner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/class/src/runner.ts b/packages/class/src/runner.ts index 480874a3..e3ca529f 100644 --- a/packages/class/src/runner.ts +++ b/packages/class/src/runner.ts @@ -42,7 +42,7 @@ export function runClass(config: Config, freq = 600): ClassOutput { writeOutput(); // Update loop - while (model.t < config.runtime) { + while (model.t <= config.runtime) { model.update(); if (model.t % freq === 0) {