Contract: a Python analysis step
An analysis turns time series into something a tab can draw. There are two places to put one, and choosing correctly is most of this document.
| Put it in | Because | |
|---|---|---|
| Every study could use it | packages/dims-analysis/dims_analysis/steps/ |
one copy, one fix, reaches every study by a version bump |
| Only this study needs it | the study's own opt/step_<id>.py |
its input is that study's own upstream pipeline |
Most analyses are the second kind, and both shipped examples are: ORTHO's categorical gaze RQA reads a game database, and Karnatak's motion tracking reads video. Neither belongs in a package every study installs.
A study-owned analysis
A plain script in opt/, named step_<id>.py, run by build_assets.py and
gated by "include_<id>": true in config.json. It is handed
--config config.json and nothing else; it decides where its own output goes.
#!/usr/bin/env python3
"""What this produces, and what it reads to produce it."""
import argparse, json
from dims_analysis.common import assets, results, series
ap = argparse.ArgumentParser()
ap.add_argument('--config', default='config.json')
args = ap.parse_args()
config = json.load(open(args.config))
for video_id in config['videoIDs']:
path = assets.resolve(f'assets/timeseries/{video_id}_gaze.csv')
loaded = series.load_or_none(path, min_points=10)
if loaded is None: # already explained why, on stdout
continue
time, values = loaded
...
results.write_payload(assets.resolve(f'assets/rqa/{video_id}_rqa_data.json'),
{'video_id': video_id, 'categorical_rqa': ...})
Three things this example is doing on purpose:
assets.resolveon every path. A private study keeps its data outside the repository, at the pathdata.local.jsonnames. A script that skips this works for its author and for nobody else.results.write_payloadrather thanjson.dump. It merges into whatever is already in that file, so an analysis writing beside a shared one does not erase it. ORTHO's categorical RQA writes into the sameassets/rqa/<id>_rqa_data.jsonthe shared RQA step writes; before merging existed, whichever ran second won.- No
--output-dir.build_assets.pydeliberately does not pass one to a study-owned step, because a step that writes into an existing directory on purpose must not have that overridden.
A shared step
A class in packages/dims-analysis/dims_analysis/steps/, found through an
entry point. The runner never lists steps, so adding one edits no existing
file.
from dims_analysis.base import Step
class RQAStep(Step):
id = "rqa" # also the CLI selector
config_key = "include_RQA" # gate key in config.json
output_dir = "assets/rqa" # default; resolved through data.local.json
output_name = "{video_id}_rqa_data.json"
description = "Recurrence quantification, per video"
def run(self, config, ctx) -> None:
for video_id in config["videoIDs"]:
...
ctx.write_result(self, video_id, {"rqa_data": ...})
[project.entry-points."dims.steps"]
rqa = "dims_analysis.steps.rqa:RQAStep"
A step in a separate package registers the same way — that is how an outside module joins the pipeline without a change here.
What ctx is for
ctx is paths and results. It is not a data-access layer, and does not
wrap the analysis helpers; those are module functions you import directly.
Every method takes the step as its first argument, because one context serves
every step in a run.
ctx.project_dir |
absolute path to the study |
ctx.path(*parts) |
a path inside the study |
ctx.output_dir_for(step) |
where this step's output belongs, resolved through data.local.json; no side effects |
ctx.output_path(step, video_id) |
the full output filename, creating the directory |
ctx.write_result(step, video_id, payload) |
writes it, merging with anything already in that file |
ctx.params(step, defaults) |
per-step tuning from config.json's analysis block, over your defaults |
ctx.output_snapshot(step) |
{path: mtime}, which the runner uses to detect a step that wrote nothing |
The helpers, which are not on ctx
Import these from dims_analysis.common. They exist because each was
previously copied into ten or twelve places, in signatures that disagreed.
| module | what it settles |
|---|---|
assets |
where the data actually is: resolve(path), assets_root() |
series |
one reader: canonical Time column, NaNs dropped, sorted by time, a caller-stated minimum length. load_or_none reports and returns None |
recurrence |
one recurrence rule for RQA and cross-RQA: threshold_for_target, recurrence_rate, line_lengths, window_metrics |
reduce |
reducing for the browser without lying: block_mean for series, block_binary for a recurrence matrix |
results |
write_payload, which merges; compare_entries, which says what a re-run replaced |
arrays |
pack_bitmap / pack_f32, for anything too large to be a readable JSON list |
payload |
round_payload: browser payloads carry significant figures, not decimal places |
Rules
- Self-gate. If your key is absent, do nothing and exit cleanly. Do not raise, and do not make the runner know about you.
- Never hardcode a path. Everything through
assets.resolveorctx. Two absorbed scripts hardcoded./config.jsonand their own input directory; they could only run from one working directory, and on a private study they read a directory that did not exist while printing "assets resolved" and reporting success. - Tuning belongs in config, via
ctx.paramsor a documented key. A module-level constant cannot vary per study without editing the source, which is exactly how a fork ends up maintaining its own copy of an analysis. - Write both resolutions. The JSON is the browser payload and may be
reduced;
_full.jsonbeside it, where one exists, is the real result at the resolution it was computed at. Record the reduction factor in the payload — a reader who cannot tell a 500-point plot from a 6000-point one does not know what the axis means. Never let a reduction be the only surviving analysis. - Reduce honestly. A recurrence matrix has two properties a reader takes
from the picture: where the structure is, and how much of the plot is
recurrent. Striding keeps the rate and deletes any line off the main
diagonal — which is what a lagged coupling looks like. Block-OR keeps the
structure and inflates the rate; measured on ORTHO at factor 9, 10.9% became
54.4% beside a caption saying 10.9%. Use
reduce.block_binary, which keeps both. - Fail loudly. Raise on real errors. The runner checks that an enabled step wrote something, because for a long time a crashed analysis scrolled past in the log and the build reported success.
- Be deterministic. Seed anything stochastic and put the seed in the output. An unseeded Monte Carlo made one significance threshold vary by 0.04 between runs on identical data.
Acceptance
Run these. Do not reason about them.
- A shared step:
dims-analysis run --config config.jsonfinds it with no other file edited.dims-analysis listshows it. - A study-owned step:
python build_assets.py --checklists it under study-owned, and lists it as not enabled when its config key is absent. - With the config key absent, the run succeeds and writes nothing.
- Running twice over identical input produces byte-identical output.
- A deliberate
raiseinside the step makes the command exit non-zero. - On a study with
data.local.json, the step reads and writes under the external root, andgit statusis clean afterwards.
Generated from
docs/contracts/step.md
at v1.0.1. Edit it there, not here.