Skip to content

Refactor Solver/Integrator flow to use dataclasses, combine camera/aligned position estimates into ImuDeadReckoning#429

Open
brickbots wants to merge 16 commits into
mainfrom
refactor/pointing-estimate-dataclasses
Open

Refactor Solver/Integrator flow to use dataclasses, combine camera/aligned position estimates into ImuDeadReckoning#429
brickbots wants to merge 16 commits into
mainfrom
refactor/pointing-estimate-dataclasses

Conversation

@brickbots
Copy link
Copy Markdown
Owner

@brickbots brickbots commented May 23, 2026

Summary

Reworks the Positioning data path (solver.pyintegrator.py → consumers) off the legacy solved dict and onto typed dataclasses, then sharpens the boundaries between the solver, the integrator, and the IMU dead-reckoner. The change lands in three layers:

Reviewers

There is a lot of stuff here, but the main areas for input are the shape of the dataclasses in PiFinder/types/positioning.py and the flow of data from solver.py->integrator.py.

1. Dataclass migration — kill the solved dict (220a6484)

End-to-end adoption of the dataclasses in PiFinder/types/positioning.py:

  • PointingEstimate replaces the solved dict everywhere. Pointing is modelled as a 2 × 2 matrix — two axes (camera, aligned) × two states (solve, estimate) — reached as pointing.<axis>.<state>.<RA|Dec|Roll>.
  • The integrator owns the long-lived PointingEstimate; its solve cells survive failed plate-solves so IMU dead-reckoning keeps producing aligned estimates.
  • shared_state.solution() / set_solution() carry PointingEstimate and default to an empty instance, so all ~15 consumers (UI, server, pos_server, nearby, …) read through the access shape without None checks.
  • Alignment queues carry typed commands/responses (AlignOnRaDec / AlignCancel / ReloadSqmCalibration, AlignedResult), dispatched via isinstance().
  • Rename solve_pixeltarget_pixel across code, default_config.json, and shared_state. No config-migration logic — users re-align to write the new key.
  • Deletes dead code (solver_main.py, get_initialized_solved_dict(), the legacy dict bridge methods).
  • Adds matched_centroids / matched_stars to PointingEstimate so the SQM calibration UI can replay SQM from cached published solutions.

2. Collapse the two-axis IDR into one dual-axis instance (9a2ee904)

  • ImuDeadReckoning now handles both axes in one instance. solve(camera, aligned, q_x2imu) captures the static q_cam2aligned alongside the drifting q_eq2x; predict() composes the camera prediction with q_cam2aligned and returns both as a (camera, aligned) tuple. reset() clears both.
  • integrator.py drops the idr_camera / idr_aligned pair for a single idr.
  • The IDR stays a pure math primitive: RaDecRoll in, RaDecRoll out — no import from PiFinder.types.positioning.
  • imu_dead_reckoning_legacy.py is retained in-tree as the equivalence reference until we're comfortable removing it in a follow-up.

3. Split the solver→integrator message into a SolveResult DTO (913e5d08)

The migration in (1) had the solver build the same PointingEstimate the integrator owns and publishes — forcing it to set estimate == solve cells and emit a hollow PointingEstimate on failure. This layer separates the wire message from the published aggregate:

  • New SolveResult = SuccessfulSolve | FailedSolve, a typed DTO on solver_queue. The integrator dispatches via isinstance() and is the sole builder/owner of PointingEstimate.
  • SuccessfulSolve carries flat per-axis Pointings (no solve/estimate split — the solver never IMU-progresses) plus a single solve_time; the integrator fans these into both cells and assigns solve_time / cam_solve_time.
  • The inline failed-solve branch is promoted to _apply_failed_solve so tests exercise the real integrator path.
  • Rationale captured in ADR-0003; vocabulary added to positioning/CONTEXT.md.

Also in this layer: silence a spurious invalid value encountered in isnan RuntimeWarningnp.isnan() on the IDR's NaN-quaternion sentinel tripped numpy's FP-invalid flag. Replaced with a per-component math.isnan helper (_quat_has_nan), clearing all 25 unit-test warnings.

Docs (fb92dcd0 + this work)

docs/ax/positioning.md and docs/ax/positioning/CONTEXT.md updated to the new vocabulary; ADR-0003 added. The canonical glossary (CONTEXT.md) and the vocabulary decision (ADR-0001) remain authoritative for terms.

Test plan

  • nox -s lint type_hints smoke_tests unit_tests — all green (214 unit tests pass; 219 unit + smoke), 0 warnings.
  • Run on hardware: confirm dead-reckoning continues across failed solves and the aligned-axis offset is preserved under IMU motion.
  • Run alignment flow: confirm the IDR picks up the alignment offset on the next solve and pointing.aligned.estimate reflects it.

Additional Notes for reviewers

  • imu_dead_reckoning_legacy.py is intentionally retained as the equivalence-test reference; removal is a planned follow-up.
  • A stale handoff.md scratch doc that had slipped into the branch was removed in this PR.

🤖 Generated with Claude Code

brickbots and others added 3 commits May 22, 2026 21:15
End-to-end adoption of the dataclasses in PiFinder/types/positioning.py:

- Solver builds a fresh PointingEstimate per attempt and pushes to
  solver_queue. Alignment queue carries AlignOnRaDec / AlignCancel /
  ReloadSqmCalibration commands and AlignedResult responses, dispatched
  via isinstance().
- Integrator owns the long-lived PointingEstimate. solve cells survive
  failed plate-solves so IMU dead-reckoning keeps producing aligned
  estimates.
- shared_state.solution() / set_solution() carry PointingEstimate;
  default to an empty PointingEstimate() so consumers can always call
  .has_pointing() without a None check.
- All ~15 consumers (UI, server, pos_server, nearby, etc.) read via the
  PointingEstimate access shape (pointing.aligned.estimate.RA, etc.).
- solve_pixel -> target_pixel rename: code, default_config.json,
  shared_state.target_pixel() / set_target_pixel(), Config key. No
  user-config migration logic; users re-align to write the new key.
- Delete solver_main.py (dead code), get_initialized_solved_dict(),
  to_legacy_dict() / from_legacy_dict() bridge methods, and the
  "PROPOSAL ONLY" framing in types/positioning.py.
- Add matched_centroids / matched_stars fields to PointingEstimate so
  the SQM calibration UI can replay SQM calculations from cached
  published solutions.
- Pull the simplified ImuDeadReckoning (and legacy companion + tests)
  from idr_tests so the integrator's IDR call surface matches.
- 21 new unit tests in test_pointing_estimate.py covering dataclass
  basics, solver builders, integrator merge semantics, failed-solve
  anchor preservation, alignment dispatch, picklability, deep-copy
  isolation.
- CONTEXT.md cleaned of stale migration notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ImuDeadReckoning now handles both camera and aligned axes in one
instance. solve() takes (camera, aligned, q_x2imu) and captures
q_cam2aligned alongside q_eq2x; predict() composes the camera
prediction with q_cam2aligned and returns both as a tuple.

The integrator drops the idr_camera/idr_aligned pair for a single
idr. The IDR remains a math primitive (RaDecRoll in, tuple of
RaDecRoll out) and does not import from PiFinder.types.positioning.

Equivalence tests now parametrize over (screen_direction,
alignment_offset) and check both predicted.camera == legacy.cam and
predicted.aligned == legacy.scope for identity and real-offset cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@brickbots brickbots changed the title Collapse two-axis IDR into a single dual-axis instance Refactor Solver/Integrator paths to use dataclasses, fold aligned position estimate into ImuDeadReckoning May 24, 2026
brickbots and others added 4 commits May 23, 2026 19:01
The solver was building the full PointingEstimate that the integrator
owns and publishes, forcing it to set estimate==solve cells and emit a
hollow PointingEstimate on failure. Separate the wire message from the
published aggregate:

