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.
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.
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 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 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.
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 => typeofv === "number");
summary.cell("A1").set("Mean OD");
summary.cell("B1").set(math.mean(od));
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.
Method
Description
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.
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:
Property
Meaning
file.source.sourcePath
Full 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.hostname
The computer the File Hub runs on.
file.source.relativePath
Path relative to the File Agent’s folder, /-separated.
file.source.folder
Folder part of relativePath, empty for files at the root.
SHA-256 of the content, verified when the File Hub uploaded the file.
file.source.modifiedAt, file.source.uploadedAt
ISO timestamps in UTC: last modified on the instrument PC, and uploaded by the File Hub.
file.source.fileUploadID, file.source.fileID
The upload record and the stored file.
file.source.reprocess
true 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) {
thrownewError(`${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
You can also attach content the macro generates itself, as long as the object carries the bytes in text, json, or buffer:
awaitcontext.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.
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.
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):
This is the same curve as the GraphPad/Prism form — slope equals the Hill slope, and logEC50 = -intercept / (slope * ln(10)).
Property
Meaning
lower, upper
The asymptotes (response at zero and infinite dose)
slope
Logit slope on ln(x); equals the Hill slope
intercept
Logit intercept on ln(x)
ec50
Dose at the curve midpoint, exp(-intercept/slope); NaN for a flat curve
logEC50
log10(ec50)
hillSlope
Alias of slope (GraphPad convention)
residualSS
Sum of squared residuals of the fit
iterations
Solver 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”:
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.
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.
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.
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.
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.