diff --git a/api/routers/equipment.py b/api/routers/equipment.py index 164265c..26113d9 100644 --- a/api/routers/equipment.py +++ b/api/routers/equipment.py @@ -57,10 +57,13 @@ class ColumnDef(BaseModel): class EquipmentParamPayload(BaseModel): name: str + type: str = "float" unit: str = "" min_value: str = "" max_value: str = "" description: str = "" + equipment_parameter_id: str = "" + recipe_settable: bool = True class EquipmentOutputPayload(BaseModel): @@ -128,6 +131,7 @@ class RegistrationPayload(BaseModel): class ExperimentTypeParamPayload(BaseModel): name: str + type: str = "float" unit: str = "" is_variable: bool = True mode: str = "variable" @@ -136,6 +140,8 @@ class ExperimentTypeParamPayload(BaseModel): max_value: str = "" source: str = "equipment" description: str = "" + equipment_parameter_id: str = "" + recipe_settable: bool = True class ExperimentTypePayload(BaseModel): diff --git a/web/app/equipment/[id]/page.tsx b/web/app/equipment/[id]/page.tsx index 58008fb..13fb5ef 100644 --- a/web/app/equipment/[id]/page.tsx +++ b/web/app/equipment/[id]/page.tsx @@ -295,9 +295,25 @@ export default function EquipmentDetailPage({ {input.name} {input.unit || "N/A"} -

+

+ + {input.type || "float"} + + {input.equipment_parameter_id && ( + + Eq Param {input.equipment_parameter_id} + + )} + + {input.recipe_settable !== false ? "Recipe settable" : "Not recipe settable"} + +
+

{input.min_value ?? "—"} to {input.max_value ?? "—"}

+ {input.description && ( +

{input.description}

+ )} ))} {inputs.length === 0 && ( diff --git a/web/app/equipment/register/page.tsx b/web/app/equipment/register/page.tsx index dc958f1..97f4e69 100644 --- a/web/app/equipment/register/page.tsx +++ b/web/app/equipment/register/page.tsx @@ -21,8 +21,10 @@ import { Layers, Loader2, Plus, + Download, SlidersHorizontal, Trash2, + Upload, Wrench, } from "lucide-react"; @@ -61,10 +63,13 @@ const UNIT_OPTIONS = [ interface EquipmentParam { name: string; + type: string; unit: string; min_value: string; max_value: string; description: string; + equipment_parameter_id: string; + recipe_settable: boolean; } interface EquipmentOutput { @@ -161,6 +166,27 @@ const INITIAL_DATA: FormData = { file_column: "", }; +const PARAMETER_TYPE_OPTIONS = [ + "float", + "integer", + "string", + "datetime", + "boolean", + "array", + "map", +]; + +const PARAMETER_CSV_COLUMNS = [ + "variable_name", + "type", + "units", + "description", + "equipment_parameter_id", + "recipe_settable", + "min_value", + "max_value", +]; + function slugifyEquipmentName(name: string) { return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); } @@ -170,6 +196,94 @@ function toText(value: unknown, fallback = "") { return String(value); } +function parseCsv(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let cell = ""; + let inQuotes = false; + + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + const nextChar = text[index + 1]; + + if (char === "\"") { + if (inQuotes && nextChar === "\"") { + cell += "\""; + index += 1; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (char === "," && !inQuotes) { + row.push(cell.trim()); + cell = ""; + continue; + } + + if ((char === "\n" || char === "\r") && !inQuotes) { + if (char === "\r" && nextChar === "\n") { + index += 1; + } + row.push(cell.trim()); + if (row.some((value) => value !== "")) { + rows.push(row); + } + row = []; + cell = ""; + continue; + } + + cell += char; + } + + row.push(cell.trim()); + if (row.some((value) => value !== "")) { + rows.push(row); + } + return rows; +} + +function csvEscape(value: string) { + if (/[",\n\r]/.test(value)) { + return `"${value.replaceAll("\"", "\"\"")}"`; + } + return value; +} + +function csvBoolean(value: string) { + return ["true", "yes", "y", "1"].includes(value.trim().toLowerCase()); +} + +function buildParameterTemplateCsv() { + const example = [ + "RF2_Fwd_pwr", + "float", + "W", + "Forward power measured on RF2 supply", + "413", + "true", + "0", + "2000", + ]; + return [PARAMETER_CSV_COLUMNS, example] + .map((row) => row.map(csvEscape).join(",")) + .join("\n"); +} + +function downloadParameterTemplate() { + const blob = new Blob([buildParameterTemplateCsv()], { + type: "text/csv;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "equipment_parameters_template.csv"; + link.click(); + URL.revokeObjectURL(url); +} + function cloneInitialData(): FormData { return { ...INITIAL_DATA, @@ -224,10 +338,13 @@ function formDataFromEquipment(equipment: EquipmentInfo): FormData { parameters: equipment.parameters?.map((parameter) => ({ name: parameter.name || "", + type: parameter.type || "float", unit: parameter.unit || "", min_value: toText(parameter.min_value), max_value: toText(parameter.max_value), description: parameter.description || "", + equipment_parameter_id: parameter.equipment_parameter_id || "", + recipe_settable: parameter.recipe_settable !== false, })) ?? cloneInitialData().parameters, outputs: Array.isArray(outputData) ? outputData.map((output) => ({ @@ -285,6 +402,11 @@ function EquipmentRegisterContent() { status: string; message: string; } | null>(null); + const [csvImportResult, setCsvImportResult] = useState<{ + status: "success" | "error"; + message: string; + } | null>(null); + const [csvImportMode, setCsvImportMode] = useState<"replace" | "append">("replace"); const isEditMode = Boolean(editDomainId); useEffect(() => { @@ -328,10 +450,13 @@ function EquipmentRegisterContent() { ...previous.parameters, { name: "", + type: "float", unit: "", min_value: "", max_value: "", description: "", + equipment_parameter_id: "", + recipe_settable: true, }, ], })); @@ -340,7 +465,7 @@ function EquipmentRegisterContent() { const updateParameter = ( index: number, field: keyof EquipmentParam, - value: string, + value: string | boolean, ) => { setFormData((previous) => { const parameters = [...previous.parameters]; @@ -356,6 +481,128 @@ function EquipmentRegisterContent() { })); }; + const importParameterCsv = async (file: File | null) => { + if (!file) return; + + try { + const text = await file.text(); + const rows = parseCsv(text); + if (rows.length < 2) { + setCsvImportResult({ + status: "error", + message: "CSV must include a header row and at least one parameter row.", + }); + return; + } + + const header = rows[0].map((value) => value.trim().toLowerCase()); + const missingRequired = ["variable_name", "type", "units", "description"].filter( + (column) => !header.includes(column), + ); + if (missingRequired.length > 0) { + setCsvImportResult({ + status: "error", + message: `Missing required CSV columns: ${missingRequired.join(", ")}.`, + }); + return; + } + + const columnIndex = (column: string) => header.indexOf(column); + const imported = rows.slice(1).map((row) => ({ + name: row[columnIndex("variable_name")]?.trim() || "", + type: row[columnIndex("type")]?.trim() || "float", + unit: row[columnIndex("units")]?.trim() || "", + description: row[columnIndex("description")]?.trim() || "", + equipment_parameter_id: + columnIndex("equipment_parameter_id") >= 0 + ? row[columnIndex("equipment_parameter_id")]?.trim() || "" + : "", + recipe_settable: + columnIndex("recipe_settable") >= 0 + ? csvBoolean(row[columnIndex("recipe_settable")] || "") + : true, + min_value: + columnIndex("min_value") >= 0 + ? row[columnIndex("min_value")]?.trim() || "" + : "", + max_value: + columnIndex("max_value") >= 0 + ? row[columnIndex("max_value")]?.trim() || "" + : "", + })); + + const invalidRows = imported + .map((parameter, index) => ({ + index: index + 2, + parameter, + })) + .filter( + ({ parameter }) => + !parameter.name || + !parameter.type || + !parameter.unit || + !parameter.description, + ); + if (invalidRows.length > 0) { + setCsvImportResult({ + status: "error", + message: `Rows ${invalidRows.map((row) => row.index).join(", ")} are missing required values.`, + }); + return; + } + + const existingParameters = + csvImportMode === "append" + ? formData.parameters.filter((parameter) => parameter.name.trim()) + : []; + const nextParameters = [...existingParameters, ...imported]; + const duplicateNames = nextParameters + .map((parameter) => parameter.name.trim()) + .filter((name) => name) + .filter((name, index, names) => names.indexOf(name) !== index); + if (duplicateNames.length > 0) { + setCsvImportResult({ + status: "error", + message: `CSV import would create duplicate input names: ${[ + ...new Set(duplicateNames), + ].join(", ")}.`, + }); + return; + } + + const duplicateEquipmentParameterIds = nextParameters + .map((parameter) => parameter.equipment_parameter_id.trim()) + .filter((id) => id) + .filter((id, index, ids) => ids.indexOf(id) !== index); + if (duplicateEquipmentParameterIds.length > 0) { + setCsvImportResult({ + status: "error", + message: `CSV import would create duplicate Eq Param IDs: ${[ + ...new Set(duplicateEquipmentParameterIds), + ].join(", ")}.`, + }); + return; + } + + setFormData((previous) => ({ + ...previous, + parameters: nextParameters, + })); + setCsvImportResult({ + status: "success", + message: + csvImportMode === "append" + ? `Appended ${imported.length} input parameter${imported.length === 1 ? "" : "s"}.` + : `Imported ${imported.length} input parameter${imported.length === 1 ? "" : "s"}.`, + }); + } catch (error) { + setCsvImportResult({ + status: "error", + message: error instanceof Error ? error.message : "Could not read CSV file.", + }); + } + }; + const addOutput = () => { setFormData((previous) => ({ ...previous, @@ -416,10 +663,13 @@ function EquipmentRegisterContent() { .map((parameter) => ({ ...parameter, name: parameter.name.trim(), + type: parameter.type.trim(), unit: parameter.unit.trim(), min_value: parameter.min_value.trim(), max_value: parameter.max_value.trim(), description: parameter.description.trim(), + equipment_parameter_id: parameter.equipment_parameter_id.trim(), + recipe_settable: Boolean(parameter.recipe_settable), })) .filter((parameter) => parameter.name); @@ -432,6 +682,23 @@ function EquipmentRegisterContent() { return; } + const incompleteParameters = normalizedParameters.filter( + (parameter) => + !parameter.type || + !parameter.unit || + !parameter.description, + ); + if (incompleteParameters.length > 0) { + setSubmitResult({ + status: "error", + message: `Each input requires type, unit, and description. Missing values for: ${incompleteParameters + .map((parameter) => parameter.name) + .join(", ")}.`, + }); + setSubmitting(false); + return; + } + const duplicateParameters = normalizedParameters .map((parameter) => parameter.name.trim()) .filter((name) => name) @@ -447,6 +714,21 @@ function EquipmentRegisterContent() { return; } + const duplicateEquipmentParameterIds = normalizedParameters + .map((parameter) => parameter.equipment_parameter_id) + .filter((id) => id) + .filter((id, index, ids) => ids.indexOf(id) !== index); + if (duplicateEquipmentParameterIds.length > 0) { + setSubmitResult({ + status: "error", + message: `Equipment parameter IDs must be unique when provided: ${[ + ...new Set(duplicateEquipmentParameterIds), + ].join(", ")}.`, + }); + setSubmitting(false); + return; + } + const normalizedOutputs = formData.outputs .map((output) => ({ ...output, @@ -695,85 +977,176 @@ function EquipmentRegisterContent() { case 3: return (
-
-

Inputs / Control Variables

-

- Declare the equipment inputs that can be controlled or recorded. - The registrar should enter the complete known list for this - physical tool; the form does not pre-populate etcher-specific - inputs. -

-
-
- Name - Unit - Min - Max - Description - -
-
- {formData.parameters.map((parameter, index) => ( -
- - updateParameter(index, "name", event.target.value) - } - placeholder="Input name" - className={inputClass} - /> +
+
+

Inputs / Control Variables

+

+ Declare the equipment inputs that can be controlled or recorded. + The registrar should enter the complete known list for this + physical tool; the form does not pre-populate etcher-specific + inputs. +

+
+
+ + + +
+
+ + {csvImportResult && ( +
+ {csvImportResult.message} +
+ )} + +
+
+
+ Name + Type + Unit + Eq Param ID + Range + Description + Recipe +
- ))} + {formData.parameters.map((parameter, index) => ( +
+ + updateParameter(index, "name", event.target.value) + } + placeholder="Input name" + className={inputClass} + /> + + + + updateParameter(index, "equipment_parameter_id", event.target.value) + } + placeholder="413" + className={inputClass} + /> +
+ + updateParameter(index, "min_value", event.target.value) + } + placeholder="Min" + className={inputClass} + /> + + updateParameter(index, "max_value", event.target.value) + } + placeholder="Max" + className={inputClass} + /> +
+ + updateParameter(index, "description", event.target.value) + } + placeholder="What this input controls" + className={inputClass} + /> + + +
+ ))} +