Skip to content

Tests and simplification for ImuDeadReckoning#422

Open
brickbots wants to merge 8 commits into
mainfrom
idr_tests
Open

Tests and simplification for ImuDeadReckoning#422
brickbots wants to merge 8 commits into
mainfrom
idr_tests

Conversation

@brickbots
Copy link
Copy Markdown
Owner

This is an exploratory PR to see if several of the intermediate calculations can be dropped from the ImuDeadReckoning Class. Tests have been added to help validate the refactor and the previous implementation has been saved for side-by-side value validation.

The math involved here is not at all my strong point, so I relied on Claude for implementing my intuition that a few of these terms could drop out, especially if the existing Tetra3 camera pixel space offset alignment is relied on.

@TakKanekoGit this will really need your eyes to see if I've subtly broken something. The tests all look good and it seems to produce the same results. There may be further caching/optimization possible

Calculation Changes

Plate-solve update (solve)

  • Same end result for q_eq2x, written in a more compact form: q_eq2x = q_eq2pointing · (q_x2imu · q_imu2pointing).conj() instead of the old q_eq2cam · q_cam2imu · q_x2imu.conj(). Same value (using
    the identity q_cam2imu · q_x2imu.conj() = (q_x2imu · q_imu2cam).conj()), but it removes the separately stored q_cam2imu = q_imu2cam.conj() — the conjugate is taken inline at the one site that needs
    it.
  • q_eq2cam is no longer cached as a side effect — the radec→quaternion conversion still happens, but the result is consumed immediately to form q_eq2x and then discarded.
  • The "plate solve without IMU updates only the cam cache" branch is gone. Because there's no cache, a solve() with NaN q_x2imu is now a pure no-op rather than a partial update.

Dead-reckoning (predict)

  • Collapsed from two multiplies to one. Old: q_eq2cam = q_eq2x · q_x2imu · q_imu2cam, then q_eq2scope = q_eq2cam · q_cam2scope (effectively the four-term product q_eq2x · q_x2imu · q_imu2cam ·
    q_cam2scope). New: single three-term product q_eq2pointing = q_eq2x · q_x2imu · q_imu2pointing, with q_imu2pointing standing in for q_imu2cam.
  • The trailing · q_cam2scope factor is gone because alignment is no longer applied (see below).
  • Normalization happens once (on the single output) instead of twice (once on each of q_eq2cam and q_eq2scope).
  • Predictions are computed on demand from q_eq2x and the live q_x2imu rather than read from cached q_eq2cam / q_eq2scope fields.

Alignment calculation — removed entirely

  • No more q_eq2cam · q_eq2scope.conj() solve for q_cam2scope.
  • No more q_imu2cam · q_cam2scope pre-composition for the (never-used) q_imu2scope.
  • q_imu2pointing is now exactly the old q_imu2cam from the screen_direction table — hardware geometry only, with no alignment factor folded in.

Sentinel / NaN handling

  • reset() now writes a NaN quaternion to q_eq2x instead of Python None, so the subsequent np.isnan(q_eq2x) check at the top of predict() works (the old code raised TypeError after a reset).
  • The two NaN gates (pointing.valid and np.isnan(q_x2imu)) are now in one place at the top of solve(), instead of split across update_plate_solve_and_imu / update_imu.

Per-screen-direction q_imu2pointing table

  • Bit-identical to the old q_imu2cam table. The values for flat, flat3, left, right, straight, and as_bloom are unchanged; only the attribute name moved.

Verified by the 14 cross-check tests in test_imu_dead_reckoning_equivalence.py: under identity alignment, the new predict() matches the old get_scope_radec() to 1e-9 radians across all six screen
directions through mixed solve/predict sequences.

Code Changes

