Add Fittable/mutable framework, rewrite the Path and Frame registries, require Python 3.11 - #203
Add Fittable/mutable framework, rewrite the Path and Frame registries, require Python 3.11#203markshowalter wants to merge 38 commits into
Conversation
The main unit test suite goes from 19 failing tests to zero, and the host gold-master suite from four of five failing to zero. Library fixes: * mutable: _refresh_internal never applied _refresh() on an object's first pass, so the mutable.refresh(self) at the end of every constructor did nothing and derived attributes such as _transform, _times and _pos_x were never created. _needs_refresh_internal read two attributes that do not exist. * path_: five utility classes cached themselves in Frame._FRAME_CACHE instead of Path._PATH_CACHE, leaving the path cache unused and the frame cache polluted with Paths; _wrt lacked the reversal branch its Frame counterpart has, so linking a root path to one of its descendants recursed until the stack overflowed; _register omitted the two-element cache key; RelativePath discarded the origin Path it needs for the subtraction and advertised the frame of the wrong Path. * frame_: the LinkedFrame origin check rejected a null frame origin, which is the ordinary case rather than an error. * quickpath, quickframe: restored the "already quick" guards dropped in the rewrite, and repaired extend() -- a dict read as a method, frame keys used in a path class, a stale _steps, arrays whose lengths disagreed, a seam that left the times non-monotonic, and a tuple passed to a two-argument signature. * spicepath: get() ignored origin and frame when asked for the SSB. SPICE accepts several names per body, so the name a caller uses is now registered alongside the canonical one; "GLL" resolves to GALILEO_ORBITER. * keplerpath: eleven attribute names left behind by an incomplete privatization. * hosts/juno: os.path.basename() applied to an FCPath, which is not os.PathLike; use the FCPath.name property, as the other hosts do. Tests were updated for the current APIs. Two were also unsound: SpicePath tests restored Path._USE_QUICKPATHS to True in tearDown when the class default is False, corrupting later tests through the base class, and test_spice_shape did not clear the Path registry, so a custom path ID was ignored whenever another test had already registered VENUS. tests/hosts is restored to its state on main, apart from import paths that still referred to oops.backplane.gold_master and to modules that moved out of oops/hosts into tests/hosts. Python 3.8 through 3.10 are no longer supported. requires-python and both CI matrices now begin at 3.11. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the conventions and traps that are not evident from the code: the unittest-based test entry points, the flake8 targets and the deliberate whitespace ignores, the 80/90-column split between legacy and refactored modules, the banner-comment and trailing-underscore module conventions, and the environment variables the tests depend on. Also notes the architectural traps -- the late attribute injection at the foot of oops/__init__.py, masked polymath values, read-only cached Events and backplanes, the implicit km/sec-TDB/radian units, and quick=True disabling the optimization it appears to request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
SpicePath.USE_SPICEPATH_SHORTCUTS was lost in the rewrite. It let a caller
force the general ancestry walk instead of the class-specific shortcut, which
is how a disagreement between the two gets localized.
The old flag cannot come back as it was: shortcuts are no longer specific to
SpicePath but are a generic _get_shortcut() hook, implemented by SpicePath,
SSBPath, SpiceFrame, SpiceType1Frame and J2000Frame. Path._USE_SHORTCUTS and
Frame._USE_SHORTCUTS therefore follow the existing _USE_QUICKPATHS pattern and
are consulted where _wrt() calls the hook. Both are read from the base class
rather than from the instance, so a subclass cannot shadow the switch and
leave part of the hierarchy still taking shortcuts.
The switch immediately finds one such disagreement, which is left for a
separate change: Path.as_path('SSB').wrt('EARTH', 'IAU_MARS') agrees with
cspyce.spkez to 1.5e-8 km through the SpicePath shortcut, but without it
RotatedPath rejects a frame whose center of rotation differs from the path's
origin. The SpicePath tests note this where they would otherwise loop over
both settings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RotatedPath refused to rotate a Path into a Frame whose center of rotation
differed from the Path's origin. The constraint does not hold: expressing a
state in rotating axes uses (d/dt)_rot A = (d/dt)_inertial A - omega x A,
which is valid for any vector A, so only the relative state being rotated
matters and not where the center of rotation lies.
The guard was added by the rewrite; main has no equivalent, and main ran
Path.as_path('SSB').wrt('EARTH', 'IAU_MARS') through the general ancestry walk
and agreed with cspyce.spkez. This branch only passed that test because the
SpicePath shortcut bypassed the guard.
With the guard gone, the general walk produces a RotatedPath agreeing with
cspyce.spkez to 3.0e-8 km in position and 6.4e-12 km/s in velocity over the
tested epochs, against test tolerances of 1e-7 and 1e-9.
The SpicePath tests now run twice again, once with shortcuts disabled and once
with them enabled, which is what the switch restored in the previous commit is
for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merging main brought in a Galileo call to SpiceType1Frame, written against
main's signature, into a branch that had rewritten that class. Nothing had
constructed a SpiceType1Frame on this branch before, so three faults surfaced
at once and the four Galileo gold-master tests failed.
* main's signature is (spice_frame, spice_host, tick_tolerance, ...); this
branch dropped spice_host, so SpiceType1Frame("GLL_SCAN_PLATFORM", -77, 40)
passed the spacecraft ID as the tick tolerance and the tolerance as the
reference frame. The two Voyager calls were stale in the same way. The host
argument is dropped from all three.
* The host is not lost by dropping it: _fill_spice_info already derives it as
cspyce.frinfo(frame_name)[0], which returns -77 for GLL_SCAN_PLATFORM and
-31/-32 for the Voyager scan platforms, matching the literals exactly. It is
now retained as _spice_origin_code rather than discarded as a local.
* SpiceType1Frame read _spice_body_code at fourteen sites and
_spice_origin_code at one, neither of which was ever assigned; both are the
spacecraft clock code that spice_host used to supply. They are unified on
_spice_origin_code.
* SpiceFrame._FOR_CODE does not exist; the attribute is _FOR_NAME, and the
surrounding call already keys it by frame name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
img.py and spe.py both opened with "from polymath import *" while referencing no polymath name at all, which is what flake8 reported as F401 alongside the F403 for the wildcard itself. Neither module uses eval, exec or getattr, and both are imported only as modules rather than for any name they might re-export, so the imports are simply deleted. This matches the treatment junocam received in #198. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The jiram package __init__ carried the same unused "from polymath import *" as img.py and spe.py. It references no polymath name, uses no eval, exec or getattr, and the only name imported from it elsewhere is JIRAM, which is defined in the file. No wildcard polymath import now remains anywhere under oops/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The matrix had been switched to ubuntu-latest, macos-latest and windows-latest. The tests cannot run there: scripts/automated_tests/ oops_main_test.sh sources ~/oops_runner_secrets and exits unless SPICE_PATH, SPICE_SQLITE_DB_NAME and OOPS_RESOURCES are set, none of which exist on a GitHub-hosted runner. Its "pip uninstall -y `pip freeze`" step also assumes a dedicated environment rather than a shared image. The job name also read "Test pdstemplate", which belongs to a different package. The cross-product form of the matrix is kept; it yields the same nine combinations as the previous explicit include list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four issue forms (bug report, feature request, other, plus a config that disables blank issues) and a pull request template whose sections are Purpose, Changes/Implementation Details, Type of Change, Testing, Potential Impacts, Checklist and Notes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The condition tested for ubuntu-latest, which has never appeared in this workflow's matrix, so the coverage report was never uploaded. It now matches self-hosted-linux on Python 3.13, which is one of the nine combinations. The path itself was already correct: oops_main_test.sh writes coverage.xml through "python -m coverage xml" once the suites have run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #203 +/- ##
==========================================
+ Coverage 75.40% 78.90% +3.50%
==========================================
Files 192 211 +19
Lines 24094 25868 +1774
Branches 2926 2812 -114
==========================================
+ Hits 18168 20412 +2244
+ Misses 5088 4593 -495
- Partials 838 863 +25 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Exercised this branch as a drop-in replacement for 1.
|
rfrenchseti
left a comment
There was a problem hiding this comment.
See earlier comment
|
Follow-up to my earlier comment: there is also a significant performance regression on this branch, concentrated in frames that aren't SymptomOn a geometry workload that evaluates ring surface intercepts across a full Cassini ISS frame, wall clock goes 25.1 s -> 53.5 s (2.1x). A second workload over the same image set that is dominated by FFT work outside oops is unchanged (33.69 s -> 33.81 s), and a mixed 75-image batch comes out at 1.30x overall — so this is not a flat per-call overhead, it is specific to certain frames. Where it goes
Instrumenting the size of the Proximate cause
if not frame._USE_QUICKFRAMES:
return frameAcross the whole tree only two classes opt in — On One caveat, since it is the obvious thing to trySetting Worth stressing that this is purely a performance issue — apart from the LORRI import break in my earlier comment, results on this branch match |
|
Regarding "2. Unregistered frames are retained for the life of the process", we can look into whether a strict upper limit on the Check out Until we get there, I think doing nothing is a reasonable approach to this issue, sinceit just wastes a bit of core memory, nothing more. If memory really is an issue, we could implement a workaround, such as limiting the size of the BTW, the |
|
"1. oops.hosts.newhorizons.lorri cannot be imported" is fixed in this checkin. "3. Two behavior/API changes that deserve a line in the PR description" is addressed by a few words in the PR description. The performance issue will take a little bit of investigation to determine whether the old or new behavior is correct. Specifically, what should happen is that the SpiceFrame uses a QuickFrame but the RingFrame should not. RingFrame + SpiceFrame should invisibly be implemented as RingFrame + QuickFrame, not as its own unique QuickFrame. |
|
Next point about timing. You should be using RingFrame(epoch=0.) for Saturn, because the rotation pole is (essentially) fixed. That means that the RingFrame, which is a "despun" version of the planet's PCK frame, is inertial. So by fixing the epoch at any time, you will be devoting roughly zero time to the evaluation of this frame. I see the timing issue you raised, and am looking into the best solution. But still, this is time you could be saving. |
The per-observation ownership documented for set_cmatrix(frame_id=None) is emergent from how the current registry treats unregistered frames, not a property Cmatrix or Frame.register promises. PR #203's registry rewrite dedups equal-valued unregistered frames to a shared wayframe and retains every construction globally, which would falsify the contract without anything failing. Pin it: equal-valued C-matrices yield distinct frame objects, and a default load leaves the wayframe registry and frame cache unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Wg9zMfz6FNEotebnS1hmm
|
Raising an interaction with #201, following @rfrenchseti's note there (#201 (comment)): as they describe it, the registry rewrite here inserts every frame into a per-subclass Two consequences for
#201 now pins its contract with a test ( |
QuickFrames were reaching only SpiceFrame, so a RingFrame built on a SpiceFrame fell back to one SPICE call per evaluation. A Cassini ISS ring-plane solve over 1e6 rays took 12.03 s and 4 million SPICE calls; it now takes 1.04 s and 822. - The photon solvers in Surface pass `quick` through to the frame rather than quick=False, which had also suppressed the QuickFrame that SpiceFrame creates for itself. - Frame subclasses whose own transforms vary slowly with time opt in through _USE_QUICKFRAMES. The flag describes a frame's own time dependence, not its reference's, and LinkedFrame and ReversedFrame inherit it from the frames they combine. - LinkedFrame.transform_at_time_if_possible referred to self.parent and self.frame, which no longer exist. It is called only when a LinkedFrame is tabulated, so the error had been unreachable. - QuickFrame makes its tabulated quaternions continuous in sign before splining them. Quaternions q and -q describe the same rotation, and the reversal where the rotation angle passes pi made interpolation return noise for any frame turning more than pi within the window. - QuickFrame now reports a meaningful error when asked to tabulate a frame whose transform does not vary with time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tabulating a frame re-entered the quick machinery, so building one QuickFrame over a short span built two, at twice the SPICE calls. The recursion stopped only because the cost heuristic declined a third. - QuickFrame sampled its slow frame through transform_at_time_if_possible without quick=False, so tabulating a SpiceFrame made that SpiceFrame quicken itself for the tabulation times. QuickPath already passes quick=False in the equivalent places. - LinkedFrame.transform_at_time_if_possible accepted a quick parameter and then called both component frames without it, so a composite still quickened its SpiceFrame component once QuickFrame began passing quick=False in. A RingFrame or a TwoVectorFrame atop a SpiceFrame now resolves to a single QuickFrame, tabulated with 219 SPICE calls rather than 427. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
QuickPath and QuickFrame are built by the same machinery, but only the frame half had been brought up to date. A Path reached through wrt() was never tabulated unless it was a SpicePath, and the photon solvers then evaluated it with quick=False, which also suppressed the QuickPath that SpicePath builds for itself. Solving for a photon from a KeplerPath over 300,000 rays took 21.65 s; it now takes 0.69 s, with the light times agreeing to 1.5e-08 s. - LinkedPath, RelativePath, ReversedPath, and RotatedPath inherit _USE_QUICKPATHS from the paths they combine, as LinkedFrame and ReversedFrame already do. - KeplerPath opts in. Evaluating one at a million times costs 32 s directly and 0.007 s through a QuickPath. CirclePath and LinearPath stay opted out because they are already cheap, and FixedPath because its state does not vary with time. - The photon solvers pass quick along to the path, as they already do to the frame. - QuickPath reports a meaningful error when asked to tabulate a path whose state does not vary with time, rather than an IndexError from the spline setup. Also drops a duplicated pair of quick_path and quick_frame calls in Surface._solve_photon_by_coords. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three classes carried attribute names left behind by the Path and Frame rewrite, none of them covered by a test module. PathShift and Navigation raised AttributeError from their constructors, so neither could be built at all. - PathShift._refresh read self.link; the attribute is _link. FrameShift exposes a link property, but PathShift does not. - Navigation declared _FRAME_IDS where the registration machinery looks for _WAYFRAMES, tested an _epoch attribute it never defines, and left _angles unset when constructed as a link to another Navigation. _set_params also read self.link. - KeplerPath.photon_to_event built its ray from Event.pos_j2000, which does not exist, and assigned the light travel times with the signs reversed relative to Path._solve_photon. It now locates the body from the planet's own position, so the ray length matches the light travel time to a part in 1e4, the residual being the orbit radius divided by the range. Adds test modules for PathShift and Navigation, and a photon solution check to the KeplerPath tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PathShift, TimeShift, and FrameShift implement the same idea, but each exposed it differently: FrameShift kept the linked object in _link and published a link property, PathShift had _link and no property, and TimeShift used a public link attribute throughout. All three now store _link and publish the same documented property. TimeShift is also exported from oops.cadence, as PathShift and FrameShift are from oops.path and oops.frame. Without it the class could only be reached through its module. Adds a test module for TimeShift, which had no coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With an observer defined, KeplerPath sets its origin to that observer, but event_at_time returned a position measured from the planet. It added planet_event.pos, which a photon solution always leaves at zero because it places each event relative to its own path. Every consumer of the path was therefore short by the planet-to-observer vector: a photon solved through the base solver reported a light travel time of 0.54 s where the correct value is 4400.8 s. The planet is now located from the two events' positions relative to the solar system barycenter, taken at the time the photon departed and the time it arrived, as in Path._solve_photon. An event on the path now sits within one orbit radius of the planet's own position, and photon_to_event returns the ray as the negative of that position, which is what the original code was reaching for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LinkedPath took its frame from the path being linked rather than from the parent it is linked to, but event_at_time rotates into the parent's frame before adding the parent's event, so every Event it returns is in the parent's frame. RelativePath already uses the origin's frame, with a comment explaining why, and the LinkedPath docstring says the same. The mismatch stayed hidden while both components used J2000. It surfaced through SpicePath._get_shortcut, which links a SPICE path in J2000 to a remainder in the requested frame: asking for a path in a ring frame returned one that claimed J2000, and any Event built from it failed with "Events must share a common frame for path addition". That made photon_to_event unusable for a KeplerPath defined without an observer, since such a path uses its planet's ring frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With an observer defined, event_at_time() takes the time at the observer and solves the light travel time itself. photon_to_event() handed it the time the photon left the planet, so the correction was applied a second time and the body was placed where it had been one light time earlier still. It now passes the arrival time, as the method expects, and stamps the resulting state with the departure time to form the departure event. The ray and the light travel time agree an order of magnitude more closely, to 1.3e-05 rather than 1.2e-04; what remains is the radial part of the orbital offset, which this class does not solve for because its light travel time is measured to the planet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TimeShift kept its time shift in a public dt attribute, while PathShift and FrameShift keep theirs in _dt behind a dt property. It now matches them, so all three shift classes present the same surface. The rest of the object is unchanged: the Cadence contract names, and the wrapped cadence, stay public attributes, as they are in every other Cadence subclass. With dt private, the public attributes of a TimeShift are exactly those of a ReshapedCadence, and the only private state is _dt and _link, matching how the other subclasses reserve underscored names for internal values. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Navigation is linkable in the same way as the three shift classes, but kept its linked object in _link with no way to read it. It now has the same documented property they do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
flake8 reported 136 F401 warnings against oops, nearly all of them the re-export lines that give each package its public names. Declaring those names in __all__ says what the package publishes and silences the warning for the right reason. - A package that only re-exports lists the names it re-exports. - A package that defines code lists the public classes and functions it defines. - backplane/all.py and gold_master/all.py exist only for their import side effects and carry a file-level noqa instead, as do the two deferred imports of gold_master.all. Also removes 21 genuinely dead imports found alongside them, in files from oops/fittable.py to the host packages. oops/__init__.py documents "from oops import *", so its list keeps every name that form used to provide. It now yields 56 names rather than 59; the three it drops are cspyce, spice_support and oops itself, none of which the package meant to publish. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The timing issue should be resolved now. Please check. And I really do think you should revisit how you define the We are going to need to make some changes to the |
Both classes were labelled placeholders and neither worked. - FrameShift.transform_at_time read self.frame, an attribute Frame does not have, so the class could not produce a transform at all. Its "+" frame_id branch asked a Frame for _path_id. - Platescale never declared nparams and named its parameter property _params, so set_params raised. It also copied uv_area from the reference FOV without scaling it, leaving area_factor() wrong by the square of the plate scale; uv_area now scales with the factor and is refreshed alongside uv_scale. - Fittable.set_params read self._nparams when reporting the wrong parameter count, turning that ValueError into an AttributeError. The new test modules cover the shift and scale themselves, linking, refitting, freezing, and the errors raised for a bad parameter count or a frozen object. Each assertion was checked against the unfixed source. The placeholder labels come off FrameShift, Platescale and PathShift, all three of which now have tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
It will change how I use oops during navigation, or how I use oops to get the final cmatrix? I don’t see how I can change the navigation process. It’s done on an original, non-offset image and associated backplanes, and that isn’t going to change. The final offset is in pixels. I then have to convert the pixels to an offset frame and a new cmatrix. That fundamental process can’t change. |
|
It sounds like you are using something morally equivalent to an |
The five Fittable Path and Frame classes -- PathShift, FrameShift, KeplerPath, Navigation and Rotation -- plus the two fixed frames Cmatrix and PosTargFrame no longer keep a _WAYPOINTS or _WAYFRAMES dictionary, so two instances built from the same values are no longer collapsed into one. For the fittable classes that collapse was unsafe: refitting one object would silently redefine every other object built from the same parameters. _register() and _reregister() assumed every class had such a dictionary, so a class without one never had its waypoint or wayframe assigned at all. Each now takes itself as its own waypoint or wayframe, and _reregister() returns early because there is no key to move. The _waypoint_key and _wayframe_key methods of those seven classes are removed, having become unreachable; the sixteen classes that keep a dictionary keep theirs. InclinedFrame freezes the Rotation it builds internally, whose inclination is fixed once the frame exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
for_frame() and for_path() hand back a QuickFrame or QuickPath from the cache without checking whether the object it tabulates has changed. If that object is fittable, the tabulation is of the parameters it held when it was built, so a re-fit is silently ignored: a QuickPath of a KeplerPath returned positions 11,987 km stale after its semimajor axis moved by 10,000 km. The mutable machinery already tracks this -- the tabulation records the frame or path as mutable and carries its version -- so the reused object only needs refreshing. Both the reuse and the extend branch now do so. A QuickPath of a KeplerPath has been reachable since KeplerPath opted in; a QuickFrame of a fittable frame became reachable when LinkedFrame began inheriting the opt-in from the frames it combines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Give both classes a _WAYFRAMES registry and a _wayframe_key(), so that two frames built from identical definitions share a wayframe instead of each becoming its own.
from_file() accepts a frame of its own and an option to wrap it in a Navigation, so an observation can be pointed by a fitted C matrix rather than by a C kernel. The SPICE camera frames are now built lazily by define_camera_frames(), so an observation with a custom frame never constructs them. When the pointing did not come from a CK, any CK that happens to be furnished is unrelated to the observation, so used_kernels() takes a ck option that leaves those kernels out of the reported list. Record the rotation between the SPICE and oops frame conventions as the module constant CMATRIX_ROTATION, and attach it to each observation as its spice_to_cmatrix subfield. Drop the unused offset_wac option.
_refresh() is free to replace a sub-object with a newly constructed one, but the replacement had never been refreshed itself, so it reported that it was stale on every later test, and so did the object holding it. A Backplane over a Navigation-framed observation therefore never settled: needs_refresh() stayed True and every refresh() recomputed the whole observation event and all cached backplanes. Refresh the sub-objects that _refresh() replaced. That exposed a latent defect. _get_info() and _refresh_internal() both guard against cycles in the object graph, but _needs_refresh_internal() did not; it was saved only by returning early before it walked deep enough to reach one. An Event whose _wod_ refers to itself now sends it into unbounded recursion, so give it the same guard. Add _invalidate(), which discards the cached record of which sub-objects are mutable. The set is determined once and then cached, so replacing a sub-object in place otherwise goes unnoticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An independent copy of a Fittable had no general mechanism. copy.copy() goes through __setstate__, which freezes the result and re-registers it under the original's ID; both are right for unpickling and wrong for copying. copy() instead reconstructs the object from the constructor arguments that __getstate__ already returns, dropping the trailing ID of a registered Frame or Path so the copy is left unregistered. The objects inside that state, such as a reference frame or an underlying FOV, are shared rather than duplicated. KeplerPath overrides it, being the one class whose constructor cannot take its state positionally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The constructor accepts only one of uv_offset and xy_offset, deriving the other from it, but __getstate__ returned both and __setstate__ fed both back, so pickling an OffsetFOV raised a ValueError. Save only uv_offset, the one the Fittable interface uses. The constructor also stored the offset exactly as given, so an offset supplied as a tuple left uv_offset without the Pair interface that params and set_params depend on. Coerce both offsets to Pair. Add a test covering the coercion, the pickle and copy round trips, and the equivalence of the two ways of specifying an offset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
copy() returns an observation that shares the data array, and shares the frame, path, FOV and cadence because those are canonical registered objects. It does not share its own state: the copy gets a new subfield dictionary and none of the original's record of what has been modified. A Fittable sub-object is duplicated, so fitting one observation leaves the other alone. navigate() is rebuilt on copy(). It previously took a shallow copy of __dict__, which left the two observations sharing one subfield dictionary and one mutable-state record; the shared record could report that an observation was up to date after its frame had changed. set_frame() replaces the frame in place, refusing to act on a frozen observation. An observation that is not mutable reports itself frozen because it has nothing to freeze, so both tests are needed to single out one that was frozen deliberately. get_spice_cmatrix() moves up from Snapshot, where it evaluated the frame against its own reference rather than J2000 and applied the convention rotation on the wrong side, so it returned the identity for every Cassini ISS observation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Point an observation with a C matrix given in the convention of the SPICE toolkit's C kernel, inverting get_spice_cmatrix(). The C matrix is converted with Matrix3.as_matrix3() first. Without that, a 3x3 array, which is what cspyce.pxform() returns, multiplies as a Scalar rather than as a matrix and comes back unchanged, so the rotation between the SPICE and oops conventions is left out. The resulting frame is rotated 180 degrees about the boresight, which leaves the boresight itself pointing correctly and shows up only across the field of view. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
I think this checkin now addresses the remaining issues. Claude repaired a few more bugs. In addition, this addresses my concerns about #201. I took Joe's changes, merged them into mine, and let Claude proceed. There is now a general new Observation method There are also two methods The Cassini ISS implementation is slightly different from what Joe submitted. Observation gets an optional subfield "spice_to_cmatrix", a Matrix3 that rotates the SPICE C matrix to the OOPS version. If the Observation has this subfield, then those two new methods work; otherwise, it doesn't. I think this is cleaner than saving the entire host's class object inside the Observation. |
Add a Mutable base class that exposes the mutable API as methods, and have Backplane, Cadence, FOV, Frame, Observation, Path and Surface inherit it. Seventy-six calls to the module-level functions become method calls, and twenty-six imports of oops.mutable are no longer needed. The query methods take a leading underscore -- _is_frozen, _is_mutable, _needs_refresh, _mutable_names, _unfrozen_names and _version -- so that they do not shadow the is_frozen and version properties that Fittable publishes. A class such as Navigation inherits from both, with Mutable ahead of Fittable in the MRO, so an unprefixed is_frozen() resolves to the method and reads as permanently true: set_params() refuses every fit, and Navigation and FrameShift discard their links on construction. refresh() and freeze() are left unprefixed. Both classes define them and both delegate to the same module-level functions, so which one the MRO selects makes no difference.
The subfield holds the rotation from the instrument's SPICE frame to the oops frame, so the name now describes what it converts to rather than how it is used. Cassini ISS is the only host that inserts it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Looks good overall to me, but what is the justification for assuming the mid time instead of including it as an arg? |
Purpose
This branch introduces a Fittable/mutable framework for in-place parameter fitting, rewrites the Path and Frame registries around it, and fixes the regressions that rewrite introduced. It also drops Python 3.8 through 3.10.
The Fittable work is the motivation: pointing corrections, time shifts, plate scales and orbital elements need to be adjustable in place, with any object that depends on them refreshing itself when they change. Supporting that required reworking how Paths and Frames are registered, cached and linked.
A second pass repairs what the rewrite left behind in the QuickPath and QuickFrame machinery, in
LinkedPath,PathShift,NavigationandKeplerPath, and settles the API of the four linkable Fittable classes.A third pass puts the framework to work: an observation can now be pointed by a C matrix of its own rather than by a C kernel, which is what image navigation needs, and can be copied so that a fit applied to the copy leaves the original alone. Getting that right exposed further defects in the mutable framework and in
OffsetFOV.A fourth pass settles how the framework is reached. The mutable API had been a set of module-level functions called as
mutable.refresh(obj); the seven OOPS base classes now inherit it as methods.Changes / Implementation Details
Fittable and mutable framework
oops/fittable.pyis rebuilt aroundset_params,refreshandfreeze. The newoops/mutable.pypropagates staleness through objects that merely contain a Fittable sub-object, tracking state through injected_FITTABLE*/_MUTABLE*attributes and an integer version counter.PathShift,FrameShift,TimeShiftandPlatescaleare new;OffsetFOV,Navigation,RotationandKeplerPathare converted. The four new classes are marked as untested placeholders.Path and Frame registry rewrite
Wayframe,Waypoint,AliasPath,AliasFrameandRelativeFrameare gone, replaced byNullFrame/J2000Frame,NullPath/SSBPath,ReversedFrame,RelativePath,ReversedPathandRotatedPath. Registries and the registration hook are private.QuickFrameandQuickPathmove into their own modules. A newoops/cache.pyprovides an LRU cache whoseclean_key()makes polymath objects usable as dictionary keys.Fixes to that rewrite
mutable._refresh_internalnever applied_refresh()on an object's first pass, so themutable.refresh(self)at the end of every constructor did nothing and derived attributes such as_transform,_timesand_pos_xwere never created.Frame._FRAME_CACHErather thanPath._PATH_CACHE, leaving the path cache unused and the frame cache holding Paths.Path._wrtlacked the reversal branch its Frame counterpart has, so linking a root path to one of its descendants recursed until the stack overflowed.RelativePathdiscarded the origin Path it needs for the subtraction, and advertised the frame of the wrong Path.LinkedFrame's origin check rejected a null frame origin, which is the ordinary case.QuickPath.for_pathandQuickFrame.for_frame, andextend()repaired in both -- a dict read as a method, frame keys used in a path class, a stale_steps, arrays whose lengths disagreed, a seam that left the tabulated times non-monotonic, and a tuple passed to a two-argument signature.SpicePath.get()ignoredoriginandframewhen asked for the SSB. SPICE accepts several names per body, so the name a caller uses is now registered alongside the canonical one;"GLL"resolves toGALILEO_ORBITER.KeplerPathhad eleven attribute names left behind by an incomplete privatization.os.path.basename()was applied to anFCPathin the Juno hosts, which is notos.PathLike; these now use theFCPath.nameproperty as the other hosts do.QuickPath and QuickFrame reach the objects that need them
QuickFrames were reaching only
SpiceFrame, so aRingFramebuilt on one fell back to a SPICE call per evaluation, and the photon solvers then evaluated it withquick=False, which also suppressed the QuickFrameSpiceFramebuilds for itself._USE_QUICKFRAMESnow describes a Frame's own time dependence -- a Frame returning a fixed Transform has nothing to tabulate -- andLinkedFrame/ReversedFrameinherit it from the frames they combine.RingFrame,PoleFrame,TrackerFrame,LaplaceFrame,InclinedFrameandSynchronousFrameopt in. The same treatment is applied to Paths: the four composite Path classes propagate_USE_QUICKPATHS, andKeplerPathopts in.Three latent defects surfaced along the way:
LinkedFrame.transform_at_time_if_possiblereferencedself.parentandself.frame, which no longer exist, and then dropped thequickargument it was passed. Both were unreachable whileLinkedFramewas never tabulated.QuickFramesampled its slow frame withoutquick=False, so tabulating aSpiceFramemade that frame quicken itself for the tabulation times -- one QuickFrame built two, at twice the SPICE calls.QuickPathalready passedquick=Falsein the same places.QuickFramesplined the tabulated quaternions without making them continuous in sign. Since q and -q describe the same rotation, any frame turning through more than pi within the tabulated window returned noise.IAU_JUPITERwould have broken past a window of roughly five hours.Both classes now report a meaningful error when asked to tabulate an object whose state does not vary with time, rather than an
IndexErroror aValueErrorfrom scipy.Path and Frame repairs
LinkedPathtook its frame from the path being linked rather than the parent it is linked to, thoughevent_at_timerotates into the parent's frame before adding. Invisible while both were J2000; throughSpicePath._get_shortcutit produced a path claiming J2000 while returning ring-frame events, and any Event built from it failed with "Events must share a common frame for path addition".PathShiftandNavigationraisedAttributeErrorfrom their constructors and could not be built at all:self.linkforself._link,_FRAME_IDSwhere the registration machinery looks for_WAYFRAMES, a test of an_epochattribute that is never defined, and_anglesleft unset when constructed as a link.KeplerPathset its origin to the observer butevent_at_timereturned a position measured from the planet, so a photon solved through the base solver reported a light travel time of 0.54 s where the correct value is 4400.8 s.photon_to_eventbuilt its ray fromEvent.pos_j2000, which does not exist, assigned the light travel times with the signs reversed, and applied the light time a second time by passing an already-retarded time back intoevent_at_time.The placeholder classes
FrameShiftandPlatescalewere labelledPLACEHOLDER CODE ... NOT YET TESTEDand neither worked.FrameShift.transform_at_timereadself.frame, an attributeFramedoes not have, so it could not produce a transform at all, and its"+"frame_id branch asked a Frame for_path_id.Platescalenever declarednparamsand named its parameter property_params, soset_paramsraised; it also copieduv_areafrom the reference FOV without scaling it, leavingarea_factor()wrong by the square of the plate scale.Fittable.set_paramsitself readself._nparamswhen reporting a wrong parameter count, turning thatValueErrorinto anAttributeError. All three classes with placeholder labels --FrameShift,PlatescaleandPathShift-- now have test modules and the labels are gone.Pointing an observation without a C kernel
cassini.iss.from_file()accepts aframeof its own and anavigationoption that wraps it in aNavigation, so an observation can be pointed by a fitted C matrix. The SPICE camera frames are built lazily byISS.define_camera_frames(), so an observation with a custom frame never constructs them, andCassini.used_kernels()takes ackoption so that a CK which happens to be furnished is not reported as used by an observation whose pointing did not come from one. The rotation between the SPICE and oops frame conventions is recorded asiss.CMATRIX_ROTATIONand attached to each observation as itsspice_to_cmatrixsubfield.Observationgainsget_spice_cmatrix()andset_spice_cmatrix(), which read and write the pointing in the convention of the SPICE toolkit's C kernel, andset_frame(), which replaces the frame in place and refuses to act on a frozen observation. An observation that is not mutable reports itself as frozen because it has nothing to freeze, so that guard tests both.CmatrixandPosTargFramegain the_WAYFRAMESregistry and_wayframe_key()that let two identically defined frames share a wayframe.Copying an observation, and copying a Fittable
Observation.copy()returns an observation that shares the data array, and shares the frame, path, FOV and cadence because those are canonical registered objects, but has its own subfield dictionary and none of the original's mutable-state record. A Fittable sub-object is duplicated, so fitting one observation leaves the other alone.navigate()is rebuilt on it. It previously took a shallow copy of__dict__, which left the two observations sharing one subfield dictionary and one mutable-state record; because the record is keyed by attribute name while the two frames are distinct objects, it could report that an observation was up to date after its frame had genuinely changed.Duplicating a Fittable had no general mechanism, so
Fittable.copy()is new.copy.copy()goes through__setstate__, which freezes the result and re-registers it under the original's ID -- right for unpickling, wrong for copying.copy()instead reconstructs from the constructor arguments__getstate__already returns, dropping the trailing ID of a registered Frame or Path so the copy is left unregistered.KeplerPathoverrides it, being the one class whose constructor cannot take its state positionally.Further repairs to the mutable framework
_refresh()is free to replace a sub-object with a newly constructed one, but the replacement had never been refreshed itself, so it reported that it was stale on every later test, and so did the object holding it. ABackplaneover aNavigation-framed observation therefore never settled:needs_refresh()stayedTrueand everyrefresh()recomputed the observation event, the gridless event and all cached backplanes.Fixing that exposed a latent defect.
_get_info()and_refresh_internal()both guard against cycles in the object graph;_needs_refresh_internal()did not, and was saved only by returning early before it walked deep enough to reach one. AnEventwhose_wod_refers to itself sends it into unbounded recursion, so it now carries the same guard._invalidate()is added to discard the cached record of which sub-objects are mutable, which is otherwise determined once and never revisited, so that replacing a sub-object in place is noticed.OffsetFOV
The constructor accepts only one of
uv_offsetandxy_offset, deriving the other from it, but__getstate__returned both and__setstate__fed both back, so pickling anOffsetFOVraisedValueError. It now saves onlyuv_offset. The constructor also stored the offset exactly as given, so an offset supplied as a tuple leftuv_offsetwithout thePairinterface thatparamsandset_paramsdepend on; both offsets are now coerced withPair.as_pair().The mutable API becomes an interface
Mutableis a base class that publishes the mutable API as methods, andBackplane,Cadence,FOV,Frame,Observation,PathandSurfaceinherit it. Seventy-six calls to the module-level functions become method calls and twenty-six imports ofoops.mutablefall away; the functions remain, and are what the methods delegate to.The six query methods carry a leading underscore --
_is_frozen,_is_mutable,_needs_refresh,_mutable_names,_unfrozen_namesand_version-- so that they do not shadow theis_frozenandversionproperties thatFittablepublishes. A class such asNavigationinherits from both, withMutableahead ofFittablein the MRO, so an unprefixedis_frozen()resolves to the method; the bound method is always truthy,set_params()refuses every fit, andNavigationandFrameShiftdiscard their links on construction.refresh()andfreeze()are left unprefixed, both classes defining them and both delegating to the same functions, so the MRO's choice makes no difference.API consistency
PathShift,TimeShift,FrameShiftandNavigationall hold their linked object in_linkand publish the samelinkproperty;TimeShiftalso moves its offset to_dtbehind adtproperty, matching the other two shift classes, and is now exported fromoops.cadence. Every__init__.pydeclares its public API in__all__, and 21 dead imports are removed.Python version
requires-pythonmoves to>=3.11and both CI matrices now begin at 3.11, testing 3.11 through 3.13 on Linux, macOS and Windows.Type of Change
Testing
All three entry points pass:
Eight test modules are new --
test_quickframe,test_quickpath,test_pathshift,test_navigation,test_timeshift,test_frameshift,test_platescaleandtest_offsetfov-- covering classes that had no coverage at all, five of which could not previously be constructed or used. Each new assertion was checked against the unfixed source to confirm it fails there. Fortest_offsetfovthat was done once per defect, since the coercion failure aborts the test before it reaches the pickle assertions and would otherwise mask them.The main suite went from 19 failing tests to zero, and the host gold-master suite from four of five failing to zero. The Cassini ISS and Galileo SSI gold masters both compare clean, which exercises observation loading, backplane generation and comparison end to end.
Beyond the suite, the reversal path in
Path._wrtwas checked against independently computed geometry:earth.wrt(moon)matches both-moon.wrt(earth)and a separately constructedSpicePath('EARTH','MOON')to zero residual across 121 epochs.QuickPath.extendandQuickFrame.extendhave no test coverage at all, so they were driven directly and their interpolation checked against the underlying slow path and frame, agreeing to 4e-16 relative and 1e-15 absolute respectively.The pointing work has no gold master of its own, so it was checked against SPICE directly. Across four Cassini ISS images, two NAC and two WAC, with and without a
Navigationframe,get_spice_cmatrix()reproducescspyce.pxform('J2000', 'CASSINI_ISS_<camera>', midtime)to zero residual.set_spice_cmatrix()inverts it to zero residual from aMatrix3, from a numpy array and from a list of lists, and an observation pointed that way yields right ascension and declination identical to the CK-pointed observation.Fittable.copy()was exercised over all eight concrete Fittable classes, confirming that each copy starts with the original's parameters, comes back unfrozen and unregistered, and can be fitted without disturbing the original.The
Mutableconversion is mostly mechanical, but its one real hazard -- a name onMutableshadowing a same-namedFittableproperty through the MRO -- is barely covered by the suite; theis_frozencollision surfaced in only three tests, all of them Frame tests, and would have gone unnoticed on the FOV and Cadence conversions. So all 82Mutablesubclasses were checked directly: for each of the eight names, every provider along the MRO was compared by descriptor kind, and no class has two providers of differing kind. EveryFittablesubclass now inheritsMutable, and all eight concrete ones were exercised through both interfaces at once, confirming that theis_frozenproperty and_is_frozen()agree and thatcopy()still returns the right class.Several tests were themselves unsound and were fixed: the SpicePath tests restored
Path._USE_QUICKPATHStoTrueintearDownwhen the class default isFalse, corrupting later tests through the base class, andtest_spice_shapenever cleared the Path registry, so its custom path ID was ignored whenever an earlier test had already registered VENUS.Potential Impacts
Breaking, public API.
Wayframe,Waypoint,AliasPath,AliasFrameandRelativeFrameno longer exist.Path.as_path(id)now returns the registered Path rather than a zero-position Waypoint; useNullPathfor the old behaviour.AliasPath(path, frame)becomesNullPath(path, frame=frame). The registries are private (_PATH_REGISTRY,_FRAME_REGISTRY,_register(),_reset_caches()), the cross-class attributes injected byoops/__init__.pyare renamed fromXXX_CLASSto_Xxx,quickis keyword-only and defaults toNone, theQUICKdictionary keysquickpath_cache/quickframe_cachegain a_sizesuffix, automaticTEMPORARY_*path IDs are gone (path_idisNonewhen unregistered; useis_registered), andSpicePath.USE_SPICEPATH_SHORTCUTSno longer exists, so there is no longer a way to disable SPICE shortcuts for debugging.Public API, new base class.
Backplane,Cadence,FOV,Frame,Observation,PathandSurfaceall gainMutableas a base class, so every Frame, Path, Surface, FOV, Cadence, Observation and Backplane in the package acquires eight public or semi-public methods it did not have. Nothing is removed and the module-level functions still work, so this is additive; but a subclass outside this repository that defines its ownrefresh,freezeor_versionwill now override the inherited version rather than sit alongside it, and one defining anis_frozenmethod will hit the shadowing described above.Breaking, Cassini ISS.
cassini.iss.from_file()takes its options as keyword arguments only, and theoffset_wacoption is gone from bothiss.initialize()andISS.initialize(). TheCASSINI_ISS_NACandCASSINI_ISS_WACframes are no longer built as a side effect ofinitialize();ISS.define_camera_frames()builds them, andfrom_file()andfrom_index()call it for themselves.Additions.
Observationgainscopy(),set_frame(),get_spice_cmatrix()andset_spice_cmatrix().Observation.navigate()keeps its signature but now returns a properly independent copy.Fittablegainscopy(), inherited by all nine subclasses.OffsetFOV.__getstate__returns a two-element state rather than three, so a pickle written by an older version will not load -- though no such pickle can exist, because writing one raised.Python support. 3.8, 3.9 and 3.10 are dropped.
oops/cache.pyuses amatchstatement, so the floor cannot go back below 3.10 without rework.Performance.
Path._PATH_CACHEwas never being written to and so never hit; paths were rebuilt on every call. It now works as intended.The Quick* changes were measured on a Cassini ISS ring-plane solve over 1,000,000 rays, with the observer in a CK-driven frame. The default path took 12.03 s and 4,000,412 SPICE calls; it now takes 1.05 s and 822. Solving for a photon from a
KeplerPathover 300,000 rays went from 64.92 s to 0.65 s, with identical light times. Tabulating a frame or path costs 219 SPICE calls rather than 427, the nested QuickFrame having been removed.Two measurements worth recording, since they contradict the intuition that eliminating SPICE calls is what matters. A
QuickFrameis worth 2.2x on a CK-driven frame but nothing at all on a text PCK, where interpolating costs as much as the SPICE call. AQuickPathis worth about 100x on the time spans a real observation spans, becausequickpath_linear_interpolation_thresholdcollapses it to linear interpolation, but it loses to raw SPICE for spans beyond about 25 s when the evaluation times are unsorted -- scipy's spline evaluation walks the knots. Neither case arises in normal use, but both are easy to hit in a synthetic benchmark.Checklist
ruff check,ruff format) — n/a: this repository lints with flake8, not ruff; there is noruff.tomland no[tool.ruff]inpyproject.toml. Repository-wide,flake8 oopsfalls from 283 findings onmainto 104 here andflake8 spicedbis unchanged at 47; F401 goes from 135 to 0. The change introduces no new findings.mypypasses — n/a: no mypy configuration exists in the repository, and annotations are confined tofittable.pyandmutable.pyby design.Raises:clause ofRotatedPath. There is no Sphinx docs tree in this repository.printstatements dumping__dict__were removed fromtests/path/test_spicepath.py.Notes
Follow-up work, none of it blocking:
KeplerPathsolves its light travel time to the planet centre rather than to the body, so the ray length and the light travel time agree only to the radial part of the orbital offset over the range, about 1e-05 for the orbit used in the tests. That is inherent to the class, not a defect.tests/hosts/juno/jiram/__init__.pyhas been migrated to the current gold-master API and theFCPathcrash fixed, so it can be enabled when wanted, but its April 2023 gold masters disagree with this branch on sky angles and ring geometry and would need triage first.Matrix3 * ndarrayreturns aScalarof shape (3, 3) in polymath rather than performing matrix multiplication, and does so silently.Observation.set_spice_cmatrix()guards its own call site withMatrix3.as_matrix3(), but the trap remains anywhere else aMatrix3meets a raw array, and is worth an issue againstrms-polymath.Observation.set_spice_cmatrix()replaces the frame outright, so calling it on an observation created withnavigation=Truediscards the fittable frame rather than re-basing it. That suits setting an absolute pointing, but it silently resets a fit in progress.Observation.copy()shares a non-Fittable frame, path, FOV and cadence deliberately, but shares a nested Fittable too -- aCmatrixwhose reference is aNavigation, say. Only a directly held Fittable is duplicated.run-lint.ymlhas itspull_request/pushtriggers commented out and pointing at amasterbranch that does not exist here, so flake8 has never run in CI.tests/hosts/unittester.pycovers two of the seven instrument packages;juno,hst,voyager,newhorizonsandkeckare all commented out.Gold-master directory rename
Gold masters are stored under a directory named after the module string, used verbatim as a path component (
oops/gold_master/__init__.py):The Juno masters were adopted in April 2023, before the host packages were renamed from
hosts.*tooops.hosts.*; the Cassini and Galileo masters were re-adopted in December 2023 and already use the new name. So the Juno tests ask foroops.hosts.juno.*and find nothing, reportingNo gold masterfor every backplane.The resource tree is not a git repository, so this has to be applied by hand wherever the resources live, including the self-hosted CI runners:
Afterwards all four directories share one convention:
The rename is reversible, and it is a no-op for anyone who does not run the Juno tests, since JunoCam and JIRAM are deliberately excluded from
tests/hosts/unittester.py. Note that once the masters are found, the JIRAM comparisons do run but disagree with this branch on sky angles and ring geometry, as noted above.🤖 Generated with Claude Code