Writing custom workflows¶
A custom workflow is a Python file twopy can load into the Custom tab. It runs against the active recording’s converted data and current ROIs, and it can write files, return tables, draw line plots, replace the ROI set, or update the response-plot visibility.
If you only want to use the Custom tab, see Running custom workflows.
A reference example showing every supported parameter type and result type lives at examples/custom_workflows/reference_showcase.py.
Pointing twopy at your workflow folder¶
Add the folders to your private config.yml:
custom_workflow_paths:
- ~/git/twopy-workflows
- /Volumes/magic/clarklab/shared/twopy_workflows
Each folder is scanned for top-level .py files. Helper modules can sit beside workflow files; prefix helper filenames with _ to keep them out of the dropdown. Invalid workflows are listed in the Custom tab status text and excluded from the dropdown.
The minimum a workflow needs¶
Every workflow imports its building blocks from twopy.custom and provides a @workflow(...) decorated function plus a frozen params dataclass. Do not import from twopy.analysis, twopy.napari, twopy.stimulus, or twopy.roi — go through CustomRunContext instead so workflows keep working when twopy internals move.
from dataclasses import dataclass, field
import numpy as np
from twopy.custom import CustomResult, CustomRunContext, CustomTable, workflow
@dataclass(frozen=True)
class DirectionSelectivityParams:
preferred_epoch: str = field(default="right", metadata={"label": "Preferred epoch", "twopy_role": "epoch"})
null_epoch: str = field(default="left", metadata={"label": "Null epoch", "twopy_role": "epoch"})
metric: str = field(default="peak", metadata={"label": "Metric", "twopy_role": "response_metric"})
window_start_seconds: float = field(default=0.0, metadata={"label": "Window start (s)", "twopy_role": "epoch_window_start"})
window_stop_seconds: float = field(default=3.0, metadata={"label": "Window end (s)", "twopy_role": "epoch_window_stop"})
@workflow(
id="direction-selectivity",
name="Direction selectivity",
version="1.0",
description="Computes a direction-selectivity index for each current ROI.",
params=DirectionSelectivityParams,
)
def run(ctx: CustomRunContext, params: DirectionSelectivityParams) -> CustomResult:
rois = ctx.current_rois()
computation = ctx.compute_standard_responses(rois)
window = ctx.epoch_window(params.window_start_seconds, params.window_stop_seconds)
preferred = ctx.epoch_metric(
computation.grouped_responses, params.preferred_epoch, params.metric, window_seconds=window,
)
null = ctx.epoch_metric(
computation.grouped_responses, params.null_epoch, params.metric, window_seconds=window,
)
dsi = np.divide(
preferred - null,
preferred + null,
out=np.full_like(preferred, np.nan),
where=np.abs(preferred + null) > 1e-12,
)
csv_path = ctx.output_path("direction_selectivity.csv")
ctx.write_roi_table(csv_path, {"preferred": preferred, "null": null, "dsi": dsi})
return CustomResult(
message=f"Computed DSI for {len(rois.labels)} ROIs.",
tables=(CustomTable("DSI", csv_path),),
)
Required at the decorator: id, name, version, description, params. Required on the function: an exact CustomRunContext first argument, the params dataclass as second, and CustomResult as the return type. Versions must use X.Y (1.0, 2.3, …).
twopy rejects workflows that are missing any of those, that use a non-frozen params dataclass, that have parameters without defaults, or that use unsupported parameter types or unknown roles.
CustomRunContext¶
CustomRunContext is the public API for workflow code. Use it instead of importing twopy internals.
ROIs and responses:
ctx.current_rois()— current ROI masks, or a clear error if none.ctx.rois_for_selector(selector)— pass the value of aroi_selectorparameter ("all_rois"or"visible_rois").ctx.roi_indices_for_selector(selector)— matching zero-based ROI rows from the full set, useful when a workflow computes on a subset but needs to update the existing Plot-tab visibility without replacing plot data.ctx.compute_standard_responses(rois)— runs the same dF/F and trial grouping the Plot tab uses.
Recording metadata:
ctx.recording_metadata()— a stable metadata snapshot. Read fields withmetadata.text("run", "rig_name", default=""),metadata.float("acquisition", "acq.zoomFactor"),metadata.int("run", "run_number"), or raw mappings such asmetadata.runandmetadata.stimulus_parameters.
Epochs:
ctx.epoch_names()—{epoch_number: epoch_name}for the loaded recording.ctx.epoch_choices()—CustomEpochobjects withnumber,name,label,selector, andduration_seconds.ctx.epoch_durations_seconds()— shortest observed duration per epoch number.ctx.min_epoch_duration_seconds()— shortest valid epoch duration in the recording.ctx.epoch_window(start, stop)— validates an epoch-relative metric window before passing it to a response helper.ctx.response_window(start, stop)— validates a response-plot-relative window. Negative starts are allowed because response plots can include pre-epoch baseline.ctx.epoch_metric(grouped, epoch, metric, window_seconds=(start, stop))— mean, peak, or minimum per ROI over one epoch and optional epoch-relative window.epochaccepts aCustomEpoch, an epoch number, an epoch name, or a GUI dropdown label such as2: Odor.
Plots:
ctx.response_plot_data(grouped, source_path=..., max_rois=..., roi_indices=...)— build response plot data forCustomResult.response_plot_data.ctx.roi_colors_for_labels(labels)—#RRGGBBcolors matching current ROI plot colors. The Custom tab auto-colorsCustomLinePlotoutputs whose labels exactly match current ROI labels whencolorsis omitted.
Outputs:
ctx.output_path("name.csv")— build a path below the workflow output folder. Always use this instead of building paths by hand.ctx.write_roi_table(path, {...})— write a per-ROI table CSV and attach workflow metadata.ctx.write_matrix_csv(path, matrix, row_labels=..., column_labels=...)— write a matrix CSV. Passcolumn_labelswhen columns have scientific coordinates like lag seconds.
Helpers from twopy.custom:
finite_mean_and_sem(values, axis=...)— same finite-sample mean / sample-SEM convention used by twopy’s response plots and CSV exports.
Parameters and their controls¶
The Custom tab renders dataclass fields as form controls. Supported field types are bool, int, float, str, Path, Literal[...], and Enum. Field metadata can set label, description, min, max, step, and decimals.
twopy_role declares standard controls and validation. Unknown roles reject the workflow before it appears in the GUI; mismatched role/type pairs also reject (e.g. twopy_role="epoch" on a float field).
|
Field type |
GUI behavior |
|---|---|---|
|
|
Loaded-recording epoch dropdown, defaults to the same gray/grey/interleave baseline the dF/F controls use. |
|
|
Loaded-recording epoch dropdown for a comparison epoch. |
|
|
Loaded-recording epoch dropdown. Passes the selected epoch label back to the workflow. |
|
|
Epoch-relative start time, min 0, step 0.1s, three decimals, recording-based max when timing is available. |
|
|
Epoch-relative stop time, min 0, step 0.1s, three decimals, recording-based max when timing is available. Workflow default is preserved. |
|
|
Marks a relative workflow output filename. Use with |
|
|
|
|
|
Response-plot start time. Min = current pre-epoch window. Step 0.1s, three decimals. |
|
|
Response-plot stop time, recording-based max when timing is available. Step 0.1s, three decimals. |
|
|
Positive integer for caps such as maximum plotted ROIs. |
|
|
Dropdown with |
|
|
Dropdown of converted stimulus columns (excluding clock and epoch columns when other columns exist). |
|
|
Non-negative threshold control with 0.05 step and three displayed decimals. Use the value with |
Roles are tied to declared metadata, not field names. A field named start, window_start_seconds, or foo gets the standard control when its metadata declares the role.
What the workflow returns¶
CustomResult can include:
message— short status string.files=(path, ...)— non-HDF5 files to attach metadata to.tables=(CustomTable(name, path, highlighted_rows=(...)), ...)— CSV/TSV tables previewed in the Custom tab.highlighted_rowsmarks zero-based data rows.plots=(CustomLinePlot(name, x, y, labels, y_label=..., colors=..., bands=(CustomLineBand(...),)), ...)— line plots.colorsaccepts#RRGGBBstrings;CustomLineBanddraws filled uncertainty bands behind a series.rois=RoiSet(...)— replace the current Labels layer ROI set.visible_roi_indices=(...)— change the Plot-tab visibility without replacing the plot data.response_plot_data=ctx.response_plot_data(...)— replacement response plot data.
All file and table outputs must live below ctx.output_dir; using ctx.output_path("name.csv") enforces this.
Provenance¶
twopy records what ran:
For CSV, PDF, PNG, and other non-HDF5 outputs, twopy writes a sidecar like
direction_selectivity.twopy-workflow.yml.For HDF5 outputs, twopy writes a
twopy_workflowmetadata group inside the file.
Both record the workflow id, name, version, source file path, source hash, twopy version, run time, parameters, and recording path. Files written via ctx.write_roi_table() and ctx.write_matrix_csv() get metadata automatically; files returned in CustomResult.files or CustomResult.tables get metadata after the workflow returns.
With analysis caching on, custom outputs and their metadata sync to analysis_output through the same path Save ROIs + analysis uses.
A local plotting workflow¶
Keep experimental or long-running code inside your workflow file (or imported lab modules). Keep what you hand back to twopy small and explicit.
import numpy as np
from twopy.custom import (
CustomLineBand,
CustomLinePlot,
CustomResult,
CustomRunContext,
finite_mean_and_sem,
workflow,
)
@workflow(
id="response-kernels",
name="Response kernels",
version="0.1",
description="Fits and plots one response kernel per ROI.",
params=KernelParams,
)
def run(ctx: CustomRunContext, params: KernelParams) -> CustomResult:
rois = ctx.current_rois()
computation = ctx.compute_standard_responses(rois)
kernels = fit_kernels(computation.grouped_responses, params)
csv_path = ctx.output_path("kernels.csv")
ctx.write_matrix_csv(csv_path, kernels.values, row_labels=rois.labels)
mean, sem = finite_mean_and_sem(kernels.values, axis=0)
return CustomResult(
message=f"Fit kernels for {len(rois.labels)} ROIs.",
plots=(
CustomLinePlot(
"Mean +/- SEM kernels",
kernels.time_seconds,
mean,
("mean",),
y_label="Weight",
bands=(
CustomLineBand(series_index=0, lower=mean - sem, upper=mean + sem, label="SEM"),
),
),
),
files=(csv_path,),
)