From 09a9cc7fe5ddbe45e94654c7435f3572329a6b68 Mon Sep 17 00:00:00 2001 From: navidgh67 Date: Wed, 14 Oct 2026 09:49:18 -0400 Subject: [PATCH 1/4] Refresh unit experiment inputs from the equipment when editing A template keeps its own copy of each equipment input, so later equipment edits (new units, renamed inputs) never reached it, and the edit page shows that copy read-only. In edit mode, take unit, type and settability from the current equipment registration, and mark inputs the equipment no longer has as 'not on equipment' so the owner can set them to Not used. Reported by Tyriq Turner (MAX Synthesis WL-1/WL-2: rates stuck at N/A, Max Temperature shown next to Temperature). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ppc5pC4MsLsz5apZ9qWTmq --- web/app/experiment/new/page.tsx | 45 +++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/web/app/experiment/new/page.tsx b/web/app/experiment/new/page.tsx index 88d12ea..eb767a3 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,41 @@ 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[] { + if (equipmentParams.length === 0) return templateParams; + 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"; + return { + ...param, + type, + unit: current.unit || param.unit, + recipe_settable: current.recipe_settable, + 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 : "", + }; + }); +} + /** Wire encoding: strings verbatim, booleans true/false, objects/arrays as JSON. */ function encodeValue(value: unknown): string { if (value === null || value === undefined) return ""; @@ -723,7 +754,7 @@ function NewExperimentContent() { // was saved (left as "not used") so they can be switched on. type_parameters: editMode ? [ - ...nextTypeParameters, + ...syncWithEquipment(nextTypeParameters, prev.type_parameters), ...prev.type_parameters.filter( (param) => !nextTypeParameters.some((next) => next.name === param.name), ), @@ -1166,6 +1197,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 +1223,14 @@ function NewExperimentContent() { tool controlled )} + {param.not_on_equipment && ( + + not on equipment + + )}
{param.unit || "—"} From 2983b02af158e22e5764c1651efa9ba09196fc85 Mon Sep 17 00:00:00 2001 From: navidgh67 Date: Wed, 14 Oct 2026 09:55:54 -0400 Subject: [PATCH 2/4] Address review: exact units, Fixed for tool-controlled, value checks - Use the equipment's unit as is, including an empty one. - Show a tool-controlled Variable input as Fixed, as it is saved. - Flag template inputs even when the equipment now has none, but only once its registration loaded. - Before saving an edit, check each used input's default and bounds against its (possibly new) type. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ppc5pC4MsLsz5apZ9qWTmq --- web/app/experiment/new/page.tsx | 57 ++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/web/app/experiment/new/page.tsx b/web/app/experiment/new/page.tsx index eb767a3..0533369 100644 --- a/web/app/experiment/new/page.tsx +++ b/web/app/experiment/new/page.tsx @@ -200,7 +200,6 @@ function definitionPayloadParam(param: TypeParam): ExperimentTypeParameterInput * has so the owner can set them to Not used. */ function syncWithEquipment(templateParams: TypeParam[], equipmentParams: TypeParam[]): TypeParam[] { - if (equipmentParams.length === 0) return templateParams; return templateParams.map((param) => { if (param.source !== "equipment") return param; const current = equipmentParams.find((equipment) => equipment.name === param.name); @@ -208,11 +207,16 @@ function syncWithEquipment(templateParams: TypeParam[], equipmentParams: TypePar 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 || param.unit, - recipe_settable: current.recipe_settable, + 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 : "", @@ -221,6 +225,34 @@ function syncWithEquipment(templateParams: TypeParam[], equipmentParams: TypePar }); } +/** 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, + }; + for (const [label, bound] of [["Min", param.min_value], ["Max", param.max_value]] as const) { + const error = bound.trim() ? runValueError(row, bound) : null; + if (error) return `${param.name} ${label}: ${error}`; + } + 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; +} + /** Wire encoding: strings verbatim, booleans true/false, objects/arrays as JSON. */ function encodeValue(value: unknown): string { if (value === null || value === undefined) return ""; @@ -304,6 +336,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 @@ -330,6 +365,7 @@ const INITIAL_DATA: FormData = { selected_type_id: "", visibility: "shared", type_parameters: [], + equipment_inputs_loaded: false, outputs: [], additional_inputs: [], selected_equipment: "", @@ -646,6 +682,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, @@ -754,7 +791,9 @@ function NewExperimentContent() { // was saved (left as "not used") so they can be switched on. type_parameters: editMode ? [ - ...syncWithEquipment(nextTypeParameters, prev.type_parameters), + ...(prev.equipment_inputs_loaded + ? syncWithEquipment(nextTypeParameters, prev.type_parameters) + : nextTypeParameters), ...prev.type_parameters.filter( (param) => !nextTypeParameters.some((next) => next.name === param.name), ), @@ -894,6 +933,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. From 5341fd4249bc05af931f777c4ad5853b95b22b05 Mon Sep 17 00:00:00 2001 From: navidgh67 Date: Wed, 14 Oct 2026 09:59:02 -0400 Subject: [PATCH 3/4] Check bounds as thresholds and date/time defaults on edit save Bounds of an integer input may be fractional, as the run validators allow; require only that a whole number lies between them. Date/time defaults are checked as ISO 8601 with real calendar and clock values, since template saves are not validated by the API. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ppc5pC4MsLsz5apZ9qWTmq --- web/app/experiment/new/page.tsx | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/web/app/experiment/new/page.tsx b/web/app/experiment/new/page.tsx index 0533369..e9e0522 100644 --- a/web/app/experiment/new/page.tsx +++ b/web/app/experiment/new/page.tsx @@ -242,9 +242,23 @@ function definitionValueError(param: TypeParam): string | null { source: "equipment", description: param.description, }; - for (const [label, bound] of [["Min", param.min_value], ["Max", param.max_value]] as const) { - const error = bound.trim() ? runValueError(row, bound) : null; - if (error) return `${param.name} ${label}: ${error}`; + 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 }, @@ -253,6 +267,19 @@ function definitionValueError(param: TypeParam): string | null { 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))); + return hour <= 23 && minute <= 59 && second <= 59; +} + /** Wire encoding: strings verbatim, booleans true/false, objects/arrays as JSON. */ function encodeValue(value: unknown): string { if (value === null || value === undefined) return ""; From 8c12a5a4ea679721630b4a1ae5a6f0f8a1ad80b9 Mon Sep 17 00:00:00 2001 From: navidgh67 Date: Wed, 14 Oct 2026 10:00:45 -0400 Subject: [PATCH 4/4] Reject date/time defaults with out-of-range timezone offsets Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ppc5pC4MsLsz5apZ9qWTmq --- web/app/experiment/new/page.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/app/experiment/new/page.tsx b/web/app/experiment/new/page.tsx index e9e0522..f77884d 100644 --- a/web/app/experiment/new/page.tsx +++ b/web/app/experiment/new/page.tsx @@ -267,7 +267,7 @@ function definitionValueError(param: TypeParam): string | null { 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})?)?$/; +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 { @@ -277,7 +277,9 @@ function isIsoDateTime(value: string): boolean { 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))); - return hour <= 23 && minute <= 59 && second <= 59; + // 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. */