Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
057152b
Add custom C-matrix support to Cassini ISS from_file
jnspitale Aug 10, 2026
cdad9ca
Add Observation.cmatrix() to derive the J2000-to-frame rotation
jnspitale Aug 10, 2026
4ab0943
Use dedicated SPICE frames for inter-camera mapping; extend cmatrix t…
jnspitale Aug 10, 2026
d92a221
Fix custom C-matrix conventions and frame leaks in Cassini ISS
jnspitale Aug 11, 2026
fc52191
Add ISS.get_cmatrix, the inverse of set_cmatrix
jnspitale Aug 11, 2026
5ef6ce7
Use the ROT180 constant directly in define_camera_frames
jnspitale Aug 11, 2026
c31fc06
Extract set_cmatrix_both_cameras from set_cmatrix
jnspitale Aug 11, 2026
43a8749
Rename set_cmatrix_both_cameras to map_other_camera
jnspitale Aug 11, 2026
9ef57d5
Map the current camera's pointing in map_other_camera
jnspitale Aug 11, 2026
31ca78a
Derive the spice-frame C-matrix in get_cmatrix from the observation
jnspitale Aug 12, 2026
86c7635
Add generic Observation.set_cmatrix from a spice-convention C-matrix
jnspitale Aug 12, 2026
e1faddb
Remove map_other_camera and its dedicated *_SPICE reference frames
jnspitale Aug 12, 2026
0d226df
Delete ISS.set_cmatrix in favor of Observation.set_cmatrix
jnspitale Aug 12, 2026
202305b
Remove unused ISS.oops_from_spice/spice_from_oops conversion helpers
jnspitale Aug 12, 2026
87c6062
Fix custom C-matrix mechanism: single-attribute rotation, override on…
jnspitale Aug 13, 2026
3ddb070
Reject from_file frame_id without cmatrix
jnspitale Aug 13, 2026
dd7f883
Exclude CK kernels from spice_kernels on custom-cmatrix loads
jnspitale Aug 13, 2026
b54fbf2
Fix typo and stale comment in ISS camera-frame setup
jnspitale Aug 13, 2026
6081acc
Remove the offset_wac WAC frame-offset option
jnspitale Aug 17, 2026
181298b
Validate custom C-matrices and protect the frame registry
jnspitale Aug 17, 2026
9216286
Extract C-matrix validation into Observation.validate_cmatrix
jnspitale Aug 18, 2026
7f501f6
Pin the frame_id=None isolation contract with a registry test
jnspitale Aug 18, 2026
33a72c9
Merge branch 'main' into jns-200-custom_Cmatrix
jnspitale Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions oops/frame/cmatrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class Cmatrix(Frame):
# expendable. Frame IDs are not preserved during pickling.

#===========================================================================
def __init__(self, cmatrix, reference=None, frame_id=None):
def __init__(self, cmatrix, reference=None, frame_id=None, override=False):
"""Constructor for a Cmatrix frame.

Input:
Expand All @@ -30,6 +30,8 @@ def __init__(self, cmatrix, reference=None, frame_id=None):
None for J2000.
frame_id the ID under which the frame will be registered; None
to leave the frame unregistered
override if True, replace the primary definition of any frame
already registered under this frame_id.
"""

self.cmatrix = Matrix3.as_matrix3(cmatrix)
Expand All @@ -42,7 +44,7 @@ def __init__(self, cmatrix, reference=None, frame_id=None):
self.keys = set()

# Update wayframe and frame_id; register if not temporary
self.register()
self.register(override=override)

