Skip to content

Architecture: aim vector as canonical state for moving heads (replace DMX-as-truth model) #806

@SlyWombat

Description

@SlyWombat

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:

  • 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_stagesphere.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

  1. 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.
  2. Single source for 3D viz and calibrate. Both look at the same field. They cannot disagree — one updates the other doesn't, etc.
  3. Closes SPA/Android: orientation indicator drifts independently of physical mover after calibrate #757-B / Calibrate-end captures wrong aim vector — silent legacy IK fallback in _mover_current_aim_stage #805 by construction. _mover_current_aim_stage becomes 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.
  4. 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.
  5. 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)

  1. Add aim_stage (or equivalent) field to fixture records, populated on every successful aim write. Default unset; transition state populated by first write.
  2. Single canonical store — likely a per-fixture cache keyed by fid, updated atomically with the universe-buffer write.
  3. Refactor _mover_current_aim_stage to return the stored vector. Remove sphere.dmx_to_aim call from this path.
  4. Refactor /api/fixtures/live aim field to read the stored vector. Remove the per-request derive.
  5. Refactor calibrate-end to capture the stored vector directly.
  6. 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.
  7. 3D viz rendering reads the stored vector. Fallback only when unset.
  8. _park_fixture_at_home writes both DMX and the home aim vector.
  9. Round-trip test: any write of an aim vector followed by a read returns bit-equal vector. No IK loss.
  10. Migration: existing in-flight claims / running timelines read DMX→derive on first poll, then transition to canonical-vector-driven on next write.

Acceptance

  1. 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.
  2. Calibrate-end on Stage Right (or any mover) with steady phone: head does not jump regardless of fixture rotation, home pose, profile cone shape, or clamp boundary. The bug class behind SPA/Android: orientation indicator drifts independently of physical mover after calibrate #757-B / Android mover-control: calibrate-release pan jumps on angular_only fixture (regression of #510 for no-SMART path; spec violation per #738) #748 / Calibrate-end captures wrong aim vector — silent legacy IK fallback in _mover_current_aim_stage #805 cannot recur.
  3. 3D viz cone direction matches the calibrate-end captured anchor exactly when both are read at the same time.
  4. 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.
  5. 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.

Related

Out of scope

  • Performer-class fixtures (LEDs, ESP/D1) — they don't have an aim vector. Pan/tilt DMX-as-truth is fine for non-mover DMX fixtures.
  • Camera fixtures — handled by a separate rotation-from-layout path; not part of this model.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions