diff --git a/web/app/experiment/new/page.tsx b/web/app/experiment/new/page.tsx index 88d12ea..f77884d 100644 --- a/web/app/experiment/new/page.tsx +++ b/web/app/experiment/new/page.tsx @@ -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"; @@ -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 ""; @@ -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 @@ -299,6 +394,7 @@ const INITIAL_DATA: FormData = { selected_type_id: "", visibility: "shared", type_parameters: [], + equipment_inputs_loaded: false, outputs: [], additional_inputs: [], selected_equipment: "", @@ -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, @@ -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), ), @@ -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. @@ -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."}

{/* Header row */} @@ -1190,6 +1301,14 @@ function NewExperimentContent() { tool controlled )} + {param.not_on_equipment && ( + + not on equipment + + )}
{param.unit || "—"}