# It needs a wayframe before we can construct the transform
self.transform = Transform(cmatrix, Vector3.ZERO,
Expand Down
22 changes: 19 additions & 3 deletions oops/hosts/cassini/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,21 @@ def spice_frames_kernel(asof=None):

#===========================================================================
@staticmethod
def used_kernels(time, inst, return_all_planets=False):
def used_kernels(time, inst, return_all_planets=False, ck=True):
"""The list of kernels associated with a Cassini observation at a
selected range of times.

Input:
time a (start, stop) tuple of times in seconds TDB.
inst the instrument name, e.g., 'iss'.
return_all_planets Include kernels for all planets not just
Jupiter or Saturn.
ck True (default) to include CK (pointing)
kernels; False to exclude them, e.g. for an
observation whose pointing came from a custom
cmatrix rather than SPICE, where any
furnished CK is unrelated to how the
observation was actually pointed.
"""
if return_all_planets:
bodies = [1, 199, 2, 299, 3, 399, 4, 499, 5, 599, 6, 699,
Expand All @@ -367,7 +379,11 @@ def used_kernels(time, inst, return_all_planets=False):
else:
bodies = [5, 599] + Body.JUPITER_MOONS_LOADED

return spicedb.used_basenames(time=time, inst=inst, sc=-82,
bodies=bodies)
types = None
if not ck:
types = [t for t in spicedb.KERNEL_TYPE_SORT_ORDER if t != 'CK']

return spicedb.used_basenames(types=types, time=time, inst=inst,
sc=-82, bodies=bodies)

################################################################################
166 changes: 122 additions & 44 deletions oops/hosts/cassini/iss.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,27 @@
import oops
from oops.hosts.cassini import Cassini

# There are two C-matrix conventions here, related by CMATRIX_ROTATION (a 180-degree spin
# about the boresight):
# * spice-frame: the pointing straight from SPICE (a CK or cspyce.pxform), i.e.
# the J2000 -> CASSINI_ISS_<camera> rotation. Axes: z along the line of
# sight, x to the left, y up.
# * oops-frame: the oops observation frame that the FOV uses. Axes follow the
# oops convention: z along the line of sight, x to the right, y down.
# oops-frame = CMATRIX_ROTATION * spice-frame. The instrument's internal coordinate system
# matches the oops-frame, so a recorded (spice-frame) C-matrix is rotated by
# CMATRIX_ROTATION at the boundary to build the observation frame. The
# generic Observation.get_cmatrix()/set_cmatrix() methods derive the inverse
# (spice-frame from oops-frame) by transposing CMATRIX_ROTATION.
CMATRIX_ROTATION = oops.Matrix3([[-1,0,0],[0,-1,0],[0,0,1]])

################################################################################
# Standard class methods
################################################################################

def from_file(filespec, fast_distortion=True,
return_all_planets=False, **parameters):
return_all_planets=False, cmatrix=None, frame_id=None,
**parameters):
"""A general, static method to return a Snapshot object based on a given
Cassini ISS image file.

Expand All @@ -31,8 +46,36 @@ def from_file(filespec, fast_distortion=True,

return_all_planets Include kernels for all planets not just
Jupiter or Saturn.

cmatrix an oops.Matrix3 (or 3x3 array) giving the spice-frame
C-matrix: the rotation from J2000 into the SPICE
CASSINI_ISS_<camera> frame, exactly as returned by
cspyce.pxform() or recorded in a CK (z along the line
of sight, x left, y up). The fixed 180-degree rotation
relating it to the oops-frame observation frame is
applied internally. It must be a proper rotation with
floating-point values; anything else raises
ValueError. None uses SPICE pointing as before.
Note that with a custom cmatrix no CK is loaded, so
the SPICE camera frame has no pointing for this
epoch: the observation's pointing lives only in its
oops frame, and cspyce.pxform() against
CASSINI_ISS_<camera> at this epoch will fail.

frame_id when cmatrix is given, register the C-matrix under
this frame ID and use only that (shared, registered)
frame. None (default) gives the observation its own
unregistered frame, so loading other images never
disturbs this one's pointing. Requires cmatrix. An ID
already registered by anything other than a previous
custom-cmatrix load (e.g. 'CASSINI_ISS_NAC' or
'J2000') raises ValueError rather than silently
replacing that frame.
"""

if cmatrix is None and frame_id is not None:
raise ValueError('frame_id requires a cmatrix')

ISS.initialize() # Define everything the first time through; use defaults
# unless initialize() is called explicitly.

Expand Down Expand Up @@ -67,27 +110,49 @@ def from_file(filespec, fast_distortion=True,
elif vicar_dict['GAIN_MODE_ID'][:2] == '12':
gain_mode = 3

# Make sure the SPICE kernels are loaded
Cassini.load_cks( tstart, tstart + texp)
# Make sure the SPICE kernels are loaded. SPKs are always needed for the
# spacecraft path; CKs (pointing) are only needed when the pointing comes
# from SPICE, i.e. no custom cmatrix.
Cassini.load_spks(tstart, tstart + texp)
if cmatrix is None:
Cassini.load_cks(tstart, tstart + texp)

# Determine the observation frame. With a custom C-matrix, SPICE pointing
# is circumvented: the Snapshot is built with a placeholder J2000 frame
# (no CKs needed) and Observation.set_cmatrix replaces it below.
if cmatrix is not None:
frame = 'J2000'
else:
ISS.define_camera_frames() # use the SPICE-derived pointing
frame = 'CASSINI_ISS_' + camera

# Create a Snapshot
result = oops.obs.Snapshot(('v','u'), tstart, texp,
ISS.fovs[camera,mode, fast_distortion],
path = 'CASSINI',
frame = 'CASSINI_ISS_' + camera,
frame = frame,
dict = vicar_dict, # Add the VICAR dict
data = vic.data_2d, # Add the data array
host = ISS, # Add the host class
instrument = 'ISS',
detector = camera,
sampling = mode,
filter1 = filter1,
filter2 = filter2,
gain_mode = gain_mode)

# Apply the custom pointing, replacing the placeholder frame.
if cmatrix is not None:
result.set_cmatrix(cmatrix, frame_id=frame_id)

# With a custom cmatrix, pointing never came from a CK, so any CK that
# happens to be furnished (e.g. the gapfill CKs loaded unconditionally by
# Cassini.initialize()) is unrelated to this observation and must not be
# reported as used.
result.insert_subfield('spice_kernels',
Cassini.used_kernels(result.time, 'iss',
return_all_planets))
return_all_planets,
ck=(cmatrix is None)))
result.insert_subfield('filespec', filespec)
result.insert_subfield('basename', filespec.name)

