Skip to content

Commit 0c6d863

Browse files
authored
Fireplume (#155)
* Refactoring python fireplume code * Rename some variables; add fire settings to config; add output as single time * use gridded profiles from class package in app. TODO: fix piecewise lapse rate, it shouldn't vary in time * Refactor piecewise lapse rate in gridded version; behaviour seems consistent now, but still observe strange behaviour around the time that the ML starts to shrink in the varnavas case, where the ML is pushing the free troposphere profile away * Access fire plume model in web app; can now see the data in console, but still need to include it in plot * Use last index if height or time exceed specified lapse rate segments or hourly flux values, resp * Include fire profiles in plot; hardcoded for now, but it seems to work well * Plume variables now respond to variable picker * Also include fire plume in thermodynamic plot * formatting * type fix * Update reference data for tests
1 parent 734eafa commit 0c6d863

24 files changed

Lines changed: 1628 additions & 380 deletions

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

Lines changed: 121 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import type { Config } from "@classmodel/class/config";
2-
import { type ClassOutput, outputVariables } from "@classmodel/class/output";
2+
import { type Parcel, calculatePlume } from "@classmodel/class/fire";
3+
import {
4+
type ClassOutput,
5+
type OutputVariableKey,
6+
getOutputAtTime,
7+
outputVariables,
8+
} from "@classmodel/class/output";
9+
import {
10+
type ClassProfile,
11+
NoProfile,
12+
generateProfiles,
13+
} from "@classmodel/class/profiles";
314
import * as d3 from "d3";
415
import { saveAs } from "file-saver";
516
import { toBlob } from "html-to-image";
@@ -18,8 +29,6 @@ import {
1829
import { createStore } from "solid-js/store";
1930
import type { Observation } from "~/lib/experiment_config";
2031
import {
21-
getThermodynamicProfiles,
22-
getVerticalProfiles,
2332
observationsForProfile,
2433
observationsForSounding,
2534
} from "~/lib/profiles";
@@ -34,10 +43,10 @@ import {
3443
} from "~/lib/store";
3544
import { MdiCamera, MdiDelete, MdiImageFilterCenterFocus } from "./icons";
3645
import { AxisBottom, AxisLeft, getNiceAxisLimits } from "./plots/Axes";
37-
import { Chart, ChartContainer } from "./plots/ChartContainer";
46+
import { Chart, ChartContainer, type ChartData } from "./plots/ChartContainer";
3847
import { Legend } from "./plots/Legend";
39-
import { Line } from "./plots/Line";
40-
import { SkewTPlot } from "./plots/skewTlogP";
48+
import { Line, Plume, type Point } from "./plots/Line";
49+
import { SkewTPlot, type SoundingRecord } from "./plots/skewTlogP";
4150
import { Button } from "./ui/button";
4251
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
4352
import {
@@ -115,23 +124,20 @@ const uniqueTimes = () => [...new Set(_allTimes())].sort((a, b) => a - b);
115124

116125
// TODO: could memoize all reactive elements here, would it make a difference?
117126
export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) {
118-
const symbols = Object.fromEntries(
119-
outputVariables.map((v) => [v.key, v.symbol]),
120-
);
121-
const getKey = Object.fromEntries(
122-
outputVariables.map((v) => [v.symbol, v.key]),
123-
);
127+
const vars = Object.entries(outputVariables);
128+
const symbols = Object.fromEntries(vars.map(([k, v]) => [k, v.symbol]));
129+
const getKey = Object.fromEntries(vars.map(([k, v]) => [v.symbol, k]));
124130
const labels = Object.fromEntries(
125-
outputVariables.map((v) => [v.key, `${v.symbol} [${v.unit}]`]),
131+
vars.map(([k, v]) => [k, `${v.symbol} [${v.unit}]`]),
126132
);
127133

128134
const allX = () =>
129135
flatExperiments().flatMap((e) =>
130-
e.output ? e.output[analysis.xVariable] : [],
136+
e.output ? e.output[analysis.xVariable as OutputVariableKey] : [],
131137
);
132138
const allY = () =>
133139
flatExperiments().flatMap((e) =>
134-
e.output ? e.output[analysis.yVariable] : [],
140+
e.output ? e.output[analysis.yVariable as OutputVariableKey] : [],
135141
);
136142

137143
const granularity = () => (analysis.xVariable === "t" ? 600 : undefined);
@@ -146,8 +152,12 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) {
146152
data:
147153
// Zip x[] and y[] into [x, y][]
148154
output?.t.map((_, t) => ({
149-
x: output ? output[analysis.xVariable][t] : Number.NaN,
150-
y: output ? output[analysis.yVariable][t] : Number.NaN,
155+
x: output
156+
? output[analysis.xVariable as OutputVariableKey][t]
157+
: Number.NaN,
158+
y: output
159+
? output[analysis.yVariable as OutputVariableKey][t]
160+
: Number.NaN,
151161
})) || [],
152162
};
153163
});
@@ -225,10 +235,16 @@ export function VerticalProfilePlot({
225235
}: { analysis: ProfilesAnalysis }) {
226236
const variableOptions = {
227237
"Potential temperature [K]": "theta",
228-
"Specific humidity [kg/kg]": "q",
238+
"Virtual potential temperature [K]": "thetav",
239+
"Specific humidity [kg/kg]": "qt",
229240
"u-wind component [m/s]": "u",
230241
"v-wind component [m/s]": "v",
231-
};
242+
"Pressure [Pa]": "p",
243+
"Exner function [-]": "exner",
244+
"Temperature [K]": "T",
245+
"Dew point temperature [K]": "Td",
246+
"Density [kg/m³]": "rho",
247+
} as const satisfies Record<string, keyof ClassProfile>;
232248

