diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 70c91e9..516b766 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? @@ -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) => { @@ -185,11 +191,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: formatUTC, + 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 */} @@ -264,7 +273,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) }; @@ -407,6 +416,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, @@ -429,12 +442,12 @@ function TimeSlider( class="w-full max-w-md" >
-

Time:

+

Time (UTC):

-

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

+

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

); @@ -468,7 +481,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 2e02555..13ff8ba 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; diff --git a/packages/class/src/class.ts b/packages/class/src/class.ts index 9fd6142..5782516 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 11a945d..5423eca 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 d820e09..4495c0a 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 7a6ce86..e5e185b 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 7fa5e58..5182789 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/runner.ts b/packages/class/src/runner.ts index 480874a..e3ca529 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) { diff --git a/packages/class/src/validate.test.ts b/packages/class/src/validate.test.ts index 40fcde2..f9dc391 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,