Skip to content
Merged
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
123 changes: 121 additions & 2 deletions web/app/experiment/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ interface TypeParam {
// Allowed values kept as defined and saved back under the same key.
allowed_values?: unknown[];
allowed_key?: AllowedValuesKey;
// Edit mode only, never saved: the equipment no longer has this input.
not_on_equipment?: boolean;
}

type AllowedValuesKey = "allowed_values" | "enum" | "options";
Expand Down Expand Up @@ -184,12 +186,102 @@ function allowedValuesOf(param: ExperimentTypeParameterInput): { key: AllowedVal

/** Definition payload: restore allowed values under the key they were read from. */
function definitionPayloadParam(param: TypeParam): ExperimentTypeParameterInput {
const { allowed_values: allowed, allowed_key: key, ...rest } = param;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { allowed_values: allowed, allowed_key: key, not_on_equipment: _notOnEquipment, ...rest } = param;
const payload: ExperimentTypeParameterInput = { ...rest };
if (allowed && allowed.length > 0) payload[key || "allowed_values"] = allowed;
return payload;
}

/**
* A template keeps its own copy of each equipment input. When it is edited,
* take unit, type and settability from the current equipment registration so
* later equipment edits carry over, and flag inputs the equipment no longer
* has so the owner can set them to Not used.
*/
function syncWithEquipment(templateParams: TypeParam[], equipmentParams: TypeParam[]): TypeParam[] {
return templateParams.map((param) => {
if (param.source !== "equipment") return param;
const current = equipmentParams.find((equipment) => equipment.name === param.name);
if (!current) return { ...param, not_on_equipment: true };
const type = current.type || param.type;
const kind = valueKind(type, "float");
const numeric = kind === "number" || kind === "integer";
const settable = current.recipe_settable !== false;
// Saving turns a Variable tool-controlled input into Fixed; show it that way.
const mode = !settable && param.mode === "variable" ? "fixed" : param.mode;
return {
...param,
type,
unit: current.unit,
recipe_settable: settable,
mode,
is_variable: mode === "variable",
equipment_parameter_id: current.equipment_parameter_id || param.equipment_parameter_id,
// Bounds apply only to numbers; don't keep hidden stale ones.
min_value: numeric ? param.min_value : "",
max_value: numeric ? param.max_value : "",
};
});
}

/** First problem with a used input's default or bounds under its declared type. */
function definitionValueError(param: TypeParam): string | null {
const kind = valueKind(param.type, "float");
const row: RunInputRow = {
key: param.name,
name: param.name,
label: param.name,
kind,
declaredType: param.type || "float",
unit: param.unit,
mode: param.mode,
min_value: "",
max_value: "",
required: false,
source: "equipment",
description: param.description,
};
if (kind === "number" || kind === "integer") {
// Bounds are thresholds: any finite number, as the run validators read them.
for (const [label, bound] of [["Min", param.min_value], ["Max", param.max_value]] as const) {
const error = bound.trim() ? runValueError({ ...row, kind: "number" }, bound) : null;
if (error) return `${param.name} ${label}: ${error}`;
}
const min = param.min_value.trim() === "" ? NaN : Number(param.min_value);
const max = param.max_value.trim() === "" ? NaN : Number(param.max_value);
if (kind === "integer" && Number.isFinite(min) && Number.isFinite(max) && Math.ceil(min) > Math.floor(max)) {
return `${param.name}: no whole number lies between Min and Max.`;
}
}
const value = param.default_value.trim();
// runValueError leaves date/time format to the API; templates are not
// checked there, so check ISO 8601 here.
if (kind === "datetime" && value && !isIsoDateTime(value)) {
return `${param.name} default: Enter an ISO 8601 date or date-time (YYYY-MM-DD or YYYY-MM-DDThh:mm:ss).`;
}
const error = runValueError(
{ ...row, min_value: param.min_value, max_value: param.max_value, allowed: param.allowed_values },
param.default_value,
);
return error ? `${param.name} default: ${error}` : null;
}

const ISO_DATETIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-](\d{2}):?(\d{2}))?)?$/;

