diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 130b8e8..81a9416 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -1,16 +1,15 @@ import type { Config } from "@classmodel/class/config"; -import { calculatePlume, transposePlumeData } from "@classmodel/class/fire"; +import { FirePlume, calculatePlume, noPlume } from "@classmodel/class/fire"; import { - type ClassOutput, type OutputVariableKey, - getOutputAtTime, outputVariables, } from "@classmodel/class/output"; import { type ClassProfile, - NoProfile, generateProfiles, + noProfile, } from "@classmodel/class/profiles"; +import type { ClassData } from "@classmodel/class/runner"; import * as d3 from "d3"; import { saveAs } from "file-saver"; import { toBlob } from "html-to-image"; @@ -78,7 +77,7 @@ interface FlatExperiment { color: string; linestyle: string; config: Config; - output?: ClassOutput; + output?: ClassData; } // Create a derived store for looping over all outputs: @@ -119,7 +118,7 @@ const flatObservations: () => Observation[] = createMemo(() => { }); const _allTimes = () => - new Set(flatExperiments().flatMap((e) => e.output?.utcTime ?? [])); + new Set(flatExperiments().flatMap((e) => e.output?.timeseries.utcTime ?? [])); const uniqueTimes = () => [...new Set(_allTimes())].sort((a, b) => a - b); // TODO: could memoize all reactive elements here, would it make a difference? @@ -133,11 +132,15 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) { const allX = () => flatExperiments().flatMap((e) => - e.output ? e.output[analysis.xVariable as OutputVariableKey] : [], + e.output + ? e.output.timeseries[analysis.xVariable as OutputVariableKey] + : [], ); const allY = () => flatExperiments().flatMap((e) => - e.output ? e.output[analysis.yVariable as OutputVariableKey] : [], + e.output + ? e.output.timeseries[analysis.yVariable as OutputVariableKey] + : [], ); const granularities: Record = { @@ -157,12 +160,12 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) { ...formatting, data: // Zip x[] and y[] into [x, y][] - output?.t.map((_, t) => ({ + output?.timeseries.t.map((_, t) => ({ x: output - ? output[analysis.xVariable as OutputVariableKey][t] + ? output.timeseries[analysis.xVariable as OutputVariableKey][t] : Number.NaN, y: output - ? output[analysis.yVariable as OutputVariableKey][t] + ? output.timeseries[analysis.yVariable as OutputVariableKey][t] : Number.NaN, })) || [], }; @@ -241,7 +244,7 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) { export function VerticalProfilePlot({ analysis, }: { analysis: ProfilesAnalysis }) { - const variableOptions = { + const profileVariables = { "Potential temperature [K]": "theta", "Virtual potential temperature [K]": "thetav", "Specific humidity [kg/kg]": "qt", @@ -255,79 +258,71 @@ export function VerticalProfilePlot({ "Density [kg/m³]": "rho", "Relative humidity [%]": "rh", } as const satisfies Record; + type PlumeVariable = "theta" | "qt" | "thetav" | "T" | "Td" | "rh" | "w"; const classVariable = () => - variableOptions[analysis.variable as keyof typeof variableOptions]; + profileVariables[analysis.variable as keyof typeof profileVariables]; - type PlumeVariable = "theta" | "qt" | "thetav" | "T" | "Td" | "rh" | "w"; function isPlumeVariable(v: string): v is PlumeVariable { return ["theta", "qt", "thetav", "T", "Td", "rh", "w"].includes(v); } const showPlume = createMemo(() => isPlumeVariable(classVariable())); - const observations = () => - flatObservations().map((o) => observationsForProfile(o, classVariable())); - - const profileData = () => - flatExperiments().map((e) => { + // Precalculate profile lines for classVariable() for all times + const allProfileLines = () => + flatExperiments().flatMap((e) => { const { config, output, ...formatting } = e; - const t = output?.utcTime.indexOf(uniqueTimes()[analysis.time]); - if (config.sw_ml && output && t !== undefined && t !== -1) { - const outputAtTime = getOutputAtTime(output, t); - return { ...formatting, data: generateProfiles(config, outputAtTime) }; - } - return { ...formatting, data: NoProfile }; + + return uniqueTimes().map((time, tIndex) => { + const profile = output?.profiles?.[tIndex] ?? noProfile; + + return { + ...formatting, + time, + tIndex, + data: extractLine(profile, classVariable(), "z"), + }; + }); }); - const firePlumes = () => - flatExperiments().map((e, i) => { + // Also precalculate plume lines + const allPlumeLines = () => + flatExperiments().flatMap((e) => { const { config, output, ...formatting } = e; - if (config.sw_fire && isPlumeVariable(classVariable())) { - const plume = transposePlumeData( - calculatePlume(config, profileData()[i].data), - ); + + return uniqueTimes().map((time, tIndex) => { + const plume = output?.plumes?.[tIndex] ?? noPlume; + return { ...formatting, + time, + tIndex, linestyle: "4", - data: plume.z.map((z, i) => ({ - x: plume[classVariable() as PlumeVariable][i], - y: z, - })), + data: extractLine(plume, classVariable() as PlumeVariable, "z"), }; - } - return { ...formatting, data: [] }; + }); }); - // TODO: There should be a way that this isn't needed. - const profileDataForPlot = () => - profileData().map(({ data, label, color, linestyle }) => ({ - label, - color, - linestyle, - data: data.z.map((z, i) => ({ - x: data[classVariable()][i], - y: z, - })), - })) as ChartData[]; + const observationLines = () => + flatObservations().map((o) => observationsForProfile(o, classVariable())); - const allX = () => [ - ...firePlumes().flatMap((p) => p.data.map((d) => d.x)), - ...profileDataForPlot().flatMap((p) => p.data.map((d) => d.x)), - ...observations().flatMap((obs) => obs.data.map((d) => d.x)), - ]; - const allY = () => [ - ...firePlumes().flatMap((p) => p.data.map((d) => d.y)), - ...profileDataForPlot().flatMap((p) => p.data.map((d) => d.y)), - ...observations().flatMap((obs) => obs.data.map((d) => d.y)), + const allLines = () => [ + ...allPlumeLines(), + ...allProfileLines(), + ...observationLines(), ]; - // 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]; + const limits = () => { + const { xmin, xmax, ymin, ymax } = extractLimits(allLines()); + return { xLim: [xmin, xmax], yLim: [ymin, ymax] }; + }; + + const xLim = () => getNiceAxisLimits(limits().xLim); + const yLim = () => getNiceAxisLimits(limits().yLim); function chartData() { - return [...profileData(), ...observations()]; + return [...allPlumeLines(), ...observationLines()]; } const [toggles, setToggles] = createStore>({}); @@ -346,33 +341,43 @@ export function VerticalProfilePlot({ setResetPlot(analysis.id); } + const profilesAtSelectedTime = () => { + const t = analysis.time; + return allProfileLines().filter((line) => line.tIndex === t); + }; + + const plumesAtSelectedTime = () => { + const t = analysis.time; + return allPlumeLines().filter((line) => line.tIndex === t); + }; + return ( <>
[...profileData(), ...observations()]} + entries={() => [...profilesAtSelectedTime(), ...observationLines()]} toggles={toggles} onChange={toggleLine} /> - + {(d) => ( )} - + {(d) => ( )} - + {(d) => ( @@ -386,7 +391,7 @@ export function VerticalProfilePlot({ analysis.variable} setValue={(v) => changeVar(v)} - options={Object.keys(variableOptions)} + options={Object.keys(profileVariables)} label="variable: " /> {TimeSlider( @@ -483,7 +488,7 @@ export function ThermodynamicPlot({ analysis }: { analysis: SkewTAnalysis }) { const outputAtTime = getOutputAtTime(output, t); return { ...formatting, data: generateProfiles(config, outputAtTime) }; } - return { ...formatting, data: NoProfile }; + return { ...formatting, data: noProfile }; }); const firePlumes = () => @@ -620,3 +625,41 @@ export function AnalysisCard(analysis: Analysis) { ); } + +// Helper functions + +function extractLine>( + data: T, + xvar: keyof T, + yvar: keyof T, +) { + const xs = data[xvar] ?? []; + const ys = data[yvar] ?? []; + + const n = Math.min(xs.length, ys.length); + + const result = new Array(n); + for (let i = 0; i < n; i++) { + result[i] = { x: xs[i], y: ys[i] }; + } + + return result; +} + +function extractLimits(lines: { data: { x: number; y: number }[] }[]) { + let xmin = Number.POSITIVE_INFINITY; + let xmax = Number.NEGATIVE_INFINITY; + let ymin = Number.POSITIVE_INFINITY; + let ymax = Number.NEGATIVE_INFINITY; + + for (const line of lines) { + for (const p of line.data) { + if (p.x < xmin) xmin = p.x; + if (p.x > xmax) xmax = p.x; + if (p.y < ymin) ymin = p.y; + if (p.y > ymax) ymax = p.y; + } + } + + return { xmin, xmax, ymin, ymax }; +} diff --git a/apps/class-solid/src/lib/download.ts b/apps/class-solid/src/lib/download.ts index d965a65..0af0c19 100644 --- a/apps/class-solid/src/lib/download.ts +++ b/apps/class-solid/src/lib/download.ts @@ -1,4 +1,7 @@ -import type { ClassOutput, OutputVariableKey } from "@classmodel/class/output"; +import type { + ClassTimeSeries, + OutputVariableKey, +} from "@classmodel/class/output"; import { BlobReader, BlobWriter, ZipWriter } from "@zip.js/zip.js"; import { toPartial } from "./encode"; import type { ExperimentConfig } from "./experiment_config"; @@ -11,7 +14,7 @@ export function toConfigBlob(experiment: ExperimentConfig) { }); } -function outputToCsv(output: ClassOutput) { +function outputToCsv(output: ClassTimeSeries) { const headers = Object.keys(output) as OutputVariableKey[]; const lines = [headers.join(",")]; for (let i = 0; i < output[headers[0]].length; i++) { diff --git a/apps/class-solid/src/lib/runner.ts b/apps/class-solid/src/lib/runner.ts index bae7006..98fdcd7 100644 --- a/apps/class-solid/src/lib/runner.ts +++ b/apps/class-solid/src/lib/runner.ts @@ -1,6 +1,5 @@ import type { Config } from "@classmodel/class/config"; -import type { ClassOutput } from "@classmodel/class/output"; -import type { runClass } from "@classmodel/class/runner"; +import type { ClassData, runClass } from "@classmodel/class/runner"; import { wrap } from "comlink"; const worker = new Worker(new URL("./worker.ts", import.meta.url), { @@ -9,7 +8,7 @@ const worker = new Worker(new URL("./worker.ts", import.meta.url), { const asyncRunner = wrap(worker); -export async function runClassAsync(config: Config): Promise { +export async function runClassAsync(config: Config): Promise { try { const output = asyncRunner(config); return output; diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index 190bc42..b747484 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -2,12 +2,13 @@ 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/output"; import { mergeConfigurations, pruneConfig, } from "@classmodel/class/config_utils"; + +import type { ClassData } from "@classmodel/class/runner"; import { decodeAppState } from "./encode"; import { parseExperimentConfig } from "./experiment_config"; import type { ExperimentConfig } from "./experiment_config"; @@ -15,8 +16,8 @@ import { findPresetByName } from "./presets"; import { runClassAsync } from "./runner"; interface ExperimentOutput { - reference?: ClassOutput; - permutations: Array; + reference?: ClassData; + permutations: Array; running: number | false; } diff --git a/packages/class/src/cli.ts b/packages/class/src/cli.ts index 60f175b..3f05630 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, OutputVariableKey } from "./output.js"; +import type { ClassTimeSeries, OutputVariableKey } from "./output.js"; import { runClass } from "./runner.js"; import { parse } from "./validate.js"; @@ -51,7 +51,7 @@ async function writeTextFile(body: string, fn: string): Promise { /** * Create a DSV (delimiter-separated values) string from an object of arrays. */ -function dsv(output: ClassOutput, delimiter: string): string { +function dsv(output: ClassTimeSeries, delimiter: string): string { const keys = Object.keys(output) as OutputVariableKey[]; // order of headers is now in which they were added to the object // TODO make configurable: which columns and in which order @@ -67,7 +67,7 @@ function dsv(output: ClassOutput, delimiter: string): string { /** * Format the output. */ -function formatOutput(output: ClassOutput, format: string): string { +function formatOutput(output: ClassTimeSeries, format: string): string { switch (format) { case "json": return JSON.stringify(output, null, 2); diff --git a/packages/class/src/fire.ts b/packages/class/src/fire.ts index 6d13ea0..94ac1b3 100644 --- a/packages/class/src/fire.ts +++ b/packages/class/src/fire.ts @@ -62,6 +62,26 @@ export interface Parcel { rh: number; // Relative humidity [%] } +export type FirePlume = Record; +export const noPlume: FirePlume = { + z: [], + w: [], + thetal: [], + theta: [], + qt: [], + thetav: [], + qsat: [], + b: [], + m: [], + area: [], + e: [], + d: [], + T: [], + Td: [], + p: [], + rh: [], +}; + /** * Initialize fire parcel with ambient conditions and fire properties */ @@ -144,7 +164,7 @@ export function calculatePlume( fire: FireConfig, bg: ClassProfile, plumeConfig: PlumeConfig = defaultPlumeConfig, -): Parcel[] { +): FirePlume { const { dz } = plumeConfig; let parcel = initializeFireParcel(bg, fire); const plume: Parcel[] = [parcel]; @@ -219,15 +239,13 @@ export function calculatePlume( plume.push(parcel); } - return plume; + return transposePlumeData(plume); } /** * Convert array of objects into object of arrays */ -export function transposePlumeData( - plume: Parcel[], -): Record { +export function transposePlumeData(plume: Parcel[]): FirePlume { if (plume.length === 0) { return {} as Record; } diff --git a/packages/class/src/output.ts b/packages/class/src/output.ts index c320afa..42d3350 100644 --- a/packages/class/src/output.ts +++ b/packages/class/src/output.ts @@ -118,14 +118,4 @@ export const outputVariables = { } as const satisfies Record; export type OutputVariableKey = keyof typeof outputVariables; -export type ClassOutput = Record; -export type ClassOutputAtSingleTime = Record; - -export function getOutputAtTime( - output: ClassOutput, - timeIndex: number, -): ClassOutputAtSingleTime { - return Object.fromEntries( - Object.entries(output).map(([key, values]) => [key, values[timeIndex]]), - ) as ClassOutputAtSingleTime; -} +export type ClassOutput = Record; diff --git a/packages/class/src/profiles.ts b/packages/class/src/profiles.ts index 06b8de3..b54649f 100644 --- a/packages/class/src/profiles.ts +++ b/packages/class/src/profiles.ts @@ -1,7 +1,7 @@ // profiles.ts import type { MixedLayerConfig, NoWindConfig, WindConfig } from "./config.js"; -import type { ClassOutputAtSingleTime } from "./output.js"; +import type { ClassOutput } from "./output.js"; import { dewpoint, qsatLiq, @@ -18,7 +18,7 @@ const CONSTANTS = { /** * Atmospheric vertical profiles */ -export interface ClassProfile { +export interface ClassProfile extends Record { z: number[]; // Height levels (cell centers) [m] theta: number[]; // Potential temperature [K] thetav: number[]; // Virtual potential temperature [K] @@ -34,7 +34,7 @@ export interface ClassProfile { rh: number[]; // Relative humidity [%] } -export const NoProfile: ClassProfile = { +export const noProfile: ClassProfile = { z: [], theta: [], thetav: [], @@ -55,8 +55,8 @@ export const NoProfile: ClassProfile = { */ export function generateProfiles( config: MixedLayerConfig & (WindConfig | NoWindConfig), - output: ClassOutputAtSingleTime, - dz = 1, + output: ClassOutput, + dz = 10, ): ClassProfile { const { Rd, cp, g } = CONSTANTS; const { h, theta, qt, u, v, dtheta, dqt, du, dv } = output; diff --git a/packages/class/src/runner.ts b/packages/class/src/runner.ts index e3ca529..416c3de 100644 --- a/packages/class/src/runner.ts +++ b/packages/class/src/runner.ts @@ -5,13 +5,24 @@ */ import { CLASS } from "./class.js"; import type { Config } from "./config.js"; +import { type FirePlume, calculatePlume } from "./fire.js"; import { type ClassOutput, type OutputVariableKey, outputVariables, } from "./output.js"; +import { type ClassProfile, generateProfiles } from "./profiles.js"; import { parse } from "./validate.js"; +type ClassTimeSeries = Record; +type ClassProfiles = ClassProfile[]; +type ClassFirePlumes = FirePlume[]; +export interface ClassData { + timeseries: ClassTimeSeries; + profiles?: ClassProfiles; + plumes?: ClassFirePlumes; +} + /** * Runs the CLASS model with the given configuration and frequency. * @@ -19,24 +30,41 @@ import { parse } from "./validate.js"; * @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 { +export function runClass(config: Config, freq = 600): ClassData { const validatedConfig = parse(config); const model = new CLASS(validatedConfig); - const output_keys = Object.keys(outputVariables) as OutputVariableKey[]; + const outputKeys = Object.keys(outputVariables) as OutputVariableKey[]; const writeOutput = () => { - for (const key of output_keys) { + const output: Partial = {}; + for (const key of outputKeys) { const value = model.getValue(key); if (value !== undefined) { - (output[key] as number[]).push(value as number); + output[key] = model.getValue(key); + timeseries[key].push(value as number); + } + + // Include profiles + if (config.sw_ml) { + const profile = generateProfiles(config, output as ClassOutput); + profiles.push(profile); + + // Include fireplumes + if (config.sw_fire) { + const plume = calculatePlume(config, profile); + plumes.push(plume); + } } } }; - const output = Object.fromEntries( - output_keys.map((key) => [key, []]), - ) as unknown as ClassOutput; + // Initialize output arrays + const timeseries = Object.fromEntries( + outputKeys.map((key) => [key, []]), + ) as unknown as ClassTimeSeries; + const profiles: ClassProfiles = []; + const plumes: ClassFirePlumes = []; // Initial time writeOutput(); @@ -50,5 +78,12 @@ export function runClass(config: Config, freq = 600): ClassOutput { } } - return output; -} + // Construct ClassData + if (config.sw_ml) { + if (config.sw_fire) { + return { timeseries, profiles, plumes }; + } + return { timeseries, profiles }; + } + return { timeseries }; +} \ No newline at end of file