- Add SolveResult = SuccessfulSolve | FailedSolve, a typed DTO on
  solver_queue. The integrator dispatches via isinstance() and remains
  the sole builder/owner of PointingEstimate.
- SuccessfulSolve carries flat per-axis Pointings (no solve/estimate
  split, since the solver never IMU-progresses) plus a single
  solve_time; the integrator fans these into both cells and assigns
  solve_time/cam_solve_time.
- Promote the inline failed-solve branch to _apply_failed_solve so the
  test exercises the real integrator path instead of a copy.
- Document the split in positioning/CONTEXT.md, positioning.md, and
  new ADR-0003.

Also silence a spurious "invalid value encountered in isnan"
RuntimeWarning: np.isnan() on the IDR's NaN-quaternion sentinel trips
numpy's FP-invalid flag. Replace with a per-component math.isnan helper
(_quat_has_nan), clearing all 25 unit-test warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handoff.md was a session-handoff note for the (now-completed) IDR
dual-axis collapse; it's WIP scratch that shouldn't ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tetra3 is a git submodule (PiFinder/tetra3) and is imported as a
top-level `tetra3` via the dev-only symlink python/tetra3 ->
PiFinder/tetra3/tetra3. That symlink was untracked, so nox.yml CI —
which checks out the submodule (submodules: true) but never created the
symlink — failed to `import tetra3`, breaking the unit tests that import
PiFinder.solver (test_pointing_estimate.py).

