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
237 changes: 221 additions & 16 deletions web/app/equipment/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ const PARAMETER_TYPE_OPTIONS = [
"map",
];

const OUTPUT_TYPE_OPTIONS = [...PARAMETER_TYPE_OPTIONS, "file"];

const PARAMETER_CSV_COLUMNS = [
"variable_name",
"type",
Expand All @@ -196,6 +198,15 @@ const PARAMETER_CSV_COLUMNS = [
"max_value",
];

const OUTPUT_CSV_COLUMNS = [
"variable_name",
"type",
"units",
"description",
"equipment_parameter_id",
"artifact",
];

function slugifyEquipmentName(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
}
Expand Down Expand Up @@ -281,6 +292,20 @@ function buildParameterTemplateCsv() {
.join("\n");
}

function buildOutputTemplateCsv() {
const example = [
"Etch_Rate",
"float",
"nm/min",
"Measured etch rate from post-process metrology",
"901",
"false",
];
return [OUTPUT_CSV_COLUMNS, example]
.map((row) => row.map(csvEscape).join(","))
.join("\n");
}

function downloadParameterTemplate() {
const blob = new Blob([buildParameterTemplateCsv()], {
type: "text/csv;charset=utf-8",
Expand All @@ -293,6 +318,18 @@ function downloadParameterTemplate() {
URL.revokeObjectURL(url);
}

function downloadOutputTemplate() {
const blob = new Blob([buildOutputTemplateCsv()], {
type: "text/csv;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "equipment_outputs_template.csv";
link.click();
URL.revokeObjectURL(url);
}

function cloneInitialData(): FormData {
return {
...INITIAL_DATA,
Expand Down Expand Up @@ -417,6 +454,11 @@ function EquipmentRegisterContent() {
message: string;
} | null>(null);
const [csvImportMode, setCsvImportMode] = useState<"replace" | "append">("replace");
const [outputCsvImportResult, setOutputCsvImportResult] = useState<{
status: "success" | "error";
message: string;
} | null>(null);
const [outputCsvImportMode, setOutputCsvImportMode] = useState<"replace" | "append">("replace");
const isEditMode = Boolean(editDomainId);

useEffect(() => {
Expand Down Expand Up @@ -613,6 +655,120 @@ function EquipmentRegisterContent() {
}
};

const importOutputCsv = async (file: File | null) => {
if (!file) return;

try {
const text = await file.text();
const rows = parseCsv(text);
if (rows.length < 2) {
setOutputCsvImportResult({
status: "error",
message: "CSV must include a header row and at least one output 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) {
setOutputCsvImportResult({
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() || ""
: "",
artifact:
columnIndex("artifact") >= 0
? csvBoolean(row[columnIndex("artifact")] || "")
: false,
}));

const invalidRows = imported
.map((output, index) => ({
index: index + 2,
output,
}))
.filter(
({ output }) =>
!output.name ||
!output.type ||
!output.unit ||
!output.description,
);
if (invalidRows.length > 0) {
setOutputCsvImportResult({
status: "error",
message: `Rows ${invalidRows.map((row) => row.index).join(", ")} are missing required values.`,
});
return;
}

const existingOutputs =
outputCsvImportMode === "append"
? formData.outputs.filter((output) => output.name.trim())
: [];
const nextOutputs = [...existingOutputs, ...imported];
const duplicateNames = nextOutputs
.map((output) => output.name.trim())
.filter((name) => name)
.filter((name, index, names) => names.indexOf(name) !== index);
if (duplicateNames.length > 0) {
setOutputCsvImportResult({
status: "error",
message: `CSV import would create duplicate output names: ${[
...new Set(duplicateNames),
].join(", ")}.`,
});
return;
}

const duplicateEquipmentParameterIds = nextOutputs
.map((output) => output.equipment_parameter_id.trim())
.filter((id) => id)
.filter((id, index, ids) => ids.indexOf(id) !== index);
if (duplicateEquipmentParameterIds.length > 0) {
setOutputCsvImportResult({
status: "error",
message: `CSV import would create duplicate Eq Param IDs: ${[
...new Set(duplicateEquipmentParameterIds),
].join(", ")}.`,
});
return;
}

setFormData((previous) => ({
...previous,
outputs: nextOutputs,
}));
setOutputCsvImportResult({
status: "success",
message:
outputCsvImportMode === "append"
? `Appended ${imported.length} output${imported.length === 1 ? "" : "s"}.`
: `Imported ${imported.length} output${imported.length === 1 ? "" : "s"}.`,
});
} catch (error) {
setOutputCsvImportResult({
status: "error",
message: error instanceof Error ? error.message : "Could not read CSV file.",
});
}
};

const addOutput = () => {
setFormData((previous) => ({
...previous,
Expand Down Expand Up @@ -1171,15 +1327,67 @@ function EquipmentRegisterContent() {
case 4:
return (
<div className="space-y-5">
<div>
<h3 className="text-lg font-semibold">Outputs</h3>
<p className="text-sm text-[hsl(var(--muted-foreground))]">
Declare the processed measurements and raw artifacts this
equipment can produce. The registrar should enter these outputs
explicitly; optimization targets will later be chosen from
experiment outputs, not from equipment registration.
</p>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<h3 className="text-lg font-semibold">Outputs</h3>
<p className="text-sm text-[hsl(var(--muted-foreground))]">
Declare the processed measurements and raw artifacts this
equipment can produce. The registrar should enter these outputs
explicitly; optimization targets will later be chosen from
experiment outputs, not from equipment registration.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 text-sm">
<span className="text-[hsl(var(--muted-foreground))]">CSV mode</span>
<select
value={outputCsvImportMode}
onChange={(event) =>
setOutputCsvImportMode(event.target.value as "replace" | "append")
}
className={cn(inputClass, "w-32")}
>
<option value="replace">Replace</option>
<option value="append">Append</option>
</select>
</label>
<button
type="button"
onClick={downloadOutputTemplate}
className="inline-flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm font-medium transition-colors hover:bg-[hsl(var(--accent))]"
>
<Download className="h-4 w-4" />
CSV Template
</button>
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm font-medium transition-colors hover:bg-[hsl(var(--accent))]">
<Upload className="h-4 w-4" />
Upload CSV
<input
type="file"
accept=".csv,text/csv"
className="sr-only"
onChange={(event) => {
void importOutputCsv(event.target.files?.[0] ?? null);
event.currentTarget.value = "";
}}
/>
</label>
</div>
</div>

{outputCsvImportResult && (
<div
className={cn(
"rounded-lg border px-4 py-3 text-sm",
outputCsvImportResult.status === "success"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"
: "border-red-500/30 bg-red-500/10 text-red-500",
)}
>
{outputCsvImportResult.message}
</div>
)}

<div className="grid grid-cols-[2fr_1fr_1fr_1fr_2fr_1fr_auto] gap-2 px-1 text-xs font-medium text-[hsl(var(--muted-foreground))]">
<span>Name</span>
<span>Type</span>
Expand Down Expand Up @@ -1210,14 +1418,11 @@ function EquipmentRegisterContent() {
}
className={inputClass}
>
<option value="float">float</option>
<option value="integer">integer</option>
<option value="string">string</option>
<option value="datetime">datetime</option>
<option value="boolean">boolean</option>
<option value="array">array</option>
<option value="map">map</option>
<option value="file">file</option>
{OUTPUT_TYPE_OPTIONS.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
{renderUnitSelect("", output.unit, (value) => updateOutput(index, "unit", value))}
<input
Expand Down