Expand All @@ -101,6 +166,7 @@ def from_index(filespec, **parameters):
label of the index file.
"""
ISS.initialize() # Define everything the first time through
ISS.define_camera_frames() # use the SPICE-derived pointing

filespec = FCPath(filespec)

Expand Down Expand Up @@ -129,6 +195,7 @@ def from_index(filespec, **parameters):
'CASSINI', 'CASSINI_ISS_' + camera,
dict = row_dict, # Add index dictionary
index_dict = row_dict, # Old name
host = ISS, # Add the host class
instrument = 'ISS',
detector = camera,
sampling = mode)
Expand All @@ -151,7 +218,7 @@ def from_index(filespec, **parameters):
return snapshots

#===============================================================================
def initialize(ck='reconstructed', planets=None, offset_wac=False, asof=None,
def initialize(ck='reconstructed', planets=None, asof=None,
spk='reconstructed', gapfill=True,
mst_pck=True, irregulars=True):
"""Initialize key information about the ISS instrument.
Expand All @@ -165,8 +232,6 @@ def initialize(ck='reconstructed', planets=None, offset_wac=False, asof=None,
'none' if the kernels are to be managed manually.
planets A list of planets to pass to define_solar_system. None or
0 means all.
offset_wac True to offset the WAC frame relative to the NAC frame as
determined by star positions.
asof Only use SPICE kernels that existed before this date; None
to ignore.
gapfill True to include gapfill CKs. False otherwise.
Expand All @@ -175,7 +240,7 @@ def initialize(ck='reconstructed', planets=None, offset_wac=False, asof=None,
irregulars True to include the irregular satellites;
False otherwise.
"""
ISS.initialize(ck=ck, planets=planets, offset_wac=offset_wac, asof=asof,
ISS.initialize(ck=ck, planets=planets, asof=asof,
spk=spk, gapfill=gapfill,
mst_pck=mst_pck, irregulars=irregulars)

Expand All @@ -186,6 +251,15 @@ class ISS(object):
instrument_kernel = None
fovs = {}
initialized = False
frames_defined = False

# Exposed on the class so the generic Observation.set_cmatrix() and
# get_cmatrix() can read the host's convention rotation via the
# observation's `host` subfield: CMATRIX_ROTATION converts spice-frame to
# oops-frame. Any host supporting the generic cmatrix methods must expose
# this single attribute; the inverse (oops-frame to spice-frame) is
# derived by transposition. See the module-level definition above.
CMATRIX_ROTATION = CMATRIX_ROTATION

# Create a master version of the NAC and WAC distortion models from
# Owen Jr., W.M., 2003. Cassini ISS Geometric Calibration of April 2003.
Expand Down Expand Up @@ -294,7 +368,7 @@ class ISS(object):

#===========================================================================
@staticmethod
def initialize(ck='reconstructed', planets=None, offset_wac=False, asof=None,
def initialize(ck='reconstructed', planets=None, asof=None,
spk='reconstructed', gapfill=True,
mst_pck=True, irregulars=True):
"""Initialize key information about the ISS instrument.
Expand All @@ -309,8 +383,6 @@ def initialize(ck='reconstructed', planets=None, offset_wac=False, asof=None,
managed manually.
planets A list of planets to pass to define_solar_system. None
or 0 means all.
offset_wac True to offset the WAC frame relative to the NAC frame
as determined by star positions.
asof Only use SPICE kernels that existed before this date;
None to ignore.
gapfill True to include gapfill CKs. False otherwise.
Expand Down Expand Up @@ -374,41 +446,46 @@ def initialize(ck='reconstructed', planets=None, offset_wac=False, asof=None,
ISS.fovs[detector, 'SUM2'] = oops.fov.SubsampledFOV(full_fov_none, 2)
ISS.fovs[detector, 'SUM4'] = oops.fov.SubsampledFOV(full_fov_none, 4)

# Construct a SpiceFrame for each camera
# Deal with the fact that the instrument's internal
# coordinate system is rotated 180 degrees
rot180 = oops.Matrix3([[-1,0,0],[0,-1,0],[0,0,1]])
nac_flipped = oops.frame.SpiceFrame('CASSINI_ISS_NAC',
frame_id='CASSINI_ISS_NAC_FLIPPED')
wac_flipped = oops.frame.SpiceFrame('CASSINI_ISS_WAC',
frame_id='CASSINI_ISS_WAC_FLIPPED')
nac_frame = oops.frame.Cmatrix(rot180, nac_flipped,
frame_id='CASSINI_ISS_NAC')

if offset_wac:

# Apply offset for WAC relative to NAC
info = ISS.instrument_kernel['INS']['CASSINI_ISS_NAC']
xfov = info['FOV_REF_ANGLE']
yfov = info['FOV_CROSS_ANGLE']
lines = info['PIXEL_LINES']
samples = info['PIXEL_SAMPLES']
ISS.initialized = True

xpixel = np.arctan(np.tan(xfov * oops.RPD) / (samples/2.))
ypixel = np.arctan(np.tan(yfov * oops.RPD) / (lines/2.))
#===========================================================================
@staticmethod
def define_camera_frames():
"""Register the SPICE-derived CASSINI_ISS_NAC and CASSINI_ISS_WAC frames.

# This is Rob's determination of WAC - NAC in units of NAC pixels
xshift = -7. * xpixel
yshift = 4.4 * ypixel
wac_frame_no = oops.frame.Cmatrix(rot180, wac_flipped,
frame_id='CASSINI_ISS_WAC-NO_OFFSET')
wac_frame = oops.frame.Navigation((xshift,yshift), wac_frame_no,
frame_id='CASSINI_ISS_WAC')
else:
wac_frame = oops.frame.Cmatrix(rot180, wac_flipped,
frame_id='CASSINI_ISS_WAC')
ISS.initialize() must have been called first. Built lazily (and only
once) so that observations using a custom C-matrix never construct or
depend on the SPICE camera frames.
"""

ISS.initialized = True
# Quick exit after first call
if ISS.frames_defined:
return

# Construct a SpiceFrame for each camera
# Deal with the fact that the instrument's internal
# coordinate system is rotated 180 degrees (see CMATRIX_ROTATION)
nac_frame_spice = oops.frame.SpiceFrame('CASSINI_ISS_NAC',
frame_id='CASSINI_ISS_NAC_SPICE')
wac_frame_spice = oops.frame.SpiceFrame('CASSINI_ISS_WAC',
frame_id='CASSINI_ISS_WAC_SPICE')

# Observation-facing camera frames: the raw *_SPICE pointing rotated
# 180 degrees about the boresight (CMATRIX_ROTATION) into the oops-frame
# convention. The *_SPICE SpiceFrames above stay registered as the
# spice-convention pointing (exactly what cspyce.pxform returns).
# override=True replaces CASSINI_ISS_NAC/_WAC's primary registration on
# each rebuild. Note that neither ISS.reset() nor Cassini.reset() clears
# the frame registry, so the *_SPICE SpiceFrames above (registered
# without override) leave a stale primary definition after a rebuild;
# that's harmless here since nac_frame_spice/wac_frame_spice are used
# directly as object references below, not looked up by ID.
nac_frame = oops.frame.Cmatrix(CMATRIX_ROTATION, nac_frame_spice,
frame_id='CASSINI_ISS_NAC', override=True)
wac_frame = oops.frame.Cmatrix(CMATRIX_ROTATION, wac_frame_spice,
frame_id='CASSINI_ISS_WAC', override=True)

ISS.frames_defined = True

#===========================================================================
@staticmethod
Expand All @@ -421,6 +498,7 @@ def reset():
ISS.instrument_kernel = None
ISS.fovs = {}
ISS.initialized = False
ISS.frames_defined = False

Cassini.reset()

Expand Down
Loading
Loading