Commit the symlink so it is restored on checkout. The submodule provides
the real package for it to point at, so the unit suite runs against real
tetra3 with no per-test stubbing. Drop the now-redundant manual `ln -s`
from web-integration-tests.yml (it would otherwise fail with "File
exists").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@brickbots brickbots changed the title Refactor Solver/Integrator paths to use dataclasses, fold aligned position estimate into ImuDeadReckoning Refactor Solver/Integrator flow to use dataclasses, combine camera/aligned position estimates into ImuDeadReckoning May 24, 2026
@brickbots
Copy link
Copy Markdown
Owner Author

@TakKanekoGit Here's a partially simplified ImuDeadReckoning class + a whole lot of refactoring around it to make the solver->integrator flow much more understandable. Hopefully this overall simplification helps everyone, but it's a ton to review.

I'm pretty sure about everything but the math in ImuDeadReckoning - Looking at the changes is a bit hard to follow, but the class is pretty small now. I think I preserved the camera center to alignment point logic/math properly and apply it correctly, but I'm still very much not across the quaternion transform maths.

What should be happening is that all the estimation is being done from a camera_center basis (the camera pointing), the delta between the camera and aligned solves is computed every time there is a new image solve, and the aligned estimate is derived from the camera estimate not calculated from scratch which should help with the missing roll from Tetra3 for the target_pixel solution.

Whew! I'm going to test this under the stars to verify this all, but I think it should work 🤞

Copy link
Copy Markdown
Contributor

@TakKanekoGit TakKanekoGit left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a big change! I've just started the review but must dash off. I'll hopefully have time tomorrow (sorry about that).

Good luck with the star testing! Have you tested it in demo mode? I often managed to catch errors with ImuDeadReckoning() using the demo mode.

Comment thread python/PiFinder/integrator.py Outdated
elif isinstance(solve_result, FailedSolve):
estimate = _apply_failed_solve(estimate, solve_result)
shared_state.set_solution(copy.deepcopy(estimate))
shared_state.set_solve_state(False)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shared_state.set_solve_state() appears in pairs with shared_state.set_solution(). Would it make sense to incorporate setting the solve_state into the latter to enforce that this is set?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — they were a hand-maintained pair and they're actually the same fact: solve_state is exactly solution().has_pointing() 👍

I went with the spirit of your suggestion but had set_solution derive the flag rather than hardcode it — self.__solve_state = v.has_pointing()

I kept it as a cached bool rather than making solve_state() compute on read, because the UI polls it every frame and reading the bool avoids round-tripping the whole PointingEstimate across the manager proxy.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds great!

@brickbots
Copy link
Copy Markdown
Owner Author

@mrosseel This is a pretty big change in the data models flowing through the solver->integrator and then used by the rest of the system. It will definitely impact you telemetry work, so wanted to flag you here so you could have a heads up and provide any input. Hoping to merge this soon so avoid creating more potential conflicts with new code 👍

brickbots and others added 4 commits May 24, 2026 18:48
The merge of main brought in the UI module smoke harness (#438), whose
"warm" fixture built the legacy `solved` dict via
`get_initialized_solved_dict` and published it with `set_solution`. This
branch's refactor deleted that helper and changed `set_solution` to take
a `PointingEstimate`, so the harness no longer imported.

- Rewrite the warm fixture to build a `PointingEstimate` by hand: a fresh
  camera plate-solve with both axes' solve+estimate cells populated
  (estimate == solve, no IMU progression), aligned == camera (no
  alignment offset), imu_anchor None, and SolveDiagnostics. Mirrors what
  the integrator produces from a SuccessfulSolve.
- Swap the import for the positioning dataclasses; reword the docstring's
  "solved fixture" to canonical vocabulary.
- docs/ax/ui.md §9.2: correct the stale note that solution() returns
  None by default (it now defaults to an empty PointingEstimate()).

Validated: pytest -m integration tests/test_ui_modules.py -> 191 passed,
2 skipped (the intentional UIAlign sweep skips).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
solve_state is, by the glossary's own definition, exactly
solution().has_pointing() -- and the integrator kept the two in sync by
hand at three sites (set_solve_state True/True/False paired with
set_solution). That dual-write is a drift hazard: a new publish path that
forgets the partner call leaves the flag stale.

Derive solve_state inside set_solution (= v.has_pointing()) and drop the
three manual set_solve_state calls in the integrator. solve_state stays a
bare bool so the UI can keep polling it cheaply without round-tripping the
whole PointingEstimate across the manager proxy every frame -- it's now an
enforced cache rather than a hand-maintained parallel.

Behavioral note: the SuccessfulSolve branch previously flipped solve_state
True eagerly, before the freshness gate; now the gated publish (step 5)
sets it. A successful solve that fails the gate is a stale/duplicate
solve_time (already deduped by the solver via exposure_end), so in practice
the flag still tracks every real solve -- and no longer claims "solved" on
a dropped duplicate.

Docs (positioning.md / CONTEXT.md) and the UI harness warm fixture updated
to the single-writer model.

Addresses TakKanekoGit's review note on PR #429.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
set_solution now assigns v.has_pointing() (typed bool) to __solve_state,
which mypy had inferred as None-typed from its `= None` init -> assignment
error. The old set_solve_state took an untyped (Any) value, so this never
surfaced. Annotate the field as Optional[bool], matching its documented
tri-state (None = no solve yet / True / False).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@brickbots
Copy link
Copy Markdown
Owner Author

Just tested this briefly under the stars... It seems to work just like the current code in main, which is nice.

Both this branch and main are just AWESOME. Fast, smooth chart updates, works regardless of what weird angle I put the PiFinder in, just amazing work @TakKanekoGit

I did find one bug in this version that I'll fix up. It sometimes reverts to saying 'no solve' even though it's had one and was updating it with the IMU. I'm sure it's something simple leaking through, maybe related to the derivation of the solve_state when the solution is updated 🤷‍♂️

Copy link
Copy Markdown
Contributor

@TakKanekoGit TakKanekoGit left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @brickbots. This looks fantastic. Very nice. The algorithm looks all fine. I've added a few very minor comments.

Comment thread python/PiFinder/integrator.py
Comment thread python/PiFinder/integrator.py Outdated
camera_radecroll, aligned_radecroll = predicted

# predict() returned non-None RaDecRoll, so .get() values are not None.
ra_a, dec_a, roll_a = aligned_radecroll.get(deg=True)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Roll isn't defined for aligned so do we want to make this explicit via some RA/Dec type or setting roll to None?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm.. Roll is generated for align when it is calculated from the estimated camera pointing via the q_cam2aligned so it is available... not sure how accurate it is across the sky though.

In order to generate q_cam2aligned we need a matched ra/dec/roll for camera and aligned. The one for camera comes directly from the solver, but the solver only produces ra/dec for the target_pixel so I'm just copying the image center (camera) roll to the target_pixel (aligned) value, which is bound to be problematic at times.

In practice the aligned ra/dec can't be more than ~5 degrees from the center value where we have a ground truth roll. This can make a pretty big different around the celestial poles, but most of the time the two roll values will be pretty close.

Are suggesting that we don't use roll at all outside the solver/integrator? I could support this, but we need it for the chart and eyepiece image orientation. For the chart, we've had a discussion of orientation wrt the screen (zenith up or EQ oriented) so we could do without it there, but we do need it for the eyepiece image orientation and this will vary based on the roll of the scope which is well captured by the solver roll 🤔

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm.. Roll is generated for align when it is calculated from the estimated camera pointing via the q_cam2aligned so it is available... not sure how accurate it is across the sky though.

If we're being super-precise, we can't define the roll at pointing (scope) and we shouldn't generate it lest the knowledge gets lost and roll at pointing gets abused 😁.

In order to generate q_cam2aligned we need a matched ra/dec/roll for camera and aligned. The one for camera comes directly from the solver, but the solver only produces ra/dec for the target_pixel so I'm just copying the image center (camera) roll to the target_pixel (aligned) value, which is bound to be problematic at times.

I think that comes from my Maths but it might be incorrect. I'll have a think about the proper way to do this.

In practice the aligned ra/dec can't be more than ~5 degrees from the center value where we have a ground truth roll. This can make a pretty big different around the celestial poles, but most of the time the two roll values will be pretty close.

Yes, it probably doesn't actually cause any problems in practice.

Are suggesting that we don't use roll at all outside the solver/integrator? I could support this, but we need it for the chart and eyepiece image orientation. For the chart, we've had a discussion of orientation wrt the screen (zenith up or EQ oriented) so we could do without it there, but we do need it for the eyepiece image orientation and this will vary based on the roll of the scope which is well captured by the solver roll 🤔

Yes. Roll is only needed to hand-over between solve and IMU.

I didn't know about the eyepiece image. Are the image orientations controlled by whether the mount is AltAz or EQ? I think we shouldn't use roll here because if the PiFinder is mounted at an odd angle, the roll won't reflect the image orientation. We should probably do the same thing as we do for chart orientation. There could be a corner case when you look near zenith with AltAz, though...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've included a couple of functions in the comments below that will enable you to calculate the RA, Dec at the target properly.

Comment thread python/PiFinder/integrator.py Outdated


@dataclass
class ImuSample:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should also have a timestamp attribute so that we can use this for solve_time in the integrator when it uses the IMU sample for dead-reckoning.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! I'm adding this in here and taking the opportunity to remove the dictionary passing for imu data 👍 This bloats this PR even more, but is much better long-term

Comment thread python/PiFinder/integrator.py Outdated
brickbots and others added 3 commits May 25, 2026 19:12
Clarify what the published timing field means: estimate_time is the
measurement epoch of the data behind the current estimate (camera frame
exposure_end, or IMU sample timestamp), not the integrator's publish
time. "solve" is now reserved for plate-solve everywhere; the current
(possibly IMU-progressed) value is the estimate.

- PointingEstimate.solve_time -> estimate_time; drop cam_solve_time
  (value-identical to last_solve_success under epoch semantics).
  cam_active -> is_camera_solve(); "time since solve" -> last_solve_success.
- SuccessfulSolve drops its redundant solve_time; the integrator promotes
  last_solve_success (the frame's exposure_end) to estimate_time.
- Migrate the shared_state.imu() dict to the ImuSample dataclass, which
  gains a `timestamp` (sample epoch, stamped in the IMU process) and a
  to_dict() for the web API; drop the unused move_start/move_end.
- IMU dead-reckoning now stamps estimate_time = imu.timestamp.

Glossary (CONTEXT.md) + docs/adr/0004-pointing-estimate-timing.md.
Addresses TakKanekoGit's review notes on PR #429.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the bug observed on PR #429 where the display sometimes reverted
to "no solve" after having solved and while tracking with the IMU.

A FailedSolve cleared pointing.aligned.estimate and the integrator
published that immediately (to feed auto-exposure), dropping
solve_state to False via has_pointing(). The IMU advance was meant to
re-fill the cells, but it only fires above the motion deadband -- so a
solve failing while held steady left the system stuck at "no solve"
until the next success or a larger nudge.

_apply_failed_solve now preserves the estimate cells (and estimate_time):
once anchored, the last IMU-progressed pointing stays published and the
IMU keeps advancing it. "No solve" is reserved for the genuinely
unanchored state (before the first successful solve). Auto-exposure is
unaffected -- the unconditional failed-solve publish stays, now carrying
the preserved pointing.

Flips the failed-solve test to assert the cells survive + has_pointing()
holds. Glossary + docs/adr/0005-failed-solve-preserves-estimate.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tak's review note on PR #429: the RaDecRoll->Pointing assignment in the
integrator was clumsy (per-field .get(deg=True) + cast(float, ...)).

Add Pointing.from_radecroll() as the inverse of the existing
Pointing.as_radecroll(), completing the degrees<->radians bridge pair.
It lives on Pointing (not RaDecRoll) so the dependency only runs
positioning -> coordinates, never back, and reads the radian fields
directly (always floats), so the casts disappear. _advance_with_imu's
two 4-line blocks collapse to two assignments; cast import dropped.

Keeps the deliberate split: Pointing/degrees is the published data
model; RaDecRoll/radians stays confined to the dead-reckoning math
(rejecting the "use RaDecRoll everywhere" alternative). Glossary updated
with both bridge directions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@brickbots
Copy link
Copy Markdown
Owner Author

@TakKanekoGit Thank you for the second round of review. I've committed all my updates so please take a look and resolve or add new comments to the conversations.

I've not tested the "no solve" bug fix under the stars yet. I'm on a work trip until Friday so that will have to wait for me, but anyone who wants pull this branch and test is very welcome!

@mrosseel
Copy link
Copy Markdown
Collaborator

mrosseel commented May 26, 2026

@mrosseel This is a pretty big change in the data models flowing through the solver->integrator and then used by the rest of the system. It will definitely impact you telemetry work, so wanted to flag you here so you could have a heads up and provide any input. Hoping to merge this soon so avoid creating more potential conflicts with new code 👍

probably best if I wait to see what comes out of it and then merge with telemetry branch, which should be up to date with main as of now.

@mrosseel
Copy link
Copy Markdown
Collaborator

mrosseel commented May 26, 2026 via email

@TakKanekoGit
Copy link
Copy Markdown
Contributor

@mrosseel This is a pretty big change in the data models flowing through the solver->integrator and then used by the rest of the system. It will definitely impact you telemetry work, so wanted to flag you here so you could have a heads up and provide any input. Hoping to merge this soon so avoid creating more potential conflicts with new code 👍

probably best if I wait to see what comes out of it and then merge with telemetry branch, which should be up to date with main as of now.

@mrosseel -- Just to let you know, I'm working on changes to imu_pi.py to use gyro measurements directly which will probably conflict with your telemetry PR. I'll probably need your help to fix the merge conflicts when we get to it.

if not self.is_initialized():
return None
q_eq2cam = (self.q_eq2x * q_x2imu * self.q_imu2cam).normalized()
q_eq2aligned = (q_eq2cam * self.q_cam2aligned).normalized()
Copy link
Copy Markdown
Contributor

@TakKanekoGit TakKanekoGit May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these two function will solve the issue with roll being undefined at the target. The first one needs to be called once after alignment. The second one needs to be called after IMU predict. You could also use it after solve or you could take the target RA/Dec solved by tetra3.

I hope that helps to clear things up? I haven't tested it thoroughly but it's probably easiest to do sky testing.

# Do this once after alignment

def get_camera_to_target_vector(
        q_eq2cam: quaternion,  # Orientation of camera frame relative to the eq frame
        ra_target, dec_target  # RA/Dec of target [rad]  TODO: Use RaDec?
        ) -> np.ndarray:
    """
    Returns the pointing of the target relative the boresight (z_cam axis) of
    the camera frame as a unit vector. This is fixed until the target pixel
    is re-aligned.
    """
    # Target vector in equatorial frame  TODO: Move this to a method in RaDec?
    v_eq_target = np.array([
        np.cos(dec_target) * np.cos(ra_target),
        np.cos(dec_target) * np.sin(ra_target),
        np.sin(dec_target)
    ])
    q_eq_target = quaternion.from_vector_part(v_eq_target)  # Pure quaternion

    # Rotate into camera frame
    q_cam_target = q_eq2cam * q_eq_target * q_eq2cam.conjugate()  # Pure quaternion
    v_cam_target = q_cam_target.vec

    return v_cam_target / np.linalg.norm(v_cam_target)  # Unit vector of target relative to the camera frame

# Do this after every IMU dead-reckoning

def get_target_radec(
        q_eq2cam: quaternion,  # Orientation of camera frame relative to the eq frame
        v_cam_target: np.ndarray  # Unit vector of target in camera frame 
        ):
    """
    Get the target RA/Dec from q_eq2cam.
    """
    v_cam_target /= np.linalg.norm(v_cam_target)
    q_cam_target = quaternion.from_vector_part(v_cam_target)  # Pure quaternion

    # Rotate target into equatorial frame
    q_eq_target = q_eq2cam.conjugate() * q_cam_target * q_eq2cam  # Pure quaternion

    # Convert to RA/Dec  TODO: Move this to a method in RaDec?
    dec_target = np.arcsin(q_eq_target.z)
    ra_target = np.arctan2(q_eq_target.y, q_eq_target.x)
    ra_target = ra_target % (2.0 * np.pi)

    return ra_target, dec_target  # [rad]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @TakKanekoGit Sorry, I'm a bit confused here... this seems very similar to what is already implemented, just describing the delta between camera and align in a different space. The only difference functional I see is that the aligned pointing estimate currently does return a roll, which may be less accurate than the one for camera, but could still be useful.

Please let me know if I'm missing a key difference here as I'm not great with reasoning about the math involved and not realize the change you're proposing

WRT to Roll....
We can migrate away from the integrator provided roll in the future, figuring out better solutions for each use case:

Chart
You've already come up with a nice plan here, just need to implement it 🚀

Eyepiece image orientation
This may not be possible in all cases without user calibration, but the roll of the sky relative to the camera does provide really useful information here, and the current system uses it well to fairly accurately orient object images with the correct rotation, if you are using an alt-az newtonian scope and the PiFinder is positioned upright. This is not at all a universal solution, but covers enough of the users right now to be something I don't want to just drop.

The universal solution may require a one-time calibration from the user to work out the roll orientation of the camera vs. their scope optics, but after that the roll of the solution will still be required to properly orient the eyepiece image, so I think we're going to continue to need the roll out of the solver/integrator for both camera and aligned.

Since your Quaternion improvements and the refactor in this PR does not regress the roll accuracy from the current naive alt-az estimation, I think we can get this out into the world and then come back and improve the aligned roll estimate in some way if possible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants