Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 220 additions & 61 deletions frontend/src/components/scanInterface/ParameterCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import {
MenuItem,
Select,
TextField,
ToggleButton,
ToggleButtonGroup,
} from "@mui/material";
import { useContext, useMemo } from "react";
import { useParameter } from "../../hooks/useParameter";
import { ParameterStoreContext } from "../../contexts/ParameterStoreContext";
import { DeviceInfoContext } from "../../contexts/DeviceInfoContext";
import { ExperimentsContext } from "../../contexts/ExperimentsContext";
import { ParameterDisplayGroupsContext } from "../../contexts/ParameterDisplayGroupsContext";
Expand All @@ -17,6 +21,7 @@ import {
experimentIdToNamespace,
} from "../../utils/experimentUtils";
import {
ScanInputMode,
ScanParameterInfo,
ScanPattern,
scanPatterns,
Expand Down Expand Up @@ -62,6 +67,7 @@ export const ParameterCard = ({
);
const deviceInfo = useContext(DeviceInfoContext);
const experiments = useContext(ExperimentsContext);
const parameterStore = useContext(ParameterStoreContext);

// Create a mapping from namespace to experiment display name
const namespaceToDisplayName: Record<string, string> = Object.fromEntries(
Expand Down Expand Up @@ -138,6 +144,55 @@ export const ParameterCard = ({
);
}, [param, parameterDisplayGroups, deviceInfo]);

const inputMode: ScanInputMode = param.generation.inputMode ?? "startStop";

// Live value of the currently selected parameter — used to seed "Center" when
// switching to span/center mode.
const [currentParamValue] = useParameter(param.id);
const currentNumericValue =
typeof currentParamValue === "number" ? currentParamValue : null;

// Derived quantities for span/center mode. Center and span are reconstructed from
// start/stop on every render, so round away the floating-point reconstruction
// noise (e.g. 0.015099999999996783 for a typed 0.0151) well above the ~16-digit
// float precision but far below any physically meaningful digit.
const roundFloatNoise = (value: number) => Number(value.toPrecision(12));
const center = roundFloatNoise((param.generation.start + param.generation.stop) / 2);
const span = roundFloatNoise(param.generation.stop - param.generation.start);

const handleInputModeChange = (
_: React.MouseEvent,
newMode: ScanInputMode | null,
) => {
if (!newMode || newMode === inputMode) return;

if (newMode === "spanCenter") {
// Seed center from the live parameter value when available, otherwise keep the
// midpoint of the existing range.
const newCenter = currentNumericValue !== null ? currentNumericValue : center;
const newSpan = Math.abs(span) || 1; // keep existing span (>0 guard)
dispatchScanInfoStateUpdate({
type: "UPDATE_PARAMETER",
index,
payload: {
generation: {
...param.generation,
inputMode: "spanCenter",
start: newCenter - newSpan / 2,
stop: newCenter + newSpan / 2,
},
},
});
} else {
// Switching back to start/stop: just flip the mode flag; start/stop are already correct.
dispatchScanInfoStateUpdate({
type: "UPDATE_PARAMETER",
index,
payload: { generation: { ...param.generation, inputMode: "startStop" } },
});
}
};

return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div
Expand Down Expand Up @@ -241,11 +296,32 @@ export const ParameterCard = ({
value={param.id}
title={param.id}
onChange={(e) => {
dispatchScanInfoStateUpdate({
type: "UPDATE_PARAMETER",
index,
payload: { id: e.target.value },
});
const newParamId = e.target.value;

if (inputMode === "spanCenter") {
// Re-centre on the new parameter's live value (if numeric); keep the span.
const rawValue = parameterStore?.get(newParamId);
const newCenter = typeof rawValue === "number" ? rawValue : center;
const currentSpan = Math.abs(span) || 1;
dispatchScanInfoStateUpdate({
type: "UPDATE_PARAMETER",
index,
payload: {
id: newParamId,
generation: {
...param.generation,
start: newCenter - currentSpan / 2,
stop: newCenter + currentSpan / 2,
},
},
});
} else {
dispatchScanInfoStateUpdate({
type: "UPDATE_PARAMETER",
index,
payload: { id: newParamId },
});
}
}}
renderValue={(value) => {
const selectedDisplayName = parameterOptions[value]?.displayName;
Expand All @@ -262,66 +338,149 @@ export const ParameterCard = ({
</FormControl>

<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<TextField
required
disabled={!param.id}
label="Start"
<ToggleButtonGroup
value={inputMode}
exclusive
size="small"
type="number"
fullWidth
value={param.generation.start}
onChange={(e) =>
dispatchScanInfoStateUpdate({
type: "UPDATE_PARAMETER",
index,
payload: {
generation: {
...param.generation,
start: Number(e.target.value),
},
},
})
}
variant="outlined"
slotProps={{
input: {
inputProps: {
min: parameterOptions[param.id]?.min,
max: parameterOptions[param.id]?.max,
},
},
}}
/>
<TextField
required
disabled={!param.id}
label="Stop"
size="small"
type="number"
fullWidth
value={param.generation.stop}
onChange={(e) =>
dispatchScanInfoStateUpdate({
type: "UPDATE_PARAMETER",
index,
payload: {
generation: {
...param.generation,
stop: Number(e.target.value),
onChange={handleInputModeChange}
>
<ToggleButton value="startStop" sx={{ textTransform: "none" }}>
Start / Stop
</ToggleButton>
<ToggleButton value="spanCenter" sx={{ textTransform: "none" }}>
Center / Span
</ToggleButton>
</ToggleButtonGroup>

{inputMode === "spanCenter" ? (
<>
<TextField
required
disabled={!param.id}
label="Center"
size="small"
type="number"
fullWidth
value={center}
onChange={(e) => {
const newCenter = Number(e.target.value);
dispatchScanInfoStateUpdate({
type: "UPDATE_PARAMETER",
index,
payload: {
generation: {
...param.generation,
start: newCenter - span / 2,
stop: newCenter + span / 2,
},
},
});
}}
variant="outlined"
slotProps={{
input: {
inputProps: {
min: parameterOptions[param.id]?.min,
max: parameterOptions[param.id]?.max,
},
},
},
})
}
variant="outlined"
slotProps={{
input: {
inputProps: {
min: parameterOptions[param.id]?.min,
max: parameterOptions[param.id]?.max,
},
},
}}
/>
}}
/>
<TextField
required
disabled={!param.id}
label="Span"
size="small"
type="number"
fullWidth
value={Math.abs(span)}
onChange={(e) => {
const newSpan = Math.abs(Number(e.target.value));
dispatchScanInfoStateUpdate({
type: "UPDATE_PARAMETER",
index,
payload: {
generation: {
...param.generation,
start: center - newSpan / 2,
stop: center + newSpan / 2,
},
},
});
}}
variant="outlined"
slotProps={{
input: { inputProps: { min: 0 } },
}}
/>
</>
) : (
<>
<TextField
required
disabled={!param.id}
label="Start"
size="small"
type="number"
fullWidth
value={param.generation.start}
onChange={(e) =>
dispatchScanInfoStateUpdate({
type: "UPDATE_PARAMETER",
index,
payload: {
generation: {
...param.generation,
start: Number(e.target.value),
},
},
})
}
variant="outlined"
slotProps={{
input: {
inputProps: {
min: parameterOptions[param.id]?.min,
max: parameterOptions[param.id]?.max,
},
},
}}
/>
<TextField
required
disabled={!param.id}
label="Stop"
size="small"
type="number"
fullWidth
value={param.generation.stop}
onChange={(e) =>
dispatchScanInfoStateUpdate({
type: "UPDATE_PARAMETER",
index,
payload: {
generation: {
...param.generation,
stop: Number(e.target.value),
},
},
})
}
variant="outlined"
slotProps={{
input: {
inputProps: {
min: parameterOptions[param.id]?.min,
max: parameterOptions[param.id]?.max,
},
},
}}
/>
</>
)}

<TextField
required
disabled={!param.id}
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/scanInterface/ScanInterfaceComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useContext, useEffect, useState } from "react";
import {
Select,
MenuItem,
Expand All @@ -10,7 +10,9 @@ import {
Tooltip,
} from "@mui/material";
import { useNotifications } from "@toolpad/core";
import { ParameterDisplayGroupsContext } from "../../contexts/ParameterDisplayGroupsContext";
import { useScanContext } from "../../hooks/useScanContext";
import { getScanParameterBounds } from "../../utils/scanUtils";
import { submitJob } from "../../utils/submitJob";
import ScanParameterTable from "./ScanParameterTable";

Expand All @@ -19,6 +21,7 @@ interface ScanInterfaceProps {
}
const ScanInterface = ({ experimentId }: ScanInterfaceProps) => {
const { scanInfoState, dispatchScanInfoStateUpdate } = useScanContext();
const { parameterDisplayGroups } = useContext(ParameterDisplayGroupsContext);
const notifications = useNotifications();
const [submitDisabled, setSubmitDisabled] = useState(false);

Expand Down Expand Up @@ -92,7 +95,10 @@ const ScanInterface = ({ experimentId }: ScanInterfaceProps) => {
event.preventDefault();

if (validateForm()) {
submitJob(experimentId, scanInfoState);
const parameterBounds = scanInfoState.parameters.map((param) =>
getScanParameterBounds(param, parameterDisplayGroups),
);
submitJob(experimentId, scanInfoState, parameterBounds);
notifications.show("Job submitted", {
severity: "success",
autoHideDuration: 3000,
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/types/ScanParameterGenerationSpec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ScanPattern } from "./ScanParameterInfo";
import { ScanInputMode, ScanPattern } from "./ScanParameterInfo";

export interface ScanParameterGenerationSpec {
start: number;
stop: number;
points: number;
pattern: ScanPattern;
/** Controls which pair of fields is shown in the UI. "startStop" is the default. */
inputMode?: ScanInputMode;
}
3 changes: 3 additions & 0 deletions frontend/src/types/ScanParameterInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { ScanParameterGenerationSpec } from "./ScanParameterGenerationSpec";
export const scanPatterns = ["linear", "scatter", "centred", "forwardReverse"] as const;
export type ScanPattern = (typeof scanPatterns)[number];

export const scanInputModes = ["startStop", "spanCenter"] as const;
export type ScanInputMode = (typeof scanInputModes)[number];

export interface ScanParameterInfo {
id: string;
deviceNameOrDisplayGroup: string;
Expand Down
Loading