Skip to content

Commit 570ae12

Browse files
authored
Merge pull request #197 from classmodel/redo-profiles
Pre-calculate and compress profiles and plumes
2 parents c6f0023 + 3be8e83 commit 570ae12

File tree

11 files changed

+370
-179
lines changed

11 files changed

+370
-179
lines changed

apps/class-solid/src/components/Analysis.tsx

Lines changed: 138 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
import type { Config } from "@classmodel/class/config";
2-
import { calculatePlume, transposePlumeData } from "@classmodel/class/fire";
32
import {
4-
type ClassOutput,
53
type OutputVariableKey,
6-
getOutputAtTime,
74
outputVariables,
85
} from "@classmodel/class/output";
9-
import {
10-
type ClassProfile,
11-
NoProfile,
12-
generateProfiles,
13-
} from "@classmodel/class/profiles";
6+
import type { ClassProfile } from "@classmodel/class/profiles";
7+
import type { ClassData } from "@classmodel/class/runner";
148
import * as d3 from "d3";
159
import { saveAs } from "file-saver";
1610
import { toBlob } from "html-to-image";
@@ -43,7 +37,7 @@ import { MdiCamera, MdiDelete, MdiImageFilterCenterFocus } from "./icons";
4337
import { AxisBottom, AxisLeft, getNiceAxisLimits } from "./plots/Axes";
4438
import { Chart, ChartContainer, type ChartData } from "./plots/ChartContainer";
4539
import { Legend } from "./plots/Legend";
46-
import { Line, type Point } from "./plots/Line";
40+
import { Line } from "./plots/Line";
4741
import { SkewTPlot, type SoundingRecord } from "./plots/skewTlogP";
4842
import { Button } from "./ui/button";
4943
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
@@ -76,7 +70,7 @@ interface FlatExperiment {
7670
color: string;
7771
linestyle: string;
7872
config: Config;
79-
output?: ClassOutput;
73+
output?: ClassData;
8074
}
8175

8276
// Create a derived store for looping over all outputs:
@@ -117,7 +111,7 @@ const flatObservations: () => Observation[] = createMemo(() => {
117111
});
118112

119113
const _allTimes = () =>
120-
new Set(flatExperiments().flatMap((e) => e.output?.utcTime ?? []));
114+
new Set(flatExperiments().flatMap((e) => e.output?.timeseries.utcTime ?? []));
121115
const uniqueTimes = () => [...new Set(_allTimes())].sort((a, b) => a - b);
122116

