Skip to content

Spreadsheet Macros

Macros let you write JavaScript that reads and writes the worksheets of the spreadsheet section they belong to. They run in a sandboxed environment with a hard 10-second timeout. The full mathjs library is available for numeric and statistical calculations.

For an overview of how to manage and run macros, see Spreadsheets.


In the spreadsheet’s Macros dialog, click + Macro, give it a label, and paste:

const source = context.getSheet("Sheet1");
const target = context.getSheet("Plate-1");
const total = math.sum(
source.range("A1:A10").read().flat().filter(v => typeof v === "number"),
);
target.cell("A1").set(total);
target.cell("A1").setStyle({ background: "#ffd" });

Click Run. The target sheet’s A1 cell is set to the sum of the first ten cells of Sheet1’s column A, with a yellow background. The result row below the editor shows how many cells were written.


Inside every macro, two globals are in scope:

  • context — the spreadsheet API. Lets you read and write the sheets, cells, and ranges of the macro’s own spreadsheet section.
  • math — the full mathjs library. All numeric, statistical, matrix, and unit operations work normally. A small set of code-execution functions are disabled for safety (evaluate, import, simplify, derivative, resolve, createUnit, reviver).

A macro takes effect entirely through the writes it queues (cell.set, range.set, setStyle, …). There is no output value to return — write your results into a sheet instead.


Returns the worksheet currently shown in the spreadsheet.

const sheet = context.activeSheet();
sheet.cell("A1").set("Hello");

Returns the worksheet with the given name, or — when given a number — the worksheet at that 1-based tab position. Throws if no sheet matches or the index is out of range — a typo surfaces immediately rather than silently doing nothing.

const plate = context.getSheet("Plate-1");
plate.cell("B2").set(42);
const first = context.getSheet(1); // leftmost tab, regardless of its name

Use the index form when a macro should work across templates whose sheet names vary.

Both forms resolve within the macro’s own spreadsheet section, where worksheet names are unique — there is nothing to disambiguate.

Returns every worksheet (tab) in the macro’s spreadsheet section. Useful for “do something to every sheet” macros.

for (const sheet of context.getSheets()) {
sheet.cell("Z1").set(new Date().toISOString());
}

MemberDescription
sheet.nameThe worksheet’s display name.
sheet.rowsThe current snapshot’s 2D row array. Includes any writes the macro has queued so far.
sheet.cell(addr)Get a Cell handle. addr is "A1" style or { x, y } 0-indexed coords.
sheet.range(addr)Get a Range handle. addr is "A1:B10" style.
const sheet = context.getSheet("Sheet1");
sheet.cell("A1").set(1);
sheet.cell({ x: 0, y: 1 }).set(2);
sheet.range("A1:B2").read(); // [[1, null], [2, null]]

MemberDescription
cell.addressThe A1 address, e.g. "B5".
cell.x, cell.y0-indexed column / row.
cell.read()Returns the cell’s current value. Sees writes the macro has already queued — cell.set(42); cell.read() returns 42.
cell.set(value)Queues a write. Strings starting with = are treated as formulas.
cell.setBlock(values)Anchors a 2D array at this cell and queues writes for every element. Dimensions are inferred — handy when you don’t know how many rows a CSV will have. Returns the resulting Range.
cell.setStyle(style)Queues a style write. style is a CSS-property map, e.g. { background: "#ffd", fontWeight: "bold" }.
cell.setMeta(meta)Queues a per-cell metadata write — an arbitrary { key: value } payload persisted with the workbook.
const c = context.activeSheet().cell("D7");
c.set("=SUM(A7:C7)");
c.setStyle({ background: "#dfd", color: "#060" });

MemberDescription
range.addressThe A1:B10 form.
range.start, range.end{ x, y } 0-indexed corners.
range.width, range.heightDimensions in cells.
range.read()2D array of values. Sees queued writes. Cells that don’t exist yet read as null.
range.set(values)Queues writes for a 2D array. Dimensions must match the range exactly.
range.setStyle(style)Applies the same style to every cell in the range.
range.cells()Iterator yielding a Cell handle for every coordinate in row-major order.
const sheet = context.activeSheet();
// Bulk write a 3×2 block
sheet.range("A1:B3").set([
["a", 1],
["b", 2],
["c", 3],
]);
// Apply a header style across a row
sheet.range("A1:Z1").setStyle({ fontWeight: "bold", background: "#eee" });