Class changes (pointing_model/imu_dead_reckoning.py, ~230 → ~110 lines):

  • Collapsed the 4 stored body rotations (q_imu2cam, q_cam2imu, q_cam2scope, q_imu2scope) into a single q_imu2pointing set at construction from screen_direction. The IMU-to-pointing rotation is
    pre-composed once; downstream math is one quaternion-triple multiply per update instead of two.
  • API is now four methods: solve(pointing, q_x2imu), predict(q_x2imu) -> RaDecRoll | None, is_initialized(), reset(). The cached q_eq2cam/q_eq2scope and the dead_reckoning/tracking flags are gone;
    predictions are computed on demand.
  • solve() returns early on invalid pointing or NaN IMU; reset() now correctly sets q_eq2x to a NaN quaternion (the original was assigning None, which broke the subsequent np.isnan() check).
  • Behavior change: camera/scope alignment is no longer applied inside this class. Relies on pixel offset alignment via Tetra3 and the offset RA/DEC/ROLL is provided downstream into the integrator

Integrator (integrator.py):

  • Wrappers slimmed to thin deg/rad converters around solve() and predict().
  • set_cam2scope_alignment helper deleted; imu_dead_reckoning.tracking replaced with is_initialized().
  • IMU dead-reckoning writes the predicted pointing to both solved["RA"/"Dec"/"Roll"] and solved["camera_center"][...] (identical values, since alignment is no longer applied).

Tests:

  • tests/test_imu_dead_reckoning.py (17 tests) pins the new API: solve/predict/reset semantics, round-trip identity, IMU-delta rotation, state transitions.
  • tests/test_imu_dead_reckoning_equivalence.py (14 tests) drives the new class and a preserved copy of the old class through identical input sequences and asserts the scope-pointing outputs agree to
    1e-9 across all six screen_direction values.

Temporary scaffolding (delete in follow-up PR after field validation):

  • pointing_model/imu_dead_reckoning_legacy.py — verbatim copy of the pre-refactor class, renamed ImuDeadReckoningLegacy. Marked temporary in its module docstring.
  • tests/test_imu_dead_reckoning_equivalence.py — the cross-check suite above.

Test plan

  • pytest -m unit --ignore=tests/website — 158 passed, 0 failed
  • pytest tests/test_imu_dead_reckoning.py tests/test_imu_dead_reckoning_equivalence.py -v — 31 passed
  • ruff check on changed files — clean
  • mypy on changed files — clean (modulo pre-existing unrelated library-stub warnings)
  • Smoke-run on real hardware: python3 -m PiFinder.main starts cleanly, plate-solves and IMU dead-reckons through scope motion
  • On-device: confirm tracking quality during sweep is comparable to pre-refactor; check chart and SkySafari pointing during dead-reckoning intervals
  • Verify on a unit with non-trivial alignment that the behavior change (no alignment correction in this class) is working

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 remarkable how compact and simple the code set for imu_dead_reckoning.py it's been refactored. It's really nice.

One issue (and it needs to be checked by Claude) is that it does not seem to account for the camera-to-scope alignment and it writes the camera-centre coordinates to solved["RA"]andsolved["Dec"].

Otherwise it looks great!

Comment thread python/PiFinder/integrator.py Outdated
Comment thread python/PiFinder/pointing_model/imu_dead_reckoning.py Outdated
Comment thread python/PiFinder/integrator.py Outdated
@brickbots
Copy link
Copy Markdown
Owner Author

Thanks @TakKanekoGit for the review, it's very appreciated!

For this refactor, I'm heading back to relying solely on using the pixel offset in Tetra3 to get the RA/DEC of the pixel selected during alignment and then feed this through as the RA/DEC/Roll that everything else uses. So it's sort of baked in early as part of the plate solving... but we do loose the two solutions (aligned and camera center), but it reduces the calculations needed and hopefully simplifies most of the code that now only needs to deal with a single pointing location.

I think that the camera center only ended up only being used for the alignment UI. I need to do a followup or addition to this PR to fully remove that distinction and make sure the align screen still works as intended, probably by temporarily zeroing out the pixel offset while the alignment chart is being drawn 🤔

