Skip to content

Commit b8c463b

Browse files
Add parity plot to lab results page
Parity plots with diagonals showing x=y and 10% offsets.
1 parent 2e69138 commit b8c463b

4 files changed

Lines changed: 180 additions & 4 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import React, { useState } from "react";
2+
import { Button, NativeSelect, Typography } from "@equinor/eds-core-react";
3+
import ScatterPlot, { ScatterDataSet } from "@/components/ScatterPlot";
4+
import { ExperimentResult } from "@/dto/ExperimentResult";
5+
import { SimulationResults } from "@/dto/SimulationResults";
6+
7+
const buildParityDatasets = (
8+
experiments: ExperimentResult[],
9+
simulationsPerExperiment: Record<string, SimulationResults[]>,
10+
component: string
11+
): ScatterDataSet[] => {
12+
const byModel: Record<string, { x: number; y: number }[]> = {};
13+
experiments.forEach((exp) => {
14+
const measured = exp.finalConcentrations[component];
15+
if (measured === undefined) return;
16+
const simulations = simulationsPerExperiment[exp.name] ?? [];
17+
simulations.forEach((sim) => {
18+
const modelId = sim.input.models[0].modelId;
19+
const modelled = sim.results[0]?.concentrations?.[component] ?? 0;
20+
(byModel[modelId] ??= []).push({ x: measured, y: modelled });
21+
});
22+
});
23+
return Object.entries(byModel).map(([modelId, data]) => ({ label: modelId, data }));
24+
};
25+
26+
interface ParityPlotsProps {
27+
availableComponents: string[];
28+
experiments: ExperimentResult[];
29+
simulationsPerExperiment: Record<string, SimulationResults[]>;
30+
}
31+
32+
const ParityPlots: React.FC<ParityPlotsProps> = ({ availableComponents, experiments, simulationsPerExperiment }) => {
33+
const [components, setComponents] = useState<string[]>([]);
34+
35+
if (availableComponents.length === 0) return null;
36+
37+
return (
38+
<div style={{ margin: "1rem 0" }}>
39+
<Typography variant="h4">Parity plots</Typography>
40+
<div style={{ display: "flex", flexWrap: "wrap", gap: "1rem" }}>
41+
{components.map((comp, idx) => (
42+
<div key={idx} style={{ flex: "1 1 calc(50% - 0.5rem)", minWidth: "300px" }}>
43+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
44+
<NativeSelect
45+
id={`parity-component-${idx}`}
46+
label="Component"
47+
value={comp}
48+
onChange={(e) =>
49+
setComponents((prev) => prev.map((c, i) => (i === idx ? e.target.value : c)))
50+
}
51+
>
52+
<option value="">Select component…</option>
53+
{availableComponents.map((c) => (
54+
<option key={c} value={c}>{c}</option>
55+
))}
56+
</NativeSelect>
57+
<Button
58+
variant="ghost_icon"
59+
onClick={() => setComponents((prev) => prev.filter((_, i) => i !== idx))}
60+
>
61+
62+
</Button>
63+
</div>
64+
{comp && (
65+
<ScatterPlot
66+
datasets={buildParityDatasets(experiments, simulationsPerExperiment, comp)}
67+
xLabel={`Measured ${comp} (ppm)`}
68+
yLabel={`Modelled ${comp} (ppm)`}
69+
showDiagonal
70+
/>
71+
)}
72+
</div>
73+
))}
74+
</div>
75+
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
76+
<Button variant="outlined" onClick={() => setComponents((prev) => [...prev, ""])}>
77+
+
78+
</Button>
79+
<Button variant="outlined" onClick={() => setComponents(availableComponents)}>
80+
One per component
81+
</Button>
82+
</div>
83+
</div>
84+
);
85+
};
86+
87+
export default ParityPlots;
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React from "react";
2+
import { Scatter } from "react-chartjs-2";
3+
import { Chart as ChartJS, LinearScale, PointElement, LineElement, Tooltip, Legend } from "chart.js";
4+
import { getDistributedColor } from "@/functions/Colors";
5+
6+
ChartJS.register(LinearScale, PointElement, LineElement, Tooltip, Legend);
7+
8+
export interface ScatterDataSet {
9+
label: string;
10+
data: { x: number; y: number }[];
11+
color?: string;
12+
}
13+
14+
interface ScatterPlotProps {
15+
datasets: ScatterDataSet[];
16+
xLabel?: string;
17+
yLabel?: string;
18+
showDiagonal?: boolean;
19+
}
20+
21+
const ScatterPlot: React.FC<ScatterPlotProps> = ({ datasets, xLabel, yLabel, showDiagonal }) => {
22+
if (datasets.length === 0) return null;
23+
24+
const maxVal = Math.max(...datasets.flatMap((ds) => ds.data.flatMap((p) => [Math.abs(p.x), Math.abs(p.y)])), 0);
25+
26+
const chartDatasets = datasets.map((ds, idx) => ({
27+
label: ds.label,
28+
data: ds.data,
29+
backgroundColor: ds.color ?? getDistributedColor(idx, datasets.length),
30+
}));
31+
32+
if (showDiagonal) {
33+
const diagonalBase = {
34+
backgroundColor: "transparent",
35+
showLine: true,
36+
borderColor: "#aaa",
37+
borderDash: [6, 4],
38+
pointRadius: 0,
39+
borderWidth: 1,
40+
};
41+
chartDatasets.unshift(
42+
{ ...diagonalBase, label: "x = y", data: [{ x: 0, y: 0 }, { x: maxVal, y: maxVal }] } as any,
43+
{ ...diagonalBase, label: "+10%", borderDash: [3, 3], borderWidth: 0.5, data: [{ x: 0, y: 0 }, { x: maxVal, y: maxVal * 1.1 }] } as any,
44+
{ ...diagonalBase, label: "−10%", borderDash: [3, 3], borderWidth: 0.5, data: [{ x: 0, y: 0 }, { x: maxVal, y: maxVal * 0.9 }] } as any,
45+
);
46+
}
47+
48+
return (
49+
<Scatter
50+
data={{ datasets: chartDatasets }}
51+
options={{
52+
responsive: true,
53+
aspectRatio: 1.5,
54+
plugins: {
55+
legend: {
56+
position: "right",
57+
labels: {
58+
boxWidth: 10,
59+
font: { size: 11 },
60+
filter: (item) => !["x = y", "+10%", "−10%"].includes(item.text ?? ""),
61+
},
62+
},
63+
tooltip: {
64+
callbacks: {
65+
label: (ctx) => `${ctx.dataset.label}: (${ctx.parsed.x?.toFixed(4)}, ${ctx.parsed.y?.toFixed(4)})`,
66+
},
67+
},
68+
},
69+
scales: {
70+
x: { min: 0, title: { display: true, text: xLabel ?? "" } },
71+
y: { min: 0, title: { display: true, text: yLabel ?? "" } },
72+
},
73+
}}
74+
/>
75+
);
76+
};
77+
78+
export default ScatterPlot;

frontend/src/functions/Formatting.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,4 @@ export const downloadTabulatedDataAsCSV = (csvContent: string, fileName: string)
150150
document.body.removeChild(a);
151151
URL.revokeObjectURL(url);
152152
};
153+

frontend/src/pages/LabResults.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import React, { useEffect, useMemo, useState } from "react";
1+
import React, { useEffect, useState } from "react";
22
import { useQuery } from "@tanstack/react-query";
33
import { getLabResults } from "@/api/api";
44
import { paperResults } from "@/assets/morland2019acid.ts";
55
import { Button, Card, Checkbox, Divider, Typography } from "@equinor/eds-core-react";
66
import { useAvailableModels } from "@/contexts/ModelContext";
77
import LabResultsPlot from "@/components/LabResultsPlot";
8+
import ParityPlots from "@/components/ParityPlots";
89
import LabResultsTable from "@/components/LabResultsTable";
910
import { ExperimentResult } from "@/dto/ExperimentResult.tsx";
1011
import { useSimulationQueries } from "@/hooks/useSimulationQueriesResult.ts";
@@ -42,11 +43,14 @@ const LabResults: React.FC = () => {
4243
retry: false,
4344
});
4445

45-
const selectedExperimentData = useMemo(
46-
() => labResults.filter((result) => selectedExperiments.some((exp) => exp.name === result.name)),
47-
[labResults, selectedExperiments]
46+
const selectedExperimentData = labResults.filter((result) =>
47+
selectedExperiments.some((exp) => exp.name === result.name)
4848
);
4949

50+
const availableComponents = Array.from(
51+
new Set(selectedExperimentData.flatMap((exp) => Object.keys(exp.finalConcentrations)))
52+
).sort();
53+
5054
const { startExperiment, statuses } = useSimulationQueries();
5155

5256
const onSetSelectedExperiments = (selected: ExperimentResult[]) => {
@@ -147,6 +151,12 @@ const LabResults: React.FC = () => {
147151
simulationsPerExperiment={simulationsPerExperiment}
148152
/>
149153

154+
<ParityPlots
155+
availableComponents={availableComponents}
156+
experiments={selectedExperimentData}
157+
simulationsPerExperiment={simulationsPerExperiment}
158+
/>
159+
150160
{selectedExperiments.length > 0 && (
151161
<div
152162
style={{

0 commit comments

Comments
 (0)