Skip to content

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 table dtypes 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 as schema/schema_frozen.json. That manifest is generated by scripts/dump_schema.py, which runs tether.io.schema.build_manifest against a throwaway create_project store, 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 table datasets, including field order — a compound dtype's on-disk layout is positional, so new fields may only be appended (_diff_compound in src/tether/io/schema.py requires 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 format and schema_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_version bump. 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_version only in a file whose writer stamped it — see the root-group section below) — continuing to exist unchanged for as long as schema_version stays 1. That is exactly the set schema-guard mechanically 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>.lock single-writer marker is a sidecar file, not part of the in-HDF5 schema (src/tether/io/schema.py module docstring); create_project never 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 alphacompute_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_window is not a provenance signal. In a natively extracted store the window starts equal to frame_range, so a narrower one does mean photobleach detection or a curator edit. That inference breaks on an analysis-only import: _normalize_source reads the SMD's tMAVEN pre_list/post_list and _build_molecule_rows writes them straight into analysis_window (src/tether/project/analysis_import.py), so a freshly imported row can already be narrower than frame_range with bleach_frames still -1 and curation_label still 0. And because the auto-refine fires only on a window that still equals frame_range, a later compute_photobleach leaves those imported windows alone. On a real 2-molecule import with pre_list = [3, 0] / post_list = [30, 40] over 40 frames, the rows read analysis_window [3, 30] and [0, 40] against frame_range [0, 40], and compute_photobleach reported n_windows_autoset=1 — it refined only the second row. To tell the cases apart, read /settings/analysis_only or the round-trip-unavailable tag, 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 parserparse_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_layers copies ds[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 /movies rows to check against. In every case the valid extent of row i is /molecules.frame_range[i] — use it, do not infer it from where the zeros start.
  • Row order matches /molecules/table exactly.
  • 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 .mat series 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_LAYERS is the single source of truth for which pair an intensity_quantity selects: corrected and raw only — there is no background quantity.
  • An analysis-only import (from a legacy Deep-LASI text or SMD file) writes only donor_corrected and acceptor_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 only donor_corrected/acceptor_corrected unless include_raw=True, which is not the default. The raw and background layers travel or are dropped as a set, deliberately: corrected = raw − background exactly, so keeping background would make raw reconstructable and defeat the §5.4 movie-less invariant. Detect it from the tether_subset_include_raw root 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_project accepts optional donor_patches/acceptor_patches, and _resolve_patches (src/tether/project/reconstruct.py) zero-fills an (n_molecules, 21, 21) stack for whichever is None — the movie link makes the real crops re-cacheable later. No shipped caller passes them (the GUI wizard's reconstruct_project call omits both), so in practice every reconstructed store's /patches are entirely zero: a real reconstruction of the committed 4-molecule UCKOPSB .mat writes donor and acceptor of shape (4, 21, 21) float32 with max() == 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_json carries "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 /traces and /patches, whose row order matches /molecules/table positionally, these five arrays cover only the molecules included in this fit: the writer sizes them from len(molecule_keys) and stamps that count as the group's n_molecules attribute. A subset export narrows them further. Join on molecule_id — never index them with a /molecules/table row number. A real store with 3 molecules, idealized over 2 of them, holds idealized of shape (2, 40) and n_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.json in 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.