A macro sees exactly the worksheets (tabs) of the spreadsheet section it belongs to. It cannot read or write other sections or other steps — use multiple worksheet tabs within one section when a macro needs to combine data from several sheets.

  • Reads happen against a snapshot taken when the run starts. They’re always fast and synchronous.
  • Writes flow through the live spreadsheet immediately, and the section auto-saves as usual.
const plate = context.getSheet("Plate-1"); // one tab
const summary = context.getSheet("Summary"); // another tab, same section
const od = plate.range("B2:B11").read().flat().filter(v => typeof v === "number");
summary.cell("A1").set("Mean OD");
summary.cell("B1").set(math.mean(od));

Interactive UI — alerts, confirms, prompts, file uploads and attachments

Section titled “Interactive UI — alerts, confirms, prompts, file uploads and attachments”

Macros can pause mid-run to ask the user for input. The 10-second timeout is suspended while a dialog is open.

MethodDescription
await context.alert(message)Show an OK-only message dialog.
await context.confirm(message, opts?)Yes/No dialog. Returns true/false. Cancel counts as false. opts.yes / opts.no rename the buttons.
await context.prompt(message, defaultValue?)Single-line text input. Returns the entered string, or null if cancelled.
await context.openFile(opts?)File picker. Returns a parsed file object, or null if cancelled. See below.
await context.openHubFile(opts?)Pick a file a File Agent uploaded and nobody has processed yet. Returns the same parsed file object as openFile plus file.source, or null if cancelled. See below.
await context.attachFile(file, filename?)Attach a file to the current step. Returns { id, name }. See below.

Every dialog title is prefixed with Macro "<your label>" so users can see that it is macro-driven.

await context.openFile({
accept?: string[]; // e.g. [".csv", ".xlsx"]
as?: "text" | "csv" | "json" | "xlsx" | "blob"; // default "text"
}): IUploadedFile | null

The returned object contains the parsed file data:

PropertySet when
file.name, file.type, file.sizeAlways
file.textas = "text"
file.jsonas = "json"
file.rowsas = "csv" — 2D array of rows
file.sheetsas = "xlsx" — array of { name, rows }, one per sheet
file.bufferas = "blob"

XLSX note: file.rows is always empty for XLSX files. Use file.sheets and pick the sheet by name:

const file = await context.openFile({ accept: [".xlsx"], as: "xlsx" });
if (file) {
const sheet = file.sheets.find(s => s.name === "Results") ?? file.sheets[0];
context.activeSheet().cell("A1").setBlock(sheet.rows);
}

Use openHubFile instead of openFile when instrument files must come from a File Agent rather than from the user’s computer, for example in a GMP process. The macro opens a picker that lists the files File Agents uploaded today and nobody has processed yet, each with its full path on the instrument PC. The picker has no way to choose a local file.

await context.openHubFile({
instruments?: string[]; // instrument names or IDs; default: every instrument with a File Agent
folder?: string; // only files under this folder, relative to the File Agent's folder
accept?: string[]; // same as openFile
as?: "text" | "csv" | "json" | "xlsx" | "blob"; // same as openFile, default "text"
message?: string; // instruction shown at the top of the picker
}): IUploadedFile | null

folder may contain wildcards: * matches within one folder level and ** across levels, so "Seq_2026-10-*" matches every October 2026 sequence folder.

The picker lists the unprocessed files in that scope uploaded today, newest first, with their instrument, computer, modified and uploaded times (in local time), size and status. The macro decides the scope. Within it, two toggles widen the list:

  • Show files from earlier days also lists files uploaded before today.
  • Include processed files also lists files that were already processed. See Processing a file again.

The user clicks a row to select it and then Load file. Every file can be downloaded, and text files (such as .txt, .csv and .json) can be previewed, before loading. The list updates while the picker is open: new uploads appear, and the selected row stays selected.

The returned object carries the same parsed data as openFile, plus file.source, which says where the file came from:

PropertyMeaning
file.source.sourcePathFull path the file was read from, e.g. D:\SparkControl\Exports\run.csv. It is the File Agent’s folder joined with relativePath.
file.source.hostnameThe computer the File Hub runs on.
file.source.relativePathPath relative to the File Agent’s folder, /-separated.
file.source.folderFolder part of relativePath, empty for files at the root.
file.source.instrumentID, file.source.instrumentNameThe instrument the File Agent uploads for.
file.source.fileAgentID, file.source.fileAgentNameThe File Agent that uploaded the file.
file.source.sha256SHA-256 of the content, verified when the File Hub uploaded the file.
file.source.modifiedAt, file.source.uploadedAtISO timestamps in UTC: last modified on the instrument PC, and uploaded by the File Hub.
file.source.fileUploadID, file.source.fileIDThe upload record and the stored file.
file.source.reprocesstrue when the file was already processed and the user confirmed processing it again.

Each file is processed once. A file picked with openHubFile is marked as processed when the macro finishes without errors, and it is linked to the step’s Files section (the stored file is linked, not copied). It then disappears from File Watcher sections and from the picker’s default list. If the macro fails or is terminated, or the user cancels the picker, the file stays unprocessed and can be picked again. If another user processes the same file while your macro runs, your run fails and the sheet is left unchanged. A Check run opens the picker but never marks, links or records a file.

In the step’s Files section, a linked File Hub file shows its full path on the instrument PC under its name, and an Instrument column shows the instrument and when the file was uploaded. The time it was last modified on the instrument PC is in the tooltip.

With Include processed files on, the picker also lists files that were already processed, with status Applied. Loading one asks the user to confirm processing it again. When the run succeeds, the file is linked to the step again and the confirmation is recorded in the activity log of its upload record, as a HubFileReprocessed event naming the step and experiment. The file is not marked as processed a second time. If the run fails, nothing is linked or recorded.

file.source.reprocess is true for such a file, so a macro that must only use new files can refuse it:

if (file.source.reprocess) {
throw new Error(`${file.name} was already processed.`);
}

Calling context.attachFile with a file from openHubFile links the stored file instead of uploading it again, and the file is not linked a second time when the run finishes. Like the other dialogs, openHubFile is only available in manually run macros.

Paths from a shared folder. A File Hub on a Raspberry Pi reads the instrument PC’s shared folder under a Linux path. Set Folder shown to users in the File Agent’s configuration (e.g. \\QC-PI-02\spark) so sourcePath and the picker show the path users recognize.

Attach a file to the current step — it appears in the step’s Files section. Typically you attach a file the user just picked with openFile:

await context.attachFile(
file, // an object returned by context.openFile(...)
filename?, // optional — overrides the stored file name
): { id, name } // the created file's id and name

The original bytes are uploaded as-is, regardless of which as mode you used in openFile — so you don’t need as: "blob" just to attach. The optional filename overrides the name the file is stored under.

// Let the user pick a file and attach it to this step
const file = await context.openFile();
if (file) {
const attached = await context.attachFile(file, "raw-data.csv");
await context.alert(`Attached ${attached.name}.`);
}

You can also attach content the macro generates itself, as long as the object carries the bytes in text, json, or buffer:

await context.attachFile(
{ name: "summary.txt", type: "text/plain", text: "All checks passed.\n" },
"summary.txt",
);

Attaching is only available in manually run macros, and only on steps you can edit.

Attachments persist immediately. attachFile uploads the file the moment it is called — it is not rolled back if the macro fails or is terminated later. To avoid leaving a file behind on a failed run, call attachFile near the end of your macro, after your sheet writes. If a run does fail after attaching, delete the unwanted file from the step’s Files section. Re-running a macro attaches the file again, so it can create duplicates.

// Confirm before a destructive write
if (await context.confirm("Wipe column A?", { yes: "Wipe", no: "Keep" })) {
context.activeSheet().range("A1:A1000").set(Array(1000).fill([null]));
}
// Prompt for a numeric threshold
const raw = await context.prompt("OD threshold (e.g. 0.5):", "0.5");
if (raw !== null) {
const threshold = parseFloat(raw);
// ... use threshold to filter, format, etc.
}
// Import a CSV
const file = await context.openFile({ accept: [".csv"], as: "csv" });
if (file) {
context.activeSheet().cell("A1").setBlock(file.rows);
await context.alert(`Imported ${file.rows.length} rows from ${file.name}.`);
}
// Load a plate reader export from the File Agent and record where it came from
const batch = context.getSheet("Summary").cell("B2").read();
const export1 = await context.openHubFile({
instruments: ["Tecan Spark 2"],
accept: [".csv"],
as: "csv",
message: `Select the plate 1 export for batch ${batch}.`,
});
if (export1) {
if (!export1.name.includes(batch)) {
// Throwing fails the run, so the file stays unprocessed and can be picked again
throw new Error(`${export1.name} is not from batch ${batch}.`);
}
const raw = context.getSheet("Raw OD");
raw.cell("A1").setBlock(export1.rows);
raw.cell("H1").set(export1.source.sourcePath);
}

