diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 95288f9..589120d 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -1,5 +1,5 @@ import type { Config } from "@classmodel/class/config"; -import { type ClassOutput, outputVariables } from "@classmodel/class/runner"; +import { type ClassOutput, outputVariables } from "@classmodel/class/output"; import * as d3 from "d3"; import { saveAs } from "file-saver"; import { toBlob } from "html-to-image"; @@ -226,6 +226,8 @@ export function VerticalProfilePlot({ const variableOptions = { "Potential temperature [K]": "theta", "Specific humidity [kg/kg]": "q", + "u-wind component [m/s]": "u", + "v-wind component [m/s]": "v", }; const classVariable = () => @@ -233,26 +235,7 @@ export function VerticalProfilePlot({ const observations = () => flatObservations().map((o) => observationsForProfile(o, classVariable())); - const obsAllX = () => - observations().flatMap((obs) => obs.data.map((d) => d.x)); - const obsAllY = () => - observations().flatMap((obs) => obs.data.map((d) => d.y)); - - const allValues = () => [ - ...flatExperiments().flatMap((e) => - e.output ? e.output[classVariable()] : [], - ), - ...obsAllX(), - ]; - const allHeights = () => [ - ...flatExperiments().flatMap((e) => (e.output ? e.output.h : [])), - ...obsAllY(), - ]; - // TODO: better to include jump at top in extent calculation rather than adding random margin. - const xLim = () => getNiceAxisLimits(allValues(), 1); - const yLim = () => - [0, getNiceAxisLimits(allHeights(), 0)[1]] as [number, number]; const profileData = () => flatExperiments().map((e) => { const { config, output, ...formatting } = e; @@ -271,6 +254,19 @@ export function VerticalProfilePlot({ }; }); + const allX = () => [ + ...profileData().flatMap((p) => p.data.map((d) => d.x)), + ...observations().flatMap((obs) => obs.data.map((d) => d.x)), + ]; + const allY = () => [ + ...profileData().flatMap((p) => p.data.map((d) => d.y)), + ...observations().flatMap((obs) => obs.data.map((d) => d.y)), + ]; + + // TODO: better to include jump at top in extent calculation rather than adding random margin. + const xLim = () => getNiceAxisLimits(allX(), 1); + const yLim = () => [0, getNiceAxisLimits(allY(), 0)[1]] as [number, number]; + function chartData() { return [...profileData(), ...observations()]; } diff --git a/apps/class-solid/src/lib/download.ts b/apps/class-solid/src/lib/download.ts index aa58c30..f512e7e 100644 --- a/apps/class-solid/src/lib/download.ts +++ b/apps/class-solid/src/lib/download.ts @@ -1,4 +1,4 @@ -import type { ClassOutput } from "@classmodel/class/runner"; +import type { ClassOutput } from "@classmodel/class/output"; import { BlobReader, BlobWriter, ZipWriter } from "@zip.js/zip.js"; import { toPartial } from "./encode"; import type { ExperimentConfig } from "./experiment_config"; diff --git a/apps/class-solid/src/lib/presets.ts b/apps/class-solid/src/lib/presets.ts index ace4524..2dfce3f 100644 --- a/apps/class-solid/src/lib/presets.ts +++ b/apps/class-solid/src/lib/presets.ts @@ -8,6 +8,7 @@ import { overwriteDefaultsInJsonSchema } from "@classmodel/form/utils"; import type { DefinedError, JSONSchemaType, ValidateFunction } from "ajv"; // TODO replace with preset of a forest fire import deathValley from "./presets/death-valley.json"; +import varnavas from "./presets/varnavas.json"; const presetConfigs = [ { @@ -15,6 +16,7 @@ const presetConfigs = [ description: "The classic default configuration", }, deathValley, + varnavas, ] as const; export interface Preset { diff --git a/apps/class-solid/src/lib/presets/death-valley.json b/apps/class-solid/src/lib/presets/death-valley.json index 17f3f85..440c683 100644 --- a/apps/class-solid/src/lib/presets/death-valley.json +++ b/apps/class-solid/src/lib/presets/death-valley.json @@ -1,19 +1,19 @@ { "name": "Death Valley", "description": "Preset with Death Valley conditions", - "theta_0": 323, - "h_0": 200, - "dtheta_0": 1, - "q_0": 0.008, - "dq_0": -0.001, + "theta": 323, + "h": 200, + "dtheta": 1, + "q": 0.008, + "dq": -0.001, "dt": 60, "runtime": 43200, "wtheta": [0.1], "advtheta": 0, - "gammatheta": 0.006, - "wq": 0.0001, + "gammatheta": [0.006], + "wq": [0.0001], "advq": 0, - "gammaq": 0, + "gammaq": [0], "divU": 0, "beta": 0.2 } diff --git a/apps/class-solid/src/lib/presets/varnavas.json b/apps/class-solid/src/lib/presets/varnavas.json new file mode 100644 index 0000000..1fecfad --- /dev/null +++ b/apps/class-solid/src/lib/presets/varnavas.json @@ -0,0 +1,42 @@ +{ + "name": "Varnavas", + "description": "EWED Reference configuration", + "h": 665.4086303710938, + "theta": 301.971435546875, + "dtheta": 0.889312744140625, + "gammatheta": [0.0015336532378569245, 0.006792282219976187], + "z_theta": [1667.025390625, 3720], + "q": 0.010794480331242085, + "dq": -0.001073240302503109, + "gammaq": [-2.382889988439274e-6, -2.5458646177867195e-6], + "z_q": [1360.52392578125, 3720], + "divU": 1.0981982995872386e-5, + "u": -1.6769592761993408, + "du": -1.1726601123809814, + "gamma_u": [-0.001995581667870283, -0.002064943080767989], + "z_u": [1573.5614013671875, 3720], + "v": -10.087028503417969, + "dv": -1.5727958679199219, + "gamma_v": [-0.0005029001622460783, 0.004509935155510902], + "z_v": [3037.957763671875, 3720], + "wtheta": [ + 0.33137200988670706, 0.28088430353706567, 0.21148639643745845, + 0.1354453158978963, 0.0558820378352221, 0.0001602229911650289, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0 + ], + "wq": [ + 1.9756698748096824e-5, 2.111953654093668e-5, 2.2018328309059143e-5, + 2.2002774130669422e-5, 1.9648385205073282e-5, 1.6331905499100685e-5, + 1.3947577826911584e-5, 1.2534562301880214e-5, 1.1651739441731479e-5, + 1.1526963135111146e-5, 1.3352756468520965e-5, 1.3273539479996543e-5 + ], + "sw_wind": true, + "advtheta": 0, + "advq": 0, + "advu": 0, + "advv": 0, + "beta": 0.2, + "ustar": 0.3, + "dt": 60, + "runtime": 43200 +} diff --git a/apps/class-solid/src/lib/profiles.ts b/apps/class-solid/src/lib/profiles.ts index 4d82375..62f4170 100644 --- a/apps/class-solid/src/lib/profiles.ts +++ b/apps/class-solid/src/lib/profiles.ts @@ -1,5 +1,6 @@ import type { Config } from "@classmodel/class/config"; -import type { ClassOutput } from "@classmodel/class/runner"; +import type { ClassOutput } from "@classmodel/class/output"; +import { findInsertIndex } from "@classmodel/class/utils"; import type { Point } from "~/components/plots/Line"; import type { Observation } from "./experiment_config"; @@ -15,31 +16,126 @@ export function getVerticalProfiles( return []; } - // Extract height profile - const height = output.h.slice(t)[0]; - const dh = 1600; // how much free troposphere to display? - const hProfile = [0, height, height, height + dh]; if (variable === "theta") { - // Extract potential temperature profile - const theta = output.theta.slice(t)[0]; + let z = output.h.slice(t)[0]; + let theta = output.theta.slice(t)[0]; const dtheta = output.dtheta.slice(t)[0]; const gammatheta = config.gammatheta; - const thetaProfile = [ - theta, - theta, - theta + dtheta, - theta + dtheta + dh * gammatheta, + const z_theta = config.z_theta; + const maxHeight = z_theta.slice(-1)[0]; + + // Mixed layer + const profile = [ + { x: theta, y: 0 }, + { x: theta, y: z }, ]; - return hProfile.map((h, i) => ({ x: thetaProfile[i], y: h })); + + // Inversion + theta += dtheta; + profile.push({ x: theta, y: z }); + + // Free troposphere + while (z < maxHeight) { + const idx = findInsertIndex(z_theta, z); + const lapse_rate = gammatheta[idx] ?? 0; + const dz = z_theta[idx] - z; + z += dz; + theta += lapse_rate * dz; + profile.push({ x: theta, y: z }); + } + return profile; } + if (variable === "q") { - // Extract humidity profile - const q = output.q.slice(t)[0]; + let z = output.h.slice(t)[0]; + let q = output.q.slice(t)[0]; const dq = output.dq.slice(t)[0]; const gammaq = config.gammaq; - const qProfile = [q, q, q + dq, q + dq + dh * gammaq]; - return hProfile.map((h, i) => ({ x: qProfile[i], y: h })); + const z_q = config.z_q; + const maxHeight = z_q.slice(-1)[0]; + + // Mixed layer + const profile = [ + { x: q, y: 0 }, + { x: q, y: z }, + ]; + + // Inversion + q += dq; + profile.push({ x: q, y: z }); + + // Free troposphere + while (z < maxHeight) { + const idx = findInsertIndex(z_q, z); + const lapse_rate = gammaq[idx] ?? 0; + const dz = z_q[idx] - z; + z += dz; + q += lapse_rate * dz; + profile.push({ x: q, y: z }); + } + return profile; + } + + if (config.sw_wind && variable === "u") { + let z = output.h.slice(t)[0]; + let u = output.u.slice(t)[0]; + const du = output.du.slice(t)[0]; + const gammau = config.gamma_u; + const z_u = config.z_u; + const maxHeight = z_u.slice(-1)[0]; + + // Mixed layer + const profile = [ + { x: u, y: 0 }, + { x: u, y: z }, + ]; + + // Inversion + u += du; + profile.push({ x: u, y: z }); + + // Free troposphere + while (z < maxHeight) { + const idx = findInsertIndex(z_u, z); + const lapse_rate = gammau[idx] ?? 0; + const dz = z_u[idx] - z; + z += dz; + u += lapse_rate * dz; + profile.push({ x: u, y: z }); + } + return profile; + } + + if (config.sw_wind && variable === "v") { + let z = output.h.slice(t)[0]; + let v = output.v.slice(t)[0]; + const dv = output.dv.slice(t)[0]; + const gammav = config.gamma_v; + const z_v = config.z_v; + const maxHeight = z_v.slice(-1)[0]; + + // Mixed layer + const profile = [ + { x: v, y: 0 }, + { x: v, y: z }, + ]; + + // Inversion + v += dv; + profile.push({ x: v, y: z }); + + // Free troposphere + while (z < maxHeight) { + const idx = findInsertIndex(z_v, z); + const lapse_rate = gammav[idx] ?? 0; + const dz = z_v[idx] - z; + z += dz; + v += lapse_rate * dz; + profile.push({ x: v, y: z }); + } + return profile; } + return []; } @@ -142,6 +238,8 @@ export function getThermodynamicProfiles( const h = output.h.slice(t)[0]; const gammaTheta = config.gammatheta; const gammaq = config.gammaq; + const z_theta = config.z_theta; + const z_q = config.z_q; const nz = 25; let dz = h / nz; @@ -169,11 +267,18 @@ export function getThermodynamicProfiles( soundingData.push({ p, T, Td }); // Free troposphere + let z = zArrayMixedLayer.slice(-1)[0]; dz = 200; while (p > 100) { - theta += dz * gammaTheta; - q += dz * gammaq; + // Note: idx can exceed length of anchor points, then lapse becomes undefined and profile stops + const idx_th = findInsertIndex(z_theta, z); + const lapse_theta = gammaTheta[idx_th]; + const idx_q = findInsertIndex(z_q, z); + const lapse_q = gammaq[idx_q]; + theta += dz * lapse_theta; + q += dz * lapse_q; p += pressureDiff(T, q, p, dz); + z += dz; T = thetaToT(theta, p); Td = dewpoint(q, p); @@ -194,10 +299,23 @@ export function observationsForProfile(obs: Observation, variable = "theta") { const p = obs.pressure[i]; const theta = tToTheta(T, p); const q = calculateSpecificHumidity(T, p, rh); - if (variable === "theta") { - return { y: h, x: theta }; + const { u, v } = windSpeedDirectionToUV( + obs.windSpeed[i], + obs.windDirection[i], + ); + + switch (variable) { + case "theta": + return { y: h, x: theta }; + case "q": + return { y: h, x: q }; + case "u": + return { y: h, x: u }; + case "v": + return { y: h, x: v }; + default: + throw new Error(`Unknown variable '${variable}'`); } - return { y: h, x: q }; }), }; } @@ -217,3 +335,15 @@ export function observationsForSounding(obs: Observation) { linestyle: "none", }; } + +function windSpeedDirectionToUV( + speed: number, + directionDeg: number, +): { u: number; v: number } { + const directionRad = (directionDeg * Math.PI) / 180; + + const u = -speed * Math.sin(directionRad); // zonal (east-west) + const v = -speed * Math.cos(directionRad); // meridional (north-south) + + return { u, v }; +} diff --git a/apps/class-solid/src/lib/runner.ts b/apps/class-solid/src/lib/runner.ts index 3dcf765..bae7006 100644 --- a/apps/class-solid/src/lib/runner.ts +++ b/apps/class-solid/src/lib/runner.ts @@ -1,5 +1,6 @@ import type { Config } from "@classmodel/class/config"; -import type { ClassOutput, runClass } from "@classmodel/class/runner"; +import type { ClassOutput } from "@classmodel/class/output"; +import type { runClass } from "@classmodel/class/runner"; import { wrap } from "comlink"; const worker = new Worker(new URL("./worker.ts", import.meta.url), { diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index d737a15..2e02555 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -2,7 +2,7 @@ import { createUniqueId } from "solid-js"; import { createStore, produce, unwrap } from "solid-js/store"; import type { Config } from "@classmodel/class/config"; -import type { ClassOutput } from "@classmodel/class/runner"; +import type { ClassOutput } from "@classmodel/class/output"; import { mergeConfigurations, diff --git a/apps/class-solid/tests/big-app-state.json b/apps/class-solid/tests/big-app-state.json index b06ff28..a61213e 100644 --- a/apps/class-solid/tests/big-app-state.json +++ b/apps/class-solid/tests/big-app-state.json @@ -3,11 +3,11 @@ "description": "", "reference": { "initialState": { - "h_0": 200, - "theta_0": 288, - "dtheta_0": 1, - "q_0": 0.008, - "dq_0": -0.001 + "h": 200, + "theta": 288, + "dtheta": 1, + "q": 0.008, + "dq": -0.001 }, "timeControl": { "dt": 60, @@ -16,20 +16,20 @@ "mixedLayer": { "wtheta": 0.1, "advtheta": 0, - "gammatheta": 0.006, - "wq": 0.0001, + "gammatheta": [0.006], + "wq": [0.0001], "advq": 0, - "gammaq": 0, + "gammaq": [0], "divU": 0, "beta": 0.2 } }, "permutations": [ { - "name": "h_0=100, beta=0.1", + "name": "h=100, beta=0.1", "config": { "initialState": { - "h_0": 100 + "h": 100 }, "mixedLayer": { "beta": 0.1 @@ -37,10 +37,10 @@ } }, { - "name": "h_0=100, beta=0.3", + "name": "h=100, beta=0.3", "config": { "initialState": { - "h_0": 100 + "h": 100 }, "mixedLayer": { "beta": 0.3 @@ -48,10 +48,10 @@ } }, { - "name": "h_0=100, beta=0.4", + "name": "h=100, beta=0.4", "config": { "initialState": { - "h_0": 100 + "h": 100 }, "mixedLayer": { "beta": 0.4 @@ -59,10 +59,10 @@ } }, { - "name": "h_0=100, beta=0.5", + "name": "h=100, beta=0.5", "config": { "initialState": { - "h_0": 100 + "h": 100 }, "mixedLayer": { "beta": 0.5 @@ -70,10 +70,10 @@ } }, { - "name": "h_0=100, beta=0.6", + "name": "h=100, beta=0.6", "config": { "initialState": { - "h_0": 100 + "h": 100 }, "mixedLayer": { "beta": 0.6 @@ -81,10 +81,10 @@ } }, { - "name": "h_0=300, beta=0.1", + "name": "h=300, beta=0.1", "config": { "initialState": { - "h_0": 300 + "h": 300 }, "mixedLayer": { "beta": 0.1 @@ -92,10 +92,10 @@ } }, { - "name": "h_0=300, beta=0.3", + "name": "h=300, beta=0.3", "config": { "initialState": { - "h_0": 300 + "h": 300 }, "mixedLayer": { "beta": 0.3 @@ -103,10 +103,10 @@ } }, { - "name": "h_0=300, beta=0.4", + "name": "h=300, beta=0.4", "config": { "initialState": { - "h_0": 300 + "h": 300 }, "mixedLayer": { "beta": 0.4 @@ -114,10 +114,10 @@ } }, { - "name": "h_0=300, beta=0.5", + "name": "h=300, beta=0.5", "config": { "initialState": { - "h_0": 300 + "h": 300 }, "mixedLayer": { "beta": 0.5 @@ -125,10 +125,10 @@ } }, { - "name": "h_0=300, beta=0.6", + "name": "h=300, beta=0.6", "config": { "initialState": { - "h_0": 300 + "h": 300 }, "mixedLayer": { "beta": 0.6 @@ -136,10 +136,10 @@ } }, { - "name": "h_0=400, beta=0.1", + "name": "h=400, beta=0.1", "config": { "initialState": { - "h_0": 400 + "h": 400 }, "mixedLayer": { "beta": 0.1 @@ -147,10 +147,10 @@ } }, { - "name": "h_0=400, beta=0.3", + "name": "h=400, beta=0.3", "config": { "initialState": { - "h_0": 400 + "h": 400 }, "mixedLayer": { "beta": 0.3 @@ -158,10 +158,10 @@ } }, { - "name": "h_0=400, beta=0.4", + "name": "h=400, beta=0.4", "config": { "initialState": { - "h_0": 400 + "h": 400 }, "mixedLayer": { "beta": 0.4 @@ -169,10 +169,10 @@ } }, { - "name": "h_0=400, beta=0.5", + "name": "h=400, beta=0.5", "config": { "initialState": { - "h_0": 400 + "h": 400 }, "mixedLayer": { "beta": 0.5 @@ -180,10 +180,10 @@ } }, { - "name": "h_0=400, beta=0.6", + "name": "h=400, beta=0.6", "config": { "initialState": { - "h_0": 400 + "h": 400 }, "mixedLayer": { "beta": 0.6 @@ -191,10 +191,10 @@ } }, { - "name": "h_0=500, beta=0.1", + "name": "h=500, beta=0.1", "config": { "initialState": { - "h_0": 500 + "h": 500 }, "mixedLayer": { "beta": 0.1 @@ -202,10 +202,10 @@ } }, { - "name": "h_0=500, beta=0.3", + "name": "h=500, beta=0.3", "config": { "initialState": { - "h_0": 500 + "h": 500 }, "mixedLayer": { "beta": 0.3 @@ -213,10 +213,10 @@ } }, { - "name": "h_0=500, beta=0.4", + "name": "h=500, beta=0.4", "config": { "initialState": { - "h_0": 500 + "h": 500 }, "mixedLayer": { "beta": 0.4 @@ -224,10 +224,10 @@ } }, { - "name": "h_0=500, beta=0.5", + "name": "h=500, beta=0.5", "config": { "initialState": { - "h_0": 500 + "h": 500 }, "mixedLayer": { "beta": 0.5 @@ -235,10 +235,10 @@ } }, { - "name": "h_0=500, beta=0.6", + "name": "h=500, beta=0.6", "config": { "initialState": { - "h_0": 500 + "h": 500 }, "mixedLayer": { "beta": 0.6 @@ -246,10 +246,10 @@ } }, { - "name": "h_0=600, beta=0.1", + "name": "h=600, beta=0.1", "config": { "initialState": { - "h_0": 600 + "h": 600 }, "mixedLayer": { "beta": 0.1 @@ -257,10 +257,10 @@ } }, { - "name": "h_0=600, beta=0.3", + "name": "h=600, beta=0.3", "config": { "initialState": { - "h_0": 600 + "h": 600 }, "mixedLayer": { "beta": 0.3 @@ -268,10 +268,10 @@ } }, { - "name": "h_0=600, beta=0.4", + "name": "h=600, beta=0.4", "config": { "initialState": { - "h_0": 600 + "h": 600 }, "mixedLayer": { "beta": 0.4 @@ -279,10 +279,10 @@ } }, { - "name": "h_0=600, beta=0.5", + "name": "h=600, beta=0.5", "config": { "initialState": { - "h_0": 600 + "h": 600 }, "mixedLayer": { "beta": 0.5 @@ -290,10 +290,10 @@ } }, { - "name": "h_0=600, beta=0.6", + "name": "h=600, beta=0.6", "config": { "initialState": { - "h_0": 600 + "h": 600 }, "mixedLayer": { "beta": 0.6 diff --git a/apps/class-solid/tests/experiment.spec.ts b/apps/class-solid/tests/experiment.spec.ts index 1725f2d..d2f103f 100644 --- a/apps/class-solid/tests/experiment.spec.ts +++ b/apps/class-solid/tests/experiment.spec.ts @@ -48,7 +48,7 @@ test("Duplicate experiment with a permutation", async ({ page }, testInfo) => { if (!config1.permutations[0].sw_ml) { throw new Error("sw_ml is not defined"); } - expect(config1.permutations[0].h_0).toEqual(800); + expect(config1.permutations[0].h).toEqual(800); // Download configuration of experiment 2 experiment2.getByRole("button", { name: "Download" }).click(); @@ -62,7 +62,7 @@ test("Duplicate experiment with a permutation", async ({ page }, testInfo) => { throw new Error("sw_ml is not defined"); } expect(config2.reference.beta).toEqual(0.3); - expect(config2.permutations[0].h_0).toEqual(800); + expect(config2.permutations[0].h).toEqual(800); // visually check that timeseries plot has 4 non-overlapping lines await testInfo.attach("timeseries plot with 4 non-overlapping lines", { @@ -114,7 +114,7 @@ test("Swap permutation with default reference", async ({ page }) => { if (!config1.reference.sw_ml) { throw new Error("Mixed layer is turned off"); } - expect(config1.reference.h_0).toEqual(800); + expect(config1.reference.h).toEqual(800); }); test("Swap permutation with custom reference", async ({ page }) => { @@ -164,9 +164,9 @@ test("Swap permutation with custom reference", async ({ page }) => { throw new Error("sw_ml is not defined"); } - expect(config1.reference.h_0).toEqual(800); - expect(config1.reference.dtheta_0).toEqual(0.8); - expect(config1.permutations[0].h_0).toEqual(400); + expect(config1.reference.h).toEqual(800); + expect(config1.reference.dtheta).toEqual(0.8); + expect(config1.permutations[0].h).toEqual(400); }); test("Promote permutation to a new experiment", async ({ page }) => { @@ -211,7 +211,7 @@ test("Promote permutation to a new experiment", async ({ page }) => { if (!config2.reference.sw_ml) { throw new Error("sw_ml is not defined"); } - expect(config2.reference.h_0).toEqual(800); + expect(config2.reference.h).toEqual(800); expect(config2.permutations.length).toEqual(0); }); @@ -265,6 +265,6 @@ test("Duplicate permutation", async ({ page }) => { if (!config1.permutations[0].sw_ml || !config1.permutations[1].sw_ml) { throw new Error("sw_ml is not defined"); } - expect(config1.permutations[0].h_0).toEqual(800); - expect(config1.permutations[1].h_0).toEqual(400); + expect(config1.permutations[0].h).toEqual(800); + expect(config1.permutations[1].h).toEqual(400); }); diff --git a/apps/class-solid/tests/share.spec.ts b/apps/class-solid/tests/share.spec.ts index f0580a6..13702e2 100644 --- a/apps/class-solid/tests/share.spec.ts +++ b/apps/class-solid/tests/share.spec.ts @@ -40,7 +40,7 @@ test("Create share link from an experiment", async ({ page }) => { throw new Error("Mixed layer is turned off"); } - expect(config1.reference.h_0).toEqual(800); + expect(config1.reference.h).toEqual(800); // TODO: finalheight is gone; implement alternative check to see that experiment finished }); diff --git a/apps/class-solid/tests/varnavas.json b/apps/class-solid/tests/varnavas.json new file mode 100644 index 0000000..87cbfba --- /dev/null +++ b/apps/class-solid/tests/varnavas.json @@ -0,0 +1,128 @@ +{ + "reference": { + "name": "Varnavas", + "description": "Varnavas wildfire case with observations" + }, + "preset": "Varnavas", + "permutations": [], + "observations": [ + { + "name": "2024-08-11_2144.sounding", + "height": [ + 20, 40, 60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280, 300, + 320, 340, 360, 380, 400, 420, 440, 460, 480, 500, 520, 540, 560, 580, + 600, 620, 640, 660, 680, 700, 720, 740, 760, 780, 800, 820, 840, 860, + 880, 900, 920, 940, 960, 980, 1000, 1020, 1040, 1060, 1080, 1100, 1120, + 1140, 1160, 1180, 1200, 1220, 1240, 1260, 1280, 1300, 1320, 1340, 1360, + 1380, 1400, 1420, 1440, 1460, 1480, 1500, 1520, 1540, 1560, 1580, 1600, + 1620, 1640, 1660, 1680, 1700, 1720, 1740, 1760, 1780, 1800, 1820, 1840, + 1860, 1880, 1900, 1920, 1940, 1960, 1980, 2000, 2020, 2040, 2060, 2080, + 2100, 2120, 2140, 2160, 2180, 2200, 2220, 2240, 2260, 2280, 2300, 2320, + 2340, 2360, 2380, 2400, 2420, 2440, 2460, 2480, 2500, 2520, 2540, 2560, + 2580, 2600, 2620, 2640, 2660, 2680, 2700, 2720, 2740, 2760, 2780, 2800, + 2820, 2840, 2860, 2880, 2900, 2920, 2940, 2960, 2980, 3000, 3020, 3040, + 3060, 3080, 3100, 3120, 3140, 3160, 3180, 3200, 3220, 3240, 3260, 3280, + 3300, 3320, 3340, 3360, 3380, 3400, 3420, 3440, 3460, 3480, 3500, 3520, + 3540, 3560, 3580, 3600, 3620, 3640, 3660, 3680, 3700, 3720 + ], + "pressure": [ + 958.2, 956.04, 953.88, 951.73, 949.58, 947.43, 945.29, 943.16, 941.02, + 938.89, 936.77, 934.64, 932.53, 930.41, 928.3, 926.19, 924.09, 921.99, + 919.89, 917.8, 915.71, 913.63, 911.55, 909.47, 907.4, 905.33, 903.26, + 901.2, 899.14, 897.08, 895.03, 892.98, 890.93, 888.89, 886.85, 884.82, + 882.79, 880.77, 878.75, 876.73, 874.72, 872.71, 870.7, 868.69, 866.69, + 864.7, 862.7, 860.71, 858.72, 856.74, 854.76, 852.78, 850.81, 848.84, + 846.87, 844.91, 842.95, 840.99, 839.04, 837.09, 835.14, 833.2, 831.26, + 829.32, 827.39, 825.46, 823.53, 821.61, 819.69, 817.77, 815.85, 813.94, + 812.04, 810.15, 808.26, 806.38, 804.5, 802.62, 800.75, 798.87, 797.01, + 795.15, 793.29, 791.44, 789.59, 787.75, 785.9, 784.07, 782.23, 780.4, + 778.57, 776.75, 774.93, 773.11, 771.29, 769.48, 767.67, 765.87, 764.06, + 762.27, 760.47, 758.68, 756.9, 755.11, 753.33, 751.56, 749.79, 748.02, + 746.26, 744.5, 742.74, 740.99, 739.24, 737.49, 735.75, 734.01, 732.27, + 730.54, 728.81, 727.09, 725.37, 723.65, 721.94, 720.23, 718.52, 716.82, + 715.12, 713.43, 711.74, 710.05, 708.37, 706.69, 705.01, 703.34, 701.67, + 700, 698.34, 696.68, 695.02, 693.36, 691.71, 690.06, 688.42, 686.77, + 685.13, 683.49, 681.86, 680.23, 678.6, 676.97, 675.35, 673.73, 672.12, + 670.5, 668.89, 667.29, 665.68, 664.08, 662.48, 660.89, 659.3, 657.71, + 656.12, 654.54, 652.96, 651.38, 649.81, 648.24, 646.67, 645.1, 643.55, + 642, 640.45, 638.91, 637.37, 635.83, 634.3, 632.77, 631.24, 629.72, + 628.2, 626.68, 625.17, 623.66, 622.15, 620.65 + ], + "temperature": [ + 26.25, 26.32, 26.31, 26.22, 26.04, 25.84, 25.68, 25.56, 25.42, 25.28, + 25.15, 24.98, 24.85, 24.67, 24.51, 24.39, 24.25, 24.11, 23.93, 23.78, + 23.69, 23.58, 23.43, 23.25, 23.05, 22.85, 22.58, 22.34, 22.15, 21.99, + 21.84, 21.75, 21.7, 21.66, 21.6, 21.57, 21.57, 21.46, 21.3, 21.06, + 20.81, 20.64, 20.44, 20.31, 20.16, 19.98, 19.78, 19.58, 19.31, 19.1, + 18.92, 18.71, 18.56, 18.49, 18.43, 18.32, 18.19, 18.04, 17.87, 17.65, + 17.42, 17.2, 17.02, 16.82, 16.58, 16.36, 16.19, 15.88, 15.48, 15.23, + 14.99, 14.84, 15.84, 16.99, 17.14, 17.4, 17.61, 17.71, 17.7, 17.61, + 17.49, 17.38, 17.23, 17.07, 16.92, 16.75, 16.62, 16.49, 16.35, 16.19, + 16.02, 15.85, 15.66, 15.47, 15.29, 15.12, 14.97, 14.86, 14.81, 14.82, + 14.84, 14.79, 14.72, 14.67, 14.65, 14.61, 14.5, 14.38, 14.3, 14.25, + 14.16, 14.03, 13.89, 13.76, 13.7, 13.68, 13.61, 13.51, 13.46, 13.52, + 13.65, 13.74, 13.75, 13.71, 13.63, 13.52, 13.44, 13.38, 13.31, 13.19, + 13.06, 12.92, 12.77, 12.63, 12.5, 12.37, 12.21, 12.03, 11.88, 11.69, + 11.51, 11.31, 11.09, 10.89, 10.7, 10.56, 10.49, 10.41, 10.3, 10.14, + 9.99, 9.86, 9.71, 9.5, 9.31, 9.17, 9.01, 8.86, 8.72, 8.6, 8.52, 8.45, + 8.34, 8.2, 8.05, 7.86, 7.69, 7.54, 7.43, 7.47, 7.96, 8.46, 8.92, 8.94, + 8.84, 8.72, 8.65, 8.6, 8.49, 8.35, 8.23, 8.11, 8, 7.89, 7.78, 7.65 + ], + "relativeHumidity": [ + 44.5, 44, 43.9, 43.9, 44.2, 44.5, 44.8, 45, 45.2, 45.3, 45.5, 45.8, 46, + 46.3, 46.6, 46.8, 47, 47.2, 47.6, 47.8, 48, 48.2, 48.4, 48.7, 49.2, + 49.6, 50.2, 50.8, 51.3, 51.7, 52, 52.1, 51.8, 51.6, 51.6, 51.4, 51.3, + 51.2, 51.4, 51.9, 52.5, 52.9, 53.3, 53.5, 53.9, 54.3, 54.7, 55.1, 55.7, + 56.2, 56.7, 57.1, 57.5, 57.5, 57.4, 57.4, 57.6, 57.8, 58.1, 58.6, 59.3, + 60.1, 60.8, 61.5, 62.3, 62.9, 63.3, 64.3, 65.6, 66.8, 68, 68.7, 58, + 44.8, 41.2, 36.5, 33.1, 31.2, 30.4, 30.4, 30.3, 30.1, 30, 30.1, 30.4, + 30.8, 30.8, 30.9, 31, 31.2, 31.5, 31.8, 32.2, 32.5, 32.8, 33.2, 33.6, + 33.8, 33.7, 33.2, 32.1, 30.3, 28.7, 27.3, 26, 25.2, 24.8, 24.6, 24, + 23.2, 22.6, 22.3, 22.2, 21.6, 20.7, 19.7, 18.7, 17.9, 17.1, 16.3, 16, + 16.1, 16.2, 16.3, 16.4, 16.5, 16.6, 16.5, 16.3, 16.4, 16.6, 16.6, 16.4, + 16.3, 16.2, 16.2, 16.2, 16.2, 16.2, 16.2, 16.3, 16.5, 16.7, 16.9, 17, + 16.7, 16.2, 16, 16.9, 18.4, 19.4, 19.8, 19.9, 20, 20.1, 20.1, 20, 19.9, + 19.4, 17.7, 15.1, 12.7, 10.3, 8.9, 8.4, 8.6, 9, 9.8, 11.7, 14.1, 16.6, + 16.4, 13, 11.5, 11, 10.4, 9.5, 8.7, 8, 7.4, 7.1, 6.6, 6.1, 5.6, 5.2, 5 + ], + "windSpeed": [ + 4.57, 5.3, 6.04, 6.97, 7.68, 7.88, 7.92, 7.77, 7.54, 7.61, 8.15, 8.6, + 9.31, 9.57, 9.78, 9.85, 9.87, 9.96, 10.22, 10.3, 10.21, 9.79, 9.56, 9.2, + 9.27, 9.32, 9.68, 9.59, 9.68, 9.97, 11.23, 12.99, 14.43, 15.11, 15.27, + 14.77, 14.22, 13.93, 13.63, 13.19, 12.94, 12.47, 12.39, 12.45, 12.98, + 13.79, 15.16, 16.56, 17.42, 17.95, 18.31, 18.57, 18.54, 18.28, 17.7, + 17.22, 16.81, 16.76, 16.96, 17.35, 17.72, 18.16, 18.47, 18.84, 19.39, + 20.02, 20.05, 20.04, 20, 20.19, 20.27, 20.61, 20.33, 19.9, 18.8, 17.97, + 17.21, 16.66, 16.17, 15.86, 15.58, 15.16, 14.73, 14.37, 13.99, 13.72, + 13.43, 13.16, 12.82, 12.44, 12.13, 11.84, 11.69, 11.5, 11.33, 11.06, + 10.73, 10.35, 10.02, 9.74, 9.65, 9.64, 9.66, 9.67, 9.63, 9.49, 9.36, + 9.14, 8.92, 8.82, 8.88, 9.02, 9.28, 9.62, 9.96, 10.48, 11.04, 11.66, + 12.09, 12.44, 12.63, 12.63, 12.53, 12.4, 12.13, 11.76, 11.35, 10.84, + 10.49, 10.24, 10.01, 9.7, 9.45, 9.14, 8.87, 8.76, 8.69, 8.7, 8.7, 8.63, + 8.56, 8.48, 8.39, 8.24, 8.11, 7.95, 7.96, 8.01, 8.21, 8.44, 8.61, 8.68, + 8.77, 8.79, 8.71, 8.6, 8.51, 8.42, 8.29, 8.2, 8.09, 7.95, 7.82, 7.81, + 7.9, 7.98, 7.94, 7.97, 7.98, 7.98, 8.98, 9.39, 10.08, 10.79, 11.34, + 11.9, 12.38, 12.87, 13.25, 13.63, 13.96, 14.26, 14.62, 14.91, 15.1, + 15.28 + ], + "windDirection": [ + 355.8, 358, 359.9, 2.3, 3.5, 5, 6.7, 5.3, 359.1, 353.6, 356.6, 359.9, + 4.2, 9.5, 13.1, 13.5, 13.7, 13.9, 13.5, 13.7, 15.3, 17.1, 21.7, 29.2, + 33.1, 31.5, 32.2, 35.2, 34.2, 34.4, 37, 35.4, 27.4, 24.5, 25.3, 26.5, + 27.9, 30.5, 32.9, 33.8, 33.4, 33.3, 37.9, 42.6, 47, 48.4, 49.6, 50.5, + 51.7, 53.3, 52.6, 50.4, 51.5, 54, 54.7, 51.2, 47, 44.1, 43.1, 42.3, + 41.9, 40.7, 39.9, 38.7, 35.9, 32.3, 29.8, 26.5, 23.4, 23.5, 26.1, 28.3, + 30.4, 31.3, 31.2, 31, 30.9, 30.7, 29.7, 29.2, 29.1, 28.9, 28.6, 28.4, + 29.1, 30.2, 31.4, 32.2, 33.4, 34.4, 34.5, 34.2, 34.5, 34.5, 34.4, 34, + 33.9, 33.6, 33.4, 34.5, 35.8, 36.6, 37.8, 39.7, 41.8, 43.1, 43.9, 45.8, + 48.4, 50.9, 52.1, 52.9, 53.3, 53.3, 52.9, 52.9, 51.9, 50.6, 48.7, 48.2, + 49.2, 51, 52, 53.1, 53.9, 54.2, 53, 52.3, 53.4, 56.6, 57.9, 58.4, 58.4, + 58.7, 59.4, 59.9, 60, 59.9, 60.3, 60.4, 60.1, 58.3, 55.6, 53.2, 50.6, + 48.8, 48.4, 48.1, 46.3, 43.9, 42.8, 43.4, 44.7, 45.8, 46.8, 48.4, 48.9, + 47.4, 45.7, 43.1, 40.8, 39.6, 39.3, 37.7, 35.4, 35.1, 34.8, 32.7, 29.3, + 27.6, 34.8, 42.1, 49.6, 51.9, 53.8, 55.7, 56.7, 57.3, 56.9, 57.5, 57.7, + 58.2, 57.8, 57.4, 57.1, 56.4 + ] + } + ] +} diff --git a/packages/class/package.json b/packages/class/package.json index 4168f07..c90038d 100644 --- a/packages/class/package.json +++ b/packages/class/package.json @@ -35,6 +35,18 @@ "types": "./dist/runner.d.ts" } }, + "./output": { + "import": { + "default": "./dist/output.js", + "types": "./dist/output.d.ts" + } + }, + "./utils": { + "import": { + "default": "./dist/utils.js", + "types": "./dist/utils.d.ts" + } + }, "./validate": { "import": { "default": "./dist/validate.js", diff --git a/packages/class/src/class.test.ts b/packages/class/src/class.test.ts index 6876c3d..9c1da65 100644 --- a/packages/class/src/class.test.ts +++ b/packages/class/src/class.test.ts @@ -14,10 +14,10 @@ describe("CLASS model", () => { if (!model._cfg.sw_ml) { throw new Error("sw_ml not set"); } - assert.strictEqual(model._cfg.h_0, 200); + assert.strictEqual(model._cfg.h, 200); assert.strictEqual(model._cfg.dt, 60); assert.deepEqual(model._cfg.wtheta, [0.1]); - assert.strictEqual(model._cfg.wq, 0.0001); + assert.deepEqual(model._cfg.wq, [0.0001]); }); test("calling update advances the model time", () => { diff --git a/packages/class/src/class.ts b/packages/class/src/class.ts index 10e81a1..c3f4a25 100644 --- a/packages/class/src/class.ts +++ b/packages/class/src/class.ts @@ -2,29 +2,34 @@ * This module contains the CLASS model implementation. * @module */ -import type { Config } from "./config.js"; +import type { Config, MixedLayerConfig, WindConfig } from "./config.js"; +import { findInsertIndex, interpolateHourly } from "./utils.js"; // Constants -const rho = 1.2; /** Density of air [kg m-3] */ -const cp = 1005.0; /** Specific heat of dry air [J kg-1 K-1] */ +const CONSTANTS = { + fc: 1e-4, // Coriolis parameter [m s-1] +}; -/** - * CLASS model definition - * @property _cfg: object containing the model settings - * @property h: ABL height [m] - * @property theta: Mixed-layer potential temperature [K] - * @property dtheta: Temperature jump at h [K] - * @property q: Mixed-layer specific humidity [kg kg-1] - * @property dq: Specific humidity jump at h [kg kg-1] - * @property t: Model time [s] - */ -export class CLASS { - _cfg: Config; +// Group related variables so we can require them as complete sets +type Wind = { + u: number; + v: number; + du: number; + dv: number; +}; + +type MixedLayer = { h: number; theta: number; dtheta: number; q: number; dq: number; +}; + +export class CLASS { + _cfg: Config; + ml?: MixedLayer; + wind?: Wind; t = 0; /** @@ -33,52 +38,82 @@ export class CLASS { */ constructor(config: Config) { this._cfg = config; + // Initialize state variables from config if (config.sw_ml) { - this.h = config.h_0; - this.theta = config.theta_0; - this.dtheta = config.dtheta_0; - this.q = config.q_0; - this.dq = config.dq_0; - } else { - // TODO dont have defaults here, but have it work without this else block - this.h = 200; - this.theta = 288; - this.dtheta = 1; - this.q = 0.008; - this.dq = -0.001; + const { h, theta, dtheta, q, dq } = config; + this.ml = { h, theta, dtheta, q, dq }; + + if (config.sw_wind) { + const { u, v, du, dv } = config; + this.wind = { u, v, du, dv }; + } } } + /** * Integrate mixed layer */ update() { const dt = this._cfg.dt; - this.h += dt * this.htend; - this.theta += dt * this.thetatend; - this.dtheta += dt * this.dthetatend; - this.q += dt * this.qtend; - this.dq += dt * this.dqtend; + if (this.ml) { + this.ml.h += dt * this.htend; + this.ml.theta += dt * this.thetatend; + this.ml.dtheta += dt * this.dthetatend; + this.ml.q += dt * this.qtend; + this.ml.dq += dt * this.dqtend; + if (this.wind) { + this.wind.u += dt * this.utend; + this.wind.v += dt * this.vtend; + this.wind.du += dt * this.dutend; + this.wind.dv += dt * this.dvtend; + } + } this.t += dt; } - /** - * Type guard assertion function that checks if mixed layer mode is enabled in the configuration. - * @throws {Error} When mixed layer is not enabled in the configuration. - * @typeAssertion {CLASS & {_cfg: Config & {sw_ml: true}}} - Asserts that this instance has mixed layer enabled - * @private - */ - private hasMixedLayer(): asserts this is CLASS & { - _cfg: Config & { sw_ml: true }; - } { - if (!this._cfg.sw_ml) { - throw new Error("Mixed layer is not enabled"); - } + get utend(): number { + this.assertWind(); + // TODO make sure all variables are available + return ( + -CONSTANTS.fc * this.wind.dv + + (this.uw + this.we * this.wind.du) / this.ml.h + + this._cfg.advu + ); + // return 0 + } + + get vtend(): number { + this.assertWind(); + // TODO make sure all variables are available + return ( + CONSTANTS.fc * this.wind.du + + (this.vw + this.we * this.wind.dv) / this.ml.h + + this._cfg.advv + ); + } + + get dutend(): number { + this.assertWind(); + return this.gamma_u * this.we - this.utend; + } + + get dvtend(): number { + this.assertWind(); + return this.gamma_v * this.we - this.vtend; + } + + get uw(): number { + this.assertWind(); + const { u, v } = this.wind; + const { ustar } = this._cfg; + return -Math.sign(u) * (ustar ** 4 / (v ** 2 / u ** 2 + 1)) ** 0.5; } - private interpolatedWtheta(): number { - this.hasMixedLayer(); - // TODO interpolated based on this.t / this.runtime and the wtheta values - return this._cfg.wtheta[0]; + get vw(): number { + this.assertWind(); + const { u, v } = this.wind; + const { ustar } = this._cfg; + return -Math.sign(v) * (ustar ** 4 / (u ** 2 / v ** 2 + 1)) ** 0.5; } /** Tendency of CLB [m s-1]*/ @@ -88,29 +123,28 @@ export class CLASS { /** Tendency of mixed-layer potential temperature [K s-1] */ get thetatend(): number { - this.hasMixedLayer(); - const wtheta = this.interpolatedWtheta(); - return (wtheta - this.wthetae) / this.h + this._cfg.advtheta; + this.assertMixedLayer(); + return (this.wtheta - this.wthetae) / this.ml.h + this._cfg.advtheta; } /** Tendency of potential temperature jump at h [K s-1] */ get dthetatend(): number { - this.hasMixedLayer(); + this.assertMixedLayer(); const w_th_ft = 0.0; // TODO: add free troposphere switch - return this._cfg.gammatheta * this.we - this.thetatend + w_th_ft; + return this.gammatheta * this.we - this.thetatend + w_th_ft; } /** Tendency of mixed-layer specific humidity [kg kg-1 s-1] */ get qtend(): number { - this.hasMixedLayer(); - return (this._cfg.wq - this.wqe) / this.h + this._cfg.advq; + this.assertMixedLayer(); + return (this.wq - this.wqe) / this.ml.h + this._cfg.advq; } /** Tendency of specific humidity jump at h[kg kg-1 s-1] */ get dqtend(): number { - this.hasMixedLayer(); + this.assertMixedLayer(); const w_q_ft = 0; // TODO: add free troposphere switch - return this._cfg.gammaq * this.we - this.qtend + w_q_ft; + return this.gammaq * this.we - this.qtend + w_q_ft; } /** Entrainment velocity [m s-1]. */ @@ -127,38 +161,131 @@ export class CLASS { /** Large-scale vertical velocity [m s-1]. */ get ws(): number { - this.hasMixedLayer(); - return -this._cfg.divU * this.h; + this.assertMixedLayer(); + return -this._cfg.divU * this.ml.h; } /** Entrainment kinematic heat flux [K m s-1]. */ get wthetae(): number { - return -this.we * this.dtheta; + this.assertMixedLayer(); + return -this.we * this.ml.dtheta; } /** Entrainment moisture flux [kg kg-1 m s-1]. */ get wqe(): number { - return -this.we * this.dq; + this.assertMixedLayer(); + return -this.we * this.ml.dq; } /** Entrainment kinematic virtual heat flux [K m s-1]. */ get wthetave(): number { - this.hasMixedLayer(); + this.assertMixedLayer(); return -this._cfg.beta * this.wthetav; } /** Virtual temperature jump at h [K]. */ get dthetav(): number { + this.assertMixedLayer(); return ( - (this.theta + this.dtheta) * (1.0 + 0.61 * (this.q + this.dq)) - - this.theta * (1.0 + 0.61 * this.q) + (this.ml.theta + this.ml.dtheta) * + (1.0 + 0.61 * (this.ml.q + this.ml.dq)) - + this.ml.theta * (1.0 + 0.61 * this.ml.q) ); } + get wtheta(): number { + this.assertMixedLayer(); + return interpolateHourly(this._cfg.wtheta, this.t); + } + + get wq(): number { + this.assertMixedLayer(); + return interpolateHourly(this._cfg.wq, this.t); + } + /** Surface kinematic virtual heat flux [K m s-1]. */ get wthetav(): number { - this.hasMixedLayer(); - const wtheta = this.interpolatedWtheta(); - return wtheta + 0.61 * this.theta * this._cfg.wq; + this.assertMixedLayer(); + return this.wtheta + 0.61 * this.ml.theta * this.wq; + } + + // Lapse rates + + /** Free atmosphere potential temperature lapse rate */ + get gammatheta(): number { + this.assertMixedLayer(); + const { z_theta, gammatheta } = this._cfg; + const i = findInsertIndex(z_theta, this.ml.h); + return gammatheta[i] ?? 0; + } + + /** Free atmosphere specific humidity lapse rate */ + get gammaq(): number { + this.assertMixedLayer(); + const { z_q, gammaq } = this._cfg; + const i = findInsertIndex(z_q, this.ml.h); + return gammaq[i] ?? 0; + } + + /** Free atmosphere u-wind lapse rate */ + get gamma_u(): number { + this.assertWind(); + const { z_u, gamma_u } = this._cfg; + const i = findInsertIndex(z_u, this.ml.h); + return gamma_u[i] ?? 0; + } + + /** Free atmosphere v-wind lapse rate */ + get gamma_v(): number { + this.assertWind(); + const { z_v, gamma_v } = this._cfg; + const i = findInsertIndex(z_v, this.ml.h); + return gamma_v[i] ?? 0; + } + + // Type guards + private assertMixedLayer(): asserts this is CLASS & { + _cfg: MixedLayerConfig; + ml: MixedLayer; + } { + if (!this._cfg.sw_ml || this.ml === undefined) { + throw new Error("Mixed layer is not enabled in config"); + } + } + + private assertWind(): asserts this is CLASS & { + _cfg: MixedLayerConfig & WindConfig; + ml: MixedLayer; + wind: Wind; + } { + this.assertMixedLayer(); + if (!this._cfg.sw_wind || this.wind === undefined) { + throw new Error( + "Wind is not enabled in config or wind state is not initialized.", + ); + } + } + + get q() { + return this.ml?.q || 999; + } + + /** + * Retrieve a value by name, treating nested state (wind, ml) as if it's flat. + * + * Lookup order: + * 1. Property on the class instance itself. + * 2. Property on `wind`, if available. + * 3. Property on `ml`, if available. + * + * Returns `999` if the property is not found in any of the above. + */ + getValue(name: string): number { + // biome-ignore lint/suspicious/noExplicitAny: + const self = this as any; + if (name in self && typeof self[name] === "number") return self[name]; + if (this.wind && name in this.wind) return this.wind[name as keyof Wind]; + if (this.ml && name in this.ml) return this.ml[name as keyof MixedLayer]; + return 999; } } diff --git a/packages/class/src/cli.ts b/packages/class/src/cli.ts index f646eec..e88a583 100755 --- a/packages/class/src/cli.ts +++ b/packages/class/src/cli.ts @@ -7,7 +7,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { EOL } from "node:os"; import { Command, Option } from "@commander-js/extra-typings"; import { jsonSchemaOfConfig } from "./config.js"; -import type { ClassOutput } from "./runner.js"; +import type { ClassOutput } from "./output.js"; import { runClass } from "./runner.js"; import { parse } from "./validate.js"; diff --git a/packages/class/src/config.ts b/packages/class/src/config.ts index 209d941..480735d 100644 --- a/packages/class/src/config.ts +++ b/packages/class/src/config.ts @@ -50,6 +50,12 @@ const untypedSchema = { title: "Mixed-layer switch", default: true, }, + sw_wind: { + type: "boolean", + "ui:group": "Wind", + title: "Wind switch", + default: false, + }, }, required: ["name", "dt", "runtime"], allOf: [ @@ -64,7 +70,7 @@ const untypedSchema = { // biome-ignore lint/suspicious/noThenProperty: part of JSON Schema then: { properties: { - h_0: { + h: { symbol: "h", type: "number", title: "ABL height", @@ -72,7 +78,7 @@ const untypedSchema = { default: 200, "ui:group": "Mixed layer", }, - theta_0: { + theta: { symbol: "θ", type: "number", "ui:group": "Mixed layer", @@ -84,7 +90,7 @@ const untypedSchema = { "The potential temperature of the mixed layer at the initial time.", unit: "K", }, - dtheta_0: { + dtheta: { symbol: "Δθ", type: "number", title: "Temperature jump at h", @@ -92,7 +98,7 @@ const untypedSchema = { default: 1, unit: "K", }, - q_0: { + q: { symbol: "q", type: "number", "ui:group": "Mixed layer", @@ -100,7 +106,7 @@ const untypedSchema = { default: 0.008, title: "Mixed-layer specific humidity", }, - dq_0: { + dq: { symbol: "Δq", type: "number", description: "Specific humidity jump at h", @@ -130,18 +136,26 @@ const untypedSchema = { }, gammatheta: { symbol: "γθ", - type: "number", + type: "array", + items: { + type: "number", + }, + minItems: 1, "ui:group": "Mixed layer", unit: "K m⁻¹", - default: 0.006, + default: [0.006], title: "Free atmosphere potential temperature lapse rate", }, wq: { symbol: "(w'q')ₛ", - type: "number", + type: "array", + items: { + type: "number", + }, "ui:group": "Mixed layer", unit: "kg kg⁻¹ m s⁻¹", - default: 0.0001, + default: [0.0001], + minItems: 1, title: "Surface kinematic moisture flux", }, advq: { @@ -154,10 +168,14 @@ const untypedSchema = { }, gammaq: { symbol: "γq", - type: "number", + type: "array", + items: { + type: "number", + }, + minItems: 1, "ui:group": "Mixed layer", unit: "kg kg⁻¹ m⁻¹", - default: 0, + default: [0], title: "Free atmosphere specific humidity lapse rate", }, divU: { @@ -175,13 +193,41 @@ const untypedSchema = { default: 0.2, title: "Entrainment ratio for virtual heat", }, + z_theta: { + symbol: "zθ", + type: "array", + items: { + type: "number", + }, + minItems: 1, + "ui:group": "Mixed layer", + unit: "m", + default: [5000], + title: "Anchor point(s) for γ_θ", + description: + "Each value specifies the end of the corresponding segment in γ_θ", + }, + z_q: { + symbol: "zq", + type: "array", + items: { + type: "number", + }, + minItems: 1, + "ui:group": "Mixed layer", + unit: "m", + default: [5000], + title: "Anchor point(s) for γ_q", + description: + "Each value specifies the end of the corresponding segment in γ_q", + }, }, required: [ - "h_0", - "theta_0", - "dtheta_0", - "q_0", - "dq_0", + "h", + "theta", + "dtheta", + "q", + "dq", "wtheta", "advtheta", "gammatheta", @@ -190,40 +236,202 @@ const untypedSchema = { "gammaq", "divU", "beta", + "z_theta", + "z_q", + ], + }, + }, + { + if: { + properties: { + sw_wind: { + const: true, + }, + }, + }, + // biome-ignore lint/suspicious/noThenProperty: part of JSON Schema + then: { + properties: { + u: { + symbol: "u", + type: "number", + unit: "m s⁻¹", + default: 6, + title: "Mixed-layer u-wind speed", + "ui:group": "Wind", + }, + du: { + symbol: "Δu", + type: "number", + unit: "m s⁻¹", + default: 4, + title: "U-wind jump at h", + "ui:group": "Wind", + }, + advu: { + symbol: "adv(u)", // _adv not possible in unicode + type: "number", + "ui:group": "Wind", + unit: "m s⁻²", + default: 0, + title: "Advection of u-wind", + }, + gamma_u: { + symbol: "γu", + type: "array", + items: { + type: "number", + }, + minItems: 1, + unit: "s⁻¹", + default: [0], + title: "Free atmosphere u-wind speed lapse rate", + description: "Specify one or multiple segments", + "ui:group": "Wind", + }, + z_u: { + symbol: "z_u", + type: "array", + items: { + type: "number", + }, + minItems: 1, + unit: "m", + default: [5000], + title: "Anchor point(s) for γ_u", + description: + "Each value specifies the end of the corresponding segment in γ_u", + "ui:group": "Wind", + }, + v: { + symbol: "v", + type: "number", + unit: "m s⁻¹", + default: -4, + title: "Mixed-layer v-wind speed", + "ui:group": "Wind", + }, + dv: { + symbol: "Δv", + type: "number", + unit: "m s⁻¹", + default: 4, + title: "V-wind jump at h", + "ui:group": "Wind", + }, + advv: { + symbol: "adv(v)", // _adv not possible in unicode + type: "number", + "ui:group": "Wind", + unit: "m s⁻²", + default: 0, + title: "Advection of v-wind", + }, + gamma_v: { + symbol: "γv", + type: "array", + items: { + type: "number", + }, + minItems: 1, + unit: "s⁻¹", + default: [0], + title: "Free atmosphere v-wind speed lapse rate", + description: "Specify one or multiple segments", + "ui:group": "Wind", + }, + z_v: { + symbol: "z_v", + type: "array", + items: { + type: "number", + }, + minItems: 1, + unit: "m", + default: [5000], + title: "Anchor point(s) for γ_v", + description: + "Each value specifies the end of the corresponding segment in γ_v", + "ui:group": "Wind", + }, + ustar: { + symbol: "u*", + type: "number", + unit: "m s⁻¹", + title: "Surface friction velocity", + "ui:group": "Wind", + default: 0.3, + }, + }, + required: [ + "u", + "du", + "advu", + "gamma_u", + "z_u", + "v", + "dv", + "advv", + "gamma_v", + "z_v", + "ustar", ], }, }, ], }; -// TODO generate this from ./config.schema.json -// at the momemt json-schema-to-typescript does not understand if/then/else -// and cannot generate such minimalistic types -export type Config = { +type GeneralConfig = { name: string; description?: string; dt: number; runtime: number; -} & ( // Mixed layer - | { - sw_ml: true; - h_0: number; - theta_0: number; - dtheta_0: number; - q_0: number; - dq_0: number; - wtheta: number[]; - advtheta: number; - gammatheta: number; - wq: number; - advq: number; - gammaq: number; - divU: number; - beta: number; - } - // Else, sw_ml key should be absent or false - | { sw_ml?: false } -); +}; + +export type WindConfig = { + sw_wind: true; + u: number; + v: number; + du: number; + dv: number; + advu: number; + advv: number; + gamma_u: number[]; + gamma_v: number[]; + z_u: number[]; + z_v: number[]; + ustar: number; +}; +type NoWindConfig = { + sw_wind?: false; +}; + +export type MixedLayerConfig = { + sw_ml: true; + h: number; + theta: number; + dtheta: number; + q: number; + dq: number; + wtheta: number[]; + advtheta: number; + gammatheta: number[]; + wq: number[]; + advq: number; + gammaq: number[]; + divU: number; + beta: number; + z_theta: number[]; + z_q: number[]; +}; +type NoMixedLayerConfig = { + sw_ml?: false; +}; + +// TODO: Don't allow WindConfig with NoMixedLayerConfig +export type Config = GeneralConfig & + (MixedLayerConfig | NoMixedLayerConfig) & + (WindConfig | NoWindConfig); export type JsonSchemaOfConfig = JSONSchemaType; export const jsonSchemaOfConfig = diff --git a/packages/class/src/output.ts b/packages/class/src/output.ts new file mode 100644 index 0000000..1fc455f --- /dev/null +++ b/packages/class/src/output.ts @@ -0,0 +1,115 @@ +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'θ')ᵥ", + }, + { + key: "wtheta", + title: "Surface kinematic heat flux", + unit: "K m s⁻¹", + symbol: "(w'θ')ₛ", + }, + { + key: "wq", + title: "Surface kinematic heat flux", + unit: "kg kg⁻¹ m s⁻¹", + symbol: "(w'q')ₛ", + }, + { + key: "u", + title: "Mixed-layer u-wind component", + unit: "m s⁻¹", + symbol: "u", + }, + { + key: "v", + title: "Mixed-layer v-wind component", + unit: "m s⁻¹", + symbol: "v", + }, + { + key: "du", + title: "U-wind jump at h", + unit: "m s⁻¹", + symbol: "Δu", + }, + { + key: "dv", + title: "V-wind jump at h", + unit: "m s⁻¹", + symbol: "Δv", + }, +]; + +export type ClassOutput = { + [K in (typeof outputVariables)[number]["key"]]: number[]; +}; diff --git a/packages/class/src/runner.ts b/packages/class/src/runner.ts index b868a2e..644bc41 100644 --- a/packages/class/src/runner.ts +++ b/packages/class/src/runner.ts @@ -5,88 +5,9 @@ */ import { CLASS } from "./class.js"; import type { Config } from "./config.js"; +import { type ClassOutput, outputVariables } from "./output.js"; import { parse } from "./validate.js"; -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[]; -}; - /** * Runs the CLASS model with the given configuration and frequency. * @@ -100,8 +21,7 @@ export function runClass(config: Config, freq = 600): ClassOutput { const writeOutput = () => { for (const v of outputVariables) { - const value = - model[v.key as keyof CLASS] ?? (v.key === "t" ? model.t : undefined); + const value = model.getValue(v.key); if (value !== undefined) { (output[v.key] as number[]).push(value as number); } diff --git a/packages/class/src/utils.ts b/packages/class/src/utils.ts new file mode 100644 index 0000000..894948d --- /dev/null +++ b/packages/class/src/utils.ts @@ -0,0 +1,33 @@ +/** + * Interpolate between hourly reference values based on current runtime + * Padding is applied if not all hours are provided + * + * @param arr array with hourly reference values for variable + * @param t time in seconds + * @param pad fill value if t/3600 exceeds array length + * @returns interpolated value + */ +export function interpolateHourly(arr: number[], t: number, pad = 0) { + const maxIndex = arr.length - 1; + const i = Math.floor(t / 3600); + const p = (t % 3600) / 3600; + + const left = i <= maxIndex ? arr[i] : pad; + const right = i + 1 <= maxIndex ? arr[i + 1] : pad; + return left + p * (right - left); +} + +/** + * Find the position in an array where value should be inserted to maintain sorted order. + * + * @param arr Sorted array + * @param value Value that should be inserted + * @returns index where value should be inserted to maintain sorted array + */ +export function findInsertIndex(arr: number[], value: number) { + let i = 0; + while (i < arr.length && value >= arr[i]) { + i++; + } + return i; +} diff --git a/packages/class/src/validate.test.ts b/packages/class/src/validate.test.ts index e3bd01f..e125466 100644 --- a/packages/class/src/validate.test.ts +++ b/packages/class/src/validate.test.ts @@ -35,11 +35,11 @@ describe("validate", () => { ); test("given string should coerce to number", () => { - const input = { sw_ml: true, h_0: "42" }; + const input = { sw_ml: true, h: "42" }; validate(input); - assert.ok(typeof input.h_0 === "number"); + assert.ok(typeof input.h === "number"); }); }); @@ -50,30 +50,44 @@ describe("parse", () => { const output = parse(input); const expected = { - name: "", - description: "", - sw_ml: true, - h_0: 200, - theta_0: 288, - dtheta_0: 1, - q_0: 0.008, - dq_0: -0.001, - dt: 60, - runtime: 43200, + h: 200, + theta: 288, + dtheta: 1, + q: 0.008, + dq: -0.001, wtheta: [0.1], advtheta: 0, - gammatheta: 0.006, - wq: 0.0001, + gammatheta: [0.006], + wq: [0.0001], advq: 0, - gammaq: 0, + gammaq: [0], divU: 0, beta: 0.2, + z_theta: [5000], + z_q: [5000], + u: 6, + du: 4, + advu: 0, + gamma_u: [0], + z_u: [5000], + v: -4, + dv: 4, + advv: 0, + gamma_v: [0], + z_v: [5000], + ustar: 0.3, + name: "", + description: "", + dt: 60, + runtime: 43200, + sw_ml: true, + sw_wind: false, }; assert.deepEqual(output, expected); }); test("given partial config should return full config", () => { - const input = { h_0: 100, sw_ml: true }; + const input = { h: 100, sw_ml: true }; const output = parse(input); @@ -81,12 +95,12 @@ describe("parse", () => { if (!expected.sw_ml) { throw new Error("sw_ml is enabled"); } - expected.h_0 = 100; + expected.h = 100; assert.deepEqual(output, expected); }); test("given partial string config should return full coerced config", () => { - const input = { h_0: "100", sw_ml: true }; + const input = { h: "100", sw_ml: true }; const output = parse(input); @@ -94,12 +108,12 @@ describe("parse", () => { if (!expected.sw_ml) { throw new Error("sw_ml is enabled"); } - expected.h_0 = 100; + expected.h = 100; assert.deepEqual(output, expected); }); test("given emptry string should return default", () => { - const input = { h_0: "", sw_ml: true }; + const input = { h: "", sw_ml: true }; const output = parse(input); diff --git a/packages/form/src/Form.tsx b/packages/form/src/Form.tsx index ec23104..68483fc 100644 --- a/packages/form/src/Form.tsx +++ b/packages/form/src/Form.tsx @@ -346,25 +346,42 @@ function createLabel(item: Base) { }); } -const DescriptionTooltip: Component<{ schema: SchemaOfProperty }> = (props) => { +const LabelWithTooltip: Component<{ + schema: SchemaOfProperty; + label: string; + extraDescription?: string; + for: string; +}> = (props) => { const UiComponents = useFormContext().uiComponents; return ( } > } + as={UiComponents.Button<"button">} variant="ghost" - class="ml-2 size-8 rounded-full" + class="h-auto p-2" > - ? +

{!props.schema.symbol || props.schema.title}

{props.schema.description}

+ +

{props.extraDescription}

+
@@ -433,6 +450,7 @@ interface ValueGetSet { */ // TODO if error is truthy also update AccordionTrigger to havea an error badge error?: string; + extraDescription?: string; } const TextFieldWrapperControlled: ParentComponent< @@ -443,6 +461,7 @@ const TextFieldWrapperControlled: ParentComponent< const UiComponents = useFormContext().uiComponents; return (
- - +
= (props) => { return (
- {label()} - +
= (props) => { return (
- {label()} - +
= (props) => { value={stringVal()} onChange={onChange} error={error()} + extraDescription="Give multiple values seperated by commas" >