Refactor Solver/Integrator flow to use dataclasses, combine camera/aligned position estimates into ImuDeadReckoning#429
Conversation
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>
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>
|
@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 Whew! I'm going to test this under the stars to verify this all, but I think it should work 🤞 |
TakKanekoGit
left a comment
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
|
@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 👍 |
…imate-dataclasses
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>
|
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 🤷♂️ |
TakKanekoGit
left a comment
There was a problem hiding this comment.
Hi @brickbots. This looks fantastic. Very nice. The algorithm looks all fine. I've added a few very minor comments.
| 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) |
There was a problem hiding this comment.
Roll isn't defined for aligned so do we want to make this explicit via some RA/Dec type or setting roll to None?
There was a problem hiding this comment.
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 🤔
There was a problem hiding this comment.
Hmmm.. Roll is generated for
alignwhen it is calculated from the estimatedcamerapointingvia 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
cameraandaligned. The one for camera comes directly from the solver, but the solver only produces ra/dec for thetarget_pixelso 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
alignedra/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...
There was a problem hiding this comment.
I've included a couple of functions in the comments below that will enable you to calculate the RA, Dec at the target properly.
|
|
||
|
|
||
| @dataclass | ||
| class ImuSample: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
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>
|
@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! |
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. |
|
Hi, telemetry branch has the fix and should be up to date with main
…On Tue, 26 May 2026, 19:37 Tak Kaneko, ***@***.***> wrote:
***@***.**** commented on this pull request.
------------------------------
In python/PiFinder/integrator.py
<#429 (comment)>:
>
- # Set up dead-reckoning tracking by the IMU:
- imu_dead_reckoning = ImuDeadReckoning(cfg.get_option("screen_direction"))
- # imu_dead_reckoning.set_cam2scope_alignment(q_scope2cam) # TODO: Enable when q_scope2cam is available from alignment
+ # Single IMU dead-reckoner handling both axes. Seeded with the
+ # (camera, aligned) pair at each successful plate-solve.
+ idr = ImuDeadReckoning(screen_direction)
Hi @brickbots <https://github.com/brickbots> -- I have a function to
estimate this (q_imu2cam) but I paused it until the issue with the
invalid solves are fixed and I can record some new telemetry data (perhaps
this PR might even fix it?). @mrosseel <https://github.com/mrosseel> has
the details on the root cause. Hopefully, we'll be able to include it with
the next release.
—
Reply to this email directly, view it on GitHub
<#429?email_source=notifications&email_token=AALFXXX4ZXLLOJ7HGQMLP3D44XI7FA5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTIMZWGYYDQMZVGA22M4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLDGN5XXIZLSL5RWY2LDNM#discussion_r3305707765>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AALFXXVFAOIL7F47IIV3HX344XI7FAVCNFSM6AAAAACZKB5HXCVHI2DSMVQWIX3LMV43YUDVNRWFEZLROVSXG5CSMV3GSZLXHM2DGNRWGA4DGNJQGU>
.
Triage notifications on the go with GitHub Mobile for iOS
<https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675>
or Android
<https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub>.
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
@mrosseel -- Just to let you know, I'm working on changes to |
| 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() |
There was a problem hiding this comment.
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]There was a problem hiding this comment.
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.
Summary
Reworks the Positioning data path (
solver.py→integrator.py→ consumers) off the legacysolveddict 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.pyand the flow of data from solver.py->integrator.py.1. Dataclass migration — kill the
solveddict (220a6484)End-to-end adoption of the dataclasses in
PiFinder/types/positioning.py:PointingEstimatereplaces thesolveddict everywhere. Pointing is modelled as a 2 × 2 matrix — two axes (camera,aligned) × two states (solve,estimate) — reached aspointing.<axis>.<state>.<RA|Dec|Roll>.PointingEstimate; itssolvecells survive failed plate-solves so IMU dead-reckoning keeps producing aligned estimates.shared_state.solution()/set_solution()carryPointingEstimateand default to an empty instance, so all ~15 consumers (UI,server,pos_server,nearby, …) read through the access shape withoutNonechecks.AlignOnRaDec/AlignCancel/ReloadSqmCalibration,AlignedResult), dispatched viaisinstance().solve_pixel→target_pixelacross code,default_config.json, andshared_state. No config-migration logic — users re-align to write the new key.solver_main.py,get_initialized_solved_dict(), the legacy dict bridge methods).matched_centroids/matched_starstoPointingEstimateso the SQM calibration UI can replay SQM from cached published solutions.2. Collapse the two-axis IDR into one dual-axis instance (
9a2ee904)ImuDeadReckoningnow handles both axes in one instance.solve(camera, aligned, q_x2imu)captures the staticq_cam2alignedalongside the driftingq_eq2x;predict()composes the camera prediction withq_cam2alignedand returns both as a(camera, aligned)tuple.reset()clears both.integrator.pydrops theidr_camera/idr_alignedpair for a singleidr.RaDecRollin,RaDecRollout — no import fromPiFinder.types.positioning.imu_dead_reckoning_legacy.pyis 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
SolveResultDTO (913e5d08)The migration in (1) had the solver build the same
PointingEstimatethe integrator owns and publishes — forcing it to setestimate == solvecells and emit a hollowPointingEstimateon failure. This layer separates the wire message from the published aggregate:SolveResult=SuccessfulSolve|FailedSolve, a typed DTO onsolver_queue. The integrator dispatches viaisinstance()and is the sole builder/owner ofPointingEstimate.SuccessfulSolvecarries flat per-axisPointings (nosolve/estimatesplit — the solver never IMU-progresses) plus a singlesolve_time; the integrator fans these into both cells and assignssolve_time/cam_solve_time._apply_failed_solveso tests exercise the real integrator path.positioning/CONTEXT.md.Also in this layer: silence a spurious
invalid value encountered in isnanRuntimeWarning—np.isnan()on the IDR's NaN-quaternion sentinel tripped numpy's FP-invalid flag. Replaced with a per-componentmath.isnanhelper (_quat_has_nan), clearing all 25 unit-test warnings.Docs (
fb92dcd0+ this work)docs/ax/positioning.mdanddocs/ax/positioning/CONTEXT.mdupdated 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.pointing.aligned.estimatereflects it.Additional Notes for reviewers
imu_dead_reckoning_legacy.pyis intentionally retained as the equivalence-test reference; removal is a planned follow-up.handoff.mdscratch doc that had slipped into the branch was removed in this PR.🤖 Generated with Claude Code