The .tether project store
This page is for anyone who has been handed a .tether file and wants to read it —
with plain h5py, in another language, or in a script that must keep working across
Tether releases. It is the reference for the internal project store only. What
Tether writes out (SMD-HDF5 hand-off, CSV/Parquet tables, plot exports) is a
separate surface, described in Exports (files, columns, units).
A .tether file is an ordinary HDF5 file. There is no proprietary container, no
sidecar index, and no compression scheme beyond stock gzip on the bulk arrays.
The format has two layers, and they carry different guarantees:
- The frozen skeleton — the 12 group paths (the root plus the 11 top-level
groups), the four compound
tabledtypes and the three root attributes — is declared in exactly one place,src/tether/io/schema.py, and a machine-checked copy of it is committed asschema/schema_frozen.json. That manifest is generated byscripts/dump_schema.py, which runstether.io.schema.build_manifestagainst a throwawaycreate_projectstore, so it cannot drift from the code. Where this page and that manifest disagree about the skeleton, the manifest is right and this page is a bug. - Everything a pipeline stage writes later —
/traces/*,/patches/*,/settings/*,/idealization/{model},/features/table,/conditions/categories,/calibration/*— is additive per-record data declared by the module that writes it. None of it appears in the manifest, so the manifest cannot arbitrate it: the writer named in each section below is the authority. Note the/*: the container groups these live in are themselves part of the frozen skeleton above, and only their contents are additive.
Every claim below names the module, constant, or writer behind it.
Opening a store
import h5py
with h5py.File("experiment.tether", "r") as f:
assert f.attrs["format"] == "tether-project"
print(int(f.attrs["schema_version"])) # 1
mols = f["/molecules/table"][:] # a structured (compound) array
print(mols["molecule_key"][0].decode("utf-8")) # vlen UTF-8 reads back as bytes
Tether's own validation entry point is
tether.io.schema.assert_is_compatible_project(path), which checks, in this order:
the file is readable HDF5; the root format attribute is tether-project; a
schema_version stamp exists; the frozen top-level skeleton is present (so a foreign
or truncated HDF5 file is rejected rather than silently accepted); and that version
is not newer than the running app. It returns the on-disk schema_version. The order
matters when you are diagnosing a rejection: a stamp-less file fails on the stamp
before the skeleton is ever inspected.
String fields are HDF5 variable-length UTF-8
(h5py.string_dtype(encoding="utf-8"), the _str() helper in
src/tether/io/schema.py). h5py hands them back as bytes — decode them yourself.
What is frozen, and what that means for your reader
The entire group skeleton was forward-declared and version-stamped at milestone M0 and is additive-only thereafter (ADR-0005). Concretely, the freeze covers:
- the 12 group paths listed on this page;
- the four compound
tabledatasets, including field order — a compound dtype's on-disk layout is positional, so new fields may only be appended (_diff_compoundinsrc/tether/io/schema.pyrequires the golden field sequence to remain an exact prefix of the current one); - each field's dtype and sub-array shape;
- each table's
maxshape(all four are(None,)— 1-D and resizable); - the presence and type of the three root attributes, plus the values of
formatandschema_version.
Those rules are enforced mechanically, not by review: the schema-guard CI job
(.github/workflows/schema-guard.yml) runs python scripts/dump_schema.py --check,
which fails a removal, rename, reorder or retype and passes an added group, dataset,
attribute or trailing table field. The release policy behind them is not
restated here:
what each release may change sets out
which release types may touch what, and
the .tether on-disk format lists which
parts of this page are a promise rather than a description.
What that means if you write your own reader:
Tolerate what you do not recognise. Read compound tables by field name, never by positional index or a hard-coded record size, and ignore fields, datasets, groups and attributes you do not know — a later Tether release may add them without a
schema_versionbump. Conversely, you may rely on the frozen skeleton above — the group paths, the four tables' field names, dtypes and order, and the three root attributes (app_versiononly in a file whose writer stamped it — see the root-group section below) — continuing to exist unchanged for as long asschema_versionstays1. That is exactly the setschema-guardmechanically enforces against the writer.
The additive payloads described further down (/traces/*, /patches/*,
/settings/*, /idealization/{model}, /features/table, /calibration/*) are
documented convention, not part of the freeze. Their container groups are not:
/traces, /patches, /settings, /idealization, /features, /calibration and
/models are all in the golden's groups list and in _CONTAINER_GROUPS
(src/tether/io/schema.py), so a file missing any one of them is rejected by
assert_is_compatible_project as not a project — a required group your reader may
rely on, even when it is empty. Only what is inside them is unarbitrated:
build_manifest introspects a freshly created, empty store, so none of those child
names is in the golden and diff_manifest never compares them; renaming or retyping
one would pass schema-guard with schema_version untouched. They have been additive in practice, and /features/table
carries its own independent feature_schema_version precisely so its columns can
change without touching the store's schema_version. Pin your reader to the writer
module named in each section, and treat a missing or unfamiliar payload as normal.
schema_version is currently 1 (SCHEMA_VERSION, src/tether/io/schema.py)
and has never been bumped. What a bump would mean for a file you already hold, and
what a non-additive change has to carry before one is allowed, are set out under
Project file compatibility; how a
covered name is retired is the
deprecation policy on the same page.
Two properties worth knowing before you build on the format:
- The store is a superset of SMD, the tMAVEN interchange model (ADR-0002): every SMD concept has a home here, plus the coordinates, patches, corrections and provenance SMD has no slot for. Round-trip fidelity is therefore a property of the data model, not of a converter.
- The
<file>.locksingle-writer marker is a sidecar file, not part of the in-HDF5 schema (src/tether/io/schema.pymodule docstring);create_projectnever writes it.
A freshly created project is nearly empty
tether.io.schema.create_project(path) writes the whole skeleton and nothing else:
11 top-level groups, four zero-row compound tables, and three root attributes.
Everything else on this page is data written later by a specific pipeline stage.
(Do not treat the file's size as a format property — the same skeleton measures
12,950 bytes under HDF5 2.0.0 and 13,164 under 1.14.x. Only the structure is fixed.)
The /traces and /patches groups always exist — they are part of the validated
skeleton (_CONTAINER_GROUPS), and a file missing one is rejected as not a project.
What a reader must not assume is that anything is inside them: no /traces/* or
/patches/* dataset, no /features/table, and no /idealization/{model} is
guaranteed to be there.
| Path | In a fresh store | First written by |
|---|---|---|
/movies/table, /molecules/table, /conditions/table, /labels/table |
yes, 0 rows | create_project (src/tether/io/schema.py) |
/calibration, /traces, /patches, /idealization, /settings, /features, /models |
yes, empty groups | create_project |
/calibration/<calibration_id>/… |
no | write_calibration (src/tether/imaging/calibrate.py) |
/movies and /molecules rows, /traces/*, /patches/*, /settings/extraction |
no rows / absent | write_extraction (src/tether/imaging/extract.py) |
/idealization/{model} |
no | write_idealization_model (src/tether/project/idealize.py) |
/labels rows |
0 rows | set_curation_label (src/tether/project/labels.py) |
/settings/leakage, /settings/gamma, /settings/correction, /settings/batch |
no | tether.project.leakage / .gamma / .correct / .batch |
/conditions rows, /conditions/categories/<condition_id>, /settings/condition_audit |
0 rows / absent | tether.project.conditions |
/features/table |
no | compute_features (src/tether/project/features.py) |
/settings/analysis_only |
no | import_analysis_only_project (src/tether/project/analysis_import.py) |
/models/* |
no | nothing yet — see below |
write_extraction is not the only writer of /molecules rows and /traces. Four
shipped provenances produce a populated store, and they differ in what they fill in.
The rest of this page describes the native-extraction case unless it says otherwise:
| Store provenance | Writer | What differs |
|---|---|---|
| Native extraction | write_extraction (src/tether/imaging/extract.py) |
The fullest record: /movies rows, coordinates, all six /traces layers, /patches — but only for a movie that yielded at least one molecule; see /traces for the zero-molecule case. |
| Deep-LASI reconstruction | reconstruct_project (src/tether/project/reconstruct.py) |
Also goes through write_extraction, so the /movies, /molecules, /traces and /patches layout is the same — but the trace values are the legacy .mat series copied verbatim, not an integration this build performed, and no calibration is written: reconstruct_project never calls write_calibration, so /calibration stays empty whatever the caller passes. The shipped GUI caller — the wizard is the only caller; no tether subcommand reconstructs — additionally leaves the calibration key and channel geometry unset (_movie_metadata, src/tether/gui/deeplasi_executor.py), so in practice /movies.calibration_id is "" and the crop/rotation/flip fields are all zero. |
| Analysis-only import | import_analysis_only_project (src/tether/project/analysis_import.py) |
No /movies rows, movie_id = "", donor_xy/acceptor_xy NaN, /patches empty, only the two corrected /traces layers. |
| Subset export | export_subset_tether (src/tether/project/export.py) |
A new store: /molecules rows copied verbatim (so movie_id survives as a dangling key), no /movies rows, corrected /traces only unless include_raw=True. |
create_project opens the file with track_order=True and creates every skeleton
group with it, and the later /settings/*, /calibration/<id> and
/conditions/categories writers do the same, so those links are stored in creation
order. The promise is not store-wide: the per-model /idealization/<name> groups
and the priors subgroup inside them are created without track_order
(write_idealization_model, src/tether/project/idealize.py; the copy in
_copy_idealization, src/tether/project/export.py), so their members fall back to
HDF5's default ordering. Either way, h5py.Group.visit iterates alphabetically.
Type notation used below
The Type column uses the same canonical names as schema/schema_frozen.json, which
deliberately drops byte order so the manifest is platform-independent
(_canonical_scalar): i1, i4, i8 are signed integers of 1/4/8 bytes, f8 is a
64-bit float, and str:utf-8 is a variable-length UTF-8 string. The dtypes actually
declared in src/tether/io/schema.py are the little-endian spellings <i4, <i8,
<f8 (and i1, which has no byte order).
/ — the root group
Three attributes. create_project always writes format and schema_version; the
third is stamped by default but optional (see below):
| Attribute | Type | Value | Frozen |
|---|---|---|---|
format |
str |
tether-project (FORMAT_TAG) |
name, type and value |
schema_version |
int |
1 (SCHEMA_VERSION) |
name, type and value, monotonic |
app_version |
str |
the writing Tether version, e.g. 0.8.1.dev16+g… |
name and type only |
app_version is provenance, not structure, and what is frozen is the writer's
declaration rather than every file: create_project(path, stamp_app_version=False)
omits the attribute and assert_is_compatible_project never looks for it, so a store
without it is valid. Read it as f.attrs.get("app_version") and never reject a file
for its absence. The policy — including why its value is excluded from the manifest
— is
the .tether on-disk format section of
the stability page.
Subset exports stamp four additional root attributes onto the destination file —
tether_subset_of, tether_subset_created_utc, tether_subset_include_raw,
tether_subset_n_molecules (src/tether/project/export.py) — a good example of the
additive rule in practice: your reader should expect unknown root attributes.
/movies — one row per source movie
Group holding /movies/table, a resizable 1-D compound dataset (MOVIES_DTYPE,
20 fields). Rows are appended by write_extraction; re-appending an existing
movie_id is refused. file_size + mtime + offline_flag are the
metadata-only fast signature: a relocation/staleness check that reads zero bytes
of the movie and therefore never hydrates a cloud placeholder.
| Field | Type | Shape | Meaning / units |
|---|---|---|---|
movie_id |
str:utf-8 |
scalar | Movie identity within this store; the CLI writes mov-<uuid4> (src/tether/project/extract.py). |
uri |
str:utf-8 |
scalar | Source movie path as text; "" when unknown. |
sha256 |
str:utf-8 |
scalar | 64-char hex SHA-256 of the whole movie file (_hash_movie); seeds every molecule_key. |
file_size |
i8 |
scalar | Path.stat().st_size, bytes. |
mtime |
f8 |
scalar | Path.stat().st_mtime, POSIX epoch seconds. |
offline_flag |
i1 |
scalar | Cloud-placeholder / offline marker. Reserved: every shipped writer leaves it 0. |
n_frames |
i4 |
scalar | Frame count of the movie (validated >= 1). |
height |
i4 |
scalar | Full-frame height, pixels. |
width |
i4 |
scalar | Full-frame width, pixels — the whole frame, before the two-channel split. |
pixel_dtype |
str:utf-8 |
scalar | On-disk pixel dtype string, e.g. >u2 (src/tether/io/movie.py). |
byteorder |
str:utf-8 |
scalar | > (big-endian) or < (little-endian). |
frame_time |
f8 |
scalar | Seconds per frame. The writer stores whatever the caller's MovieMetadata holds (default 0.0), and both shipped writers — tether.project.extract and the Deep-LASI reconstruction's _movie_metadata (src/tether/gui/deeplasi_executor.py) — take it from the movie TIFF's ImageJ finterval tag, and only when that is finite and positive (_read_frame_time, src/tether/io/movie.py); 0.0 otherwise. A Deep-LASI reconstruction is no exception: it builds this row from the raw TIFF as well, and the shipped .tdat/.mat decode reads no FrameTime, so a reconstructed store does not preserve a Deep-LASI timebase. |
head_tail_hash |
str:utf-8 |
scalar | Reserved partial-content digest for the fast signature; no shipped writer sets it (stays ""). |
calibration_id |
str:utf-8 |
scalar | Join key into /calibration. |
donor_crop |
i4 |
(4,) |
[y1, x1, y2, x2], 1-based inclusive pixel bounds (src/tether/imaging/split.py). All-zero means full frame / unspecified. |
acceptor_crop |
i4 |
(4,) |
Same convention, acceptor channel. |
donor_rotation_deg |
i4 |
scalar | Channel rotation in degrees, one of {0, 90, 180, 270}. |
acceptor_rotation_deg |
i4 |
scalar | Same, acceptor channel. |
donor_flip |
i1 |
(2,) |
[vertical, horizontal] flip flags, 0/1. |
acceptor_flip |
i1 |
(2,) |
Same, acceptor channel. |
/molecules — one row per molecule
Group holding /molecules/table (MOLECULES_DTYPE, 21 fields). This is the spine
of the store: row i of /molecules/table, of every /traces/* array and of
every /patches/* array is the same molecule — the join is positional, asserted
by _validate_alignment in src/tether/imaging/extract.py.
Later stages mutate columns of existing rows; they never change the structure.
Photobleach detection rewrites bleach_frames and analysis_window, the leakage /
gamma / correction stages write alpha, gamma, correction_method and
correction_confidence, curation writes curation_label, and a condition re-key
rewrites condition_id.
| Field | Type | Shape | Meaning / units |
|---|---|---|---|
molecule_id |
str:utf-8 |
scalar | Stable per-row identity, mol-<uuid4 hex>. Unique, so this is the correct join key for per-row data. |
molecule_key |
str:utf-8 |
scalar | Cross-file content identity, 64-char hex SHA-256. Deterministic across runs and platforms. Two derivations exist (see below); the extraction one is not guaranteed unique. |
movie_id |
str:utf-8 |
scalar | Foreign key into /movies/table. "" in an analysis-only import, and dangling in a subset export (which carries no /movies rows) — resolve it defensively. |
donor_xy |
f8 |
(2,) |
Sub-pixel [x, y] donor (reference-channel) centroid, in movie pixels of the donor sub-image. |
acceptor_xy |
f8 |
(2,) |
Sub-pixel [x, y] acceptor read position, donor-anchored through the registration map, in movie pixels. |
aperture_id |
i4 |
scalar | Aperture-geometry id. Always 0 today (the standard window); no per-aperture registry exists yet. |
frame_range |
i4 |
(2,) |
[start, stop) frame indices of the molecule's valid native extent inside the zero-padded trace arrays. |
analysis_window |
i4 |
(2,) |
[start, stop) frame indices actually analysed. frame_range at native extraction, but an SMD import seeds it from the source's window (see the note below the table); auto-refined by photobleach detection only while it still equals frame_range, so an already-narrowed window wins. |
bleach_frames |
i4 |
(2,) |
(donor, acceptor) first-bleach absolute frame indices. -1 is the pre-detection sentinel (see below), not "no bleach". |
alpha |
f8 |
scalar | Donor leakage α, dimensionless. NaN = no factor applied, which is not the same as "the pass has not run": compute_leakage_alpha withholds the factor — resetting processed α rows and their downstream γ rows to NaN — when fewer than min_qualifying_traces molecules yield a valid donor-only tail (src/tether/project/leakage.py), and an analysis-only import seeds every row at NaN (src/tether/project/analysis_import.py). compute_corrected_fret then stamps apparent-E (corrections unavailable) on every analysable molecule (one whose frame_range is non-empty) and leaves the factor NaN — unless apparent_e_only forces apparent-E (user toggle) instead, or a manual alpha_override/gamma_override completes a usable pair (both effective factors finite, γ > 0), which stamps manual and persists the override into this cell. So read correction_method and /settings/leakage (withheld), never this cell, to tell the cases apart. |
gamma |
f8 |
scalar | Detection-correction γ, dimensionless. Same NaN convention as alpha — compute_gamma withholds the dataset γ below min_qualifying_traces (src/tether/project/gamma.py) and resets processed rows to NaN; a withheld leakage recomputation also resets those rows because γ was derived from the prior α. A γ pass stamps /settings/gamma with withheld = True, so read correction_method / /settings/gamma rather than this cell. |
delta |
f8 |
scalar | Direct-excitation δ, dimensionless. Inert in two-colour work; written 0.0. |
correction_method |
str:utf-8 |
scalar | Closed vocabulary from src/tether/project/correct.py: corrected, manual, apparent-E (corrections unavailable), apparent-E (user toggle). "" before any correction pass. |
correction_confidence |
f8 |
scalar | A provenance flag, not a confidence interval: 1.0 when a real correction was applied, 0.0 on apparent-E fallback, NaN before any pass. |
curation_label |
i4 |
scalar | Signed codec CurationLabel (src/tether/project/labels.py): 0 uncurated, 1 accept, -1 reject. Mirrors the most recent human label only. |
category |
str:utf-8 |
scalar | Editable per-condition category value, independent of accept/reject. "" at extraction. |
quality_class |
f8 |
scalar | Read-only ML ranker output, dimensionless. NaN when none; no shipped ML path writes it yet. |
condition_id |
str:utf-8 |
scalar | Current foreign key into /conditions/table; rewritten by a re-key. |
condition_id_provisional |
str:utf-8 |
scalar | The filename-derived condition id, retained verbatim across any re-key. |
source_filename |
str:utf-8 |
scalar | Original acquisition filename the molecule came from. |
tags |
str:utf-8 |
scalar | Comma-joined tag string; "" when none. The pipeline emits two tags — see below. |
A narrowed
analysis_windowis not a provenance signal. In a natively extracted store the window starts equal toframe_range, so a narrower one does mean photobleach detection or a curator edit. That inference breaks on an analysis-only import:_normalize_sourcereads the SMD's tMAVENpre_list/post_listand_build_molecule_rowswrites them straight intoanalysis_window(src/tether/project/analysis_import.py), so a freshly imported row can already be narrower thanframe_rangewithbleach_framesstill-1andcuration_labelstill0. And because the auto-refine fires only on a window that still equalsframe_range, a latercompute_photobleachleaves those imported windows alone. On a real 2-molecule import withpre_list = [3, 0]/post_list = [30, 40]over 40 frames, the rows readanalysis_window[3, 30]and[0, 40]againstframe_range[0, 40], andcompute_photobleachreportedn_windows_autoset=1— it refined only the second row. To tell the cases apart, read/settings/analysis_onlyor theround-trip-unavailabletag, not the window.
Two sentinels and two derivations worth reading twice
bleach_frames. -1 (_UNDETECTED_FRAME, src/tether/imaging/extract.py) is
what extraction and analysis-only import write before anyone has looked: it means
the photobleach detector has not run for that row, not that it ran and found nothing.
After compute_photobleach (src/tether/project/photobleach.py) a channel that does
not bleach within the trace is recorded as frame_range[1] — the window end — so
the comparison you want is against frame_range, never against -1. (-1 also
survives on a row with no valid native frames, which the detector skips.) On a real
40-frame store, one bleaching and two non-bleaching molecules read
[[30, 20], [40, 40], [40, 40]] against frame_range [[0, 40], [0, 40], [0, 40]].
tags. Two tags are emitted by shipped code:
| Tag | Constant | When |
|---|---|---|
low-confidence-registration |
LOW_CONFIDENCE_TAG (src/tether/imaging/calibrate.py) |
The registration fit for that molecule exceeded the confidence gate. |
round-trip-unavailable |
ANALYSIS_ONLY_TAG (src/tether/project/analysis_import.py) |
Stamped on every molecule of an analysis-only import — it is on every row of such a store. |
molecule_key. It is the cross-file join key everything downstream depends on,
and it is a pure content hash — no salt, no run state
(ADR-0016). The extraction
derivation, from molecule_key() in src/tether/imaging/extract.py, is:
q = round_half_away(donor_xy / MOLECULE_KEY_QUANTUM_PX).astype(np.int64) # 0.1 px
payload = f"{movie_sha256}|{int(q[0])}|{int(q[1])}"
key = hashlib.sha256(payload.encode("utf-8")).hexdigest()
The 0.1 px quantum absorbs float-representation jitter so the same molecule
re-extracted, or carried into a split/subset file, hashes identically. Because two
molecules can in principle quantize to the same coordinate, this key is a content
key, not a row identity: join per-row data on molecule_id.
An analysis-only import has no movie and no coordinates — molecule_key() would
in fact raise on its NaN donor_xy — so it uses a second derivation,
_analysis_only_molecule_key (src/tether/project/analysis_import.py), hashing the
source identity, the molecule's row index and the raw donor/acceptor trace bytes.
That variant is unique by construction. The upshot for a downstream reader: read
the stored key, do not recompute it — the recipe above will not reproduce the keys
of an analysis-only store.
/conditions — the structured experimental conditions
Group holding /conditions/table (CONDITIONS_DTYPE, 14 fields). The condition
identity key is (construct/variant, dye, ligand + concentration, buffer,
temperature, laser power); date, replicate and the source file deliberately vary
within one condition and are not part of the key
(ADR-0033). condition_id is derived
from that key as cond-<first 12 hex of its sha256>
(ConditionKey.condition_id() in src/tether/io/filename.py).
| Field | Type | Shape | Meaning / units |
|---|---|---|---|
condition_id |
str:utf-8 |
scalar | cond-<12 hex>, the content hash of the identity key below. |
construct_variant |
str:utf-8 |
scalar | Construct / variant name. Part of the identity key. |
dye |
str:utf-8 |
scalar | Dye pair label. Part of the identity key. |
ligand |
str:utf-8 |
scalar | Ligand name. Part of the identity key. |
ligand_concentration |
f8 |
scalar | Numeric concentration in the unit given by the next field. NaN is the "absent" sentinel and round-trips back to the same condition_id. |
ligand_concentration_unit |
str:utf-8 |
scalar | Unit string for the value above, e.g. nM. Part of the identity key. |
buffer |
str:utf-8 |
scalar | Buffer description. Part of the identity key. |
temperature_c |
f8 |
scalar | Temperature in degrees Celsius. NaN = absent. |
laser_power |
f8 |
scalar | Laser power. Never produced by the filename parser — parse_filename fills only construct/variant, dye, ligand, concentration and unit (src/tether/io/filename.py), so this is NaN in any store built purely from filenames; it is entered in the conditions editor. No unit is stored and there is no companion laser_power_unit field, though the conditions UI displays the value as mW (_key_summary, src/tether/gui/conditions.py). NaN = absent. |
date |
str:utf-8 |
scalar | Acquisition date. Within-condition provenance; not part of the identity key. |
replicate |
str:utf-8 |
scalar | Replicate label. Within-condition provenance; not part of the identity key. |
leakage_alpha |
f8 |
scalar | Per-condition leakage α, dimensionless. NaN when unset. |
leakage_alpha_source |
str:utf-8 |
scalar | Provenance of the value above (which donor-only sample it came from). |
tags |
str:utf-8 |
scalar | Free-form tag string. |
Three identity-key fields — buffer, temperature_c and laser_power — are part of
the key but are not filename-derivable: the only ConditionKey(...) construction
in parse_filename sets construct/variant, dye, ligand, concentration and unit and
nothing else. In a store whose conditions came from filename parsing alone they are
"" / NaN, and they change only through the conditions editor (a re-key, logged in
/settings/condition_audit).
The group also holds an additive child, /conditions/categories/<condition_id>: one
resizable 1-D vlen-UTF-8 dataset per condition carrying that condition's ordered,
editable category names (_write_category_names in
src/tether/project/conditions.py). It appears only once a category list has been
set; emptying the list resizes the dataset to zero rows rather than deleting it.
/labels — the append-only curation log
Group holding /labels/table (LABELS_DTYPE, 8 fields): one row per labelling
event, append-only and carrying full provenance
(ADR-0023). Rows are never
removed or reordered, and every field except weight is immutable — weight is
recomputed in place across the whole column at each retrain
(recompute_label_weights, src/tether/project/weighting.py), so do not cache it.
The audit row is written before the /molecules.curation_label mirror is updated,
so a crash leaves at worst an orphan label row, never an unaudited state change.
| Field | Type | Shape | Meaning / units |
|---|---|---|---|
molecule_key |
str:utf-8 |
scalar | Join key into /molecules.molecule_key — the cross-file key, so labels survive split-file merge-back. |
labeler |
str:utf-8 |
scalar | Curator identity; defaults to the OS login, or unknown. |
timestamp |
str:utf-8 |
scalar | Offset-aware ISO-8601, e.g. 2026-07-21T00:52:09.949494+00:00. Tether's own writes are UTC, but the writer only requires an explicit offset (set_curation_label, src/tether/project/labels.py), so a merged-in row may carry another one. |
source_file |
str:utf-8 |
scalar | Caller-supplied source provenance, stored verbatim; it only defaults to the basename of the .tether being curated (set_curation_label, src/tether/project/labels.py). Not always a .tether name — the Deep-LASI reconstruction seeds its deeplasi-provisional rows with the acquisition filename instead (parsed.source_filename, src/tether/project/reconstruct.py). |
source |
str:utf-8 |
scalar | Closed vocabulary (src/tether/project/labels.py): human, deeplasi-provisional, cross-condition-seed. The latter two are provisional cold-start priors. |
weight |
f8 |
scalar | Effective training weight, dimensionless. Human labels are 1.0; provisional weights decay as human labels accrue and this column is rewritten at retrain. |
label_value |
i4 |
scalar | The same signed codec as curation_label: 0 uncurated, 1 accept, -1 reject. |
condition_id |
str:utf-8 |
scalar | The condition the label is scoped to. |
/traces — the integrated intensity arrays
Empty in a fresh store. Up to six datasets, named
{donor,acceptor}_{raw,corrected,background}
(ADR-0016); write_extraction writes
all six only for a movie that yielded at least one molecule. A movie that
colocalized zero molecules still appends its /movies row, but creates no
/traces or /patches dataset at all
(if molecules.n_molecules:, src/tether/imaging/extract.py) — so a valid,
error-free store can have a populated /movies and an entirely empty /traces.
The other provenances write fewer layers:
| Dataset | Content |
|---|---|
donor_raw, acceptor_raw |
Uncorrected aperture sum, before background subtraction. |
donor_corrected, acceptor_corrected |
Background-subtracted disk intensity, exactly raw − background. |
donor_background, acceptor_background |
The subtracted background: the ring mean of a temporal moving average of the crop, already scaled by the disk pixel count n_psf (tether.imaging.aperture). |
Whichever are present are (n_molecules, max_T), dtype float32
(_TRACE_DTYPE = "<f4", src/tether/imaging/extract.py), chunked and
gzip-compressed, with maxshape=(None, None). Do not assume all six exist — see
the zero-molecule case above and the last two bullets below.
- In a natively appended store the time axis is zero-padded to the longest movie in
that store. Appending a shorter movie leaves zeros past its end; appending a
longer one grows the axis and zero-fills the tail of every existing row
(
_append_padded_2d). A subset export instead inherits the source store's width —_copy_trace_layerscopiesds[rows]with the source dataset's full second dimension (src/tether/project/export.py) — so a subset's width need not match the longest movie behind its own molecules, and it has no/moviesrows to check against. In every case the valid extent of rowiis/molecules.frame_range[i]— use it, do not infer it from where the zeros start. - Row order matches
/molecules/tableexactly. - The time axis unit is frames; seconds-per-frame lives only in
/movies.frame_time. - The intensity axis carries no recorded physical unit or gain, and nothing in
the code states a calibration, so do not assume one. What the numbers are depends
on the store's provenance: in a natively extracted store they are aperture sums of
raw pixel values (
tether.imaging.aperture.integrate_traces) in whatever the source pixel dtype held (/movies.pixel_dtype,/movies.byteorder); in a Deep-LASI reconstruction they are the pre-integrated.matseries copied verbatim — the aperture integration "is skipped" (_traces_from_export,src/tether/project/reconstruct.py) — and in an analysis-only import they are the SMD/text series copied verbatim. Only the first is a sum Tether itself computed. tether.project.trace_layers.INTENSITY_QUANTITY_LAYERSis the single source of truth for which pair anintensity_quantityselects:correctedandrawonly — there is nobackgroundquantity.- An analysis-only import (from a legacy Deep-LASI text or SMD file) writes
only
donor_correctedandacceptor_corrected; the raw and background layers are genuinely absent. See Legacy Deep-LASI import. - A subset export (
export_subset_tether,src/tether/project/export.py) writes onlydonor_corrected/acceptor_correctedunlessinclude_raw=True, which is not the default. The raw and background layers travel or are dropped as a set, deliberately:corrected = raw − backgroundexactly, so keeping background would make raw reconstructable and defeat the §5.4 movie-less invariant. Detect it from thetether_subset_include_rawroot attribute.
/patches — the cached image crops
Empty until write_extraction runs on a movie with at least one molecule —
a zero-molecule movie creates neither dataset, as under /traces above.
Two datasets, donor and acceptor, each
(n_molecules, window, window) float32, chunked and gzip-compressed, with
maxshape=(None, window, window) — only the molecule axis grows, because the
aperture window is fixed per project and validated on every later append.
In a natively extracted store each entry is the temporal mean image crop for
that molecule and channel, in (row, col) image order, centred on the molecule's
rounded coordinate. _mean_patches (src/tether/imaging/extract.py) zero-fills a
molecule whose window falls outside the frame, but no such molecule reaches disk:
colocalize keeps only molecules whose window fits in both channels, and
write_extraction refuses the whole write if any integrated trace is invalid
(_validate_alignment — "refusing to write an all-zero trace"). The default window is 21 px
(extract_molecules(..., window=21)); the value actually used is recorded as
/settings/extraction@window. Row order matches /molecules/table. A subset export
carries both stacks, row-subset to the exported molecules; analysis-only imports
leave /patches empty, because they have no coordinates.
A Deep-LASI reconstruction's patches are placeholders, not images.
reconstruct_projectaccepts optionaldonor_patches/acceptor_patches, and_resolve_patches(src/tether/project/reconstruct.py) zero-fills an(n_molecules, 21, 21)stack for whichever isNone— the movie link makes the real crops re-cacheable later. No shipped caller passes them (the GUI wizard'sreconstruct_projectcall omits both), so in practice every reconstructed store's/patchesare entirely zero: a real reconstruction of the committed 4-molecule UCKOPSB.matwritesdonorandacceptorof shape(4, 21, 21)float32 withmax() == 0.0. Do not read an all-zero patch as an out-of-frame crop in any store: the native writer rejects those before they reach disk, so a zero patch is either a reconstruction placeholder or a genuinely dark in-frame crop. Check the store's provenance first —/settings/extraction@profile_jsoncarries"source": "m7-deeplasi-reconstruction"for this path — rather than inferring anything from the pixels.
/settings — provenance of each pipeline stage
Empty until a stage runs. Every child is a group carrying scalar attributes and no
datasets, except condition_audit, which is a dataset.
| Child | Written by | Lifecycle |
|---|---|---|
extraction |
write_extraction (src/tether/imaging/extract.py) |
Write-once per project; later movies must match the recorded parameters or the write is rejected. |
correction |
compute_corrected_fret (src/tether/project/correct.py) |
Replaced on each pass. |
leakage |
compute_leakage_alpha (src/tether/project/leakage.py) |
Replaced on each pass. |
gamma |
compute_gamma (src/tether/project/gamma.py) |
Replaced on each pass. |
batch |
run_batch (src/tether/project/batch.py) |
Replaced on each run; adds one <stage>_status attribute per stage. |
analysis_only |
import_analysis_only_project (src/tether/project/analysis_import.py) |
Marker that movie round-trip is unavailable for this store. |
condition_audit |
tether.project.conditions |
A dataset: append-only re-key/merge log, created lazily. |
/settings/extraction is the one most readers need, because it records the
integration geometry behind /traces and /patches. In a Deep-LASI
reconstruction the same attributes are written from the standard 21 px defaults and
"describe the provenance of the layers, not a fresh integration this module
performed" (src/tether/project/reconstruct.py) — the traces were integrated by
Deep-LASI, not by Tether:
| Attribute | Type | Meaning / units |
|---|---|---|
window |
int | Patch/aperture window edge, pixels (default 21). |
disk_radius |
float | Signal-disk radius, pixels. |
ring_inner, ring_outer |
float | Background annulus radii, pixels. |
bg_window |
int | Background moving-average length, frames. |
n_psf |
int | Number of pixels in the disk mask (derived from the radii). |
molecule_key_quantum_px |
float | The molecule_key coordinate quantum, pixels (0.1). |
app_version |
str | Writing Tether version. |
profile_json |
str | The caller's settings profile as JSON — present only when one was supplied. |
/settings/condition_audit is a resizable 1-D compound dataset with the fields
event (rekey or merge), from_condition_id, to_condition_id, n_molecules
(i8), labeler, timestamp, reason, app_version — all strings except
n_molecules (_audit_dtype in src/tether/project/conditions.py). It is
append-only and never rewritten.
/idealization — one subgroup per fitted model
Empty until an idealization runs. Each fit lands in /idealization/{model_name}
(ADR-0024), staged
first as {model_name}.__writing__ and then swapped in by delete-then-rename.
The staged group is complete before the swap, so a real model name never holds a
half-written fit — but the swap itself is not fully atomic: HDF5 has no atomic
in-file rename-over, so a crash in the window between removing the old model and
renaming the staged one can leave only the .__writing__ group and no model at all
(write_idealization_model, src/tether/project/idealize.py). Re-running the write
repairs it. A reader should normally skip a name ending in .__writing__ — but must
not treat its presence as proof that a good model exists alongside it.
n_fitted below is the number of molecules this fit covered, which is not the
store's molecule count and not the store's row order — see the note under the table.
| Member | Shape | Dtype | Present |
|---|---|---|---|
mean |
(nstates,) |
float64 | always — the state FRET levels, dimensionless E |
var |
(nstates,) |
float64 | when the fit produced variances |
tmatrix |
(nstates, nstates) |
float64 | optional — unnormalized transition counts |
norm_tmatrix |
(nstates, nstates) |
float64 | optional — row-normalized transition probabilities |
rates |
(nstates, nstates) |
float64 | optional — per-frame rates |
pi |
(nstates,) |
float64 | optional — the unnormalized Dirichlet posterior; it sums to roughly the trace count and is not a probability vector |
frac |
(nstates,) |
float64 | optional — the normalized populations; use this for plots and exports, not pi |
priors/<name> |
(nstates,) or (nstates, nstates) |
float64 | optional subgroup — the variational prior hyperparameters |
idealized |
(n_fitted, n_frames) |
float64, gzip | always — per-frame idealized FRET level, NaN outside each molecule's analysis window |
state_path |
(n_fitted, n_frames) |
int64, gzip | always — state index, -1 (tether.idealize.NO_STATE) outside the window |
molecule_key |
(n_fitted,) |
vlen UTF-8 | always |
molecule_id |
(n_fitted,) |
vlen UTF-8 | always — the unique join key for staleness and export |
input_hash |
(n_fitted,) |
vlen UTF-8 | always — see below |
The per-molecule rows are a selection, not the store. Unlike
/tracesand/patches, whose row order matches/molecules/tablepositionally, these five arrays cover only the molecules included in this fit: the writer sizes them fromlen(molecule_keys)and stamps that count as the group'sn_moleculesattribute. A subset export narrows them further. Join onmolecule_id— never index them with a/molecules/tablerow number. A real store with 3 molecules, idealized over 2 of them, holdsidealizedof shape(2, 40)andn_molecules = 2.
The priors/ subgroup is not uniformly 1-D. The canonical member names, enumerated
in src/tether/idealize/driver.py, are a_prior, b_prior, beta_prior,
mu_prior and pi_prior — all (nstates,) — plus tm_prior, the transition-matrix
Dirichlet prior, which is (nstates, nstates). Read each member's own shape; do not
size a buffer from nstates alone.
The pi / frac distinction is the full Appendix-D.2 population model
(ADR-0041).
input_hash is a per-molecule composite hash of everything the fit consumed: the
windowed donor/acceptor input, the intensity_quantity, the analysis-window bounds,
and the molecule's effective applied α and γ. The correction_method string is
not folded into the digest itself: input_provenance_hash passes it through
_effective_factors (src/tether/project/idealize.py), which maps every apparent-E
method — "", apparent-E (corrections unavailable) and apparent-E (user toggle) —
to the identity (α = 0, γ = 1), and only the resulting pair is hashed. So a row
moving between those three methods keeps the same input_hash, deliberately: the
corrected E it would feed is never displayed, so nothing it feeds went stale. Fold the
method string in yourself and you will falsely mark live idealizations stale. A real
correction change re-stales exactly the dependent idealizations, with per-factor scope
(ADR-0029).
Group attributes: type, nstates, dtype (the idealized quantity, e.g. FRET),
intensity_quantity, nstates_selected_by, n_molecules (the fitted count,
i.e. n_fitted above, not the store's molecule count), app_version,
created_utc, plus elbo and elbo_by_nstates (a JSON string) when the fitter
produced them. These names are reserved — a caller's extra_attrs may not override
them (_RESERVED_MODEL_ATTRS).
/features — the ML feature cache
Empty until compute_features runs, which writes /features/table: a resizable 1-D
compound dataset of 11 fields — two id columns, molecule_id and molecule_key
(both vlen UTF-8), then exactly one column per tether.ml.features.FEATURE_NAMES
entry, in that order: n_frames, total_intensity, snr, fret_mean, fret_var,
anticorr_lag0, anticorr_lag1_magnitude, neighbor_distance, aperture_overlap.
n_frames is a member of that feature list, not a separate column beside it, and it
is the sole integer one — i8, the only entry in _INT_FEATURES; the other eight
features are f8 (_feature_table_dtype, src/tether/project/features.py).
Units: n_frames is frames, total_intensity is in the same uncalibrated intensity
units as /traces, neighbor_distance is pixels, aperture_overlap is a fraction,
and the FRET and anticorrelation features are dimensionless. Undefined features are
NaN, never fabricated.
Dataset attributes carry app_version, created_utc, intensity_quantity,
n_molecules, feature_names (a JSON list — the authoritative column order), and
feature_schema_version. That last one is a second, independent version number
(FEATURE_SCHEMA_VERSION, currently 1) governing the cache layout only; it is not
the store's schema_version. The table is a recomputable cache, so a mismatch is a
prompt to recompute, not a migration.
/calibration — the channel registration maps
Empty until write_calibration runs. Each map lands under
/calibration/<calibration_id>, referenced by /movies.calibration_id, and is
write-once per id. Two direction subgroups, ref_to_moving and moving_to_ref,
each holding four float64 datasets: a and b (the polynomial coefficient vectors)
and norm_xy and norm_uv (the (3, 3) normalization matrices).
Group attributes record the scalar provenance: reference_channel,
moving_channel, rms_residual (pixels), n_control_points, gate_px (pixels),
degree, low_confidence, source, provenance_json, plus the per-channel
geometry attributes written by _write_geometry_attrs
(src/tether/imaging/calibrate.py).
/models — reserved, and empty today
/models exists in every store because the M0 freeze forward-declared it, but
nothing in Tether writes to it. The quality ranker is persisted as a standalone
portable model file instead, deliberately: it travels with a condition across many
experiment files rather than being trapped inside one .tether
(src/tether/ml/persistence.py, which states outright that nothing in it touches a
.tether). Subset export copies the group verbatim
(src/tether/project/export.py). Treat /models as reserved: expect it to be
present and empty, and do not attach your own payload to it.
Where to look next
- Legacy Deep-LASI import — what Tether reads in, and which legacy artifacts carry coordinates.
- Architecture decisions — the record behind every rule on this
page, notably ADR-0005 (the freeze),
ADR-0016 (traces, patches and
molecule_key) and ADR-0024 (idealization layout). schema/schema_frozen.jsonin the repository — the machine-checked manifest of the frozen skeleton (12 group paths, four compound tables, three root attributes), and the tiebreaker if this page ever disagrees with it about those. It says nothing about the additive payloads; for those, the writer module named in each section is the authority.