diff --git a/README.md b/README.md index c0184d4..27fc3eb 100644 --- a/README.md +++ b/README.md @@ -117,10 +117,8 @@ by [Kobalte](https://kobalte.dev/docs/core/overview/introduction) and application and tweaked further as seen fit. It can also do charts, using [chart.js](https://www.chartjs.org/), though we might deviate from that later. -To expose the model in a standard way we use the [Basic Model Interface (BMI)](https://bmi.readthedocs.io/). - To prevent the user interface from getting blocked by running the model we use a [Web worker](https://developer.mozilla.org/en-US/docs/Web/API/Worker) to run the computation in a background task/thread. -A Web worker uses messages passing to communicate, this does not fit with the Basic Model Interface so we use [comlink](https://github.com/GoogleChromeLabs/comlink) to wrap the Web Worker in a BMI class. +We use [comlink](https://github.com/GoogleChromeLabs/comlink) to wrap the Web Worker so it behaves the same as if the runner was used directly inside the main thread. To format and lint the code, we use [biome](https://biomejs.dev/) as it combines eslint, prettier in one package. diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 76d74a5..95288f9 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -1,6 +1,6 @@ -import { BmiClass } from "@classmodel/class/bmi"; import type { Config } from "@classmodel/class/config"; -import type { ClassOutput } from "@classmodel/class/runner"; +import { type ClassOutput, outputVariables } from "@classmodel/class/runner"; +import * as d3 from "d3"; import { saveAs } from "file-saver"; import { toBlob } from "html-to-image"; import { @@ -115,9 +115,15 @@ const uniqueTimes = () => [...new Set(_allTimes())].sort((a, b) => a - b); // TODO: could memoize all reactive elements here, would it make a difference? export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) { - const xVariableOptions = ["t"]; // TODO: separate plot types for timeseries and x-vs-y? Use time axis? - // TODO: add nice description from config as title and dropdown option for the variable picker. - const yVariableOptions = new BmiClass().get_output_var_names(); + const symbols = Object.fromEntries( + outputVariables.map((v) => [v.key, v.symbol]), + ); + const getKey = Object.fromEntries( + outputVariables.map((v) => [v.symbol, v.key]), + ); + const labels = Object.fromEntries( + outputVariables.map((v) => [v.key, `${v.symbol} [${v.unit}]`]), + ); const allX = () => flatExperiments().flatMap((e) => @@ -128,7 +134,8 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) { e.output ? e.output[analysis.yVariable] : [], ); - const xLim = () => getNiceAxisLimits(allX(), 0, 600); + const granularity = () => (analysis.xVariable === "t" ? 600 : undefined); + const xLim = () => getNiceAxisLimits(allX(), 0, granularity()); const yLim = () => getNiceAxisLimits(allY()); const chartData = () => @@ -158,14 +165,34 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) { setToggles(label, value); } + const setXVar = (symbol: string) => { + updateAnalysis(analysis, { xVariable: getKey[symbol] }); + setResetPlot(analysis.id); + }; + + const setYVar = (symbol: string) => { + updateAnalysis(analysis, { yVariable: getKey[symbol] }); + setResetPlot(analysis.id); + }; + + const formatX = () => + analysis.xVariable === "t" ? formatSeconds : d3.format(".4"); + const formatY = () => + analysis.yVariable === "t" ? formatSeconds : d3.format(".4"); + return ( <> {/* TODO: get label for yVariable from model config */} - - - + + + {(d) => ( @@ -177,15 +204,15 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) {
analysis.xVariable} - setValue={(v) => updateAnalysis(analysis, { xVariable: v })} - options={xVariableOptions} + value={() => symbols[analysis.xVariable]} + setValue={(v) => setXVar(v)} + options={Object.values(symbols)} label="x-axis" /> analysis.yVariable} - setValue={(v) => updateAnalysis(analysis, { yVariable: v })} - options={yVariableOptions} + value={() => symbols[analysis.yVariable]} + setValue={(v) => setYVar(v)} + options={Object.values(symbols)} label="y-axis" />
@@ -261,6 +288,11 @@ export function VerticalProfilePlot({ setToggles(label, value); } + function changeVar(v: string) { + updateAnalysis(analysis, { variable: v }); + setResetPlot(analysis.id); + } + return ( <>
@@ -291,7 +323,7 @@ export function VerticalProfilePlot({ analysis.variable} - setValue={(v) => updateAnalysis(analysis, { variable: v })} + setValue={(v) => changeVar(v)} options={Object.keys(variableOptions)} label="variable: " /> @@ -307,7 +339,7 @@ export function VerticalProfilePlot({ type PickerProps = { value: Accessor; - setValue: Setter; + setValue: (value: string) => void; options: string[]; label?: string; }; diff --git a/apps/class-solid/src/components/plots/ChartContainer.tsx b/apps/class-solid/src/components/plots/ChartContainer.tsx index 4cd50a6..02a3859 100644 --- a/apps/class-solid/src/components/plots/ChartContainer.tsx +++ b/apps/class-solid/src/components/plots/ChartContainer.tsx @@ -119,8 +119,8 @@ export function Chart(props: { children: JSX.Element; id: string; title?: string; - formatX?: (value: number) => string; - formatY?: (value: number) => string; + formatX?: () => (value: number) => string; + formatY?: () => (value: number) => string; transformX?: (x: number, y: number, scaleY: SupportedScaleTypes) => number; }) { const [hovering, setHovering] = createSignal(false); @@ -142,12 +142,17 @@ export function Chart(props: { } }); - if (props.formatX) { - updateChart("formatX", () => props.formatX); - } - if (props.formatY) { - updateChart("formatY", () => props.formatY); - } + createEffect(() => { + if (props.formatX) { + updateChart("formatX", () => props.formatX?.()); + } + }); + createEffect(() => { + if (props.formatY) { + updateChart("formatY", () => props.formatY?.()); + } + }); + if (props.transformX) { updateChart("transformX", () => props.transformX); } diff --git a/apps/class-solid/src/components/plots/skewTlogP.tsx b/apps/class-solid/src/components/plots/skewTlogP.tsx index 6ec0e7d..684fd82 100644 --- a/apps/class-solid/src/components/plots/skewTlogP.tsx +++ b/apps/class-solid/src/components/plots/skewTlogP.tsx @@ -192,8 +192,8 @@ export function SkewTPlot(props: { d3.format(".0d")} + formatY={() => d3.format(".0d")} transformX={getTempAtCursor} > (worker); -export async function runClass(config: Config): Promise { +const asyncRunner = wrap(worker); + +export async function runClassAsync(config: Config): Promise { try { - const parsedConfig: Config = parse(config); - const model = await new AsyncBmiClass(); - await model.initialize(parsedConfig); - const output = await model.run({ - var_names: new BmiClass().get_output_var_names(), - }); + const output = asyncRunner(config); return output; } catch (error) { console.error({ config, error }); diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index 82d7404..d737a15 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -12,7 +12,7 @@ import { decodeAppState } from "./encode"; import { parseExperimentConfig } from "./experiment_config"; import type { ExperimentConfig } from "./experiment_config"; import { findPresetByName } from "./presets"; -import { runClass } from "./runner"; +import { runClassAsync } from "./runner"; interface ExperimentOutput { reference?: ClassOutput; @@ -38,7 +38,7 @@ export async function runExperiment(id: number) { // Run reference const referenceConfig = unwrap(exp.config.reference); - const newOutput = await runClass(referenceConfig); + const newOutput = await runClassAsync(referenceConfig); setExperiments(id, "output", "reference", newOutput); @@ -50,7 +50,7 @@ export async function runExperiment(id: number) { referenceConfig, permConfig, ) as Config; - const newOutput = await runClass(combinedConfig); + const newOutput = await runClassAsync(combinedConfig); setExperiments(id, "output", "permutations", permCounter, newOutput); permCounter++; } diff --git a/apps/class-solid/src/lib/worker.ts b/apps/class-solid/src/lib/worker.ts index c1549b7..e66de6b 100644 --- a/apps/class-solid/src/lib/worker.ts +++ b/apps/class-solid/src/lib/worker.ts @@ -1,4 +1,4 @@ -import { BmiClass } from "@classmodel/class/bmi"; +import { runClass } from "@classmodel/class/runner"; import { expose } from "comlink"; -expose(BmiClass); +expose(runClass); diff --git a/packages/class/package.json b/packages/class/package.json index c2e756a..4f3c14c 100644 --- a/packages/class/package.json +++ b/packages/class/package.json @@ -36,12 +36,6 @@ "types": "./dist/runner.d.ts" } }, - "./bmi": { - "import": { - "default": "./dist/bmi.js", - "types": "./dist/bmi.d.ts" - } - }, "./validate": { "import": { "default": "./dist/validate.js", diff --git a/packages/class/src/bmi.test.ts b/packages/class/src/bmi.test.ts deleted file mode 100644 index c108f60..0000000 --- a/packages/class/src/bmi.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import assert from "node:assert/strict"; -import { before, describe, test } from "node:test"; -import { BmiClass } from "./bmi.js"; - -describe("BmiClass", () => { - let bmi: BmiClass; - before(() => { - bmi = new BmiClass(); - }); - - describe("get_component_name", () => { - test("returns the component name", () => { - assert.strictEqual( - bmi.get_component_name(), - "Chemistry Land-surface Atmosphere Soil Slab model", - ); - }); - }); -}); diff --git a/packages/class/src/bmi.ts b/packages/class/src/bmi.ts deleted file mode 100644 index 0c29ee6..0000000 --- a/packages/class/src/bmi.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * This module contains the Basic Modelling Interface class. - * @module - */ -import { CLASS } from "./class.js"; -import type { Config } from "./config.js"; -import { parse } from "./validate.js"; - -/** - * A lightweight [Basic Modelling Interface (BMI)](https://bmi.readthedocs.io) like interface for the CLASS model. - * - * Inspiration https://github.com/uihilab/BMI-JS/blob/main/bmijs/bmi.js - * - * Deviations from the BMI standard - * - accessors do not use dest argument - * - initialize() accepts object instead of string - * - parameters() returns default config - * - run() as extra method - */ -interface BmiLight { - initialize(config: Config): void; - update(): void; - get_component_name(): string; - get_output_item_count(): number; - get_output_var_names(): string[]; - get_var_grid(name: string): number; - get_var_type(name: string): string; - get_var_location(name: string): string; - get_current_time(): number; - get_end_time(): number; - get_time_units(): string; - get_time_step(): number; - get_value(name: string): number[]; - get_grid_type(): string; -} - -const ouput_var_names: string[] = [ - "h", - "theta", - "dtheta", - "q", - "dq", - "dthetav", - "we", - "ws", - "wthetave", - "wthetav", -] as const; - -/** - * Class representing a BMI (Basic Model Interface) implementation for the CLASS model. - * This class provides methods to initialize, update, and retrieve information from the model. - */ -export class BmiClass implements BmiLight { - config: Config = parse({}); - model: CLASS = new CLASS(this.config); - - // Model control functions - - initialize(config: Config) { - this.config = config; - this.model = new CLASS(config); - } - - update() { - this.model.update(); - } - - // Model information functions - - get_component_name() { - return "Chemistry Land-surface Atmosphere Soil Slab model"; - } - - get_output_item_count() { - return ouput_var_names.length; - } - - get_output_var_names() { - return ouput_var_names; - } - - // Variable information functions - - get_var_grid(name: string) { - return 1; - } - - get_var_type(name: string) { - return "float"; - } - - get_var_location(name: string) { - return "node"; - } - - // Time functions - get_current_time() { - return this.model.t; - } - - get_end_time() { - return this.config.runtime; - } - - get_time_units() { - return "s"; - } - - get_time_step() { - return this.config.dt; - } - - // Variable getter and setter functions - - get_value(name: string) { - if (ouput_var_names.includes(name)) { - return [this.model[name as keyof CLASS]] as [number]; - } - throw new Error(`Variable ${name} not found`); - } - - // Model grid functions - - get_grid_type() { - return "scalar"; - } - - // Extra methods - - /** - * Runs the model till the end and returns the output data. - * - * @param options - The options for running the model. - * @param options.freq - The frequency at which to record the output data. Default is 60 seconds. - * @param options.var_names - For which output variables to record the data. Default is all output variables. - * @returns The output data, including the time values in t key and the values of the output variables. - */ - run({ - freq = 600, - var_names = ouput_var_names as T, - }: { - freq?: number; - var_names?: T; - }): { t: number[] } & { [K in T[number]]: number[] } { - const output: { t: number[] } & { [K in T[number]]: number[] } = - Object.fromEntries([["t", []], ...var_names.map((name) => [name, []])]); - while (this.model.t <= this.config.runtime) { - if (this.model.t % freq === 0) { - output.t.push(this.model.t); - for (const name of var_names) { - const value = this.model[name as keyof CLASS] as number; - output[name as T[number]].push(value); - } - // TODO progress callback? - // Initial attempt failed with "could not be cloned" error - } - this.update(); - } - return output; - } -} diff --git a/packages/class/src/runner.ts b/packages/class/src/runner.ts index 6d7e563..b868a2e 100644 --- a/packages/class/src/runner.ts +++ b/packages/class/src/runner.ts @@ -7,20 +7,120 @@ import { CLASS } from "./class.js"; import type { Config } from "./config.js"; import { parse } from "./validate.js"; -export type ClassOutput = Record; +export interface OutputVariable { + key: string; + title: string; + unit: string; + symbol: string; +} + +export const outputVariables: OutputVariable[] = [ + { + key: "t", + title: "Time", + unit: "s", + symbol: "t", + }, + { + key: "h", + title: "ABL height", + unit: "m", + symbol: "h", + }, + { + key: "theta", + title: "Potential temperature", + unit: "K", + symbol: "θ", + }, + { + key: "dtheta", + title: "Potential temperature jump", + unit: "K", + symbol: "Δθ", + }, + { + key: "q", + title: "Specific humidity", + unit: "kg kg⁻¹", + symbol: "q", + }, + { + key: "dq", + title: "Specific humidity jump", + unit: "kg kg⁻¹", + symbol: "Δq", + }, + { + key: "dthetav", + title: "Virtual temperature jump at h", + unit: "K", + symbol: "Δθᵥ", + }, + { + key: "we", + title: "Entrainment velocity", + unit: "m s⁻¹", + symbol: "wₑ", + }, + { + key: "ws", + title: "Large-scale vertical velocity", + unit: "m s⁻¹", + symbol: "wₛ", + }, + { + key: "wthetave", + title: "Entrainment virtual heat flux", + unit: "K m s⁻¹", + symbol: "(w'θ')ᵥₑ", + }, + { + key: "wthetav", + title: "Surface virtual heat flux", + unit: "K m s⁻¹", + symbol: "(w'θ')ᵥ", + }, +]; + +export type ClassOutput = { + [K in (typeof outputVariables)[number]["key"]]: number[]; +}; -export function runClass(config: Config): ClassOutput { - // TODO should we do validation/coercion here, in form, or both? +/** + * Runs the CLASS model with the given configuration and frequency. + * + * @param config - The configuration object for the CLASS model. + * @param freq - The frequency in seconds at which to write output, defaults to 600. + * @returns An object containing the output variables collected during the simulation. + */ +export function runClass(config: Config, freq = 600): ClassOutput { const validatedConfig = parse(config); const model = new CLASS(validatedConfig); - const output: ClassOutput = { t: [], h: [] }; + const writeOutput = () => { + for (const v of outputVariables) { + const value = + model[v.key as keyof CLASS] ?? (v.key === "t" ? model.t : undefined); + if (value !== undefined) { + (output[v.key] as number[]).push(value as number); + } + } + }; + + const output = Object.fromEntries( + outputVariables.map((v) => [v.key, []]), + ) as ClassOutput; + + // Initial time + writeOutput(); + + // Update loop while (model.t < config.runtime) { model.update(); - if (model.t % 60 === 0) { - output.t.push(model.t); - output.h.push(model.h); + if (model.t % freq === 0) { + writeOutput(); } }