diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index d307ba2..f140eb0 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -11,7 +11,13 @@ import { createMemo, createUniqueId, } from "solid-js"; -import { getThermodynamicProfiles, getVerticalProfiles } from "~/lib/profiles"; +import type { Observation } from "~/lib/experiment_config"; +import { + getThermodynamicProfiles, + getVerticalProfiles, + observationsForProfile, + observationsForSounding, +} from "~/lib/profiles"; import { type Analysis, type ProfilesAnalysis, @@ -68,7 +74,7 @@ const flatExperiments: () => FlatExperiment[] = createMemo(() => { return experiments .filter((e) => e.output.running === false) // skip running experiments .flatMap((e, i) => { - const reference = { + const reference: FlatExperiment = { color: colors[0], linestyle: linestyles[i % 5], label: e.config.reference.name, @@ -90,6 +96,15 @@ const flatExperiments: () => FlatExperiment[] = createMemo(() => { }); }); +// Derived store for all observations of all experiments combined +const flatObservations: () => Observation[] = createMemo(() => { + return experiments + .filter((e) => e.config.observations) + .flatMap((e) => { + return e.config.observations || []; + }); +}); + const _allTimes = () => new Set(flatExperiments().flatMap((e) => e.output?.t ?? [])); const uniqueTimes = () => [...new Set(_allTimes())].sort((a, b) => a - b); @@ -166,12 +181,23 @@ export function VerticalProfilePlot({ const classVariable = () => variableOptions[analysis.variable as keyof typeof variableOptions]; - const allValues = () => - flatExperiments().flatMap((e) => + 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()] : [], - ); - const allHeights = () => - flatExperiments().flatMap((e) => (e.output ? e.output.h : [])); + ), + ...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); @@ -199,11 +225,12 @@ export function VerticalProfilePlot({ <>
- + [...profileData(), ...observations()]} /> {(d) => Line(d)} + {(d) => Line(d)} + flatObservations().map((o) => observationsForSounding(o)); + return ( <> - + [...skewTData(), ...observations()]} /> {TimeSlider( () => analysis.time, uniqueTimes, diff --git a/apps/class-solid/src/lib/encode.ts b/apps/class-solid/src/lib/encode.ts index a2fbd5a..10ce05a 100644 --- a/apps/class-solid/src/lib/encode.ts +++ b/apps/class-solid/src/lib/encode.ts @@ -53,6 +53,7 @@ export function toPartial(config: ExperimentConfig): PartialExperimentConfig { permutations: config.permutations.map((perm) => pruneConfig(perm, config.reference, preset.config), ), + observations: config.observations, }; } diff --git a/apps/class-solid/src/lib/experiment_config.ts b/apps/class-solid/src/lib/experiment_config.ts index 44f3c36..a363bf9 100644 --- a/apps/class-solid/src/lib/experiment_config.ts +++ b/apps/class-solid/src/lib/experiment_config.ts @@ -5,6 +5,19 @@ import { overwriteDefaultsInJsonSchema } from "@classmodel/form/utils"; import type { DefinedError, JSONSchemaType } from "ajv"; import { findPresetByName } from "./presets"; +/* +Height (m AGL) Pressure (mb) Temperature (C) Relative humidity (%) Wind speed (m/s) Wind direction (true deg) +*/ +export interface Observation { + name: string; + height: number[]; + pressure: number[]; + temperature: number[]; + relativeHumidity: number[]; + windSpeed: number[]; + windDirection: number[]; +} + /** * An experiment configuration is a combination of a preset name and * a reference configuration and a set of permutation configurations. @@ -13,6 +26,7 @@ export interface ExperimentConfig { preset: string; reference: C; permutations: C[]; + observations?: Observation[]; } /** @@ -42,6 +56,30 @@ const jsonSchemaOfExperimentConfigBase = { }, default: [], }, + observations: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + height: { type: "array", items: { type: "number" } }, + pressure: { type: "array", items: { type: "number" } }, + temperature: { type: "array", items: { type: "number" } }, + relativeHumidity: { type: "array", items: { type: "number" } }, + windSpeed: { type: "array", items: { type: "number" } }, + windDirection: { type: "array", items: { type: "number" } }, + }, + required: [ + "name", + "height", + "pressure", + "temperature", + "relativeHumidity", + "windSpeed", + "windDirection", + ], + }, + }, }, required: ["preset", "reference", "permutations"], } as unknown as JSONSchemaType>; @@ -89,9 +127,13 @@ export function parseExperimentConfig(input: unknown): ExperimentConfig { } const permutations = base.permutations.map(permParse); - return { + const config: ExperimentConfig = { reference, preset: base.preset, permutations, }; + if (base.observations) { + config.observations = base.observations; + } + return config; } diff --git a/apps/class-solid/src/lib/profiles.ts b/apps/class-solid/src/lib/profiles.ts index 86e75a2..d7f669b 100644 --- a/apps/class-solid/src/lib/profiles.ts +++ b/apps/class-solid/src/lib/profiles.ts @@ -1,6 +1,7 @@ import type { Config } from "@classmodel/class/config"; import type { ClassOutput } from "@classmodel/class/runner"; import type { Point } from "~/components/plots/Line"; +import type { Observation } from "./experiment_config"; // Get vertical profiles for a single class run export function getVerticalProfiles( @@ -70,6 +71,16 @@ const thetaToT = (theta: number, p: number, p0 = 1000) => { return T; }; +/** + * Calculate potential temperature from temperature + */ +const tToTheta = (T: number, p: number, p0 = 1000) => { + const R = 287; // specific gas constant for dry air + const cp = 1004; // specific heat of dry air at constant pressure + const theta = T * (p / p0) ** -(R / cp); + return theta; +}; + /** * Calculate pressure difference over layer using hypsometric equation * Wallace & Hobbs eq (3.23) @@ -96,6 +107,24 @@ const thickness = (T: number, q: number, p: number, dp: number) => { return dz; }; +function calculateSpecificHumidity(T: number, p: number, rh: number) { + // Constants + const epsilon = 0.622; // Ratio of gas constants for dry air and water vapor + const es0 = 6.112; // Reference saturation vapor pressure in hPa + + // Calculate saturation vapor pressure (Tetens formula) + const es = es0 * Math.exp((17.67 * (T - 273.15)) / (T - 29.65)); + + // Actual vapor pressure + const e = (rh / 100) * es; + + // Mixing ratio (kg/kg) + const w = (epsilon * e) / (p - e); + + // Specific humidity + return w / (1 + w); +} + export function getThermodynamicProfiles( output: ClassOutput | undefined, config: Config, @@ -153,3 +182,38 @@ export function getThermodynamicProfiles( return soundingData; } + +export function observationsForProfile(obs: Observation, variable = "theta") { + return { + label: obs.name, + color: "red", + linestyle: "3,10", + data: obs.height.map((h, i) => { + const T = obs.temperature[i] + 273.15; + const rh = obs.relativeHumidity[i]; + const p = obs.pressure[i]; + const theta = tToTheta(T, p); + const q = calculateSpecificHumidity(T, p, rh); + if (variable === "theta") { + return { y: h, x: theta }; + } + return { y: h, x: q }; + }), + }; +} + +export function observationsForSounding(obs: Observation) { + return { + data: obs.height.map((h, i) => { + const T = obs.temperature[i] + 273.15; + const p = obs.pressure[i]; + const rh = obs.relativeHumidity[i]; + const q = calculateSpecificHumidity(T, p, rh); + const Td = dewpoint(q, p); + return { p, T, Td }; + }), + label: obs.name, + color: "red", + linestyle: "3,10", + }; +} diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index 1c4bd2e..82d7404 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -104,6 +104,9 @@ export async function uploadExperiment(rawData: unknown) { running: false, }, }; + if (upload.observations) { + experiment.config.observations = upload.observations; + } setExperiments(experiments.length, experiment); await runExperiment(experiments.length - 1); } diff --git a/apps/class-solid/tests/config-with-observations.json b/apps/class-solid/tests/config-with-observations.json new file mode 100644 index 0000000..dd40437 --- /dev/null +++ b/apps/class-solid/tests/config-with-observations.json @@ -0,0 +1,82 @@ +{ + "reference": { + "name": "EWED 1916", + "description": "Default config with observations" + }, + "preset": "Default", + "permutations": [], + "observations": [ + { + "name": "2025-02-10_1916.sounding-2.csv", + "height": [ + 20.0, 40.0, 60.0, 80.0, 100.0, 120.0, 140.0, 160.0, 180.0, 200.0, 220.0, + 240.0, 260.0, 280.0, 300.0, 320.0, 340.0, 360.0, 380.0, 400.0, 420.0, + 440.0, 460.0, 480.0, 500.0, 520.0, 540.0, 560.0, 580.0, 600.0, 620.0, + 640.0, 660.0, 680.0, 700.0, 720.0, 740.0, 760.0, 780.0, 800.0, 820.0, + 840.0, 860.0, 880.0, 900.0, 920.0, 940.0, 960.0, 980.0, 1000.0, 1020.0, + 1040.0, 1060.0, 1080.0, 1100.0, 1120.0, 1140.0, 1160.0, 1180.0, 1200.0, + 1220.0, 1240.0, 1260.0, 1280.0, 1300.0, 1320.0, 1340.0, 1360.0, 1380.0, + 1400.0, 1420.0, 1440.0, 1460.0, 1480.0, 1500.0, 1520.0, 1540.0, 1560.0, + 1580.0, 1600.0, 1620.0, 1640.0, 1660.0, 1680.0, 1700.0, 1720.0, 1740.0, + 1760.0, 1780.0, 1800.0, 1820.0, 1840.0, 1860.0, 1880.0 + ], + "pressure": [ + 970.06, 967.87, 965.7, 963.52, 961.35, 959.19, 957.03, 954.87, 952.72, + 950.57, 948.42, 946.28, 944.14, 942.0, 939.87, 937.74, 935.61, 933.49, + 931.37, 929.25, 927.14, 925.03, 922.92, 920.82, 918.72, 916.62, 914.53, + 912.44, 910.35, 908.27, 906.19, 904.11, 902.04, 899.97, 897.91, 895.84, + 893.78, 891.73, 889.68, 887.63, 885.58, 883.54, 881.5, 879.47, 877.44, + 875.41, 873.39, 871.37, 869.35, 867.34, 865.33, 863.32, 861.32, 859.32, + 857.33, 855.34, 853.35, 851.37, 849.39, 847.42, 845.44, 843.47, 841.51, + 839.54, 837.58, 835.63, 833.68, 831.73, 829.78, 827.84, 825.91, 823.97, + 822.04, 820.12, 818.2, 816.28, 814.36, 812.45, 810.54, 808.63, 806.73, + 804.83, 802.94, 801.04, 799.16, 797.27, 795.39, 793.51, 791.64, 789.76, + 787.9, 786.03, 784.17, 782.32 + ], + "temperature": [ + 27.88, 27.98, 28.01, 27.88, 27.61, 27.39, 27.2, 27.05, 26.89, 26.71, + 26.53, 26.3, 26.04, 25.85, 25.69, 25.54, 25.35, 25.08, 24.84, 24.66, + 24.45, 24.23, 24.02, 23.8, 23.64, 23.49, 23.31, 23.1, 22.85, 22.63, + 22.43, 22.21, 22.04, 21.92, 21.84, 21.73, 21.51, 21.28, 21.05, 20.83, + 20.62, 20.52, 20.39, 20.2, 20.01, 19.86, 19.77, 19.75, 19.73, 19.63, + 19.45, 19.29, 19.14, 19.03, 18.89, 18.68, 18.51, 18.36, 18.23, 18.12, + 18.01, 17.95, 17.88, 17.71, 17.53, 17.44, 17.32, 17.18, 17.09, 17.09, + 17.09, 17.02, 16.86, 16.73, 16.61, 16.44, 16.19, 15.98, 15.82, 15.63, + 15.52, 15.35, 15.09, 14.91, 14.75, 14.63, 14.51, 14.37, 14.23, 14.18, + 14.15, 14.06, 13.92, 13.85 + ], + "relativeHumidity": [ + 27.5, 26.9, 26.4, 26.5, 26.7, 27.0, 27.1, 27.2, 27.3, 27.5, 27.7, 27.8, + 28.1, 28.4, 28.6, 28.8, 28.9, 29.1, 29.3, 29.3, 29.2, 29.0, 29.1, 29.3, + 29.1, 28.9, 29.1, 29.5, 30.3, 30.7, 31.3, 32.1, 32.5, 31.9, 31.1, 30.2, + 30.3, 30.3, 30.4, 30.5, 30.5, 30.6, 30.9, 31.5, 31.8, 31.5, 30.3, 28.4, + 27.2, 27.3, 28.3, 29.2, 30.0, 30.1, 30.1, 30.1, 29.8, 28.5, 26.3, 24.2, + 22.0, 18.9, 16.5, 14.6, 13.4, 12.5, 11.7, 10.2, 8.3, 6.5, 5.5, 5.4, 5.4, + 5.5, 5.6, 5.6, 5.7, 5.8, 5.9, 6.0, 6.0, 6.2, 6.4, 6.7, 7.2, 7.7, 7.9, + 7.9, 7.9, 7.7, 7.6, 7.6, 7.8, 7.9 + ], + "windSpeed": [ + 2.79, 3.32, 3.65, 4.27, 4.63, 5.03, 5.27, 5.28, 5.34, 5.32, 5.43, 5.57, + 5.55, 5.56, 5.45, 5.26, 5.08, 5.1, 5.21, 5.47, 5.83, 6.12, 6.36, 6.53, + 6.61, 6.58, 6.6, 6.67, 6.76, 6.92, 7.06, 7.13, 7.13, 7.11, 7.05, 6.93, + 6.83, 6.7, 6.58, 6.47, 6.34, 6.23, 6.14, 6.02, 5.87, 5.7, 5.46, 5.12, + 4.83, 4.53, 4.33, 4.11, 3.97, 3.78, 3.61, 3.5, 3.36, 3.24, 3.16, 3.11, + 3.14, 3.21, 3.38, 3.56, 3.72, 3.77, 3.82, 3.86, 3.86, 3.67, 3.33, 2.75, + 2.27, 1.89, 1.78, 1.78, 1.83, 1.82, 1.69, 1.6, 1.49, 1.46, 1.62, 1.91, + 2.06, 2.19, 2.32, 2.53, 2.75, 2.91, 3.02, 3.05, 3.02, 2.99 + ], + "windDirection": [ + 236.7, 230.2, 227.8, 229.9, 230.5, 227.0, 226.5, 228.3, 232.7, 232.8, + 229.8, 227.8, 226.5, 223.8, 223.7, 225.0, 224.5, 221.8, 219.6, 219.0, + 219.8, 221.5, 221.8, 220.7, 220.3, 219.8, 218.4, 218.8, 219.6, 219.5, + 219.0, 219.6, 219.4, 218.3, 217.1, 216.1, 216.2, 216.4, 216.5, 216.7, + 216.7, 215.8, 214.7, 213.4, 211.2, 208.7, 205.8, 202.0, 198.6, 196.2, + 193.0, 191.2, 189.8, 189.1, 187.7, 186.6, 185.8, 187.4, 191.0, 195.0, + 197.3, 202.4, 205.8, 209.8, 211.3, 213.4, 216.7, 221.9, 226.8, 231.6, + 235.4, 242.8, 251.9, 259.2, 261.7, 267.7, 276.3, 284.3, 283.4, 275.0, + 267.3, 271.0, 289.2, 304.3, 315.0, 319.3, 316.0, 315.0, 318.5, 324.0, + 327.9, 331.2, 333.4, 334.0 + ] + } + ] +}