The full mathjs function set is available except for these blocked functions: evaluate, simplify, derivative, resolve, import, createUnit, reviver. Calling any of them throws an error.

math.sum([1, 2, 3]); // 6
math.std([2, 4, 4, 4, 5, 5, 7, 9]); // 2
math.mean([1, 2, 3, 4]); // 2.5
math.unit("5 km").to("m"); // 5000 m
math.format(math.pi, { precision: 4 }); // "3.142"

Scifeon adds one function on top of mathjs: math.fit4PL, documented below.

math.fit4PL — 4PL standard-curve fitting

Section titled “math.fit4PL — 4PL standard-curve fitting”

Fits a four-parameter logistic (4PL) curve — the standard model for ELISA and other dose-response standard curves — by unweighted least squares, using the Levenberg-Marquardt algorithm as implemented in ml-levenberg-marquardt. Not part of mathjs; a Scifeon addition.

math.fit4PL(
x, // number[] — doses/concentrations, all > 0
y, // number[] — responses, paired with x by index
options?: {
fixedLower?: number; // pin the lower asymptote to a known value (e.g. 0) and fit 3 parameters
maxIterations?: number; // solver iterations per attempt, default 200
},
): IFit4PLResult

The fitted curve, on the natural-log dose scale t = ln(x):

y = lower + (upper - lower) / (1 + exp(-(slope*t + intercept)))

This is the same curve as the GraphPad/Prism form — slope equals the Hill slope, and logEC50 = -intercept / (slope * ln(10)).

PropertyMeaning
lower, upperThe asymptotes (response at zero and infinite dose)
slopeLogit slope on ln(x); equals the Hill slope
interceptLogit intercept on ln(x)
ec50Dose at the curve midpoint, exp(-intercept/slope); NaN for a flat curve
logEC50log10(ec50)
hillSlopeAlias of slope (GraphPad convention)
residualSSSum of squared residuals of the fit
iterationsSolver iterations of the winning attempt
predict(x)The fitted response at dose x; NaN for x <= 0

Replicates are passed as repeated x values — different numbers of replicates per concentration are fine. The fit is deterministic: the same input always reproduces the same parameters.

Estimation is unweighted ordinary least squares: the parameters minimize the residual sum of squares SSE = Σ (yᵢ - y(tᵢ))² over all observations. Every replicate enters as its own observation and contributes equally — no averaging per concentration, no variance weighting.

Under the hood, fit4PL owns everything between “arrays of numbers in” and “fitted parameters out”:

  1. Initial estimates come from a logit linearization of the data: provisional asymptotes just outside the observed response range, then a linear regression of the logit-transformed responses on log-dose. Deterministic — no random starting points.
  2. The solver (ml-levenberg-marquardt, MIT-licensed, pinned to an exact version that only changes through Scifeon’s release process) is run once per damping magnitude (0.001, 0.1, 10). The pinned version keeps its damping fixed within a run, so multiple attempts guard against a single run stalling.
  3. The best candidate wins: every attempt — including the untouched initial estimate — is scored by its residual sum of squares, recomputed independently of the solver, and the lowest-SSE candidate with finite parameters is returned. If none qualifies, fit4PL throws "4PL fit did not converge" rather than returning garbage.

The whole pipeline is covered by automated unit and macro-pipeline tests that run on every Scifeon platform change.

Constrained vs. free fit. fixedLower pins the lower asymptote to a known value (for example 0 for blank-corrected responses) and estimates the remaining 3 parameters; without it, all 4 are estimated. For downstream statistics: with p estimated parameters (3 or 4) and n points used, residual degrees of freedom = n - p and residual variance = residualSS / (n - p). Fitted values at any dose come from predict(x) inside the macro, or from the closed-form curve formula in sheet formulas.

fit4PL throws an Error (with a descriptive message) instead of returning a bad fit when the data cannot support one: mismatched array lengths, non-finite values, doses ≤ 0, fewer than 4 points (3 with fixedLower), fewer than 2 distinct doses, or constant responses. Filter out excluded values (empty cells, "N/A", …) before calling:

// ELISA standard curve: 8 two-fold dilutions x 4 replicates in C4:F11,
// starting concentration in B3. Empty or non-numeric cells are excluded.
const sheet = context.getSheet("Standard curve");
const startConc = Number(sheet.cell("B3").read());
const plate = sheet.range("C4:F11").read();
const xs = [], ys = [];
for (let row = 0; row < plate.length; row++) {
for (const value of plate[row]) {
const num = Number(value);
if (value === null || String(value).trim() === "" || !Number.isFinite(num)) continue;
xs.push(startConc / 2 ** row);
ys.push(num);
}
}
const fit = math.fit4PL(xs, ys, { fixedLower: 0 });
sheet.cell("I15").set(fit.ec50);
sheet.cell("I16").set(fit.slope);

const sheet = context.activeSheet();
const values = sheet.range("A2:A100").read().flat().filter(v => typeof v === "number");
sheet.cell("A1").set(math.sum(values));
context.activeSheet().cell("D2").set("=SUM(A2:C2)");
const sheet = context.getSheet("QC");
const data = sheet.range("B2:B100").read();
for (let i = 0; i < data.length; i++) {
const v = data[i][0];
if (typeof v !== "number") continue;
const color = v < 0.5 ? "#fdd" : v > 1.5 ? "#dfd" : "#fff";
sheet.cell({ x: 1, y: i + 1 }).setStyle({ background: color });
}
const source = context.getSheet("Master");
const target = context.getSheet("Today");
target.range("A1:Z1").set(source.range("A1:Z1").read());
const rows = [["Sheet", "Rows"]];
for (const s of context.getSheets()) {
rows.push([s.name, s.rows.length]);
}
context.activeSheet().cell("A1").setBlock(rows);

When a macro doesn’t do what you expect, it helps to pause and look at a value mid-run.

Add a debugger; statement, open your browser’s developer tools (F12), then click Run. Execution pauses on that line so you can inspect variables and step through the code.

const values = context.activeSheet().range("A2:A100").read().flat();
debugger; // pauses here while dev tools are open
const numbers = values.filter(v => typeof v === "number");
context.activeSheet().cell("A1").set(math.sum(numbers));

With dev tools closed, debugger does nothing and the macro runs straight through. The 10-second timeout keeps counting while you are paused, so use it for a quick look — pausing too long terminates the run.

To check a value without opening dev tools, show it in a dialog. The timeout is suspended while the dialog is open:

const values = context.activeSheet().range("A2:A100").read().flat();
await context.alert(`Read ${values.length} cells: ${JSON.stringify(values.slice(0, 10))}`);

Macros run in an isolated Web Worker.

  • No DOM or network access. document, fetch, XMLHttpRequest, WebSocket, and WebAssembly are not available. Trying to use them throws an error.
  • No persistent state. Each run gets a fresh environment — nothing carries over between runs.
  • Hard 10-second timeout. Runaway macros (infinite loops, slow computation) are forcibly terminated. The app never freezes.
  • Op cap. A macro that generates more than 1,000,000 cell writes is aborted with a clear error.

Error messageCause
Worksheet "Foo" not found.getSheet("Foo") found no match. Check spelling; use getSheets() to list every available sheet.
Worksheet index 3 is out of range (workbook has 2 worksheet(s)).getSheet(3) points past the last tab. Indexes are 1-based.
Worksheet index must be a positive integer (1-based), got 0.getSheet(0) or a fractional/negative index. The first tab is getSheet(1).
No active worksheet available.activeSheet() was called before the spreadsheet finished loading. Rare.
Range A1:B2 expects 2 rows, got 1.The 2D array passed to range.set() doesn’t match the range dimensions.
Function evaluate is disabledOne of the blocked mathjs functions was called. Use direct functions instead (math.sum, math.mean, etc.).
Macro exceeded 10s and was terminated.Hit the hard timeout. Reduce work or split into multiple macros.
Macro produced too many operations (>1000000).Hit the op cap. You are probably looping over more cells than intended.
fetch is not a functionNetwork access is not available in macros.
A macro is already running.Another macro is still executing. Wait for it to finish before starting a new one.