Another thing I'm keen to do, and may do it as part of this PR, is to replace all of the untyped dictionaries involved in the solving and integrator with dataclasses to make the whole thing much more legible and robust. Right not it's not at all clear what all the key's in the dictionary does, which are required where, and it's just a bit of mess that I've created 😅

brickbots and others added 2 commits May 18, 2026 14:37
The body rotation set from screen_direction is the IMU-to-camera
hardware geometry; naming it for that physical relationship is clearer
than naming it for its role in the math. Docstring notes that the class
treats the camera frame as the pointing frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
camera_center duplicated the IMU-tracked camera FoV pointing that's
already in top-level RA/Dec/Roll after the ImuDeadReckoning refactor.
The chart in non-align mode now reads top-level RA/Dec/Roll directly;
align mode continues to use camera_solve (frozen at the last plate
solve). The integrator's solve() input switches to camera_solve, which
is value-equivalent at solve time and the right semantic source.

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

Hi @brickbots

For this refactor, I'm heading back to relying solely on using the pixel offset in Tetra3 to get the RA/DEC of the pixel selected during alignment and then feed this through as the RA/DEC/Roll that everything else uses. So it's sort of baked in early as part of the plate solving... but we do loose the two solutions (aligned and camera center), but it reduces the calculations needed and hopefully simplifies most of the code that now only needs to deal with a single pointing location.

Although it adds an extra layer, I think we need to use the camera/image centre as the reference for seamless dead-reckoning with the IMU. This is because tetra3 gives RA, Dec and Roll at the camera centre but only gives the RA, Dec at the target_pixel. We need RA, Dec, Roll to define the orientation fully.

@TakKanekoGit
Copy link
Copy Markdown
Contributor

TakKanekoGit commented May 19, 2026

I think that the camera center only ended up only being used for the alignment UI. I need to do a followup or addition to this PR to fully remove that distinction and make sure the align screen still works as intended, probably by temporarily zeroing out the pixel offset while the alignment chart is being drawn 🤔

I have a wish list item that's completely irrelevant to this PR but it would be great if the PiFinder could remember the previous alignment and continue using it. For testing, I often have to restart the PiFinder so it'll be great if I don't have to re-align. As a user, I've had the battery die and had to re-align after plugging in a Power Bank 😀.

@TakKanekoGit
Copy link
Copy Markdown
Contributor

Another thing I'm keen to do, and may do it as part of this PR, is to replace all of the untyped dictionaries involved in the solving and integrator with dataclasses to make the whole thing much more legible and robust. Right not it's not at all clear what all the key's in the dictionary does, which are required where, and it's just a bit of mess that I've created 😅

That'll be really nice. You might have already spotted it but if you're able to use the RaDecRoll class you might be able to get rid of or simplify the helper functions at the bottom of integrator.py which are mainly interfacing between the dictionary format and RaDecRoll class.

In the same file (coordinates.py), there are outlines for other coordinate classes like RaDec and AltAz.

Wouldn't it be better to do it in a different PR because that's quite a big change?

@brickbots
Copy link
Copy Markdown
Owner Author

brickbots commented May 21, 2026

@TakKanekoGit Thank you again for your sage advice, I'm slowly rediscovering some of the things you've already run across :-)

Alignment Preservation
The PiFinder does this already by saving the target pixel which it loads at startup and uses. It's still doing this, but I think the align step is explicit now in the ImuDeadReckoning class where it was more passive before when the target_pixel values from tetra3 were being used. So the overall behavior was lost in some of the changes that stopped directly relying on the target_pixel info provided by Tetra3.

Center vs. Target:
Right now I'm using the center camera solve roll for the target pixel. You are likely right that this will introduce some issues, and it's something I'd like to have your thoughts on.

To make the Alignment UI work as it does now, which I think is pretty nice, we essentially need to have all of these things updated in the data that flows through the system:

  • camera_center <- Tetra3 solve for the camera center
    • solve: ra/dec/roll from the most recent plate solve, not modified by the integrator
    • estimate: ra/dec/roll updated by the integrator using IMU motion
  • target_pixel <- Tetra3 solve for the camera center
    • solve: ra/dec/roll from the most recent plate solve, not modified by the integrator (roll not provided by tetra3 🤔 )
    • estimate: ra/dec/roll updated by the integrator using IMU motion