/** ISO 8601 date or date-time with real calendar and clock values. */
function isIsoDateTime(value: string): boolean {
const match = ISO_DATETIME.exec(value);
if (!match) return false;
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return false;
const [hour, minute, second] = [match[4], match[5], match[6]].map((part) => (part === undefined ? 0 : Number(part)));
// Offsets must be under 24 hours, as Python's fromisoformat requires.
const [offsetHour, offsetMinute] = [match[7], match[8]].map((part) => (part === undefined ? 0 : Number(part)));
return hour <= 23 && minute <= 59 && second <= 59 && offsetHour <= 23 && offsetMinute <= 59;
}

/** Wire encoding: strings verbatim, booleans true/false, objects/arrays as JSON. */
function encodeValue(value: unknown): string {
if (value === null || value === undefined) return "";
Expand Down Expand Up @@ -273,6 +365,9 @@ interface FormData {
selected_type_id: string;
visibility: "private" | "shared" | "published";
type_parameters: TypeParam[];
// True once the selected equipment's registration loaded, so an empty
// input list means the equipment has none (not that loading failed).
equipment_inputs_loaded: boolean;
outputs: EquipmentOutput[];
additional_inputs: TypeParam[];
// Step 2 - Project run
Expand All @@ -299,6 +394,7 @@ const INITIAL_DATA: FormData = {
selected_type_id: "",
visibility: "shared",
type_parameters: [],
equipment_inputs_loaded: false,
outputs: [],
additional_inputs: [],
selected_equipment: "",
Expand Down Expand Up @@ -615,6 +711,7 @@ function NewExperimentContent() {
selected_project: projects.some((project) => project.id === prev.selected_project)
? prev.selected_project
: "",
equipment_inputs_loaded: Boolean(detail),
type_parameters: nextOptions.map((option) => ({
name: option.name,
type: option.type,
Expand Down Expand Up @@ -723,7 +820,9 @@ function NewExperimentContent() {
// was saved (left as "not used") so they can be switched on.
type_parameters: editMode
? [
...nextTypeParameters,
...(prev.equipment_inputs_loaded
? syncWithEquipment(nextTypeParameters, prev.type_parameters)
: nextTypeParameters),
...prev.type_parameters.filter(
(param) => !nextTypeParameters.some((next) => next.name === param.name),
),
Expand Down Expand Up @@ -863,6 +962,16 @@ function NewExperimentContent() {
});
return;
}
// Units and types now follow the equipment, so an old default or bound
// may no longer fit; catch it here rather than saving it.
const valueError = formData.type_parameters
.filter((param) => param.name.trim() && param.mode !== "not_used")
.map(definitionValueError)
.find(Boolean);
if (valueError) {
setSubmitResult({ status: "error", message: valueError });
return;
}
const updated = await updateExperimentType(editTypeId, {
...definitionPayload(),
// The backend stores these as sent, so carry them over.
Expand Down Expand Up @@ -1166,6 +1275,8 @@ function NewExperimentContent() {
default. Change an input to Fixed or Variable only if this
unit experiment needs it. Missing equipment inputs must be added by
editing the equipment registration.
{editMode &&
" Units and types follow the equipment registration; inputs marked not on equipment were renamed or removed there, so set them to Not used."}
</p>

{/* Header row */}
Expand All @@ -1190,6 +1301,14 @@ function NewExperimentContent() {
tool controlled
</span>
)}
{param.not_on_equipment && (
<span
className="ml-2 rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] font-medium text-red-600"
title="The equipment registration no longer has this input. Set it to Not used to remove it from this unit experiment."
>
not on equipment
</span>
)}
</div>
<div className="rounded-md border border-[hsl(var(--border))] bg-[hsl(var(--muted))]/30 px-3 py-2 text-sm text-[hsl(var(--muted-foreground))] truncate">
{param.unit || "—"}
Expand Down