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({
<>
-
+
+ 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
+ ]
+ }
+ ]
+}