Skip to content

Changelog

All notable changes to sleap-roots will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

Added

  • sleap-roots circumnutation analyze + sleap_roots.circumnutation.adapters.series_to_inputs — the user-facing circumnutation CLI and the Series → CircumnutationInputs adapter (PR #17 of the circumnutation program; OpenSpec change add-circumnutation-cli). Two NEWLY created modules adapters + cli (implementation count 12 → 14 by ADDITION — two modules in one PR, a first for the program; stub count UNCHANGED at 1, only parametric/PR #11 remains). One analyze command composes the whole pipeline on one .slp: Series.load → series_to_inputs → compute_traits → write per-plant CSV → aggregate_by_genotype → write per-genotype CSV → save_plots. adapters.series_to_inputs(series, *, cadence_s, sample_uid, series_name=None, timepoint=None, plate_id=None, genotype=None, treatment=None, r_px=None, run_id=None) -> (CircumnutationInputs, identity_provenance) — the single click-free, independently-testable bridge into the pure core, formalizing the former test-only _load_plate001_inputs(): get_tracked_tips() → prefix-anchored ASCII [0-9]+ track_id coercion (strips one leading "track_", then the remainder must be unsigned ASCII digits — raises ValueError naming the offender rather than let int() silently mis-coerce "track_-1"/"track_1_2"→12/"track_ 1"/Unicode digits; "track_track_1" raises, not the global-.replace corruption to 1); plant_id = track_id; CSV-as-source / flag-as-override metadata precedence where a blank value (None/empty/whitespace) on EITHER the flag or the CSV cell resolves to absent/NaN (so --genotype "" or a whitespace cell never forms a degenerate empty-string group); timepoint read as the raw get_metadata cell and str-normalized (object dtype, bypassing the float-coercing Series.timepoint property); the six string identity columns forced to object dtype even when all-NaN. Returns an identity-provenance dict {metadata_csv_path (resolved-abs or None), metadata_csv_sha256 (whole-file hex digest or None), identity_source} where identity_source is total over the six adapter-populated fields (series/sample_uid/timepoint/plate_id/genotype/treatment; plant_id/track_id excluded — derived from the .slp) mapping each to "flag"/"metadata_csv"/"default" (the series filename-stem default) / "absent" (NaN). Logs a WARNING when --metadata-csv is supplied but sample_uid matches no plant_qr_code row (the join would otherwise silently ship all-NaN metadata), and an INFO override-notice only when a non-blank flag shadows a real, different CSV value. cli — a circumnutation click group + analyze command registered on the root CLI via main.add_command(circumnutation) (mirroring viewer); --cadence-s and --sample-uid required (--sample-uid is the stable QR/sample id + metadata-CSV join key, never silently synthesized, per CC-4); --output-dir defaults to ./<series-name>_circumnutation/. Gated per-genotype aggregation (--no-aggregate): when aggregation is on (default) and genotype is unresolved, analyze hard-errors (exit 1, before any output is written) rather than emit a vacuous (NaN,NaN,NaN) per-genotype CSV; --no-aggregate opts out (per-plant + plots only, no genotype required). --no-plots gates the matplotlib import entirely; otherwise matplotlib.use("Agg", force=True) runs before plotting is imported (headless; force=True is load-bearing because Series imports pyplot at module top). Error contract mirrors viewer/cli.py: click handles usage errors (exit 2 — missing required option, nonexistent .slp, non-numeric cadence); the adapter normalizes its raisers to ValueError; the command body is wrapped in one try/except (ValueError, FileNotFoundError) → ClickException (exit 1, clean one-line message, no traceback); no broad catch-all (genuine bugs surface tracebacks). -v/-vv → WARNING/INFO/DEBUG to stderr (stdout reserved for the result summary), restored in a finally so the CLI's global logging config doesn't leak; the CLI also quiets matplotlib's cosmetic font_manager findfont fallback WARNINGs (the scaleogram LogNorm colorbar's mathtext) to ERROR. CC-3 pure-pixel: no --px-per-mm; --help states outputs are pixel-native (pointing to convert_to_mm), documents the image y-down convention, and notes timepoint is a string label. Output tree: a top-level <output-dir>/run_metadata.json (the canonical run provenance, and the target of save_plots' hardcoded run_metadata_ref: "../run_metadata.json") + per_plant/ (CSV + .units.json + run_metadata.json) + per_genotype/ (only when aggregating) + plots/ (only when plotting) — all three run_metadata.json byte-identical (the CLI assembles provenance ONCE via gather_run_metadata and writes via write_per_plant_csv/write_per_genotype_csv directly, NOT CircumnutationPipeline.save(), so the identity provenance is complete and the sidecars share one timestamp). The distinct subdirs sidestep #238 (each _io writer's fixed-name run_metadata.json lives in its own directory) without changing the _io contract — referenced, not closed. _io.gather_run_metadata gains optional metadata_csv_path / metadata_csv_sha256 / identity_source (appended after R_px; backward-compatible — existing callers write null). Tested via click.testing.CliRunner (exit codes, validation, error paths, gated aggregation, --no-plots, -v/-vv, provenance, logging-restore) on a synthetic tracked-.slp round-trip (PIL TIFF → sio.save_slp → real Series.load → get_tracked_tips, incl. the predicted-vs-user dedup branch) carrying non-LFS coverage, plus a skipif-guarded real plate-001 end-to-end (6 plants → genotype=Nipponbare/treatment=MOCK from the metadata CSV; no pixel baselines), and verified by running the actual installed console script on plate-001 (entry point, fresh-process Agg, real exit codes 0/1/2, the on-disk tree + byte-identical sidecars + resolving plots run_metadata_ref). No new constants (_CONSTANTS_VERSION stays 6); no new dependency (click already present). Two spec scenarios reconciled during TDD (interior track_track_1 raises; the unreachable "partial genotype" scenario removed — genotype is filled uniformly per run). Reviewed across brainstorm (D1–D8) + a design critical-review + 3 /openspec-review rounds (each finding a real issue — round 2 caught a BLOCKING dangling plots-provenance pointer from the subdir split, fixed via the top-level run_metadata.json) + 2 /review-pr self-review rounds (pre-PR + post-PR, surfacing the blank-genotype gate gap, the strict-track_id Unicode hole, the logging leak, and the findfont noise — all reconciled). Follow-up #245 (thread input_path/identity provenance into save_plots' own plots_metadata.json — distinct from #241). Closes #244 (parent epic #197; OpenSpec design.md + docs/superpowers/specs/2026-06-24-add-circumnutation-cli-design.md track all reconciliation rounds.)
  • sleap_roots.circumnutation.plotting — the circumnutation diagnostic-plotting layer (PR #16 of the circumnutation program; OpenSpec change add-circumnutation-plots). Graduates plotting stub → implementation (implementation count 11 → 12, stub count 2 → 1 — only parametric/PR #11 remains a stub). Four public callables: scaleogram(scaleogram_result, out_path, *, ridge_result=None) -> Path (polymorphic over the temporal ScaleogramResult and spatial SpatialScaleogramResult; power |C|² heatmap on a LogNorm color scale with explicit n+1 cell edges, cone-of-influence dimming, optional ridge overlay faded inside the COI; spatial wavelength axis labeled uncalibrated pywt convention; raises TypeError on an unsupported scaleogram type or a mismatched ridge_result), trail_overlay(midline_result, out_path) -> Path (the smoothed tip path as a per-segment LineCollection colored by the signed curvature_px_inv on a diverging colormap symmetric about 0 with limits ±the 98th percentile of |κ|; NaN-κ segments via a copied colormap's set_bad; image-y-down; extend="both" colorbar), plate_panel(midline_results, out_path) -> Path (a 2×3 grid sharing one κ normalization + one colorbar; raises ValueError for > 6 plants), and save_plots(inputs, out_dir, *, constants=None, enabled=True) -> list[Path] (re-derives the per-plant Results by invoking the same tier helpers the analysis uses — nutation._select_signal + the nutation guards for Tier 1; frame-sort + finite-tip mask + midline.reconstruct → spatial_cwt.resample_curvature → compute_scaleogram/extract_ridge for Tier 3 — so the plotted signal equals the analyzed input). PNGs go to a plots/ subdirectory of out_dir (which must already exist) with filenames keyed on the per-plant integer track_id (plant{track_id}_scaleogram_temporal.png, …_scaleogram_spatial.png, …_trail.png, one panel.png), plus a plots_metadata.json provenance sidecar (constants version, resolved display constants, source identities, written filenames, and inputs.run_id as the join key to run_metadata.json; strict JSON). enabled=False is a no-op returning [] (the --no-plots hook; the CLI flag lands in PR #17). Pure-pixel (CC-3): every axis/colorbar uses pixel-native units, never mm. Plot display constants (DPI, figure sizes, colormaps, κ percentile) are module-level — not in ConstantsT — so _CONSTANTS_VERSION stays 6; no new dependency (matplotlib already present). Tested with the Agg backend via structural + smoke assertions (no pixel baselines) and validated on the real Nipponbare plate-001 (6 trails + 6 spatial scaleograms + the temporal set + a panel + the sidecar). Deviation: the stub-promised L_gz arc-length marker is OMITTED (blocked on #230, not computed in the pipeline) and added by a follow-up PR. Reviewed across brainstorm (D1–D8) + a design critical-review + 3 /openspec-review rounds (each of the first two surfacing a real BLOCKING bug — the _select_signal re-derivation crash on NaN tips, and the non-constructible sidecar back-reference — reconciled before implementation). Follow-up #241 (expose per-plant Result artifacts as MCP-compatible serializable views, removing the plotting recompute). (parent epic #197; tracking issue #242.)
  • sleap_roots.circumnutation.aggregation.aggregate_by_genotype + sleap_roots.circumnutation._io.write_per_genotype_csv / read_per_genotype_csv — per-genotype aggregation layer (PR #15 of the circumnutation program; OpenSpec change add-circumnutation-per-genotype-aggregation). The first post-pipeline aggregation layer, per theory.md §7.7. NEWLY created (never a stub) — implementation count 10 → 11 by ADDITION (like PR #6 nutation / PR #10 traveling_wave); the 2 stubs (parametric, plotting) are unchanged. Pure aggregate_by_genotype(per_plant_df, units) -> (per_genotype_df, per_genotype_units) consumes the PR #14 composed 46-col per-plant frame and emits one row per (plate_id, genotype, treatment) group (plates never pooled — dropna=False so a NaN group key forms its own group, never silently dropped): n_plants_passing_qc, n_plants_excluded, exclusion_reasons (clause→count over excluded plants' qc_failure_reason, _FAILURE_CLAUSE_ORDER-ordered, sentinel-first, clause-incidence), then <trait>_median/<trait>_iqr for 32 numeric traits (NaN-skipping median; IQR = Q75−Q25 via module-qualified scipy.stats.iqr(nan_policy="omit", interpolation="linear"), NaN when < 2 finite — a structural minimum, not a tunable constant), frac_nutating, handedness_mode (most-frequent sign by explicit value counts — NOT Series.mode — tie-break smallest abs then smallest signed) + handedness_consensus_frac. 73-column output. Plants with track_is_clean == False are excluded from every stat (the per-plant track_is_clean/qc_failure_reason already flag them — consumed, not mutated; excluded plants stay auditable by re-filtering the per-plant CSV). Three trait-level decisions: principal_axis_angle (a wrapping circular rad angle) is dropped — a linear median/IQR is unsound near ±π and it is an arbitrary absolute reference direction (circular-statistics summary deferred to a follow-up); helix_signed_area_px2 is aggregated by its magnitude (helix_signed_area_abs_px2_median/_iqr) because its sign is bimodal chirality that a signed median would cancel — direction is carried by handedness_mode/consensus_frac; growth_axis_unreliable is dropped (a track_is_clean failure clause → always False among passers). Other signed traits (v_long_signed*, v_lat_signed*, period_residual_vs_derr_reference) verified per-plant-consistent and aggregated signed. Degenerate-safe: all-excluded group emits a NaN row (counts kept, handedness_mode=0, int dtype preserved via list-of-dicts construction not groupby().apply); empty frame → empty frame with the full typed schema; never raises on degenerate input (the median/IQR helpers pre-filter to finite and short-circuit before any empty/small-slice op, so degenerate groups emit NaN without a RuntimeWarning — no warning suppression needed). Fails loud on a track↔plant 1:1 violation (the literal §7.7 two-level per-plant collapse is deferred to a follow-up; PR #15 raises rather than silently aggregate per-track). New _io.write_per_genotype_csv / read_per_genotype_csv mirror the per-plant pair (CSV + <stem>.units.json + run_metadata.json); the units-coverage check is factored into a shared behavior-preserving _io._validate_units_coverage(df, units, *, fn_name) (the PIPELINE_UNIT_VOCABULARY membership check stays inline in each writer). Pure-pixel (CC-3); no new units-sidecar vocabulary and no new constants (_CONSTANTS_VERSION stays 6). #222 dependency noted: the derived _median/_iqr names inherit the (currently unsuffixed) source names, so #222's unit-suffix rename must treat _median/_iqr as stat-suffixes layered after the unit suffix. Real Nipponbare plate-001 validation (the integration test): 1 passing / 5 excluded (d2_msd_agreement_high:5), all medians finite, all IQRs NaN (n=1), handedness_mode=1, round-tripped through the writer. Reviewed across brainstorm (D1–D8) + 4 /openspec-review rounds (surfacing real bugs reconciled before implementation: wrong test-file targets, the roadmap plant_id-keying contradiction, the run_metadata.json fixed-name clobber, the principal_axis_angle wrapping-angle median bug, the NaN-group-key silent-drop, and the helix_signed_area_px2 chirality-cancellation bug). (parent epic #197; tracking issue child of #197; OpenSpec design.md tracks all reconciliation rounds.)
  • sleap_roots.circumnutation.pipeline.CircumnutationPipeline / compute_traits — circumnutation pipeline composition (PR #14 of the circumnutation program; OpenSpec change add-circumnutation-pipeline). Graduates the pipeline stub into an implementation module (same stub→impl shape as PR #7/#8/#9: implementation count 9 → 10, stub count 3 → 2; callable compute_traits KEEPS its name). Composes the five trait-emitting tiers (Tier 0 kinematics, QC qc, Tier 1 nutation, Tier 2 psi_g, Tier 3c traveling_wave) into one per-plant trait table + CSV + 2 provenance sidecars. No new science — every trait is already computed and tested by its tier. A sequential merge-orchestrator, NOT the per-frame networkx TraitDef DAG the stub docstring/roadmap row described: the circumnutation tiers are per-track DataFrame→DataFrame functions, not per-frame nodes; the documented tier-dependency order (Tier 0 / QC / Tier 1 / Tier 2 independent; Tier 3c depends on Tier 0 + Tier 1) is honored as a fixed call order (deviation recorded in the spec + design). Picklable @attrs.define class with a single constants field; pure compute_traits(inputs, constants=None) -> (per_plant_df, trajectory_df, units_dict) (module-level wrapper preserved) + a separate save(out_path, per_plant_df, units, *, inputs, input_path) (I/O kept out of compute). Composed schema = 46 columns (8 row-identity + Tier 0 10 + QC 10 + Tier 1 8 + Tier 2 4 + Tier 3c 6), built by left-merging each tier projected to [*_IDENTITY_5_TUPLE, *its trait cols] onto the shared per-plant template (so the 3 non-key identity columns can't collide into _x/_y), int64 coercion-with-raise per merge; each tier's dtypes preserved (bool is_nutating, int handedness, string qc_failure_reason). Dedups the Tier 0/Tier 1 recompute PR #10 traveling_wave.compute does redundant-by-design: traveling_wave.compute gains keyword-only tier0_df/tier1_df (both-or-neither; per-frame operand-column validation; the ops-build projects to the operand column so extra columns can't leak); the pipeline passes its already-computed Tier 0/Tier 1 frames → each tier computed once, Tier 3c columns identical (atol=0) to the standalone recompute. Standalone traveling_wave.compute (kwargs omitted) is byte-identical — its 37-test suite + cross-OS canary stay green. growth_axis_unreliable coalesced to the Tier-0-owned column (QC's copy dropped after a Series.equals dtype+value cross-tier equality guard). Adds the _NUTATION_TRAIT_UNITS / _PSIG_TRAIT_UNITS maps the units sidecar needs (the #222 units-map portion only — keyed on the current column names; the broader suffix rename stays #222; noise_floor_estimate="px" an amplitude not a ratio, handedness="int", helix_signed_area_px2="px²"); the units dict is assembled from row-identity + the five tier maps and 1:1-covers all 46 columns. Provenance — cadence_s/R_px/run_id now captured in run_metadata.json (additive _io.gather_run_metadata(cadence_s=None, R_px=None) kwargs, nullable for existing callers; save threads them from inputs) so a run is reproducible from its sidecars alone — cadence_s determines every period trait and the residual and is otherwise unrecoverable from the .slp. No new constants (_CONSTANTS_VERSION stays 6). Picklable + deterministic (two in-process runs equal as full frames; no separate composed canary — defers to the per-tier canaries). Real Nipponbare plate-001 integration test (compute → save → read-back): 6 plants, the 46-col schema, all 5 tiers present, traveling_wave_residual finite < 0.30 (QPB band), cross-tier growth_axis_unreliable equality, run_metadata["cadence_s"] == 300.0. Foundation tests migrated atomically with the first non-raising commit (pipeline removed from STUB_MODULES; STUBS_WITH_CONSTANTS_KWARG + its now-subjectless test_stub_accepts_constants_kwarg dropped — pipeline was the last constants=None stub; added to IMPLEMENTATIONS_WITH_CONSTANTS_KWARG + the explicit test_module_logger_is_namespaced list + a dedicated test_implementation_accepts_constants_kwarg branch building a CircumnutationInputs). Deferred: the Series → CircumnutationInputs adapter → PR #17 (CLI). Reviewed across brainstorm (D1–D8) + 4 /openspec-review rounds (surfacing real issues reconciled before implementation: a broken merge-on-5-tuple that would _x/_y-collide the identity columns, the missing cadence_s provenance/reproducibility hole, the dedup atol=0 scoping to nutation-accepted constants, and the pinned units traps). Closes #234 (parent epic #197; OpenSpec design.md + docs/superpowers/specs/2026-06-14-add-circumnutation-pipeline-design.md track all reconciliation rounds.)
  • sleap_roots.circumnutation.traveling_wave.compute — Tier 3c traveling-wave validation traits (PR #10 of the circumnutation program; OpenSpec change add-circumnutation-tier3c-traits). A trait-emission module (mirroring PR #6 nutation / PR #7 psi_g: compute(trajectory_df, cadence_s, constants=None) -> pd.DataFrame, 5-tuple groupby, 8 row-identity columns, per-plant template merge), and the first consumer of PR #9's spatial-CWT machinery. NEWLY created (never a stub) — implementation count 8 → 9 by ADDITION (like PR #6 nutation); the 3 stubs (parametric, plotting, pipeline) are unchanged. Emits 6 float64 trait columns in declared order: lambda_spatial_median_px [px] (median cgau2-calibrated along-trail wavelength), lambda_spatial_variation [—] (orientation-robust MAD/median spread; renamed from the theory §7.4 name apex_basal_period_consistency — see Deviations), traveling_wave_residual [—] (the program's central falsifiable QPB test |λ − v·T_frames|/(v·T_frames), T_frames = T_nutation_median/cadence_s), lambda_expected_px [px] (v·T_frames), lambda_spatial_mad_px [px], coi_valid_fraction [—]. Per-track chain midline.reconstruct → spatial_cwt.resample_curvature → compute_scaleogram → extract_ridge → ~in_coi gate → cgau2 calibration → λ stats, fully guarded (degenerate/short/all-NaN-tip tracks emit a well-formed all-NaN row; never crashes compute()). Self-contained composition: recomputes Tier 0 (kinematics.compute → v) + Tier 1 (nutation.compute(coordinate="lateral") → T_nutation_median) and merges the operands on the FULL _IDENTITY_5_TUPLE with int64 coercion (a dtype mismatch would silently NaN, not raise; redundant-by-design — the PR #14 DAG dedups). Division-guarded (v non-finite or lambda_expected ≤ 0 → NaN, no RuntimeWarning); non-nutating tracks NaN the residual via the NaN-gated T_nutation_median while the pure-spatial λ traits stay valid. cgau2 calibration applied in true px via an in-package n-averaged _CGAU2_LAMBDA_CALIBRATION literal (the per-n ratio scatters ~7 % non-monotonically across n∈{200,400,600}, so the consumer averages → a documented ~±5 % systematic; the module never reads tests/data at runtime — wheel-safe; validated against the JSON by a sync test). Extends the PR #9 calibration artifact append-only (capture_spatial_coi_factor.py --append: loads the existing JSON, copies provenance + the 18 rows VERBATIM, measures only the new λ_true knots 100/120/140/150 for all n, appends at the end → +12 rows, existing 18 byte-identical; λ_reported now reaches ~168 px, covering the real plate-001 λ ~142.5 without clamping). No new constants — reuses COI_FRACTION_MAX=0.5 (its first consumer; spatial COI gate coi_valid_fraction < 1−COI_FRACTION_MAX, strict); _CONSTANTS_VERSION stays 6; adds a _TRAVELING_WAVE_TRAIT_UNITS mapping (#222 — px/dimensionless, all in PIPELINE_UNIT_VOCABULARY). Determinism (CC-6): in-process bit-identical (atol=0); the spatial-λ ridge is exact (wavelengths_px[interior], 1:1 with the argmax scale index — tie-flips are discrete, not atol-bounded); the v·T-derived columns inherit the Tier-1 atol=1e-6 canary. DESCOPED — L_gz/L_c-dependent traits + the growth-zone mask (L_gz_estimate, L_c_estimate, B_balance_number, L_gz_steady_state_residual, L_gz_resolvable) are OMITTED (blocked on #230), not reserved as NaN. Real Nipponbare plate-001 validation (NOT synthetic-only, per the PR #7/#8/#9 lesson): the actual compute() on all 6 tracks yields traveling_wave_residual ∈ [0.094, 0.21] (all < 0.30 → QPB holds to ~9–21 %, provisional); a noise-free uniform-λ synthetic recovers λ a priori (47.7 → 49.3 px) AND reads lambda_spatial_variation = 0 (no spurious argmax-quantization floor). Three recorded deviations from theory.md §7.4 (patched this PR, originals preserved in Appendix B(6)): the "bias cancels in the ratio" handoff claim was empirically FALSE (calibrate λ for all consumers); apex_basal_period_consistency (apex-vs-basal, edge-artifact-dominated + orientation-ambiguous) renamed/redefined → lambda_spatial_variation (orientation-robust spread); lambda_spatial_median → _px (pure-pixel, no L_gz mask). Reviewed across brainstorm + a documented real-data investigation + 2 design critical-review rounds + 1 /openspec-review round + 1 focused-science round (surfacing real issues reconciled before/during implementation: a 5-tuple-vs-track_id silent-NaN join bug, a raise-vs-NaN-row crash path, the calibration packaging/wheel bug, the n-dependence systematic, and an over-claimed noise floor). Closes #232 (parent epic #197; see docs/circumnutation/roadmap.md PR #10 row; OpenSpec design.md + docs/superpowers/specs/2026-06-10-add-circumnutation-tier3c-traits-design.md + the investigation report track all reconciliation rounds.)
  • sleap_roots.circumnutation.spatial_cwt — Tier 3b spatial CWT machinery (PR #9 of the circumnutation program; OpenSpec change add-circumnutation-tier3b-spatial-cwt). Graduates the spatial_cwt stub into an implementation module (same stub→impl shape as PR #7/#8: implementation count 7 → 8, stub count 4 → 3; callable compute_scaleogram KEEPS its name, the speculative wavelet=/scale_range= stub kwargs are dropped — derived from constants, mirroring temporal_cwt). A machinery PR mirroring PR #5 temporal_cwt (input-validating, frozen @attrs.define(frozen=True, eq=False) Results, a determinism contract — and no trait emission). Three public functions + three Results: resample_curvature(curvature_px_inv, arc_length_px, velocity_sub_noise_mask=None, constants=None) -> ResampleResult (the locked PR #8→#9 handoff — drops sub-noise-masked + non-finite frames, reparameterizes to the apex-origin axis s_a = max(surviving arc) − arc per theory §6.5, dedups duplicate-s_a knots by averaging κ, picks ds = median(positive Δs_a), interpolates onto a uniform grid of floor(arc_span/ds)+1 points; TWO-STAGE degenerate gate — Stage 1 count+span before max/min/median, Stage 2 output-grid-length ≥ MIN — so a non-degenerate result always feeds a valid scaleogram); compute_scaleogram(kappa, ds, constants=None) -> SpatialScaleogramResult (cgau2 spatial CWT; log-spaced 64-scale axis over [2·ds, 0.25·n·ds]; rejects non-finite kappa; cgau2-calibrated √?·scale ≈ 1.375·scale COI mask); extract_ridge(scaleogram_result, constants=None) -> SpatialRidgeResult (per-position argmax-over-scales → dominant spatial wavelength λ(s_a); ships the raw un-COI-masked ridge, spatial sibling of temporal_cwt.extract_ridge). Promotes 3 new constants to _constants.py + ConstantsT (SPATIAL_COI_EFOLDING_FACTOR=1.375 — empirically measured cgau2 e-folding via impulse 1/e half-width, the spatial sibling of COI_EFOLDING_FACTOR=√1.5; CWT_WAVELENGTH_MIN_NYQUIST_FACTOR=2.0, CWT_WAVELENGTH_MAX_SIGNAL_FRACTION=0.25 — spatial siblings of the temporal CWT_PERIOD_*); bumps _CONSTANTS_VERSION 5 → 6; snapshot 35 → 38. Deterministic across OSs at atol=1e-9 (no curve_fit — np.interp + pywt.cwt(cgau2) + argmax; a planted-sinusoid canary captured by scripts/circumnutation/capture_spatial_cwt_canary.py, with a sanctioned single-canary looser-atol fallback since cgau2 is unproven cross-OS). Analytic oracles wired through the full chain: pure-sinusoid λ recovery within the MEASURED cgau2 calibration band (the pywt.scale2frequency("cgau2") convention over-reports λ by a λ/n-dependent ≈+4–16%, recorded as a machine-readable artifact tests/data/circumnutation_spatial_cwt_calibration.json for PR #10 — NOT bias-corrected in the shipped wavelengths_px); fundamental-not-2nd-harmonic tracking; 50%-sparsity recovery with a LONG bias sign. Validated on the real Nipponbare plate-001 fixture (NOT synthetic-only, per the PR #7/#8 lesson): all 6 tracks non-degenerate, λ_spatial ≈ 85–142 px (same order as the ~65 px v·T estimate), 2·ds/λ ≈ 0.13–0.19 < NYQUIST_RATIO_MAX. DESCOPED — L_gz/L_c growth-zone-structure traits (theory §7.4): a pre-spec research:investigate on the 6 proofread tracks established the "peak of |κ(s)| envelope" premise does NOT transfer to top-view tip-trail κ(s) (the trail is a steady-amplitude oscillation over ~3000 px — the QPB steady traveling wave; growth zone ≈1 % of trail; |κ| envelope global-argmax lands basal on every track/method), exactly as theory §6.5 warned ("the recent trail cannot be inverted"). PR #9 ships the data-supported scaleogram + λ_spatial; L_gz/L_c are deferred to follow-up research issue #230 (which blocks PR #10's L_gz-dependent traits + mask, but NOT traveling_wave_residual / apex_basal_period_consistency, which consume λ(s_a)). theory.md §7.4/§6.3/§6.6 patched with the deviation (originals preserved in Appendix B) + a §7.4 "Handoff to PR #10" note (unit reconciliation λ[px] vs v[px/frame]·(T_nutation_median[s]/cadence_s) — emitted T_nutation_median is SECONDS, the §7.x "hr" annotation is stale; the cgau2 calibration map; the COI gate; and the apex_basal_period_consistency λ-offset-does-not-cancel trap). Foundation tests migrated atomically with the first non-raising commit (spatial_cwt removed from STUB_MODULES+STUBS_WITH_CONSTANTS_KWARG, added to IMPLEMENTATIONS_WITH_CONSTANTS_KWARG + the explicit test_module_logger_is_namespaced list; the _CONSTANTS_VERSION/snapshot-count assertions updated 5→6 / 35→38 across FIVE test files — foundation, temporal_cwt, synthetic, nutation, spatial_cwt — in the one atomic commit so the suite never goes red). Reviewed across brainstorm + a documented empirical investigation + 4 /openspec-review rounds (surfacing real issues reconciled before/during implementation: the L_gz/L_c descope, a commit-atomicity hazard across 5 version-assertion sites, a resample input-vs-output-length floor bug, the cgau2 wavelength-calibration band, and the PR #10 unit/handoff completeness). Closes #229 (parent epic #197; see docs/circumnutation/roadmap.md PR #9 row; OpenSpec design.md + docs/superpowers/specs/2026-06-09-add-circumnutation-tier3b-spatial-cwt-design.md track all reconciliation rounds.)
  • sleap_roots.circumnutation.midline.reconstruct — Tier 3a tip-trail-as-midline reconstruction machinery (PR #8 of the circumnutation program; OpenSpec change add-circumnutation-tier3a-midline). Graduates the midline stub into an implementation module (same stub→impl shape as PR #7: implementation count 6 → 7, stub count 5 → 4; the callable KEEPS its name reconstruct, no rename). A machinery PR mirroring PR #5 temporal_cwt (input-validating, a frozen @attrs.define(frozen=True, eq=False) MidlineResult, a determinism contract — and no trait emission), NOT the per-track compute() shape of PR #6/#7. Public reconstruct(x, y, cadence_s, sg_window=None, constants=None) -> MidlineResult reconstructs the organ midline from the tip trail (theory.md §6.1) parameterized by arc length s(τ)=∫|v|dσ, with per-frame trajectory curvature κ (theory.md §6.2) and tip speed. MidlineResult carries 7 per-frame arrays (frame_indices [int64], x_smooth_px/y_smooth_px [px], speed_px_per_frame [px/frame], arc_length_px [px], curvature_px_inv [px⁻¹], velocity_sub_noise_mask [bool]) + 6 provenance scalars (cadence_s, sg_window, sg_degree, sigma_v_px_per_frame, noise_mask_k, is_degenerate). Frame-parameterized / cadence-independent (velocity in px/frame — the program convention; px/s deliberately not used, theory.md §10 Appendix B): arc_length_px/curvature_px_inv/the mask are bit-identical for cadence_s=300 vs 1; cadence_s is validated + stored as provenance only. Savitzky-Golay smoothing/differentiation come from one fitted polynomial per coordinate so smoothing happens BEFORE the second-derivative operations curvature needs (theory.md §6.2). Adds two shared helpers: _noise.compute_sg_derivative(x, window, polynomial_order, deriv, delta=1.0, mode="interp") (SG analytic deriv 0/1/2; reuses the extracted _validate_sg_window_polyorder boundary validator shared with compute_sg_detrended, and adds a deriv-range check — scipy silently returns all-zeros for deriv>polyorder and an opaque factorial() error for deriv<0), and _geometry.compute_path_curvature(x_dot, y_dot, x_ddot, y_ddot) (κ=(ẋÿ−ẏẍ)/|v|³, px⁻¹; sign pinned by the absolute anchor compute_path_curvature([1],[0],[0],[1])==+1.0 with the y-down disambiguation + the cross-helper sign(κ)==−handedness note — the ψ_g family's swapped atan2(dx,dy) gives dψ_g/dt=−κ·|v|). Converts _noise.py to module-qualified scipy.signal.savgol_filter (all 5 call sites; drops the lone bare import) per the program scipy-import rule. No new constants — reuses NOISE_MASK_K=2/SG_WINDOW_SHORT=5/SG_DEGREE=3; _CONSTANTS_VERSION stays 5; no PIPELINE_UNIT_VOCABULARY change. Two deliberate scope splits (CC-1): builds NO L_gz growth-zone mask (→ PR #10) and does NO uniform-arc-length resampling (→ PR #9, whose compute_scaleogram(kappa, ds) stub owns the resample of the native NON-uniform arc_length_px grid); velocity_sub_noise_mask (per-FRAME, |v|≤NOISE_MASK_K·σ_v, theory.md §6.2) is a different object from the L_gz mask. Split degenerate policy (never raising on degenerate, never RuntimeWarning): ALL field-named validation runs first and unconditionally (non-finite REJECTED not dropped since SG/cumulative_trapezoid assume uniform frame spacing; cadence_s via the imported temporal_cwt._validate_cadence_s), THEN a graceful gate (n==0 first short-circuit / n<sg_window / raw-stationary np.ptp(x)==0 and np.ptp(y)==0 — detected on the RAW input since post-SG speed is float dust) returns an all-NaN MidlineResult (is_degenerate=True, velocity_sub_noise_mask=np.zeros(n,bool) since a bool array can't hold NaN); curvature under np.errstate(divide,invalid,over) + a κ[~isfinite]=NaN sweep (no inf, no warning). Deterministic across OSs at atol=1e-9 (measured full-pipeline ULP propagation ≈1e-14 — well-conditioned savgol lstsq; tighter than PR #6/#7's 1e-6, which was a coverage argument for a 4-path scipy stack); a scripts/circumnutation/capture_midline_canary.py canary locks a closed-form circle oracle (κ≡1/R, recovers 0.019982 vs 0.02) SEPARATELY from cross-OS reproducibility, plus a synthetic-generator drift canary. Validated on the real Nipponbare plate-001 fixture (NOT just synthetic, per the PR #7 lesson): per track arc monotonic, max|κ[~mask]|<1 px⁻¹ (observed 0.087–0.170), velocity_sub_noise_mask fraction in (0.1,0.85) (observed 0.376–0.605 — the rice tip moves only ~3× the localization-noise floor, so ~half the frames are genuinely sub-noise; data-dependent, NOT "flags ~nothing"); cross-tier arc_length[-1] ≤ Tier 0 path length L=ratio·D (robust SNR-independent invariant) within ~5% (observed 2.1–3.1%, σ_pos 1.79–1.92). Foundation tests migrated atomically with the first non-raising commit (midline removed from STUB_MODULES+STUBS_WITH_CONSTANTS_KWARG, added to IMPLEMENTATIONS_WITH_CONSTANTS_KWARG as the first array-typed branch + the explicit test_module_logger_is_namespaced list). theory.md §6.2 patched with the y-down + sign(κ)==−handedness clarification (original wording preserved in Appendix B(4)). Reviewed across 2 design.md critical-review rounds + 1 /openspec-review round + 1 user-requested 4th adversarial round (surfacing real issues reconciled before/during implementation: the empirically-false velocity-mask "flags ~nothing" prose, the px/s→px/frame units fix, the atol tightening, the degenerate bool-mask-can't-be-NaN contradiction, the under-specified canary, the MidlineResult.__eq__ ndarray ambiguity, and the ptp-vs-net-displacement gate-divergence documentation). (parent epic #197; see docs/circumnutation/roadmap.md PR #8 row; OpenSpec design.md + docs/superpowers/specs/2026-06-08-add-circumnutation-tier3a-midline-design.md §13 track all reconciliation rounds.)
  • sleap_roots.circumnutation.psi_g.compute — Tier 2 Bastien-Meroz ψ_g trait emission (PR #7 of the circumnutation program; OpenSpec change add-circumnutation-tier2-psi-g). Graduates the psi_g stub into an implementation module (the inverse of PR #6's addition-only transition: implementation count 5 → 6, stub count 6 → 5; renames the stub callable compute_psi_g → compute). Public compute(trajectory_df, cadence_s, constants=None) -> pd.DataFrame per-track loop mirroring nutation.compute minus the coordinate kwarg — ψ_g is computed from the raw 2-D tip trajectory via the locked _geometry.compute_psi_g (atan2(dx, dy), unwrapped). Emits 4 self-contained §7.3 trait columns per (series, sample_uid, plate_id, plant_id, track_id) 5-tuple groupby: T_psig_median_s [s] (COI-masked median of the smoothed-ridge periods of the SG-detrended ψ_g — composes PR #5/#6 compute_scaleogram → extract_ridge → smooth_ridge; the ONLY conditioned trait), delta_E_amplitude_proxy_px_per_frame [px/frame] (median(√(dx²+dy²)) over raw finite velocity samples, Eq. 21 amplitude proxy), handedness [int64 {−1,0,+1}] (int(np.sign(ψ_g[-1] − ψ_g[0])) net unwrapped rotation over all finite frames, COI-free, 1e-9 rad zero-guard; +1 ⇔ positive dψ_g/dt), and helix_signed_area_px2 [px²] (new _geometry.compute_signed_area y-down-corrected Shoelace applied to the growth-detrended tip trajectory — per-axis linear-trend removed so the enclosed area reflects the nutation orbit, not the growth ribbon; sign(area) confirms handedness on real circulating data — ≥5/6 on plate-001, vs 1/6 for the raw Shoelace). Adds geometry helper _geometry.compute_signed_area(x, y) (y-down negated Shoelace 0.5·Σ(x_{i+1}·y_i − x_i·y_{i+1}); <3 points → 0.0; non-finite → NaN). Tier 2 is self-contained — does NOT consume Tier 1 output; the 5th §7.3 trait psig_long_consistency is deferred to the roadmapped PR #13 Layer-3 work (owns both the trait and the cross-tier test). No new constants — reuses SG_WINDOW_DETREND=23/SG_DEGREE=3 + the PR #5 CWT fields; _CONSTANTS_VERSION stays 5; no PIPELINE_UNIT_VOCABULARY change (s/px/frame/int/px² already present). Degenerate handling (never raising, never RuntimeWarning): N<3 → all-degenerate row; 3≤N<24 → T_psig=NaN with raw traits defined; stationary / straight-growth N≥24 → T_psig=NaN via a zero-energy guard (np.allclose(detrended,0) — prevents a spurious 2·cadence_s period from argmax-of-zeros). Deterministic across OSs at atol=1e-6 for the 3 float traits, exact for integer handedness (CWT-free raw atan2); 3-value canary captured on Windows 11 + Python 3.11.13. Cross-tier consistency validated against Tier 0 principal_axis_angle via the reconciled identity circular_mean(ψ_g) ≈ π/2 − principal_axis_angle (branch-cut-safe): synthetic convention-lock (angle-identity at amplitude 0, handedness-lock at amplitude>0) + a plate-001 GREEN-phase reconciliation (all 6 Nipponbare proofread tracks within 0.0311 rad / 1.8°). Field-named input validation reuses the importable temporal_cwt._validate_cadence_s; _check_constants validates the SG fields psi_g consumes (deferring CWT-field validation to compute_scaleogram). Foundation tests migrated: psi_g removed from STUB_MODULES + STUBS_WITH_CONSTANTS_KWARG, added to IMPLEMENTATIONS_WITH_CONSTANTS_KWARG + the explicit test_module_logger_is_namespaced list (atomic with the stub→impl rename so the suite never goes red). Three recorded deviations from theory.md (theory.md §7.3/§6.3 patched in this PR with the originals preserved in Appendix B): handedness COI-free, delta_E px/frame (not px·hr⁻¹), ψ_g conditioning SG-detrend (not the literal "smooth"). Reviewed across 3 design.md critical-review rounds + 2 /openspec-review rounds (surfacing real bugs reconciled before/during implementation: the y-down Shoelace sign error, the SG-detrend conditioning reversal, the 3≤N<24 min-length crash, the handedness COI sign-flip + provenance contradiction, the stationary-tip spurious-period bug, and a dropped smooth_ridge MODIFIED-requirement scenario). (parent epic #197; see docs/circumnutation/roadmap.md PR #7 row; OpenSpec design.md + docs/superpowers/specs/2026-06-05-add-circumnutation-tier2-psi-g-design.md §13 track all reconciliation rounds.)
  • sleap_roots.circumnutation.nutation.compute — Tier 1 Derr-faithful trait emission (PR #6 of the circumnutation program; OpenSpec change add-circumnutation-tier1-derr-faithful). Public compute(trajectory_df, cadence_s, coordinate="lateral", constants=None) -> pd.DataFrame per-track loop mirroring kinematics.compute / qc.compute precedent. Emits 8 trait columns per (series, sample_uid, plate_id, plant_id, track_id) 5-tuple groupby: 3 strictly biological traits NaN-gated when is_nutating == False (T_nutation_median [s], T_nutation_iqr [s], A_nutation_envelope_max_px [px]) + 5 always-populated diagnostics (band_power_ratio [dimensionless], noise_floor_estimate [amplitude units, may still be NaN when undefined], is_nutating [bool], period_residual_vs_derr_reference [dimensionless ridge-of-noise diagnostic], cadence_nyquist_ratio [dimensionless engineering diagnostic; moved from PR #3 per theory.md §6.5 deferral]). 9-step pipeline composes PR #5's CWT machinery with 4 new helper primitives: _geometry.project_to_growth_axis_perpendicular(x, y) (CC-7 lateral-coordinate projection; graceful-NaN on zero net displacement), _noise.compute_sg_detrended(x, window, polynomial_order) (Savitzky-Golay residual per preliminary_results §3.4; input-validated for non-int / non-odd / polyorder ≥ window), _noise.compute_fourier_noise_floor(x, cadence_s, t_nutation_median_s, factor) (CC-8 verbatim — scipy.fft.rfft median over f > factor/T), and temporal_cwt.smooth_ridge(ridge_result, window=None, constants=None) (Mallat 1999 §4.4.2 median-filter post-filter — closes #214; smooths periods_s only, 4 other RidgeResult fields carry through unchanged). GREEN-phase Sci-I1 dimensional fix: is_nutating gate uses in_band_mean_amplitude (amplitude units) compared to 3 × noise_floor_estimate (amplitude units) for dimensional consistency; the spec-defined band_power_ratio trait remains the user-facing emission. DERR_EXPECTED_PERIOD_S = 3333.0 is rice-specific (override via ConstantsT(DERR_EXPECTED_PERIOD_S=...) for non-rice species); period_residual_vs_derr_reference = (T - DERR_EXPECTED_PERIOD_S) / DERR_EXPECTED_PERIOD_S (positive = slower than Derr reference). Promotes 6 new constants to _constants.py (RIDGE_CONTINUITY_FILTER_WINDOW=5, NOISE_FLOOR_OUT_OF_BAND_FACTOR=5.0, BAND_POWER_BAND_LOW_FACTOR=0.5, BAND_POWER_BAND_HIGH_FACTOR=2.0, DERR_EXPECTED_PERIOD_S=3333.0, TEMPORAL_NYQUIST_RATIO_MAX=0.25); bumps _CONSTANTS_VERSION 4→5; snapshot count 29→35. Reciprocal NYQUIST_RATIO_MAX ↔ TEMPORAL_NYQUIST_RATIO_MAX cross-reference docstrings (PR #3 spatial vs PR #6 temporal — numerically equal at 0.25 but semantically distinct). Deterministic across OSs at atol=1e-6 (loosened from PR #5's 1e-9 — PR #6 composes 4 unverified scipy paths via fft / ndimage / signal / stats on top of PR #5's verified pywt); 3-value canary at (T_nutation_median, band_power_ratio, noise_floor_estimate) captured on Windows 11 + Python 3.11.13 + numpy 2.3.4 + scipy 1.16.3 + pywt 1.8.0. Field-named input validation: _validate_nutation_constants rejects non-int / non-odd / non-positive RIDGE_CONTINUITY_FILTER_WINDOW, negative SG_DEGREE, SG_WINDOW_DETREND ≤ SG_DEGREE, BAND_POWER_BAND_HIGH_FACTOR ≤ BAND_POWER_BAND_LOW_FACTOR, negative DERR_EXPECTED_PERIOD_S, non-positive NOISE_FLOOR_OUT_OF_BAND_FACTOR. Foundation tests refactored: IMPLEMENTATIONS_WITH_CONSTANTS_KWARG 4 → 5; nutation added to test_module_logger_is_namespaced explicit list (Copilot-precedent fix); test_schema_version_is_1_and_constants_version_is_4 renamed → ..._is_5. Five GREEN-phase deviations from approved design documented in design.md Appendix: (a) Sci-I1 dimensional gate revision; (b) §2.H.3 Layer-2 acceptance softened to median ±25% AND ≥3 of 6 within ±30% pending preprocessing improvements tracked in new follow-up issue; (c) §2.H.4 #214 acceptance softened to "no track worsens + ≥1 improves" pending multi-plate validation tracked in new follow-up issue; (d) §2.C T-set restricted to {3333, 4500}; (e) §2.E.7 factor sensitivity restricted to {3, 5}. Reviewed across 2 design.md critical-review rounds + 1 /openspec-review round + 1 GREEN-phase Reconciliation Appendix + 2 /copilot-review rounds + 2 /review-pr self-review rounds (~45 BLOCKING + IMPORTANT findings total, all reconciled). Closes #214, closes #215 (parent epic #197; see docs/circumnutation/roadmap.md PR #6 row; design.md Appendix tracks all reconciliation rounds.)
  • sleap_roots.circumnutation.temporal_cwt — temporal CWT machinery (PR #5 of the circumnutation program; OpenSpec change add-circumnutation-temporal-cwt-machinery). Two new public functions (compute_scaleogram(x, cadence_s, constants=None) -> ScaleogramResult and extract_ridge(scaleogram_result, constants=None) -> RidgeResult) + two new public @attrs.define(frozen=True) dataclasses (ScaleogramResult, RidgeResult). Uses cmor1.5-1.0 mother wavelet by default (forensic match to Derr's Sept-2025 oracle); log-spaced 64-scale axis over auto-derived period range [2·cadence_s, 0.25·n_frames·cadence_s]; wavelet-aware pywt.scale2frequency round-trip for period derivation; wavelet-aware √B·scale = √1.5·scale ≈ 1.225·scale COI mask (first concrete realization of the QC tier's coi_fraction_t1 reliability gate from PR #3; empirically verified across cmor0.5/1.0/1.5/2.0 via step-response measurement). Deterministic across OSs at atol=1e-9 (CC-6) with a hardcoded 3-value canary at the resonant scale captured by scripts/circumnutation/capture_temporal_cwt_canary.py. Per-frame argmax ridge with documented Mallat-1999-§4.4.2 continuity caveat (follow-up GitHub issue for PR #6's potential post-filter). No trait emission — Tier 1 traits (T_nutation_median, T_nutation_iqr, A_nutation_envelope_max_px, band_power_ratio, period_residual_vs_derr_reference) all belong to PR #6. Promotes 4 new defaults to _constants.py + ConstantsT (COI_EFOLDING_FACTOR=math.sqrt(1.5), CWT_SCALE_COUNT_DEFAULT=64, CWT_PERIOD_MIN_NYQUIST_FACTOR=2.0, CWT_PERIOD_MAX_SIGNAL_FRACTION=0.25); bumps _CONSTANTS_VERSION 3 → 4. The existing NYQUIST_RATIO_MAX docstring gains a reciprocal cross-reference to CWT_PERIOD_MAX_SIGNAL_FRACTION (numerically equal at 0.25 but semantically distinct). Strict input validation: 1-D finite float-coercible x of length ≥ MIN_FRAMES_REQUIRED derived at call time from the resolved constants (= 9 at defaults); cadence_s accepts Python/numpy int/float but explicitly rejects bool/np.bool_/str. Foundation tests refactored: STUB_MODULES drops temporal_cwt (7 → 6 stubs); STUBS_WITH_CONSTANTS_KWARG drops it (5 → 4); IMPLEMENTATIONS_WITH_CONSTANTS_KWARG gains it (3 → 4); test_module_logger_is_namespaced explicit list gains it (mirroring PR #4's Copilot-fix precedent against the same silent-regression pattern); test_schema_version_is_1_and_constants_version_is_3 renamed → ..._is_4 with bumped assertion. Reviewed across 3 design.md critical-review rounds + 2 /openspec-review rounds (~24 BLOCKING + IMPORTANT findings total, all reconciled). (parent epic #197; see docs/circumnutation/roadmap.md PR #5 row; design.md Reconciliation Appendix tracks all 5 rounds.)
  • sleap_roots.circumnutation.synthetic.generate_trajectory — synthetic tip-trajectory generator for Layer-1 validation (PR #4 of the circumnutation program; OpenSpec change add-circumnutation-synthetic-generator). Closed-form realization of Rivière 2022 Eq. 4 (per docs/circumnutation/theory.md §4 + §4.4 closed-form Δφ = 2·ΔL·δ̇₀/(ωR)); literal Eq. 5 PDE integration deferred per design.md D1 to avoid coupling integration error into Layer-1 tolerances. Exposes user-facing aggregate parameters (amplitude_px, T_nutation_s, growth_rate_px_per_frame, noise_sigma_px, handedness, growth_axis_angle_rad, n_frames, cadence_s) instead of the Rivière 6-tuple — the 6-tuple is mathematically degenerate at the tip-trajectory level (only 3 aggregate observables Δφ, v_growth, ω are recoverable; PR #12's Layer-1 validation will wrap with a Rivière-named translation helper once PR #9 / #11 land spatial-CWT recovery). Per-axis noise σ = noise_sigma_px / √2 so the QC tier's xy-quadrature noise estimators recover noise_sigma_px directly. Deterministic via random_state (CC-6 / NEP 19): same int seed → bit-identical tip_x/tip_y across runs AND across 64-bit OSs. noise_sigma_px = 0 short-circuits the RNG path and preserves caller-supplied Generator state. Promotes 7 new threshold/default constants to _constants.py (SYNTHETIC_T_NUTATION_S=3333.0, SYNTHETIC_AMPLITUDE_PX=10.0, SYNTHETIC_GROWTH_RATE_PX_PER_FRAME=4.29, SYNTHETIC_NOISE_SIGMA_PX=2.0, SYNTHETIC_CADENCE_S=300.0, SYNTHETIC_N_FRAMES=575, SYNTHETIC_GROWTH_AXIS_ANGLE_RAD=π/2) with corresponding ConstantsT override fields; bumps _CONSTANTS_VERSION 2→3 per the version-sentinel contract. ConstantsT resolution-order locked (kwarg > constants > module-default; 7 Optional[float]=None sentinels). Foundation tests refactored: STUB_MODULES drops synthetic (8 → 7 stubs); new IMPLEMENTATIONS_WITH_CONSTANTS_KWARG split-table covers kinematics + qc + synthetic together. (parent epic #197; see docs/circumnutation/roadmap.md PR #4 row; design.md tracks 10 reviewer passes — brainstorm + 3 pre-scaffold reviewers + 2 rounds of 5 OpenSpec-review subagents — in the Reconciliation Appendix.)
  • sleap_roots.circumnutation.qc.compute — QC tier per-track quality-trait emission (PR #3 of the circumnutation program; OpenSpec change add-circumnutation-qc-tier). Per-track signal-quality flags computed directly from tip trajectories per docs/circumnutation/theory.md §7.6. Emits 11 trait columns onto the per-plant DataFrame: 3 independent SLEAP-localization-noise estimators (sg_residual_xy, d2_noise_xy, msd_noise_xy per CC-10), 3 pairwise agreements (sg_d2_agreement, sg_msd_agreement, d2_msd_agreement), 2 outlier-step diagnostics (frac_outlier_steps, worst_step_ratio), growth_axis_unreliable (re-emitted with equality-by-construction to Tier 0 — see PR #3 design.md D5 for the reversal of PR #2's "no re-emission" rule), track_is_clean composite bool, and qc_failure_reason stable-ordered comma-separated diagnostic. Pure-pixel + cadence-independent emission. Extends _noise.py with compute_d2_residual_xy and compute_msd_residual_xy helpers alongside the existing compute_sg_residual_xy — single source of truth for noise math. Promotes 4 threshold constants to _constants.py (FRAC_OUTLIER_STEPS_MAX=0.05, WORST_STEP_RATIO_MAX=5, SG_MSD_AGREEMENT_MAX=1.5, D2_MSD_AGREEMENT_MAX=1.5) with corresponding ConstantsT override fields; bumps _CONSTANTS_VERSION 1→2 per the version-sentinel contract. cadence_nyquist_ratio deferred to PR #6 (depends on Tier 1's T_nutation_median). (parent epic #197; see docs/circumnutation/roadmap.md PR #3 row; design.md, theory.md §7.6, and roadmap.md CC-5 step 3 all updated to reflect the equality-by-construction reversal.)
  • sleap_roots.circumnutation.kinematics.compute — Tier 0 raw kinematic-trait emission (PR #2 of the circumnutation program; OpenSpec change add-circumnutation-tier0-kinematics). Per-track scalar kinematics computed directly from tip trajectories — no spectral analysis, no midline. Emits 9 trait columns + 1 boolean flag (growth_axis_unreliable) onto the per-plant DataFrame: median per-frame step magnitude (rotation-invariant), signed and absolute medians of the longitudinal/lateral projections (4 columns; rotation-dependent — NaN'd by the gate when net displacement is comparable to per-frame noise), the magnitude-based long_lat_ratio, path_displacement_ratio, peak-to-peak angular_amplitude of the unwrapped velocity-direction time series (per Bastien-Meroz 2016 Eq. 20 / theory.md §3.5), and principal_axis_angle. Pure-pixel + cadence-independent emission: velocity traits are px/frame, downstream user composes convert_to_mm and a future convert_to_per_hour utility for mm/hr output. Adds two shared internal helpers (_noise.compute_sg_residual_xy, _geometry.compute_psi_g) reserved for reuse by PR #3 QC and PR #7 ψ_g respectively. Adds private _io._build_per_plant_template_from_df helper to support tier modules with raw-DataFrame canonical signatures (public build_per_plant_template API unchanged). Theory.md §7.1 expanded from 7 to 9 traits + flag with explicit signed/abs conventions. (parent epic #197; see docs/circumnutation/roadmap.md PR #2 row)
  • New test fixture tests/data/circumnutation_nipponbare_plate_001/ — proofread tracked .slp from the same Nipponbare 0.8% PG MOCK plate that produced the prelim §4.1 numbers (575 frames, 6 tracks, single-node skeleton, ~362 KB Git LFS). Anchors the Tier 0 reference-value sanity test and is reserved for reuse by subsequent tier PRs (#5 Tier 1, #7 Tier 2, etc.).
  • sleap_roots.circumnutation — first OpenSpec change add-circumnutation-foundation (PR #1 of the multi-PR program tracked in docs/circumnutation/roadmap.md). Foundation contracts only — no spectral analysis, no trait emission, no pipeline class. Adds the package skeleton (5 contract modules + 10 stub modules raising NotImplementedError with PR-number references), CircumnutationInputs data class, ROW_IDENTITY_COLUMNS schema, units sidecar JSON, run-metadata sidecar JSON with full provenance fields (git SHA, schema/constants versions, constants snapshot), module-level constants in _constants.py, and the pure-pixel convert_to_mm() downstream-conversion utility. Theory and feasibility docs landed at docs/circumnutation/theory.md, preliminary_results_2026-05-07.md, and roadmap.md. (#198, parent epic #197)
  • TrackedTipPipeline — root-agnostic pipeline that consumes SLEAP-tracked .slp predictions and emits per-track tip-trajectory data plus a minimum-viable substrate of per-track geometric scalars (tip_trajectory_length, tip_displacement_net, tracking_coverage, n_frames_tracked, n_frames_total). Substrate-only by design; velocity / curvature / circumnutation traits live in separate downstream pipelines that reuse this substrate. (#129, Workstream 2 of the 2026-04-23 timelapse design)
  • Series.get_tracked_tips(root_type=None) — long-format DataFrame of per-track tip rows (track_id, frame, tip_x, tip_y), sorted by (track_id, frame). Supports single-node and multi-node skeletons via the [-1] skeleton convention.
  • validate_tracked_slp(slp_path) and validate_series_for_tracked_tip(series, root_type=None) module-level helpers in sleap_roots.series for input precondition checks.
  • New notebooks/TrackedTipPipeline_Mermaid_Graph.md — DAG visualization for the new pipeline.
  • New test fixture tests/data/circumnutation_plate/ — real tracked .slp from the circumnutation source data (184 KB, 311 frames, 6 tracks, single-node skeleton) plus synthesized plate-level metadata CSV and per-#168-template README.
  • Comprehensive MkDocs documentation site with Material theme
  • Auto-generated API reference from docstrings
  • User guides for all pipeline types (7 pipelines)
  • Trait reference documentation
  • Developer guides and contributing documentation
  • 8 tutorial pages for all pipelines
  • Cookbook with code recipes (filtering, custom traits, batch optimization, exporting)
  • Troubleshooting guide with common issues and solutions
  • uv package manager support with PEP 735 dependency groups
  • A3-traits trait-extractor service (trait_extractor/, a top-level package excluded from the published wheel; OpenSpec change add-trait-extractor-service). Consumes sleap-roots-predict's per-scan {scan_key}.predictions.json manifest + named per-root .slp and a new {scan_key}.scan_metadata.json ScanMetadata sidecar, selects a pipeline via choose_pipeline (ported chooser, contiguous [age_min, age_max] windows), computes scan-grain traits, and emits a per-scan sleap-roots-contracts ResultEnvelope as JSON for Bloom write-back. Runnable as python -m trait_extractor <input_dir> <output_dir> with per-scan failure isolation. ResolvedParams.values is canonicalized to the closed set {species, mode, age:int} so the idempotency key is reproducible; contract_version is the pinned bare 0.1.0a3 (since bumped a3 → a7 → a9; see below). The consumer-side PredictionManifest duplicates predict's schema (coupled via schema_version: "1", guarded by a skip-if-unimportable cross-check test). Docs: docs/dev/trait-extractor-service.md.
  • GHCR image ghcr.io/talmolab/sleap-roots-trait-extractor for the trait_extractor/ service (OpenSpec change add-trait-extractor-image), built from a root trait-extractor.Dockerfile (base ghcr.io/astral-sh/uv:python3.12-bookworm-slim; ENTRYPOINT ["python","-m","trait_extractor"]; headless MPLBACKEND=Agg) and published by a path-filtered docker-trait-extractor.yml — build-only on PRs, build + push on main (image identity kept distinct from the library's ghcr.io/talmolab/sleap-roots, and the workflow shares no trigger with the PyPI build.yml). A slim [project.optional-dependencies] extractor extra installs sleap-roots-contracts + pyyaml into the image without the dev/docs tooling (uv sync --frozen --no-dev --extra extractor). The image bakes its build commit into SRT_TRAITS_CODE_SHA so emitted envelopes carry a non-empty provenance.traits_code_sha. Tags: latest on main + immutable sha-<sha> (and the @sha256:… digest is surfaced in the run summary). See docs/dev/trait-extractor-service.md#container-image.
  • trait_extractor consumes RunManifest + adds skip-if-done (OpenSpec change add-run-manifest-consumption; the sleap-roots leg of the cross-repo manifest-scoped-processing initiative, talmolab/sleap-roots-pipeline#37). Bumps sleap-roots-contracts 0.1.0a3 → 0.1.0a7 (needed for RunManifest/RUN_MANIFEST_FILENAME, new in a7). extract_batch now looks for run_manifest.json at the top of input_dir: when present, discovery is scoped to exactly its scan_keys (a .predictions.json present but out of scope is silently excluded — the contamination-prevention this manifest exists for; a manifest-declared scan_key with no matching file is a per-scan failure; two candidates for the same in-scope scan_key are refused via the existing duplicate guard, not silently picked; the manifest is copied forward into output_dir so write-back can see it); when absent, falls back to the original unscoped recursive discovery unchanged. Adds skip-if-done from scratch (previously absent entirely): each scan's Provenance is built early (before Series loading/pipeline selection/trait computation) and compared against any pre-existing {scan_key}.result.json's idempotency_key and contract_version — both must match to skip (comparing idempotency_key alone would let a stale contract_version survive a contracts pin bump silently, since contract_version isn't one of idempotency_key's hash inputs). A corrupt/unreadable pre-existing output is treated as not-done (recomputed, logged as a warning) rather than crashing the batch. New BatchResult.skipped bucket, and python -m trait_extractor now reports skipped scans distinctly from ok/FAIL. Deploy note: Bloom's live insert_cyl_result_envelope write-back RPC hard-pins its accepted contract_version to 0.1.0a3 (bloom PR #399) and will reject 0.1.0a7 envelopes until re-pinned — tracked as bloom#685; this repo's image must not be redeployed to the pipeline before that lands. (Resolved: bloom PR #766 re-pinned it to 0.1.0a7; bloom#685 closed 2026-09-10.)
  • trait_extractor batch CLI: Argo-ready exit codes + empty-input guard + SIGTERM handling (OpenSpec change harden-trait-extractor-exit-semantics; resolves sleap-roots#259, reconciled uniformly with the identical hardening in sleap-roots-predict#26). python -m trait_extractor <in> <out> now exits one of three driver-owned codes instead of the old two-way 0/1: 0 full success, 3 partial (one or more scans isolated-failed but the batch ran to completion — an Argo caller should treat this as done-with-partial-failures, not retry the whole batch), 1 crash (an exception escaped the batch entirely, e.g. an invalid run_manifest.json or the new empty-input guard below). Exit code 2 is deliberately left untouched — argparse already owns it for CLI usage errors, and reusing it for partial (the first draft's choice) would have made a misconfigured invocation indistinguishable from a completed batch with isolated failures. extract_batch's unscoped fallback now raises rather than silently succeeding when it discovers zero manifests anywhere (an empty or misconfigured input mount is an operator error, not a no-op); the scoped (run_manifest.json-present) case was already correctly reported as a per-scan failure and is unaffected. Adds a SIGTERM handler so Argo preemption/cancellation exits promptly with 143 instead of waiting out terminationGracePeriodSeconds — per-scan writes are already atomic (temp→rename) and the batch is idempotent on retry, so this closes a preemption-latency issue, not a data-loss one. BREAKING (informational): any caller checking exit 1 specifically for "some scan failed" must switch to checking 3; treating any nonzero exit as failure still works unchanged.
  • trait_extractor adopts sleap-roots-contracts 0.1.0a9's per-run run-manifest reader (OpenSpec change adopt-contracts-run-manifest-reader; the sleap-roots reader leg of talmolab/sleap-roots-pipeline#71). Bumps sleap-roots-contracts 0.1.0a7 → 0.1.0a9 in all three pins (uv.lock re-locked; a new test asserts the pins agree). The run identity comes from ARGO_WORKFLOW_NAME via contracts' pipeline_run_id_from_env() (new keyword extract_batch(..., pipeline_run_id=...): omitted → environment, explicit None → no identity), and the manifest is loaded once by contracts' load_run_manifest(..., allow_legacy=True): with an identity, run_manifest.<pipeline_run_id>.json first, then the legacy run_manifest.json (until pipeline#82); without one, the legacy name alone. A known identity with no manifest (RunManifestMissingError), a per-run manifest naming another run (RunManifestIdentityError), or an unusable id (ValueError) now aborts with exit 1 and a Batch aborted: line instead of widening to unscoped discovery; local and local-WSL2-* runs (no identity) keep the same scoping. While the legacy fallback is on, a legacy manifest that names this run but still carries other runs' keys (the #71 case) is honored silently; that closes only when bloomctl writes per-run names and pipeline#82 flips allow_legacy. Two cases are honored but logged as a warning: a legacy manifest naming another run, and per-run manifests left unread because the run was not scoped by a per-run manifest (unscoped, or scoped by the legacy file). The manifest is forwarded from the loaded snapshot under the name it was read from, with the source's mode, via a dot-prefixed temp file that is now removed on any failure including SIGTERM (the sleap-roots residue of talmolab/sleap-roots-predict#40); still best-effort. Behavior changes: a nonexistent input_dir raises FileNotFoundError (was RuntimeError); a non-UTF-8 manifest raises pydantic.ValidationError (was UnicodeDecodeError); a dangling-symlink or directory-named manifest aborts instead of reading as absent; exit codes are unchanged in meaning. Deploy note: envelopes now stamp contract_version = "0.1.0a9"; Bloom's insert_cyl_result_envelope accepts only 0.1.0a7 and will reject them until it accepts 0.1.0a9 (bloom#895) — an image from this change must not be applied to the cluster's trait-extractor template before that Bloom change is applied. The first run after deploy recomputes every in-scope scan (new traits_code_sha and contract_version; contracts' idempotency-key derivation is unchanged).
  • trait_extractor runs scans older than their species' window with that window's pipeline (OpenSpec change clamp-past-window-pipeline-selection; phase 1 of bloom#971). A scan whose age is above every pipeline_selection.yaml window for its species + mode now matches that species + mode's highest window, with no upper limit (e.g. rice day 18 → OlderMonocotPipeline); before, it raised No pipeline matches. The new pipeline_chooser.past_window_age(params, cards) returns the matched-as age, choose_pipeline keeps its signature, and extract_scan logs one past-window age: WARNING per clamped scan naming the scan key. params, provenance and the idempotency key keep the real age; younger-than-window, in-gap and no-card scans still raise. These traits come from a pipeline validated only up to the window's age_max, so they extrapolate past it; for rice above day 10 that is OlderMonocotPipeline, which computes crown-root traits only. A batch whose only failures were past-window scans now exits 0 instead of 3, and those scans become new Bloom rows. Deploy note: a past-window scan still fails until sleap-roots-predict's matching choose_models change is live too; re-pin both templates together, or traits first.
  • trait_extractor selects pipelines for wheat and sorghum (OpenSpec change add-wheat-sorghum-pipeline-cards; #276, step 3 of talmolab/sleap-roots-pipeline#118). New pipeline_selection.yaml cards, whose pipeline classes match past hpi_dev production runs (the windows come from the predict cards): wheat cylinder 5–14 → OlderMonocotPipeline, sorghum cylinder 3–14 → DicotPipeline. Older scans match the 14-day window, so their traits are extrapolated; younger ones still raise No pipeline matches. Wheat gets crown_* traits plus network-level traits computed from the crown points only: its model labels all seminal roots, including the primary, as crown, as the older-rice model does, and wheat laterals are not predicted. Sorghum's models predict only primary and lateral roots, hence DicotPipeline. The windows must equal the predict cards' in talmolab/sleap-roots-training#72; nothing checks this across repos. Deploy note: sleap-roots-pipeline#119 must deploy this image before training#72 links the cards to production, or every wheat and sorghum scan runs GPU inference and then fails at traits. The first run after deploy recomputes every in-scope scan (new traits_code_sha).

Internal

  • Started per-pipeline-module pattern: TrackedTipPipeline lives in sleap_roots/tracked_tip_pipeline.py instead of being appended to the existing 3763-line trait_pipelines.py megafile. Splitting the existing 8 pipelines is tracked in #189.

Changed

  • requires-python raised from >=3.7 to >=3.10. The previous declaration was stale: the lockfile pulls numpy>=2.x which itself requires Python 3.10+, and CI runs only Python 3.11 (.python-version). Python 3.7 (EOL June 2023), 3.8 (EOL Oct 2024), and 3.9 (EOL Oct 2025) classifiers removed; the project now declares the floor that matches actual support. tool.black.target-version = ["py310"] set explicitly so black uses the 3.10+ parenthesized-context-manager syntax. One pre-existing test (tests/test_viewer.py) was reformatted as a consequence — single multi-context-manager with-statement converted from the line-continuation form to the parenthesized form.
  • Added pywavelets>=1.5 and explicit scipy to runtime dependencies (used by future sleap_roots.circumnutation tier PRs). scipy was previously transitive via scikit-image; the explicit declaration prevents silent breakage.
  • Updated installation documentation with uv best practices
  • Enhanced developer setup guide with modern workflows
  • Migrated to uv for development dependency management
  • Added sleap-roots-contracts==0.1.0a3 (since bumped a3 → a7 → a9; see Added) as a dev/test/container dependency (marked python_version >= '3.11'), used only by the trait_extractor service — NOT a published runtime dependency of sleap-roots (a CI-enforced AST guard test asserts the sleap_roots library never imports it). It lives in the dev groups and a slim extractor extra (which the container image installs; also reachable as an opt-in pip install sleap-roots[extractor], which adds the contract types — not the top-level service package — while pip install sleap-roots stays pure). Set [tool.pytest.ini_options] pythonpath = ["."] so tests can import the top-level service package, and added trait_extractor* to [tool.setuptools.packages.find] exclude. Extended ci.yml to lint/test trait_extractor/ and added trait_extractor/** to the CI trigger paths.

0.1.4 - 2024-11-10

Added

  • MultiplePrimaryRootPipeline for analyzing plants with multiple primary roots
  • MultipleDicotPipeline tests for multi-plant batch analysis
  • Comprehensive Claude Code slash command suite for developer workflows
  • OpenSpec project documentation and change management

Changed

  • Improved test coverage across pipeline classes
  • Updated README with latest pipeline examples

0.1.3 - 2024-10-29

Added

  • LateralRootPipeline for lateral-root-only analysis

0.1.2 - 2024-08-26

Changed

  • Version bump and maintenance release

0.1.1 - 2024-08-26

Fixed

  • Corrected crown-curve-indices definition in trait pipeline
  • Applied Black formatting to test files

0.1.0 - 2024-05-13

Added

  • Series.load() method for loading SLEAP predictions directly
  • High-level imports: find_all_h5_paths, find_all_slp_paths, load_series_from_h5s, load_series_from_slps
  • Increased test coverage across modules

Changed

  • Breaking: Series class now takes SLEAP predictions directly using Series.load()
  • Breaking: H5 paths are now optional (but required for plotting)
  • Breaking: series_name is now an attribute instead of a property
  • Breaking: find_all_series removed (use find_all_h5_paths or find_all_slp_paths)
  • Upgraded Python requirement to 3.11
  • Improved geometry intersection helper functions

0.0.9 - 2024-04-23

Added

  • Quality control property for batch processing over genotypes

Fixed

  • Edge cases in older monocot pipeline traits

0.0.8 - 2024-04-12

Added

  • Jupyter notebooks for code instruction
  • Enhanced documentation
  • JupyterLab to development environment

Changed

  • Excluded Jupyter notebooks from language statistics

Fixed

  • Tips calculation functions

0.0.7 - 2024-03-31

Added

  • MultipleDicotPipeline for analyzing multiple dicot plants simultaneously

0.0.6 - 2024-03-11

Added

  • OlderMonocotPipeline for mature monocot analysis

Changed

  • Updated README with pipeline examples

0.0.5 - 2023-10-08

Added

  • YoungerMonocotPipeline for younger monocot plants

Changed

  • Renamed grav_index to curve_index for clarity

Fixed

  • get_network_distribution function

0.0.4 - 2023-09-13

Changed

  • Version bump

0.0.3 - 2023-09-13

Changed

  • Updated sleap-io minimum version to 0.0.11

0.0.2 - 2023-09-12

Added

  • Python 3.7 compatibility
  • Checks and tests for ellipse fitter

Fixed

  • Node index calculation
  • Dicot pipeline edge cases
  • Ellipse fitting robustness

0.0.1 - 2023-09-03

Initial release of sleap-roots package.

Added

  • Core Series class for SLEAP prediction data
  • DicotPipeline for dicot root analysis
  • Trait computation modules:
  • bases - Root base detection and analysis
  • tips - Root tip identification
  • angle - Root angle measurements
  • convhull - Convex hull calculations
  • lengths - Root length measurements
  • networklength - Network-level metrics
  • scanline - Scan line analysis
  • ellipse - Ellipse fitting
  • points - Point extraction utilities
  • summary - Summary statistics
  • Test suite with fixtures for rice and soy
  • Basic plotting functionality
  • sleap-io integration for loading predictions

New Contributors

  • @talmo - Project lead and core architecture
  • @eberrigan - Primary developer, pipelines and traits
  • @linwang9926 - Trait modules and testing
  • @emdavis02 - Test coverage improvements