123117
// TODO: could memoize all reactive elements here, would it make a difference?
@@ -131,11 +125,15 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) {
131125

132126
const allX = () =>
133127
flatExperiments().flatMap((e) =>
134-
e.output ? e.output[analysis.xVariable as OutputVariableKey] : [],
128+
e.output
129+
? e.output.timeseries[analysis.xVariable as OutputVariableKey]
130+
: [],
135131
);
136132
const allY = () =>
137133
flatExperiments().flatMap((e) =>
138-
e.output ? e.output[analysis.yVariable as OutputVariableKey] : [],
134+
e.output
135+
? e.output.timeseries[analysis.yVariable as OutputVariableKey]
136+
: [],
139137
);
140138

141139
const granularities: Record<string, number | undefined> = {
@@ -155,12 +153,12 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) {
155153
...formatting,
156154
data:
157155
// Zip x[] and y[] into [x, y][]
158-
output?.t.map((_, t) => ({
156+
output?.timeseries.t.map((_, t) => ({
159157
x: output
160-
? output[analysis.xVariable as OutputVariableKey][t]
158+
? output.timeseries[analysis.xVariable as OutputVariableKey][t]
161159
: Number.NaN,
162160
y: output
163-
? output[analysis.yVariable as OutputVariableKey][t]
161+
? output.timeseries[analysis.yVariable as OutputVariableKey][t]
164162
: Number.NaN,
165163
})) || [],
166164
};
@@ -258,87 +256,90 @@ export function VerticalProfilePlot({
258256
variableOptions[analysis.variable as keyof typeof variableOptions];
259257

260258
type PlumeVariable = "theta" | "qt" | "thetav" | "T" | "Td" | "rh" | "w";
259+
261260
function isPlumeVariable(v: string): v is PlumeVariable {
262261
return ["theta", "qt", "thetav", "T", "Td", "rh", "w"].includes(v);
263262
}
264263

265-
const showPlume = createMemo(() => isPlumeVariable(classVariable()));
264+
type LineSet = {
265+
label: string;
266+
color: string;
267+
linestyle: string;
268+
data: { x: number; y: number }[];
269+
};
266270

267-
const observations = () =>
268-
flatObservations().map((o) => observationsForProfile(o, classVariable()));
271+
function getLinesForExperiment(
272+
e: FlatExperiment,
273+
variable: string,
274+
type: "profiles" | "plumes",
275+
timeVal: number,
276+
): LineSet {
277+
const { label, color, linestyle, output } = e;
269278

270-
const profileData = () =>
271-
flatExperiments().map((e) => {
272-
const { config, output, ...formatting } = e;
273-
const t = output?.utcTime.indexOf(uniqueTimes()[analysis.time]);
274-
if (config.sw_ml && output && t !== undefined && t !== -1) {
275-
const outputAtTime = getOutputAtTime(output, t);
276-
return { ...formatting, data: generateProfiles(config, outputAtTime) };
277-
}
278-
return { ...formatting, data: NoProfile };
279-
});
279+
if (!output) return { label, color, linestyle, data: [] };
280280

281-
const firePlumes = () =>
282-
flatExperiments().map((e, i) => {
283-
const { config, output, ...formatting } = e;
284-
if (config.sw_fire && isPlumeVariable(classVariable())) {
285-
const plume = transposePlumeData(
286-
calculatePlume(config, profileData()[i].data),
287-
);
288-
return {
289-
...formatting,
290-
linestyle: "4",
291-
data: plume.z.map((z, i) => ({
292-
x: plume[classVariable() as PlumeVariable][i],
293-
y: z,
294-
})),
295-
};
296-
}
297-
return { ...formatting, data: [] };
298-
});
281+
const profile = output[type];
282+
if (!profile) return { label, color, linestyle, data: [] };
299283

300-
// TODO: There should be a way that this isn't needed.
301-
const profileDataForPlot = () =>
302-
profileData().map(({ data, label, color, linestyle }) => ({
303-
label,
284+
// Find experiment-specific time index
285+
const tIndex = output.timeseries?.utcTime?.indexOf(timeVal);
286+
if (tIndex === undefined || tIndex === -1)
287+
return { label, color, linestyle, data: [] };
288+
289+
const linesAtTime = profile[variable]?.[tIndex] ?? [];
290+
291+
return {
292+
label: type === "plumes" ? `${label} - plume` : label,
304293
color,
305-
linestyle,
306-
data: data.z.map((z, i) => ({
307-
x: data[classVariable()][i],
308-
y: z,
309-
})),
310-
})) as ChartData<Point>[];
311-
312-
const allX = () => [
313-
...firePlumes().flatMap((p) => p.data.map((d) => d.x)),
314-
...profileDataForPlot().flatMap((p) => p.data.map((d) => d.x)),
315-
...observations().flatMap((obs) => obs.data.map((d) => d.x)),
316-
];
317-
const allY = () => [
318-
...firePlumes().flatMap((p) => p.data.map((d) => d.y)),
319-
...profileDataForPlot().flatMap((p) => p.data.map((d) => d.y)),
320-
...observations().flatMap((obs) => obs.data.map((d) => d.y)),
321-
];
322-
323-
// TODO: better to include jump at top in extent calculation rather than adding random margin.
324-
const xLim = () => getNiceAxisLimits(allX(), 1);
325-
const yLim = () => [0, getNiceAxisLimits(allY(), 0)[1]] as [number, number];
294+
linestyle: type === "plumes" ? "4" : linestyle,
295+
data: linesAtTime.flat(),
296+
};
297+
}
326298

327-
function chartData() {
328-
return [...profileData(), ...observations()];
299+
/** Collect all lines across experiments for a given type */
300+
function collectLines(type: "profiles" | "plumes"): LineSet[] {
301+
const variable = classVariable();
302+
return flatExperiments().map((e) =>
303+
getLinesForExperiment(e, variable, type, uniqueTimes()[analysis.time]),
304+
);
329305
}
330306

331-
const [toggles, setToggles] = createStore<Record<string, boolean>>({});
307+
/** Lines to plot */
308+
const profileLines = () => collectLines("profiles");
309+
// Only collect plumes for experiments that actually have plume output
310+
const plumeLines = () =>
311+
flatExperiments()
312+
.filter((e) => e.output?.plumes) // only show plume when firemodel enabled
313+
.filter((e) => isPlumeVariable(classVariable())) // only show plume for plume vars
314+
.map((e) =>
315+
getLinesForExperiment(
316+
e,
317+
classVariable(),
318+
"plumes",
319+
uniqueTimes()[analysis.time],
320+
),
321+
);
322+
const obsLines = () =>
323+
flatObservations().map((o) => observationsForProfile(o, classVariable()));
324+
const allLines = () => [...profileLines(), ...plumeLines(), ...obsLines()];
332325

333-
// Initialize all lines as visible
334-
for (const d of chartData()) {
335-
setToggles(d.label, true);
336-
}
326+
/** Global axes extents across all experiments, times, and observations */
327+
const allX = () => allLines().flatMap((d) => d.data.map((p) => p.x));
328+
const allY = () => allLines().flatMap((d) => d.data.map((p) => p.y));
329+
330+
const xLim = () => getNiceAxisLimits(allX(), 1);
331+
const yLim = () => [0, getNiceAxisLimits(allY(), 0)[1]] as [number, number];
337332

333+
/** Initialize toggles for legend */
334+
const [toggles, setToggles] = createStore<Record<string, boolean>>({});
335+
for (const line of allLines()) {
336+
setToggles(line.label, true);
337+
}
338338
function toggleLine(label: string, value: boolean) {
339339
setToggles(label, value);
340340
}
341341

342+
/** Change variable handler */
342343
function changeVar(v: string) {
343344
updateAnalysis(analysis, { variable: v });
344345
setResetPlot(analysis.id);
@@ -348,39 +349,20 @@ export function VerticalProfilePlot({
348349
<>
349350
<div class="flex flex-col gap-2">
350351
<ChartContainer>
351-
<Legend
352-
entries={() => [...profileData(), ...observations()]}
353-
toggles={toggles}
354-
onChange={toggleLine}
355-
/>
352+
<Legend entries={allLines} toggles={toggles} onChange={toggleLine} />
356353
<Chart id={analysis.id} title="Vertical profile plot">
357354
<AxisBottom domain={xLim} label={analysis.variable} />
358355
<AxisLeft domain={yLim} label="Height[m]" />
359-
<For each={profileDataForPlot()}>
360-
{(d) => (
361-
<Show when={toggles[d.label]}>
362-
<Line {...d} />
363-
</Show>
364-
)}
365-
</For>
366-
<For each={observations()}>
356+
<For each={allLines()}>
367357
{(d) => (
368358
<Show when={toggles[d.label]}>
369359
<Line {...d} />
370360
</Show>
371361
)}
372362
</For>
373-
<For each={firePlumes()}>
374-
{(d) => (
375-
<Show when={toggles[d.label]}>
376-
<Show when={showPlume()}>
377-
<Line {...d} />
378-
</Show>
379-
</Show>
380-
)}
381-
</For>
382363
</Chart>
383364
</ChartContainer>
365+
384366
<Picker
385367
value={() => analysis.variable}
386368
setValue={(v) => changeVar(v)}
@@ -473,47 +455,71 @@ function Picker(props: PickerProps) {
473455
}
474456

475457
export function ThermodynamicPlot({ analysis }: { analysis: SkewTAnalysis }) {
476-
const profileData = () =>
458+
/** Extract profile lines from CLASS output at the current time index */
459+
const profileDataForPlot = () =>
477460
flatExperiments().map((e) => {
478-
const { config, output, ...formatting } = e;
479-
const t = output?.utcTime.indexOf(uniqueTimes()[analysis.time]);
480-
if (config.sw_ml && output && t !== undefined && t !== -1) {
481-
const outputAtTime = getOutputAtTime(output, t);
482-
return { ...formatting, data: generateProfiles(config, outputAtTime) };
483-
}
484-
return { ...formatting, data: NoProfile };
485-
});
461+
const { output, label, color, linestyle } = e;
462+
if (!output?.profiles) return { label, color, linestyle, data: [] };
463+
464+
const tIndex = output.timeseries?.utcTime?.indexOf(
465+
uniqueTimes()[analysis.time],
466+
);
467+
if (tIndex === undefined || tIndex === -1)
468+
return { label, color, linestyle, data: [] };
469+
470+
// Make sure each variable exists and has data at this time
471+
const pLine = output.profiles.p?.[tIndex] ?? [];
472+
const TLine = output.profiles.T?.[tIndex] ?? [];
473+
const TdLine = output.profiles.Td?.[tIndex] ?? [];
474+
475+
// If any line is empty, return empty data
476+
if (!pLine.length || !TLine.length || !TdLine.length)
477+
return { label, color, linestyle, data: [] };
478+
479+
const data: SoundingRecord[] = pLine.map((_, i) => ({
480+
p: pLine[i].x / 100,
481+
T: TLine[i].x,
482+
Td: TdLine[i].x,
483+
}));
484+
485+
return { label, color, linestyle, data };
486+
}) as ChartData<SoundingRecord>[];
486487

487488
const firePlumes = () =>
488-
flatExperiments().map((e, i) => {
489-
const { config, output, ...formatting } = e;
490-
if (config.sw_fire) {
489+
flatExperiments()
490+
.map((e) => {
491+
const output = e.output;
492+
if (!output?.plumes) return null; // skip if no plume
493+
494+
const tIndex = output.timeseries?.utcTime?.indexOf(
495+
uniqueTimes()[analysis.time],
496+
);
497+
if (tIndex === undefined || tIndex === -1) return null;
498+
499+
const pLine = output.plumes.p?.[tIndex] ?? [];
500+
const TLine = output.plumes.T?.[tIndex] ?? [];
501+
const TdLine = output.plumes.Td?.[tIndex] ?? [];
502+
503+
if (!pLine.length || !TLine.length || !TdLine.length) return null;
504+
505+
const data: SoundingRecord[] = pLine.map((_, i) => ({
506+
p: pLine[i].x,
507+
T: TLine[i].x,
508+
Td: TdLine[i].x,
509+
}));
510+
491511
return {
492-
...formatting,
512+
label: `${e.label} - fire plume`,
493513
color: "#ff0000",
494-
label: `${formatting.label} - fire plume`,
495-
data: calculatePlume(config, profileData()[i].data),
514+
linestyle: "4",
515+
data,
496516
};
497-
}
498-
return { ...formatting, data: [] };
499-
}) as ChartData<SoundingRecord>[];
517+
})
518+
.filter((d): d is ChartData<SoundingRecord> => d !== null);
500519

501520
const observations = () =>
502521
flatObservations().map((o) => observationsForSounding(o));
503522

504-
// TODO: There should be a way that this isn't needed.
505-
const profileDataForPlot = () =>
506-
profileData().map(({ data, label, color, linestyle }) => ({
507-
label,
508-
color,
509-
linestyle,
510-
data: data.p.map((p, i) => ({
511-
p: p / 100,
512-
T: data.T[i],
513-
Td: data.Td[i],
514-
})),
515-
})) as ChartData<SoundingRecord>[];
516-
517523
return (
518524
<>
519525
<SkewTPlot

apps/class-solid/src/components/plots/Axes.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,15 @@ export function getNiceAxisLimits(
8787
extraMargin = 0,
8888
roundTo?: number, // Optional rounding step, e.g. 600 for 10 minutes
8989
): [number, number] {
90-
const max = Math.max(...data.filter(Number.isFinite));
91-
const min = Math.min(...data.filter(Number.isFinite));
90+
const finiteData = data.filter(Number.isFinite);
91+
92+
if (!finiteData.length) {
93+
// Fallback limits if no data yet
94+
return [0, 1];
95+
}
96+
97+
const max = Math.max(...finiteData);
98+
const min = Math.min(...finiteData);
9299
const range = max - min;
93100

94101
if (range === 0)

0 commit comments

Comments
 (0)