233249
const classVariable = () =>
234250
variableOptions[analysis.variable as keyof typeof variableOptions];
@@ -240,26 +256,43 @@ export function VerticalProfilePlot({
240256
flatExperiments().map((e) => {
241257
const { config, output, ...formatting } = e;
242258
const t = output?.t.indexOf(uniqueTimes()[analysis.time]);
243-
return {
244-
...formatting,
245-
data:
246-
t !== -1 // -1 now means "not found in array" rather than last index
247-
? getVerticalProfiles(
248-
e.output,
249-
e.config,
250-
classVariable(),
251-
analysis.time,
252-
)
253-
: [],
254-
};
259+
if (config.sw_ml && output && t !== undefined && t !== -1) {
260+
const outputAtTime = getOutputAtTime(output, t);
261+
return { ...formatting, data: generateProfiles(config, outputAtTime) };
262+
}
263+
return { ...formatting, data: NoProfile };
264+
});
265+
266+
const firePlumes = () =>
267+
flatExperiments().map((e, i) => {
268+
const { config, output, ...formatting } = e;
269+
if (config.sw_fire) {
270+
return {
271+
...formatting,
272+
data: calculatePlume(config, profileData()[i].data),
273+
};
274+
}
275+
return { ...formatting, data: [] };
255276
});
256277

278+
// TODO: There should be a way that this isn't needed.
279+
const profileDataForPlot = () =>
280+
profileData().map(({ data, label, color, linestyle }) => ({
281+
label,
282+
color,
283+
linestyle,
284+
data: data.z.map((z, i) => ({
285+
x: data[classVariable()][i],
286+
y: z,
287+
})),
288+
})) as ChartData<Point>[];
289+
257290
const allX = () => [
258-
...profileData().flatMap((p) => p.data.map((d) => d.x)),
291+
...profileDataForPlot().flatMap((p) => p.data.map((d) => d.x)),
259292
...observations().flatMap((obs) => obs.data.map((d) => d.x)),
260293
];
261294
const allY = () => [
262-
...profileData().flatMap((p) => p.data.map((d) => d.y)),
295+
...profileDataForPlot().flatMap((p) => p.data.map((d) => d.y)),
263296
...observations().flatMap((obs) => obs.data.map((d) => d.y)),
264297
];
265298

@@ -289,6 +322,10 @@ export function VerticalProfilePlot({
289322
setResetPlot(analysis.id);
290323
}
291324

325+
const showPlume = createMemo(() => {
326+
return ["theta", "qt", "thetav", "T", "Td"].includes(classVariable());
327+
});
328+
292329
return (
293330
<>
294331
<div class="flex flex-col gap-2">
@@ -301,7 +338,7 @@ export function VerticalProfilePlot({
301338
<Chart id={analysis.id} title="Vertical profile plot">
302339
<AxisBottom domain={xLim} label={analysis.variable} />
303340
<AxisLeft domain={yLim} label="Height[m]" />
304-
<For each={profileData()}>
341+
<For each={profileDataForPlot()}>
305342
{(d) => (
306343
<Show when={toggles[d.label]}>
307344
<Line {...d} />
@@ -315,6 +352,18 @@ export function VerticalProfilePlot({
315352
</Show>
316353
)}
317354
</For>
355+
<For each={firePlumes()}>
356+
{(d) => (
357+
<Show when={toggles[d.label]}>
358+
<Show when={showPlume()}>
359+
<Plume
360+
d={d}
361+
variable={classVariable as () => keyof Parcel}
362+
/>
363+
</Show>
364+
</Show>
365+
)}
366+
</For>
318367
</Chart>
319368
</ChartContainer>
320369
<Picker
@@ -405,27 +454,56 @@ function Picker(props: PickerProps) {
405454
}
406455

407456
export function ThermodynamicPlot({ analysis }: { analysis: SkewTAnalysis }) {
408-
const skewTData = () =>
457+
const profileData = () =>
409458
flatExperiments().map((e) => {
410459
const { config, output, ...formatting } = e;
411460
const t = output?.t.indexOf(uniqueTimes()[analysis.time]);
412-
return {
413-
...formatting,
414-
data:
415-
t !== -1 // -1 now means "not found in array" rather than last index
416-
? getThermodynamicProfiles(e.output, e.config, t)
417-
: [],
418-
};
461+
if (config.sw_ml && output && t !== undefined && t !== -1) {
462+
const outputAtTime = getOutputAtTime(output, t);
463+
return { ...formatting, data: generateProfiles(config, outputAtTime) };
464+
}
465+
return { ...formatting, data: NoProfile };
419466
});
420467

468+
const firePlumes = () =>
469+
flatExperiments().map((e, i) => {
470+
const { config, output, ...formatting } = e;
471+
if (config.sw_fire) {
472+
return {
473+
...formatting,
474+
color: "#ff0000",
475+
label: `${formatting.label} - fire plume`,
476+
data: calculatePlume(config, profileData()[i].data),
477+
};
478+
}
479+
return { ...formatting, data: [] };
480+
}) as ChartData<SoundingRecord>[];
481+
421482
const observations = () =>
422483
flatObservations().map((o) => observationsForSounding(o));
423484

485+
// TODO: There should be a way that this isn't needed.
486+
const profileDataForPlot = () =>
487+
profileData().map(({ data, label, color, linestyle }) => ({
488+
label,
489+
color,
490+
linestyle,
491+
data: data.p.map((p, i) => ({
492+
p: p / 100,
493+
T: data.T[i],
494+
Td: data.Td[i],
495+
})),
496+
})) as ChartData<SoundingRecord>[];
497+
424498
return (
425499
<>
426500
<SkewTPlot
427501
id={analysis.id}
428-
data={() => [...skewTData(), ...observations()]}
502+
data={() => [
503+
...profileDataForPlot(),
504+
...observations(),
505+
...firePlumes(),
506+
]}
429507
/>
430508
{TimeSlider(
431509
() => analysis.time,

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,13 @@ export function getNiceAxisLimits(
8181
extraMargin = 0,
8282
roundTo?: number, // Optional rounding step, e.g. 600 for 10 minutes
8383
): [number, number] {
84-
const max = Math.max(...data);
85-
const min = Math.min(...data);
84+
const max = Math.max(...data.filter(Number.isFinite));
85+
const min = Math.min(...data.filter(Number.isFinite));
8686
const range = max - min;
8787

88-
// Avoid NaNs on axis for constant values
89-
if (range === 0) return [min - 1, max + 1];
88+
if (range === 0)
89+
// Avoid NaNs on axis for constant values
90+
return [min - 1, max + 1];
9091

9192
const step = roundTo ?? 10 ** Math.floor(Math.log10(range));
9293

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { createUniqueId } from "solid-js";
33
import type { ChartData } from "./ChartContainer";
44
import { useChartContext } from "./ChartContainer";
55

6+
type LegendData = Omit<ChartData<unknown>, "data">;
7+
68
export interface LegendProps<T> {
7-
entries: () => ChartData<T>[];
9+
entries: () => LegendData[];
810
toggles: Record<string, boolean>;
911
onChange: (key: string, value: boolean) => void;
1012
}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Parcel } from "@classmodel/class/fire";
12
import * as d3 from "d3";
23
import { createSignal } from "solid-js";
34
import type { ChartData } from "./ChartContainer";
@@ -35,3 +36,34 @@ export function Line(d: ChartData<Point>) {
3536
</path>
3637
);
3738
}
39+
40+
export function Plume({
41+
d,
42+
variable,
43+
}: { d: ChartData<Parcel>; variable: () => keyof Parcel }) {
44+
const [chart, _updateChart] = useChartContext();
45+
const [hovered, setHovered] = createSignal(false);
46+
47+
const l = d3.line<Parcel>(
48+
(d) => chart.scaleX(d[variable()]),
49+
(d) => chart.scaleY(d.z),
50+
);
51+
52+
const stroke = () => (hovered() ? highlight("#ff0000") : "#ff0000");
53+
54+
return (
55+
<path
56+
clip-path="url(#clipper)"
57+
onMouseEnter={() => setHovered(true)}
58+
onMouseLeave={() => setHovered(false)}
59+
fill="none"
60+
stroke={stroke()}
61+
stroke-dasharray={"4"}
62+
stroke-width="2"
63+
d={l(d.data) || ""}
64+
class="cursor-pointer"
65+
>
66+
<title>{`Fire plume for ${d.label}`}</title>
67+
</path>
68+
);
69+
}

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
useChartContext,
1212
} from "./ChartContainer";
1313
import { Legend } from "./Legend";
14-
interface SoundingRecord {
14+
export interface SoundingRecord {
1515
p: number;
1616
T: number;
1717
Td: number;
@@ -167,9 +167,12 @@ export function SkewTPlot(props: {
167167

168168
const [toggles, setToggles] = createStore<Record<string, boolean>>({});
169169

170+
const dataForLegend = () =>
171+
props.data().filter((d) => !d.label.includes("- fire plume"));
172+
170173
// Initialize all lines as visible
171174
createEffect(() => {
172-
for (const d of props.data()) {
175+
for (const d of dataForLegend()) {
173176
setToggles(d.label, true);
174177
}
175178
});
@@ -183,12 +186,12 @@ export function SkewTPlot(props: {
183186
if (!toggles || !cd) {
184187
return true;
185188
}
186-
return toggles[cd.label];
189+
return toggles[cd.label.replace(" - fire plume", "")];
187190
}
188191

189192
return (
190193
<ChartContainer>
191-
<Legend entries={props.data} toggles={toggles} onChange={toggleLine} />
194+
<Legend entries={dataForLegend} toggles={toggles} onChange={toggleLine} />
192195
<Chart
193196
id={props.id}
194197
title="Thermodynamic diagram"

apps/class-solid/src/lib/download.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ClassOutput } from "@classmodel/class/output";
1+
import type { ClassOutput, OutputVariableKey } from "@classmodel/class/output";
22
import { BlobReader, BlobWriter, ZipWriter } from "@zip.js/zip.js";
33
import { toPartial } from "./encode";
44
import type { ExperimentConfig } from "./experiment_config";
@@ -12,7 +12,7 @@ export function toConfigBlob(experiment: ExperimentConfig) {
1212
}
1313

1414
function outputToCsv(output: ClassOutput) {
15-
const headers = Object.keys(output);
15+
const headers = Object.keys(output) as OutputVariableKey[];
1616
const lines = [headers.join(",")];
1717
for (let i = 0; i < output[headers[0]].length; i++) {
1818
lines.push(headers.map((h) => output[h][i]).join(","));

0 commit comments

Comments
 (0)