I've made this all very explicit in my last commit here to help clarify a bit. I call the ImuDeadReckoning class 2x to compute the estimate for both the camera_center and target_pixel... but this is probably more expensive than it needs to be and also may be inaccurate for the target_pixel as it does not have the 'true' roll for this part of the solution and only inherits the camera_center roll.

Proposed Plan
I think there are two ways forward, and I'm going to lean on your expertise here @TakKanekoGit

  1. We can merge this, and then work this dual-solution (cam_center + target_pixel) concept directly into the ImuDeadReckoning class. Maybe there is some fancy way to:
  • look at delta between the provided tetra3 center/target solution, working out the transform between them
  • estimate the center solution, which should be more accurate with a proper roll
  • Then base the target estimate off the center estimate using the transform
    I actually suspect you were doing something like this before I 'simplified' this 😅
  1. Don't merge this, I can just add some tests to the current main branch, and then figure out where to link in the current saved alignment. One of the reasons I wanted to simplify this was to improve the overall performance, but it turns out the time spent on estimating the position is negligible and I found the performance improvements I wanted elsewhere. Those changes are already merged to main and there is not a lot of benefit to my 'simplification' if we're just going to add everything back in to make the target_pixel estimation more accurate.

Data Structure Refactoring
Yes, absolutely a different PR. I've got this roughed in on another branch and it can be done against the current code in main (option 2) or this code (option 1) depending on what you choose for the way forward.

@TakKanekoGit
Copy link
Copy Markdown
Contributor

Hi @brickbots. This is a tough one.

Center vs. Target: Right now I'm using the center camera solve roll for the target pixel. You are likely right that this will introduce some issues, and it's something I'd like to have your thoughts on.

I think we can't fake the roll at the target (RA, Dec) from tetra3 by using the roll measured at the camera/image centre. I struggle to picture what this will mean but my guess is that it will introduce an error so there could be glitches in handing over between the IMU.

Conceptually, I think it's simplest to use the camera centre as the reference and work from there. I can get my head round that.

To make the Alignment UI work as it does now, which I think is pretty nice, we essentially need to have all of these things updated in the data that flows through the system:

  • camera_center <- Tetra3 solve for the camera center

    • solve: ra/dec/roll from the most recent plate solve, not modified by the integrator
    • estimate: ra/dec/roll updated by the integrator using IMU motion
  • target_pixel <- Tetra3 solve for the camera center

    • solve: ra/dec/roll from the most recent plate solve, not modified by the integrator (roll not provided by tetra3 🤔 )
    • estimate: ra/dec/roll updated by the integrator using IMU motion

I've made this all very explicit in my last commit here to help clarify a bit. I call the ImuDeadReckoning class 2x to compute the estimate for both the camera_center and target_pixel... but this is probably more expensive than it needs to be and also may be inaccurate for the target_pixel as it does not have the 'true' roll for this part of the solution and only inherits the camera_center roll.

If we use the camera as the reference, you won't need to call ImuDeadReckoning twice in parallel.

Ideally, we want to get the mapping from the camera frame to the scope's pointing directly from align.py. If integrator.py could access this, we could do something like:

In pseudo code, I think we want to do something like this in the integrator.py in a loop:

# Update estimate
if solved.cam_radecroll:
    estimate.cam_radecroll = tracker.solve(solved.cam_radecroll, solved.imu_quat)
elif imu_quat:
    estimate.cam_radecroll = tracker.predict(imu_quat)
else:
    continue

# Find coordinate at target (q_cam2target configured by align.py)
estimate.target_radec = get_target_coord(estimate.cam_radecroll, q_cam2target)

# Update other info at target
estimate.target_constellation = get_constellation(estimate.target_radec, location, date_time)
estimate.target_altaz = get_altaz(estimate.target_radec, location, date_time)

What do you think?

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.

2 participants