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 theSeries → CircumnutationInputsadapter (PR #17 of the circumnutation program; OpenSpec changeadd-circumnutation-cli). Two NEWLY created modulesadapters+cli(implementation count 12 → 14 by ADDITION — two modules in one PR, a first for the program; stub count UNCHANGED at 1, onlyparametric/PR #11 remains). Oneanalyzecommand 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_idcoercion (strips one leading"track_", then the remainder must be unsigned ASCII digits — raisesValueErrornaming the offender rather than letint()silently mis-coerce"track_-1"/"track_1_2"→12/"track_ 1"/Unicode digits;"track_track_1"raises, not the global-.replacecorruption to1);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);timepointread as the rawget_metadatacell andstr-normalized (object dtype, bypassing the float-coercingSeries.timepointproperty); the six string identity columns forced toobjectdtype 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}whereidentity_sourceis total over the six adapter-populated fields (series/sample_uid/timepoint/plate_id/genotype/treatment;plant_id/track_idexcluded — derived from the.slp) mapping each to"flag"/"metadata_csv"/"default"(theseriesfilename-stem default) /"absent"(NaN). Logs a WARNING when--metadata-csvis supplied butsample_uidmatches noplant_qr_coderow (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— acircumnutationclick group +analyzecommand registered on the root CLI viamain.add_command(circumnutation)(mirroringviewer);--cadence-sand--sample-uidrequired (--sample-uidis the stable QR/sample id + metadata-CSV join key, never silently synthesized, per CC-4);--output-dirdefaults to./<series-name>_circumnutation/. Gated per-genotype aggregation (--no-aggregate): when aggregation is on (default) andgenotypeis unresolved,analyzehard-errors (exit 1, before any output is written) rather than emit a vacuous(NaN,NaN,NaN)per-genotype CSV;--no-aggregateopts out (per-plant + plots only, no genotype required).--no-plotsgates the matplotlib import entirely; otherwisematplotlib.use("Agg", force=True)runs beforeplottingis imported (headless;force=Trueis load-bearing becauseSeriesimportspyplotat module top). Error contract mirrorsviewer/cli.py: click handles usage errors (exit 2 — missing required option, nonexistent.slp, non-numeric cadence); the adapter normalizes its raisers toValueError; the command body is wrapped in onetry/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 afinallyso the CLI's global logging config doesn't leak; the CLI also quiets matplotlib's cosmeticfont_managerfindfontfallback WARNINGs (the scaleogramLogNormcolorbar's mathtext) toERROR. CC-3 pure-pixel: no--px-per-mm;--helpstates outputs are pixel-native (pointing toconvert_to_mm), documents the image y-down convention, and notestimepointis a string label. Output tree: a top-level<output-dir>/run_metadata.json(the canonical run provenance, and the target ofsave_plots' hardcodedrun_metadata_ref: "../run_metadata.json") +per_plant/(CSV +.units.json+run_metadata.json) +per_genotype/(only when aggregating) +plots/(only when plotting) — all threerun_metadata.jsonbyte-identical (the CLI assembles provenance ONCE viagather_run_metadataand writes viawrite_per_plant_csv/write_per_genotype_csvdirectly, NOTCircumnutationPipeline.save(), so the identity provenance is complete and the sidecars share onetimestamp). The distinct subdirs sidestep #238 (each_iowriter's fixed-namerun_metadata.jsonlives in its own directory) without changing the_iocontract — referenced, not closed._io.gather_run_metadatagains optionalmetadata_csv_path/metadata_csv_sha256/identity_source(appended afterR_px; backward-compatible — existing callers writenull). Tested viaclick.testing.CliRunner(exit codes, validation, error paths, gated aggregation,--no-plots,-v/-vv, provenance, logging-restore) on a synthetic tracked-.slpround-trip (PIL TIFF →sio.save_slp→ realSeries.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=MOCKfrom 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 plotsrun_metadata_ref). No new constants (_CONSTANTS_VERSIONstays 6); no new dependency (clickalready present). Two spec scenarios reconciled during TDD (interiortrack_track_1raises; the unreachable "partial genotype" scenario removed — genotype is filled uniformly per run). Reviewed across brainstorm (D1–D8) + a design critical-review + 3/openspec-reviewrounds (each finding a real issue — round 2 caught a BLOCKING dangling plots-provenance pointer from the subdir split, fixed via the top-levelrun_metadata.json) + 2/review-prself-review rounds (pre-PR + post-PR, surfacing the blank-genotype gate gap, the strict-track_idUnicode hole, the logging leak, and thefindfontnoise — all reconciled). Follow-up #245 (threadinput_path/identity provenance intosave_plots' ownplots_metadata.json— distinct from #241). Closes #244 (parent epic #197; OpenSpecdesign.md+docs/superpowers/specs/2026-06-24-add-circumnutation-cli-design.mdtrack all reconciliation rounds.)sleap_roots.circumnutation.plotting— the circumnutation diagnostic-plotting layer (PR #16 of the circumnutation program; OpenSpec changeadd-circumnutation-plots). Graduatesplottingstub → implementation (implementation count 11 → 12, stub count 2 → 1 — onlyparametric/PR #11 remains a stub). Four public callables:scaleogram(scaleogram_result, out_path, *, ridge_result=None) -> Path(polymorphic over the temporalScaleogramResultand spatialSpatialScaleogramResult; power|C|²heatmap on aLogNormcolor scale with explicitn+1cell edges, cone-of-influence dimming, optional ridge overlay faded inside the COI; spatial wavelength axis labeled uncalibrated pywt convention; raisesTypeErroron an unsupported scaleogram type or a mismatchedridge_result),trail_overlay(midline_result, out_path) -> Path(the smoothed tip path as a per-segmentLineCollectioncolored by the signedcurvature_px_invon a diverging colormap symmetric about 0 with limits ±the 98th percentile of|κ|; NaN-κ segments via a copied colormap'sset_bad; image-y-down;extend="both"colorbar),plate_panel(midline_results, out_path) -> Path(a 2×3 grid sharing one κ normalization + one colorbar; raisesValueErrorfor > 6 plants), andsave_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_ridgefor Tier 3 — so the plotted signal equals the analyzed input). PNGs go to aplots/subdirectory ofout_dir(which must already exist) with filenames keyed on the per-plant integertrack_id(plant{track_id}_scaleogram_temporal.png,…_scaleogram_spatial.png,…_trail.png, onepanel.png), plus aplots_metadata.jsonprovenance sidecar (constants version, resolved display constants, source identities, written filenames, andinputs.run_idas the join key torun_metadata.json; strict JSON).enabled=Falseis a no-op returning[](the--no-plotshook; 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 inConstantsT— so_CONSTANTS_VERSIONstays 6; no new dependency (matplotlib already present). Tested with theAggbackend 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-promisedL_gzarc-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-reviewrounds (each of the first two surfacing a real BLOCKING bug — the_select_signalre-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 changeadd-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 #6nutation/ PR #10traveling_wave); the 2 stubs (parametric,plotting) are unchanged. Pureaggregate_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=Falseso 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>_iqrfor 32 numeric traits (NaN-skipping median; IQR = Q75−Q25 via module-qualifiedscipy.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 — NOTSeries.mode— tie-break smallest abs then smallest signed) +handedness_consensus_frac. 73-column output. Plants withtrack_is_clean == Falseare excluded from every stat (the per-planttrack_is_clean/qc_failure_reasonalready 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 circularradangle) 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_px2is 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 byhandedness_mode/consensus_frac;growth_axis_unreliableis dropped (atrack_is_cleanfailure 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 notgroupby().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_csvmirror 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)(thePIPELINE_UNIT_VOCABULARYmembership check stays inline in each writer). Pure-pixel (CC-3); no new units-sidecar vocabulary and no new constants (_CONSTANTS_VERSIONstays 6). #222 dependency noted: the derived_median/_iqrnames inherit the (currently unsuffixed) source names, so #222's unit-suffix rename must treat_median/_iqras 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-reviewrounds (surfacing real bugs reconciled before implementation: wrong test-file targets, the roadmapplant_id-keying contradiction, therun_metadata.jsonfixed-name clobber, theprincipal_axis_anglewrapping-angle median bug, the NaN-group-key silent-drop, and thehelix_signed_area_px2chirality-cancellation bug). (parent epic #197; tracking issue child of #197; OpenSpecdesign.mdtracks all reconciliation rounds.)sleap_roots.circumnutation.pipeline.CircumnutationPipeline/compute_traits— circumnutation pipeline composition (PR #14 of the circumnutation program; OpenSpec changeadd-circumnutation-pipeline). Graduates thepipelinestub into an implementation module (same stub→impl shape as PR #7/#8/#9: implementation count 9 → 10, stub count 3 → 2; callablecompute_traitsKEEPS its name). Composes the five trait-emitting tiers (Tier 0kinematics, QCqc, Tier 1nutation, Tier 2psi_g, Tier 3ctraveling_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 networkxTraitDefDAG the stub docstring/roadmap row described: the circumnutation tiers are per-trackDataFrame→DataFramefunctions, 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.defineclass with a singleconstantsfield; purecompute_traits(inputs, constants=None) -> (per_plant_df, trajectory_df, units_dict)(module-level wrapper preserved) + a separatesave(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 (boolis_nutating, inthandedness, stringqc_failure_reason). Dedups the Tier 0/Tier 1 recompute PR #10traveling_wave.computedoes redundant-by-design:traveling_wave.computegains keyword-onlytier0_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. Standalonetraveling_wave.compute(kwargs omitted) is byte-identical — its 37-test suite + cross-OS canary stay green.growth_axis_unreliablecoalesced to the Tier-0-owned column (QC's copy dropped after aSeries.equalsdtype+value cross-tier equality guard). Adds the_NUTATION_TRAIT_UNITS/_PSIG_TRAIT_UNITSmaps 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_idnow captured inrun_metadata.json(additive_io.gather_run_metadata(cadence_s=None, R_px=None)kwargs, nullable for existing callers;savethreads them frominputs) so a run is reproducible from its sidecars alone —cadence_sdetermines every period trait and the residual and is otherwise unrecoverable from the.slp. No new constants (_CONSTANTS_VERSIONstays 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_residualfinite < 0.30 (QPB band), cross-tiergrowth_axis_unreliableequality,run_metadata["cadence_s"] == 300.0. Foundation tests migrated atomically with the first non-raising commit (pipelineremoved fromSTUB_MODULES;STUBS_WITH_CONSTANTS_KWARG+ its now-subjectlesstest_stub_accepts_constants_kwargdropped —pipelinewas the lastconstants=Nonestub; added toIMPLEMENTATIONS_WITH_CONSTANTS_KWARG+ the explicittest_module_logger_is_namespacedlist + a dedicatedtest_implementation_accepts_constants_kwargbranch building aCircumnutationInputs). Deferred: theSeries → CircumnutationInputsadapter → PR #17 (CLI). Reviewed across brainstorm (D1–D8) + 4/openspec-reviewrounds (surfacing real issues reconciled before implementation: a broken merge-on-5-tuple that would_x/_y-collide the identity columns, the missingcadence_sprovenance/reproducibility hole, the dedupatol=0scoping to nutation-accepted constants, and the pinned units traps). Closes #234 (parent epic #197; OpenSpecdesign.md+docs/superpowers/specs/2026-06-14-add-circumnutation-pipeline-design.mdtrack all reconciliation rounds.)sleap_roots.circumnutation.traveling_wave.compute— Tier 3c traveling-wave validation traits (PR #10 of the circumnutation program; OpenSpec changeadd-circumnutation-tier3c-traits). A trait-emission module (mirroring PR #6nutation/ PR #7psi_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 #6nutation); 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-robustMAD/medianspread; renamed from the theory §7.4 nameapex_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 chainmidline.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 crashescompute()). 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_TUPLEwith int64 coercion (a dtype mismatch would silently NaN, not raise; redundant-by-design — the PR #14 DAG dedups). Division-guarded (vnon-finite orlambda_expected ≤ 0→ NaN, noRuntimeWarning); non-nutating tracks NaN the residual via the NaN-gatedT_nutation_medianwhile the pure-spatial λ traits stay valid. cgau2 calibration applied in true px via an in-package n-averaged_CGAU2_LAMBDA_CALIBRATIONliteral (the per-nratio scatters ~7 % non-monotonically acrossn∈{200,400,600}, so the consumer averages → a documented ~±5 % systematic; the module never readstests/dataat 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λ_trueknots 100/120/140/150 for alln, appends at the end → +12 rows, existing 18 byte-identical;λ_reportednow reaches ~168 px, covering the real plate-001 λ ~142.5 without clamping). No new constants — reusesCOI_FRACTION_MAX=0.5(its first consumer; spatial COI gatecoi_valid_fraction < 1−COI_FRACTION_MAX, strict);_CONSTANTS_VERSIONstays 6; adds a_TRAVELING_WAVE_TRAIT_UNITSmapping (#222 — px/dimensionless, all inPIPELINE_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-1atol=1e-6canary. 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 actualcompute()on all 6 tracks yieldstraveling_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 readslambda_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-reviewround + 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; seedocs/circumnutation/roadmap.mdPR #10 row; OpenSpecdesign.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 changeadd-circumnutation-tier3b-spatial-cwt). Graduates thespatial_cwtstub into an implementation module (same stub→impl shape as PR #7/#8: implementation count 7 → 8, stub count 4 → 3; callablecompute_scaleogramKEEPS its name, the speculativewavelet=/scale_range=stub kwargs are dropped — derived fromconstants, mirroringtemporal_cwt). A machinery PR mirroring PR #5temporal_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 axiss_a = max(surviving arc) − arcper theory §6.5, dedups duplicate-s_aknots by averaging κ, picksds = median(positive Δs_a), interpolates onto a uniform grid offloor(arc_span/ds)+1points; 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-finitekappa; cgau2-calibrated√?·scale ≈ 1.375·scaleCOI 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 oftemporal_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 ofCOI_EFOLDING_FACTOR=√1.5;CWT_WAVELENGTH_MIN_NYQUIST_FACTOR=2.0,CWT_WAVELENGTH_MAX_SIGNAL_FRACTION=0.25— spatial siblings of the temporalCWT_PERIOD_*); bumps_CONSTANTS_VERSION5 → 6; snapshot 35 → 38. Deterministic across OSs atatol=1e-9(nocurve_fit—np.interp+pywt.cwt(cgau2)+ argmax; a planted-sinusoid canary captured byscripts/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 (thepywt.scale2frequency("cgau2")convention over-reports λ by a λ/n-dependent ≈+4–16%, recorded as a machine-readable artifacttests/data/circumnutation_spatial_cwt_calibration.jsonfor PR #10 — NOT bias-corrected in the shippedwavelengths_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_cgrowth-zone-structure traits (theory §7.4): a pre-specresearch:investigateon 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_care deferred to follow-up research issue #230 (which blocks PR #10'sL_gz-dependent traits + mask, but NOTtraveling_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]vsv[px/frame]·(T_nutation_median[s]/cadence_s)— emittedT_nutation_medianis SECONDS, the §7.x "hr" annotation is stale; the cgau2 calibration map; the COI gate; and theapex_basal_period_consistencyλ-offset-does-not-cancel trap). Foundation tests migrated atomically with the first non-raising commit (spatial_cwtremoved fromSTUB_MODULES+STUBS_WITH_CONSTANTS_KWARG, added toIMPLEMENTATIONS_WITH_CONSTANTS_KWARG+ the explicittest_module_logger_is_namespacedlist; 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-reviewrounds (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; seedocs/circumnutation/roadmap.mdPR #9 row; OpenSpecdesign.md+docs/superpowers/specs/2026-06-09-add-circumnutation-tier3b-spatial-cwt-design.mdtrack all reconciliation rounds.)sleap_roots.circumnutation.midline.reconstruct— Tier 3a tip-trail-as-midline reconstruction machinery (PR #8 of the circumnutation program; OpenSpec changeadd-circumnutation-tier3a-midline). Graduates themidlinestub into an implementation module (same stub→impl shape as PR #7: implementation count 6 → 7, stub count 5 → 4; the callable KEEPS its namereconstruct, no rename). A machinery PR mirroring PR #5temporal_cwt(input-validating, a frozen@attrs.define(frozen=True, eq=False)MidlineResult, a determinism contract — and no trait emission), NOT the per-trackcompute()shape of PR #6/#7. Publicreconstruct(x, y, cadence_s, sg_window=None, constants=None) -> MidlineResultreconstructs the organ midline from the tip trail (theory.md §6.1) parameterized by arc lengths(τ)=∫|v|dσ, with per-frame trajectory curvatureκ(theory.md §6.2) and tip speed.MidlineResultcarries 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/sdeliberately not used, theory.md §10 Appendix B):arc_length_px/curvature_px_inv/the mask are bit-identical forcadence_s=300vs1;cadence_sis 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_polyorderboundary validator shared withcompute_sg_detrended, and adds a deriv-range check — scipy silently returns all-zeros forderiv>polyorderand an opaquefactorial()error forderiv<0), and_geometry.compute_path_curvature(x_dot, y_dot, x_ddot, y_ddot)(κ=(ẋÿ−ẏẍ)/|v|³, px⁻¹; sign pinned by the absolute anchorcompute_path_curvature([1],[0],[0],[1])==+1.0with the y-down disambiguation + the cross-helpersign(κ)==−handednessnote — the ψ_g family's swappedatan2(dx,dy)givesdψ_g/dt=−κ·|v|). Converts_noise.pyto module-qualifiedscipy.signal.savgol_filter(all 5 call sites; drops the lone bare import) per the program scipy-import rule. No new constants — reusesNOISE_MASK_K=2/SG_WINDOW_SHORT=5/SG_DEGREE=3;_CONSTANTS_VERSIONstays 5; noPIPELINE_UNIT_VOCABULARYchange. Two deliberate scope splits (CC-1): builds NOL_gzgrowth-zone mask (→ PR #10) and does NO uniform-arc-length resampling (→ PR #9, whosecompute_scaleogram(kappa, ds)stub owns the resample of the native NON-uniformarc_length_pxgrid);velocity_sub_noise_mask(per-FRAME,|v|≤NOISE_MASK_K·σ_v, theory.md §6.2) is a different object from theL_gzmask. Split degenerate policy (never raising on degenerate, neverRuntimeWarning): ALL field-named validation runs first and unconditionally (non-finite REJECTED not dropped since SG/cumulative_trapezoidassume uniform frame spacing;cadence_svia the importedtemporal_cwt._validate_cadence_s), THEN a graceful gate (n==0first short-circuit /n<sg_window/ raw-stationarynp.ptp(x)==0 and np.ptp(y)==0— detected on the RAW input since post-SG speed is float dust) returns an all-NaNMidlineResult(is_degenerate=True,velocity_sub_noise_mask=np.zeros(n,bool)since a bool array can't hold NaN); curvature undernp.errstate(divide,invalid,over)+ aκ[~isfinite]=NaNsweep (no inf, no warning). Deterministic across OSs atatol=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); ascripts/circumnutation/capture_midline_canary.pycanary 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_maskfraction 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-tierarc_length[-1] ≤Tier 0 path lengthL=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 (midlineremoved fromSTUB_MODULES+STUBS_WITH_CONSTANTS_KWARG, added toIMPLEMENTATIONS_WITH_CONSTANTS_KWARGas the first array-typed branch + the explicittest_module_logger_is_namespacedlist). theory.md §6.2 patched with the y-down +sign(κ)==−handednessclarification (original wording preserved in Appendix B(4)). Reviewed across 2 design.md critical-review rounds + 1/openspec-reviewround + 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, theMidlineResult.__eq__ndarray ambiguity, and theptp-vs-net-displacement gate-divergence documentation). (parent epic #197; seedocs/circumnutation/roadmap.mdPR #8 row; OpenSpecdesign.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 changeadd-circumnutation-tier2-psi-g). Graduates thepsi_gstub into an implementation module (the inverse of PR #6's addition-only transition: implementation count 5 → 6, stub count 6 → 5; renames the stub callablecompute_psi_g→compute). Publiccompute(trajectory_df, cadence_s, constants=None) -> pd.DataFrameper-track loop mirroringnutation.computeminus thecoordinatekwarg — ψ_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/#6compute_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-9rad zero-guard;+1⇔ positivedψ_g/dt), andhelix_signed_area_px2[px²] (new_geometry.compute_signed_areay-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)confirmshandednesson 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 Shoelace0.5·Σ(x_{i+1}·y_i − x_i·y_{i+1});<3points →0.0; non-finite → NaN). Tier 2 is self-contained — does NOT consume Tier 1 output; the 5th §7.3 traitpsig_long_consistencyis deferred to the roadmapped PR #13 Layer-3 work (owns both the trait and the cross-tier test). No new constants — reusesSG_WINDOW_DETREND=23/SG_DEGREE=3+ the PR #5 CWT fields;_CONSTANTS_VERSIONstays 5; noPIPELINE_UNIT_VOCABULARYchange (s/px/frame/int/px²already present). Degenerate handling (never raising, neverRuntimeWarning):N<3→ all-degenerate row;3≤N<24→T_psig=NaNwith raw traits defined; stationary / straight-growthN≥24→T_psig=NaNvia a zero-energy guard (np.allclose(detrended,0)— prevents a spurious2·cadence_speriod fromargmax-of-zeros). Deterministic across OSs atatol=1e-6for the 3 float traits, exact for integerhandedness(CWT-free rawatan2); 3-value canary captured on Windows 11 + Python 3.11.13. Cross-tier consistency validated against Tier 0principal_axis_anglevia the reconciled identitycircular_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 importabletemporal_cwt._validate_cadence_s;_check_constantsvalidates the SG fields psi_g consumes (deferring CWT-field validation tocompute_scaleogram). Foundation tests migrated:psi_gremoved fromSTUB_MODULES+STUBS_WITH_CONSTANTS_KWARG, added toIMPLEMENTATIONS_WITH_CONSTANTS_KWARG+ the explicittest_module_logger_is_namespacedlist (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_Epx/frame (not px·hr⁻¹), ψ_g conditioning SG-detrend (not the literal "smooth"). Reviewed across 3 design.md critical-review rounds + 2/openspec-reviewrounds (surfacing real bugs reconciled before/during implementation: the y-down Shoelace sign error, the SG-detrend conditioning reversal, the3≤N<24min-length crash, the handedness COI sign-flip + provenance contradiction, the stationary-tip spurious-period bug, and a droppedsmooth_ridgeMODIFIED-requirement scenario). (parent epic #197; seedocs/circumnutation/roadmap.mdPR #7 row; OpenSpecdesign.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 changeadd-circumnutation-tier1-derr-faithful). Publiccompute(trajectory_df, cadence_s, coordinate="lateral", constants=None) -> pd.DataFrameper-track loop mirroringkinematics.compute/qc.computeprecedent. Emits 8 trait columns per(series, sample_uid, plate_id, plant_id, track_id)5-tuple groupby: 3 strictly biological traits NaN-gated whenis_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 perpreliminary_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 overf > factor/T), andtemporal_cwt.smooth_ridge(ridge_result, window=None, constants=None)(Mallat 1999 §4.4.2 median-filter post-filter — closes #214; smoothsperiods_sonly, 4 other RidgeResult fields carry through unchanged). GREEN-phase Sci-I1 dimensional fix:is_nutatinggate usesin_band_mean_amplitude(amplitude units) compared to3 × noise_floor_estimate(amplitude units) for dimensional consistency; the spec-definedband_power_ratiotrait remains the user-facing emission.DERR_EXPECTED_PERIOD_S = 3333.0is rice-specific (override viaConstantsT(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_VERSION4→5; snapshot count 29→35. ReciprocalNYQUIST_RATIO_MAX↔TEMPORAL_NYQUIST_RATIO_MAXcross-reference docstrings (PR #3 spatial vs PR #6 temporal — numerically equal at 0.25 but semantically distinct). Deterministic across OSs atatol=1e-6(loosened from PR #5's1e-9— PR #6 composes 4 unverified scipy paths viafft/ndimage/signal/statson top of PR #5's verifiedpywt); 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_constantsrejects non-int / non-odd / non-positiveRIDGE_CONTINUITY_FILTER_WINDOW, negativeSG_DEGREE,SG_WINDOW_DETREND ≤ SG_DEGREE,BAND_POWER_BAND_HIGH_FACTOR ≤ BAND_POWER_BAND_LOW_FACTOR, negativeDERR_EXPECTED_PERIOD_S, non-positiveNOISE_FLOOR_OUT_OF_BAND_FACTOR. Foundation tests refactored:IMPLEMENTATIONS_WITH_CONSTANTS_KWARG4 → 5;nutationadded totest_module_logger_is_namespacedexplicit list (Copilot-precedent fix);test_schema_version_is_1_and_constants_version_is_4renamed →..._is_5. Five GREEN-phase deviations from approved design documented indesign.mdAppendix: (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-reviewround + 1 GREEN-phase Reconciliation Appendix + 2/copilot-reviewrounds + 2/review-prself-review rounds (~45 BLOCKING + IMPORTANT findings total, all reconciled). Closes #214, closes #215 (parent epic #197; seedocs/circumnutation/roadmap.mdPR #6 row;design.mdAppendix tracks all reconciliation rounds.)sleap_roots.circumnutation.temporal_cwt— temporal CWT machinery (PR #5 of the circumnutation program; OpenSpec changeadd-circumnutation-temporal-cwt-machinery). Two new public functions (compute_scaleogram(x, cadence_s, constants=None) -> ScaleogramResultandextract_ridge(scaleogram_result, constants=None) -> RidgeResult) + two new public@attrs.define(frozen=True)dataclasses (ScaleogramResult,RidgeResult). Usescmor1.5-1.0mother 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-awarepywt.scale2frequencyround-trip for period derivation; wavelet-aware√B·scale = √1.5·scale ≈ 1.225·scaleCOI mask (first concrete realization of the QC tier'scoi_fraction_t1reliability gate from PR #3; empirically verified across cmor0.5/1.0/1.5/2.0 via step-response measurement). Deterministic across OSs atatol=1e-9(CC-6) with a hardcoded 3-value canary at the resonant scale captured byscripts/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_VERSION3 → 4. The existingNYQUIST_RATIO_MAXdocstring gains a reciprocal cross-reference toCWT_PERIOD_MAX_SIGNAL_FRACTION(numerically equal at 0.25 but semantically distinct). Strict input validation: 1-D finite float-coerciblexof length ≥MIN_FRAMES_REQUIREDderived at call time from the resolved constants (= 9 at defaults);cadence_saccepts Python/numpyint/floatbut explicitly rejectsbool/np.bool_/str. Foundation tests refactored:STUB_MODULESdropstemporal_cwt(7 → 6 stubs);STUBS_WITH_CONSTANTS_KWARGdrops it (5 → 4);IMPLEMENTATIONS_WITH_CONSTANTS_KWARGgains it (3 → 4);test_module_logger_is_namespacedexplicit 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_3renamed →..._is_4with bumped assertion. Reviewed across 3 design.md critical-review rounds + 2 /openspec-review rounds (~24 BLOCKING + IMPORTANT findings total, all reconciled). (parent epic #197; seedocs/circumnutation/roadmap.mdPR #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 changeadd-circumnutation-synthetic-generator). Closed-form realization of Rivière 2022 Eq. 4 (perdocs/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 / √2so the QC tier's xy-quadrature noise estimators recovernoise_sigma_pxdirectly. Deterministic viarandom_state(CC-6 / NEP 19): same int seed → bit-identicaltip_x/tip_yacross runs AND across 64-bit OSs.noise_sigma_px = 0short-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 correspondingConstantsToverride fields; bumps_CONSTANTS_VERSION2→3 per the version-sentinel contract. ConstantsT resolution-order locked (kwarg > constants > module-default; 7 Optional[float]=None sentinels). Foundation tests refactored:STUB_MODULESdropssynthetic(8 → 7 stubs); newIMPLEMENTATIONS_WITH_CONSTANTS_KWARGsplit-table coverskinematics+qc+synthetictogether. (parent epic #197; seedocs/circumnutation/roadmap.mdPR #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 changeadd-circumnutation-qc-tier). Per-track signal-quality flags computed directly from tip trajectories perdocs/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_xyper 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_cleancomposite bool, andqc_failure_reasonstable-ordered comma-separated diagnostic. Pure-pixel + cadence-independent emission. Extends_noise.pywithcompute_d2_residual_xyandcompute_msd_residual_xyhelpers alongside the existingcompute_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 correspondingConstantsToverride fields; bumps_CONSTANTS_VERSION1→2 per the version-sentinel contract.cadence_nyquist_ratiodeferred to PR #6 (depends on Tier 1'sT_nutation_median). (parent epic #197; seedocs/circumnutation/roadmap.mdPR #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 changeadd-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-basedlong_lat_ratio,path_displacement_ratio, peak-to-peakangular_amplitudeof the unwrapped velocity-direction time series (per Bastien-Meroz 2016 Eq. 20 / theory.md §3.5), andprincipal_axis_angle. Pure-pixel + cadence-independent emission: velocity traits arepx/frame, downstream user composesconvert_to_mmand a futureconvert_to_per_hourutility formm/hroutput. 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_dfhelper to support tier modules with raw-DataFrame canonical signatures (publicbuild_per_plant_templateAPI unchanged). Theory.md §7.1 expanded from 7 to 9 traits + flag with explicit signed/abs conventions. (parent epic #197; seedocs/circumnutation/roadmap.mdPR #2 row)- New test fixture
tests/data/circumnutation_nipponbare_plate_001/— proofread tracked.slpfrom 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 changeadd-circumnutation-foundation(PR #1 of the multi-PR program tracked indocs/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 raisingNotImplementedErrorwith PR-number references),CircumnutationInputsdata class,ROW_IDENTITY_COLUMNSschema, 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-pixelconvert_to_mm()downstream-conversion utility. Theory and feasibility docs landed atdocs/circumnutation/theory.md,preliminary_results_2026-05-07.md, androadmap.md. (#198, parent epic #197)TrackedTipPipeline— root-agnostic pipeline that consumes SLEAP-tracked.slppredictions 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)andvalidate_series_for_tracked_tip(series, root_type=None)module-level helpers insleap_roots.seriesfor 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 changeadd-trait-extractor-service). Consumessleap-roots-predict's per-scan{scan_key}.predictions.jsonmanifest + named per-root.slpand a new{scan_key}.scan_metadata.jsonScanMetadatasidecar, selects a pipeline viachoose_pipeline(ported chooser, contiguous[age_min, age_max]windows), computes scan-grain traits, and emits a per-scansleap-roots-contractsResultEnvelopeas JSON for Bloom write-back. Runnable aspython -m trait_extractor <input_dir> <output_dir>with per-scan failure isolation.ResolvedParams.valuesis canonicalized to the closed set{species, mode, age:int}so the idempotency key is reproducible;contract_versionis the pinned bare0.1.0a3(since bumped a3 → a7 → a9; see below). The consumer-sidePredictionManifestduplicates predict's schema (coupled viaschema_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-extractorfor thetrait_extractor/service (OpenSpec changeadd-trait-extractor-image), built from a roottrait-extractor.Dockerfile(baseghcr.io/astral-sh/uv:python3.12-bookworm-slim;ENTRYPOINT ["python","-m","trait_extractor"]; headlessMPLBACKEND=Agg) and published by a path-filtereddocker-trait-extractor.yml— build-only on PRs, build + push onmain(image identity kept distinct from the library'sghcr.io/talmolab/sleap-roots, and the workflow shares no trigger with the PyPIbuild.yml). A slim[project.optional-dependencies] extractorextra installssleap-roots-contracts+pyyamlinto the image without the dev/docs tooling (uv sync --frozen --no-dev --extra extractor). The image bakes its build commit intoSRT_TRAITS_CODE_SHAso emitted envelopes carry a non-emptyprovenance.traits_code_sha. Tags:latestonmain+ immutablesha-<sha>(and the@sha256:…digest is surfaced in the run summary). Seedocs/dev/trait-extractor-service.md#container-image. trait_extractorconsumesRunManifest+ adds skip-if-done (OpenSpec changeadd-run-manifest-consumption; thesleap-rootsleg of the cross-repo manifest-scoped-processing initiative,talmolab/sleap-roots-pipeline#37). Bumpssleap-roots-contracts0.1.0a3→0.1.0a7(needed forRunManifest/RUN_MANIFEST_FILENAME, new in a7).extract_batchnow looks forrun_manifest.jsonat the top ofinput_dir: when present, discovery is scoped to exactly itsscan_keys(a.predictions.jsonpresent but out of scope is silently excluded — the contamination-prevention this manifest exists for; a manifest-declaredscan_keywith no matching file is a per-scan failure; two candidates for the same in-scopescan_keyare refused via the existing duplicate guard, not silently picked; the manifest is copied forward intooutput_dirsowrite-backcan see it); when absent, falls back to the original unscoped recursive discovery unchanged. Adds skip-if-done from scratch (previously absent entirely): each scan'sProvenanceis built early (beforeSeriesloading/pipeline selection/trait computation) and compared against any pre-existing{scan_key}.result.json'sidempotency_keyandcontract_version— both must match to skip (comparingidempotency_keyalone would let a stalecontract_versionsurvive a contracts pin bump silently, sincecontract_versionisn't one ofidempotency_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. NewBatchResult.skippedbucket, andpython -m trait_extractornow reports skipped scans distinctly fromok/FAIL. Deploy note: Bloom's liveinsert_cyl_result_envelopewrite-back RPC hard-pins its acceptedcontract_versionto0.1.0a3(bloom PR #399) and will reject0.1.0a7envelopes 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 to0.1.0a7; bloom#685 closed 2026-09-10.)trait_extractorbatch CLI: Argo-ready exit codes + empty-input guard + SIGTERM handling (OpenSpec changeharden-trait-extractor-exit-semantics; resolves sleap-roots#259, reconciled uniformly with the identical hardening insleap-roots-predict#26).python -m trait_extractor <in> <out>now exits one of three driver-owned codes instead of the old two-way0/1:0full success,3partial (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),1crash (an exception escaped the batch entirely, e.g. an invalidrun_manifest.jsonor the new empty-input guard below). Exit code2is deliberately left untouched —argparsealready owns it for CLI usage errors, and reusing it forpartial(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 aSIGTERMhandler so Argo preemption/cancellation exits promptly with143instead of waiting outterminationGracePeriodSeconds— 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 exit1specifically for "some scan failed" must switch to checking3; treating any nonzero exit as failure still works unchanged.trait_extractoradoptssleap-roots-contracts0.1.0a9's per-run run-manifest reader (OpenSpec changeadopt-contracts-run-manifest-reader; thesleap-rootsreader leg of talmolab/sleap-roots-pipeline#71). Bumpssleap-roots-contracts0.1.0a7→0.1.0a9in all three pins (uv.lockre-locked; a new test asserts the pins agree). The run identity comes fromARGO_WORKFLOW_NAMEvia contracts'pipeline_run_id_from_env()(new keywordextract_batch(..., pipeline_run_id=...): omitted → environment, explicitNone→ no identity), and the manifest is loaded once by contracts'load_run_manifest(..., allow_legacy=True): with an identity,run_manifest.<pipeline_run_id>.jsonfirst, then the legacyrun_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 exit1and aBatch aborted:line instead of widening to unscoped discovery; local andlocal-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 whenbloomctlwrites per-run names and pipeline#82 flipsallow_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 (thesleap-rootsresidue of talmolab/sleap-roots-predict#40); still best-effort. Behavior changes: a nonexistentinput_dirraisesFileNotFoundError(wasRuntimeError); a non-UTF-8 manifest raisespydantic.ValidationError(wasUnicodeDecodeError); a dangling-symlink or directory-named manifest aborts instead of reading as absent; exit codes are unchanged in meaning. Deploy note: envelopes now stampcontract_version = "0.1.0a9"; Bloom'sinsert_cyl_result_envelopeaccepts only0.1.0a7and will reject them until it accepts0.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 (newtraits_code_shaandcontract_version; contracts' idempotency-key derivation is unchanged).trait_extractorruns scans older than their species' window with that window's pipeline (OpenSpec changeclamp-past-window-pipeline-selection; phase 1 of bloom#971). A scan whose age is above everypipeline_selection.yamlwindow for its species + mode now matches that species + mode's highest window, with no upper limit (e.g. rice day 18 →OlderMonocotPipeline); before, it raisedNo pipeline matches. The newpipeline_chooser.past_window_age(params, cards)returns the matched-as age,choose_pipelinekeeps its signature, andextract_scanlogs onepast-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'sage_max, so they extrapolate past it; for rice above day 10 that isOlderMonocotPipeline, 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 matchingchoose_modelschange is live too; re-pin both templates together, or traits first.trait_extractorselects pipelines for wheat and sorghum (OpenSpec changeadd-wheat-sorghum-pipeline-cards; #276, step 3 of talmolab/sleap-roots-pipeline#118). Newpipeline_selection.yamlcards, 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 raiseNo pipeline matches. Wheat getscrown_*traits plus network-level traits computed from thecrownpoints only: its model labels all seminal roots, including the primary, ascrown, as the older-rice model does, and wheat laterals are not predicted. Sorghum's models predict only primary and lateral roots, henceDicotPipeline. 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 toproduction, or every wheat and sorghum scan runs GPU inference and then fails at traits. The first run after deploy recomputes every in-scope scan (newtraits_code_sha).
Internal¶
- Started per-pipeline-module pattern:
TrackedTipPipelinelives insleap_roots/tracked_tip_pipeline.pyinstead of being appended to the existing 3763-linetrait_pipelines.pymegafile. Splitting the existing 8 pipelines is tracked in #189.
Changed¶
requires-pythonraised from>=3.7to>=3.10. The previous declaration was stale: the lockfile pullsnumpy>=2.xwhich 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-managerwith-statement converted from the line-continuation form to the parenthesized form.- Added
pywavelets>=1.5and explicitscipyto runtime dependencies (used by futuresleap_roots.circumnutationtier PRs).scipywas previously transitive viascikit-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 (markedpython_version >= '3.11'), used only by thetrait_extractorservice — NOT a published runtime dependency ofsleap-roots(a CI-enforced AST guard test asserts thesleap_rootslibrary never imports it). It lives in thedevgroups and a slimextractorextra (which the container image installs; also reachable as an opt-inpip install sleap-roots[extractor], which adds the contract types — not the top-level service package — whilepip install sleap-rootsstays pure). Set[tool.pytest.ini_options] pythonpath = ["."]so tests can import the top-level service package, and addedtrait_extractor*to[tool.setuptools.packages.find] exclude. Extendedci.ymlto lint/testtrait_extractor/and addedtrait_extractor/**to the CI trigger paths.
0.1.4 - 2024-11-10¶
Added¶
MultiplePrimaryRootPipelinefor analyzing plants with multiple primary rootsMultipleDicotPipelinetests 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¶
LateralRootPipelinefor 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-indicesdefinition 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:
Seriesclass now takes SLEAP predictions directly usingSeries.load() - Breaking: H5 paths are now optional (but required for plotting)
- Breaking:
series_nameis now an attribute instead of a property - Breaking:
find_all_seriesremoved (usefind_all_h5_pathsorfind_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¶
MultipleDicotPipelinefor analyzing multiple dicot plants simultaneously
0.0.6 - 2024-03-11¶
Added¶
OlderMonocotPipelinefor mature monocot analysis
Changed¶
- Updated README with pipeline examples
0.0.5 - 2023-10-08¶
Added¶
YoungerMonocotPipelinefor younger monocot plants
Changed¶
- Renamed
grav_indextocurve_indexfor clarity
Fixed¶
get_network_distributionfunction
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
Seriesclass for SLEAP prediction data DicotPipelinefor dicot root analysis- Trait computation modules:
bases- Root base detection and analysistips- Root tip identificationangle- Root angle measurementsconvhull- Convex hull calculationslengths- Root length measurementsnetworklength- Network-level metricsscanline- Scan line analysisellipse- Ellipse fittingpoints- Point extraction utilitiessummary- 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