You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Surfaced during 2026-05-05 live-test debugging of #805 / #757-B. Operator proposed the architectural shift while diagnosing why calibrate-end was capturing a wrong aim vector.
Current model (DMX-as-truth)
The orchestrator currently treats the DMX universe buffer as the source of truth for moving-head state:
Pan/tilt DMX bytes are the canonical record of "where the head is."
The aim field on /api/fixtures/live is derived from DMX every read via _mover_current_aim_stage → sphere.dmx_to_aim(panDmx16, tiltDmx16).
Calibrate-end captures the head's current direction by calling _mover_current_aim_stage (DMX → IK → vector).
3D viz renders light cones from the reverse-derived aim field.
Writers (claim orient, Track action, /api/mover/<fid>/aim, timeline bake) compute DMX from a stage-frame aim vector via sphere.aim_direction and write to the universe buffer.
This produces a two-IK-halves architecture: the read path uses dmx_to_aim, the write path uses aim_direction. Both must round-trip exactly or the head jumps when one writes after the other reads. #757-B / #748 / #805 are all instances of this fragility (mismatch between the two halves under specific conditions — clamp boundary, missing data, fallback firing, etc.).
Proposed model (aim-vector-as-truth)
Make the canonical state the aim vector (unit vector in stage frame) per fixture. DMX is the wire output, computed one-way from the aim vector at write time.
Operation
Today
Proposed
Read "where is the head pointing"
DMX → IK inversion → vector
Return stored vector
Write a new aim from claim/track/API
Vector → IK → DMX → universe buffer; aim field re-derived from buffer
Vector → store; engine pump derives DMX one-way
Calibrate-end capture
dmx_to_aim(buffer) → vector
Read stored vector
3D viz renders light cone
Read derived aim field (from buffer)
Read stored vector directly
Raw DMX write (DMX-test page, manual override)
Write to buffer (unchanged)
Write to buffer + update stored vector via dmx_to_aim (this path is the only place the inverse IK is needed)
Benefits
No round-trip risk. The dominant write path is one-way vector → DMX. Reads return the same vector that was written. The dmx_to_aim inverse only runs on the rare raw-DMX-override path.
Single source for 3D viz and calibrate. Both look at the same field. They cannot disagree — one updates the other doesn't, etc.
Cleaner ownership semantics. Each fixture has a single aim_stage slot. Whoever wrote to it last is the current source. Audit trail and 3D-viz cache invalidation become trivial.
Better operator UX. When the operator sees a vector in 3D pointing at marker X, that vector is what the head will use as reference for the next claim/calibrate. Today they see the DMX-derived vector which can disagree with the next IK pass on the same DMX.
Scope (programmer to plan)
Add aim_stage (or equivalent) field to fixture records, populated on every successful aim write. Default unset; transition state populated by first write.
Single canonical store — likely a per-fixture cache keyed by fid, updated atomically with the universe-buffer write.
Refactor _mover_current_aim_stage to return the stored vector. Remove sphere.dmx_to_aim call from this path.
Refactor /api/fixtures/liveaim field to read the stored vector. Remove the per-request derive.
Refactor calibrate-end to capture the stored vector directly.
Identify ALL DMX writers and decide per-writer whether they update the stored vector:
mover_engine claim writes — already compute the vector before writing DMX; just store it.
Track action _evaluate_track_actions — same.
/api/mover/<fid>/aim — same.
Timeline bake — same.
/api/fixtures/<fid>/dmx-test raw-channel writes — these DON'T have a vector; either compute via dmx_to_aim (the one place inverse IK runs) or mark aim_stage=None to indicate "DMX-driven, no canonical aim available."
Profile-defined defaults / blackout / park — park-at-home knows the home aim vector; store it.
3D viz rendering reads the stored vector. Fallback only when unset.
_park_fixture_at_home writes both DMX and the home aim vector.
Round-trip test: any write of an aim vector followed by a read returns bit-equal vector. No IK loss.
Migration: existing in-flight claims / running timelines read DMX→derive on first poll, then transition to canonical-vector-driven on next write.
Acceptance
After any non-DMX-test write (claim, track, /aim, bake, park), reading the fixture's aim vector returns exactly what was written. No IK round-trip in the read path.
3D viz cone direction matches the calibrate-end captured anchor exactly when both are read at the same time.
Raw DMX writes (DMX-test sliders) either update the stored vector via dmx_to_aim or mark it as "raw-driven" so the viz can show that state distinctly.
The legacy mount-relative IK in _mover_current_aim_stage (parent_server.py:11148-11172) is fully removable as a side effect — there's no read path that needs a fallback.
Should land before any further work on calibrate, mover-control orient, track actions, or 3D viz cone rendering — refactoring those individually first would multiply the migration cost.
Background
Surfaced during 2026-05-05 live-test debugging of #805 / #757-B. Operator proposed the architectural shift while diagnosing why calibrate-end was capturing a wrong aim vector.
Current model (DMX-as-truth)
The orchestrator currently treats the DMX universe buffer as the source of truth for moving-head state:
aimfield on/api/fixtures/liveis derived from DMX every read via_mover_current_aim_stage→sphere.dmx_to_aim(panDmx16, tiltDmx16)._mover_current_aim_stage(DMX → IK → vector).aimfield./api/mover/<fid>/aim, timeline bake) compute DMX from a stage-frame aim vector viasphere.aim_directionand write to the universe buffer.This produces a two-IK-halves architecture: the read path uses
dmx_to_aim, the write path usesaim_direction. Both must round-trip exactly or the head jumps when one writes after the other reads. #757-B / #748 / #805 are all instances of this fragility (mismatch between the two halves under specific conditions — clamp boundary, missing data, fallback firing, etc.).Proposed model (aim-vector-as-truth)
Make the canonical state the aim vector (unit vector in stage frame) per fixture. DMX is the wire output, computed one-way from the aim vector at write time.
aimfield re-derived from bufferdmx_to_aim(buffer)→ vectoraimfield (from buffer)dmx_to_aim(this path is the only place the inverse IK is needed)Benefits
vector → DMX. Reads return the same vector that was written. Thedmx_to_aiminverse only runs on the rare raw-DMX-override path._mover_current_aim_stagebecomes a one-line accessor. Calibrate-end's captured anchor is the same vector the viz is rendering, so a steady phone post-calibrate cannot produce a head jump.aim_stageslot. Whoever wrote to it last is the current source. Audit trail and 3D-viz cache invalidation become trivial.Scope (programmer to plan)
aim_stage(or equivalent) field to fixture records, populated on every successful aim write. Default unset; transition state populated by first write._mover_current_aim_stageto return the stored vector. Removesphere.dmx_to_aimcall from this path./api/fixtures/liveaimfield to read the stored vector. Remove the per-request derive.mover_engineclaim writes — already compute the vector before writing DMX; just store it._evaluate_track_actions— same./api/mover/<fid>/aim— same./api/fixtures/<fid>/dmx-testraw-channel writes — these DON'T have a vector; either compute viadmx_to_aim(the one place inverse IK runs) or markaim_stage=Noneto indicate "DMX-driven, no canonical aim available."_park_fixture_at_homewrites both DMX and the home aim vector.Acceptance
dmx_to_aimor mark it as "raw-driven" so the viz can show that state distinctly._mover_current_aim_stage(parent_server.py:11148-11172) is fully removable as a side effect — there's no read path that needs a fallback.Related
AimSphere(fix(aim): sphere — slope-from-home + multi-valued azimuth (replaces #798's bracketing/anchor algorithm) #799) — no math changes needed, just plumbing.Out of scope