diff --git a/oops/frame/cmatrix.py b/oops/frame/cmatrix.py index ffad650f..6474e294 100755 --- a/oops/frame/cmatrix.py +++ b/oops/frame/cmatrix.py @@ -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: @@ -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) @@ -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, diff --git a/oops/hosts/cassini/__init__.py b/oops/hosts/cassini/__init__.py index 692ecdf1..4cec6c07 100755 --- a/oops/hosts/cassini/__init__.py +++ b/oops/hosts/cassini/__init__.py @@ -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, @@ -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) ################################################################################ diff --git a/oops/hosts/cassini/iss.py b/oops/hosts/cassini/iss.py index 5fbc310c..e2643534 100755 --- a/oops/hosts/cassini/iss.py +++ b/oops/hosts/cassini/iss.py @@ -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_ 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. @@ -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_ 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_ 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. @@ -67,17 +110,30 @@ 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, @@ -85,9 +141,18 @@ def from_file(filespec, fast_distortion=True, 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) @@ -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) @@ -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) @@ -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. @@ -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. @@ -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) @@ -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. @@ -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. @@ -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. @@ -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 @@ -421,6 +498,7 @@ def reset(): ISS.instrument_kernel = None ISS.fovs = {} ISS.initialized = False + ISS.frames_defined = False Cassini.reset() diff --git a/oops/observation/observation_.py b/oops/observation/observation_.py index 989781f7..5e12fb70 100755 --- a/oops/observation/observation_.py +++ b/oops/observation/observation_.py @@ -5,9 +5,11 @@ import numpy as np import numbers -from polymath import Scalar, Pair, Vector, Vector3, Qube +from polymath import Scalar, Pair, Vector, Vector3, Qube, Matrix3 from oops.config import LOGGING, PATH_PHOTONS from oops.event import Event +from oops.frame import Frame +from oops.frame.cmatrix import Cmatrix from oops.frame.navigation import Navigation from oops.meshgrid import Meshgrid @@ -450,6 +452,157 @@ def midtime_at_uv(self, uv, tfrac=0.5): (time0, time1) = self.time_range_at_uv(uv) return tfrac * (time0 + time1) + #=========================================================================== + def get_cmatrix(self, uv=None, time=None): + """The spice-convention C-matrix of this observation. + + This derives the observation's pointing from its own frame and the fixed + convention rotation exposed by its host: it evaluates the oops + observation-frame attitude and applies the transpose of the host's + spice-frame -> oops-frame rotation (the 'host' subfield's + CMATRIX_ROTATION attribute, the same rotation applied by + set_cmatrix()). The result is the spice-frame C-matrix, as returned by + cspyce.pxform(). Note this is NOT the oops-frame convention (z along + the line of sight, x right, y down) of the observation frame itself. + + Input: + uv a (u,v) pixel location, used only to select a time when + this observation's frame is time-dependent; None to use + the center of the FOV. Ignored if time is given. + time the time in seconds TDB at which to evaluate the frame; + None to derive it from uv via midtime_at_uv(). + + Return: a Matrix3 giving the spice-convention C-matrix (for ISS, + the spice-frame C-matrix). + """ + + try: + rotation = self.host.CMATRIX_ROTATION + except AttributeError: + raise ValueError('get_cmatrix requires an observation whose host ' + 'class defines CMATRIX_ROTATION (the spice-frame ' + '-> oops-frame convention rotation)') + + if time is None: + uv = self.fov.uv_shape / 2. if uv is None else Pair.as_pair(uv) + time = self.midtime_at_uv(uv) + + oops_attitude = self.frame.wrt(Frame.J2000).transform_at_time(time).matrix + return rotation.transpose() * oops_attitude + + #=========================================================================== + # Tolerance for accepting a supplied C-matrix as a proper rotation. + # Float64 rotation matrices (e.g. from cspyce.pxform) are orthonormal to + # ~1e-15, so this never rejects a legitimate matrix. + CMATRIX_TOL = 1.e-8 + + # Frame IDs registered by set_cmatrix() itself. set_cmatrix only overrides + # a registered frame whose ID appears here; it refuses to replace a frame + # registered by anything else (e.g. 'CASSINI_ISS_NAC' or 'J2000'). + _cmatrix_frame_ids = set() + + @staticmethod + def validate_cmatrix(cmatrix): + """Validate a C-matrix as a proper rotation; return it as a Matrix3. + + A recorded C-matrix arriving from a metadata file can carry NaNs, + booleans, integer arrays, or a damaged matrix; each raises an + attributable ValueError here rather than producing NaN or skewed + geometry an hour downstream. + + Input: + cmatrix an oops.Matrix3 (or 3x3 array). It must be a proper + rotation with floating-point values: non-finite values, + integer or boolean data (a signature of a corrupted + metadata record), reflections, and non-orthogonal + matrices all raise ValueError. + + Return: the validated C-matrix as a Matrix3. + """ + + raw = cmatrix.values if isinstance(cmatrix, Qube) else np.asarray(cmatrix) + if not np.issubdtype(raw.dtype, np.floating): + raise ValueError('cmatrix must have floating-point values; got ' + 'dtype %s' % raw.dtype) + + matrix = Matrix3.as_matrix3(cmatrix) + values = matrix.values + if not np.all(np.isfinite(values)): + raise ValueError('cmatrix contains non-finite values') + + det = np.linalg.det(values) + ortho_error = np.max(np.abs(values.dot(values.T) - np.eye(3))) + if (abs(det - 1.) > Observation.CMATRIX_TOL + or ortho_error > Observation.CMATRIX_TOL): + raise ValueError('cmatrix is not a proper rotation matrix: ' + 'det = %.6g (should be 1), ' + 'max|C CT - I| = %.6g' % (det, ortho_error)) + + return matrix + + def set_cmatrix(self, cmatrix, frame_id=None): + """Set this observation's frame from a spice-convention C-matrix. + + Converts the spice-convention C-matrix into the oops observation-frame + attitude by applying the host's spice-frame -> oops-frame rotation (the + 'host' subfield's CMATRIX_ROTATION attribute; get_cmatrix() applies its + transpose). The resulting frame replaces self.frame. + + Note that geometry already derived against the previous frame (e.g. + Backplanes) is not invalidated by this call; anything built before the + call still answers the old pointing and must be rebuilt. + + Input: + cmatrix an oops.Matrix3 (or 3x3 array) giving the spice-convention + C-matrix, as returned by get_cmatrix(). It must be a + proper rotation with floating-point values; anything + else raises ValueError (see validate_cmatrix()). + frame_id None (default) attaches a fresh unregistered frame owned + by the observation, so loading other images never + disturbs its pointing; otherwise the frame is registered + under this frame ID (a shared, registered frame). Note + that re-using a frame_id re-points every observation + that shares that registered frame; that sharing is the + point of giving it an ID, but it is worth stating + explicitly. A frame_id already registered by anything + other than a previous set_cmatrix() call raises + ValueError rather than silently replacing that frame. + """ + + try: + rotation = self.host.CMATRIX_ROTATION + except AttributeError: + raise ValueError('set_cmatrix requires an observation whose host ' + 'class defines CMATRIX_ROTATION (the spice-frame ' + '-> oops-frame convention rotation)') + + matrix = Observation.validate_cmatrix(cmatrix) + + # Convert the spice-convention C-matrix into the oops observation-frame + # attitude by applying the fixed instrument convention rotation. + attitude = rotation * matrix + + if frame_id is None: + # A fresh unregistered frame owned by this observation, isolated + # from the global registry. + frame = Cmatrix(attitude) + else: + # A given frame_id registers (or re-registers, with override=True) + # a single shared frame under that ID -- but only an ID that + # set_cmatrix itself handed out may be replaced. (A registry that + # was cleared via Frame.reset_registry() forgets prior ownership; + # _cmatrix_frame_ids may then permit an override it should not, + # which matches the debugging-only status of reset_registry.) + registered = frame_id in Frame.WAYFRAME_REGISTRY + if registered and frame_id not in Observation._cmatrix_frame_ids: + raise ValueError('frame_id %r is already registered by ' + 'another frame; choose a different ID' + % frame_id) + frame = Cmatrix(attitude, frame_id=frame_id, override=registered) + Observation._cmatrix_frame_ids.add(frame_id) + + self.frame = Frame.as_wayframe(frame) + #=========================================================================== def meshgrid(self, origin=None, undersample=1, oversample=1, limit=None, center_uv=None, fov_keywords={}): diff --git a/tests/hosts/cassini/iss/__init__.py b/tests/hosts/cassini/iss/__init__.py index 636e24d6..c466e90a 100755 --- a/tests/hosts/cassini/iss/__init__.py +++ b/tests/hosts/cassini/iss/__init__.py @@ -8,6 +8,8 @@ from oops.body import Body from oops.unittester_support import TEST_DATA_PREFIX +from tests.hosts.cassini.iss.test_iss import Test_Cassini_ISS_Cmatrix + class Test_Cassini_ISS_GoldMaster(unittest.TestCase): diff --git a/tests/hosts/cassini/iss/test_iss.py b/tests/hosts/cassini/iss/test_iss.py new file mode 100644 index 00000000..111f67b5 --- /dev/null +++ b/tests/hosts/cassini/iss/test_iss.py @@ -0,0 +1,326 @@ +################################################################################ +# tests/hosts/cassini/iss/test_iss.py +################################################################################ + +import numpy as np +import unittest + +import oops +from oops.body import Body +from oops.frame import Frame +from oops.hosts.cassini import Cassini +from oops.hosts.cassini.iss import ISS, from_file +from oops.unittester_support import TEST_DATA_PREFIX + +# A rotation distinguishable from any SPICE-derived pointing, used to build +# fake "custom" C-matrices below. +_PERTURBATION = oops.Matrix3([[0,1,0],[-1,0,0],[0,0,1]]) + + +class Test_Cassini_ISS_Cmatrix(unittest.TestCase): + """Tests for the custom C-matrix support in hosts/cassini/iss.py + (from_file's cmatrix/frame_id arguments), plus the generic + Observation.get_cmatrix() / Observation.set_cmatrix() methods. + + The `cmatrix` accepted by from_file is the SPICE camera-frame C-matrix (the + J2000 -> CASSINI_ISS_ rotation, as returned by cspyce.pxform). The + oops observation frame is that frame rotated 180 degrees about the boresight; + get_cmatrix() returns the pointing back in that SPICE C-matrix convention. + """ + + FILESPEC = 'cassini/ISS/W1573721822_1.IMG' # a WAC image + + def setUp(self): + # A mapped custom C-matrix can override the global CASSINI_ISS_NAC/WAC + # frames, so every test starts from a clean registry. + Body.reset_registry() + Body.define_solar_system() + ISS.reset() + + self.filespec = TEST_DATA_PREFIX.retrieve(self.FILESPEC) + + def tearDown(self): + Body.reset_registry() + Body.define_solar_system() + ISS.reset() + + #=========================================================================== + def _spice_cmatrix(self, camera, time): + """The recorded SPICE camera-frame C-matrix for the given camera and + time. CASSINI_ISS__SPICE is the SpiceFrame wrapping SPICE's + CASSINI_ISS_, so its J2000 attitude is exactly + cspyce.pxform('J2000', 'CASSINI_ISS_', time). Requires a prior + plain load (define_camera_frames) so the *_SPICE frame is registered. + """ + + return (Frame.as_wayframe('CASSINI_ISS_' + camera + '_SPICE') + .wrt(Frame.J2000).transform_at_time(time).matrix) + + #=========================================================================== + def test_get_cmatrix_inverts_set(self): + """get_cmatrix returns the SPICE-convention C-matrix (matching + pxform) and inverts the cmatrix given to from_file.""" + + baseline = from_file(self.filespec) + camera = baseline.dict['INSTRUMENT_ID'][3:] + 'C' + spice0 = self._spice_cmatrix(camera, baseline.tstart) + + # For a SPICE-loaded observation, get_cmatrix matches pxform. + self.assertTrue(np.allclose( + baseline.get_cmatrix(time=baseline.tstart).vals, spice0.vals)) + + # It recovers the exact cmatrix given to from_file (the custom Snapshot + # frame is fixed in time, so no time argument is needed). + custom = _PERTURBATION * spice0 + obs = from_file(self.filespec, cmatrix=custom) + recovered = obs.get_cmatrix() + self.assertTrue(np.allclose(recovered.vals, custom.vals)) + + # Round-trip: feeding it back into from_file reproduces the pointing. + obs2 = from_file(self.filespec, cmatrix=recovered) + self.assertTrue(np.allclose(obs2.get_cmatrix().vals, + obs.get_cmatrix().vals)) + + #=========================================================================== + def test_default_frame_is_unregistered_and_isolated(self): + """With frame_id=None each observation gets its own unregistered frame, + so loading another image never changes an earlier one's pointing.""" + + baseline = from_file(self.filespec) + camera = baseline.dict['INSTRUMENT_ID'][3:] + 'C' + spice0 = self._spice_cmatrix(camera, baseline.tstart) + + custom1 = _PERTURBATION * spice0 + obs1 = from_file(self.filespec, cmatrix=custom1) + saved1 = obs1.get_cmatrix() + + # The observation owns an unregistered frame, not the global wayframe. + self.assertFalse(obs1.frame.is_registered()) + self.assertTrue(np.allclose(obs1.get_cmatrix().vals, custom1.vals)) + + # Loading a second custom image must not disturb the first. + custom2 = _PERTURBATION * _PERTURBATION * spice0 + obs2 = from_file(self.filespec, cmatrix=custom2) + + self.assertIsNot(obs1.frame, obs2.frame) + self.assertTrue(np.all(obs1.get_cmatrix().vals == saved1.vals)) + self.assertTrue(np.allclose(obs2.get_cmatrix().vals, custom2.vals)) + self.assertFalse(np.allclose(obs1.get_cmatrix().vals, + obs2.get_cmatrix().vals)) + + #=========================================================================== + def test_default_frames_are_distinct_and_stay_out_of_registry(self): + """Pin the frame_id=None isolation contract against registry rewrites: + observations given equal-valued C-matrices must get distinct frame + objects, and a default set_cmatrix must leave the global frame + registry and cache unchanged. A registry that dedups or retains + unregistered frames (e.g. a per-subclass wayframe table) breaks the + documented per-observation ownership; this test makes that a failure + here instead of a silent semantic change.""" + + baseline = from_file(self.filespec) + camera = baseline.dict['INSTRUMENT_ID'][3:] + 'C' + spice0 = self._spice_cmatrix(camera, baseline.tstart) + custom = _PERTURBATION * spice0 + + # Warm every lazy path once (load + get_cmatrix) before measuring. + obs1 = from_file(self.filespec, cmatrix=custom) + _ = obs1.get_cmatrix() + + n_wayframes = len(Frame.WAYFRAME_REGISTRY) + n_cached = len(Frame.FRAME_CACHE) + + # Equal-valued C-matrices: distinct frame objects per observation. + obs2 = from_file(self.filespec, cmatrix=custom) + obs3 = from_file(self.filespec, cmatrix=custom) + _ = obs2.get_cmatrix() + _ = obs3.get_cmatrix() + + self.assertIsNot(obs1.frame, obs2.frame) + self.assertIsNot(obs2.frame, obs3.frame) + self.assertTrue(np.all(obs2.get_cmatrix().vals == custom.vals)) + + # No global retention: the registry and cache did not grow. + self.assertEqual(len(Frame.WAYFRAME_REGISTRY), n_wayframes) + self.assertEqual(len(Frame.FRAME_CACHE), n_cached) + + #=========================================================================== + def test_default_does_not_leak_into_plain_load(self): + """A default custom load never touches the global camera frame, so a + subsequent plain load reads back its own SPICE pointing.""" + + baseline = from_file(self.filespec) + m0 = baseline.get_cmatrix() + camera = baseline.dict['INSTRUMENT_ID'][3:] + 'C' + spice0 = self._spice_cmatrix(camera, baseline.tstart) + + _ = from_file(self.filespec, cmatrix=_PERTURBATION * spice0) + + plain = from_file(self.filespec) + self.assertTrue(np.allclose(plain.get_cmatrix().vals, m0.vals)) + + #=========================================================================== + def test_explicit_frame_id_leaves_global_untouched(self): + """A custom cmatrix with an explicit frame_id registers a separate + frame (with the boundary rotation applied) under that frame ID.""" + + baseline = from_file(self.filespec) + camera = baseline.dict['INSTRUMENT_ID'][3:] + 'C' + spice0 = self._spice_cmatrix(camera, baseline.tstart) + custom = _PERTURBATION * spice0 + + obs = from_file(self.filespec, cmatrix=custom, + frame_id='TEST_ISS_CUSTOM_FRAME') + + self.assertEqual(obs.frame.frame_id, 'TEST_ISS_CUSTOM_FRAME') + self.assertTrue(np.allclose(obs.get_cmatrix().vals, custom.vals)) + + # The global camera frame is untouched. + unaffected = from_file(self.filespec) + self.assertTrue(np.allclose(unaffected.get_cmatrix().vals, + baseline.get_cmatrix().vals)) + + #=========================================================================== + def test_reused_frame_id_replaces_primary(self): + """Re-using a frame_id re-points every observation sharing that + registered frame; the primary registry definition is cleanly replaced, + not shadowed by a stale entry (Frame.register's secondary-definition + path).""" + + baseline = from_file(self.filespec) + camera = baseline.dict['INSTRUMENT_ID'][3:] + 'C' + spice0 = self._spice_cmatrix(camera, baseline.tstart) + + custom1 = _PERTURBATION * spice0 + obs1 = from_file(self.filespec, cmatrix=custom1, + frame_id='TEST_REUSED_FRAME') + self.assertTrue(np.allclose(obs1.get_cmatrix().vals, custom1.vals)) + + custom2 = _PERTURBATION * _PERTURBATION * spice0 + obs2 = from_file(self.filespec, cmatrix=custom2, + frame_id='TEST_REUSED_FRAME') + self.assertTrue(np.allclose(obs2.get_cmatrix().vals, custom2.vals)) + + # The primary registry definition must reflect the new pointing, not + # a stale first definition left behind by a secondary registration. + primary = Frame.as_primary_frame('TEST_REUSED_FRAME') + attitude = primary.wrt(Frame.J2000).transform_at_time(baseline.tstart).matrix + recovered = ISS.CMATRIX_ROTATION.transpose() * attitude + self.assertTrue(np.allclose(recovered.vals, custom2.vals)) + self.assertFalse(np.allclose(recovered.vals, custom1.vals)) + + #=========================================================================== + def test_colliding_frame_id_raises(self): + """A frame_id already registered by anything other than set_cmatrix + (e.g. the global camera frames or J2000) must raise rather than + silently replacing that frame's primary definition.""" + + baseline = from_file(self.filespec) + m0 = baseline.get_cmatrix() + camera = baseline.dict['INSTRUMENT_ID'][3:] + 'C' + spice0 = self._spice_cmatrix(camera, baseline.tstart) + custom = _PERTURBATION * spice0 + + with self.assertRaises(ValueError): + from_file(self.filespec, cmatrix=custom, + frame_id='CASSINI_ISS_' + camera) + + with self.assertRaises(ValueError): + from_file(self.filespec, cmatrix=custom, frame_id='J2000') + + # The refused registrations left the global pointing untouched. + plain = from_file(self.filespec) + self.assertTrue(np.allclose(plain.get_cmatrix().vals, m0.vals)) + + #=========================================================================== + def test_rejects_non_rotation_cmatrix(self): + """set_cmatrix (and therefore from_file's cmatrix argument) must + reject anything that is not a proper floating-point rotation: NaNs, + zeros, scalings, reflections, and boolean/integer data.""" + + obs = from_file(self.filespec, cmatrix=oops.Matrix3(np.eye(3))) + + bad_matrices = [ + np.diag([1., 2., 1.]), # scaling, det = 2 + np.diag([1., 1., -1.]), # reflection, det = -1 + np.full((3, 3), np.nan), # non-finite + np.zeros((3, 3)), # det = 0 + np.eye(3, dtype=bool), # boolean data + np.eye(3, dtype=int), # integer data + ] + for bad in bad_matrices: + with self.assertRaises(ValueError, msg=repr(bad)): + obs.set_cmatrix(bad) + + with self.assertRaises(ValueError): + from_file(self.filespec, cmatrix=np.diag([1., 1., -1.])) + + # A legitimate rotation still passes. + obs.set_cmatrix(_PERTURBATION) + self.assertTrue(np.allclose(obs.get_cmatrix().vals, _PERTURBATION.vals)) + + #=========================================================================== + def test_frame_id_without_cmatrix_raises(self): + """frame_id is only meaningful together with cmatrix; passing it alone + must raise rather than being silently ignored.""" + + with self.assertRaises(ValueError): + from_file(self.filespec, frame_id='TEST_FRAME_ID_ALONE') + + #=========================================================================== + def test_missing_host_contract_raises(self): + """set_cmatrix/get_cmatrix on an observation with no `host` subfield + raise an informative ValueError rather than a bare AttributeError.""" + + baseline = from_file(self.filespec) + bare = oops.obs.Snapshot(('v', 'u'), baseline.tstart, baseline.texp, + baseline.fov, path=baseline.path, + frame=baseline.frame) + + self.assertFalse(hasattr(bare, 'host')) + + with self.assertRaises(ValueError): + bare.get_cmatrix() + + with self.assertRaises(ValueError): + bare.set_cmatrix(oops.Matrix3(np.eye(3))) + + #=========================================================================== + def test_custom_cmatrix_loads_no_ck(self): + """A custom cmatrix never loads a CK (no SPICE pointing dependency).""" + + self.assertFalse(np.any(Cassini.CK_LOADED)) + + _ = from_file(self.filespec, cmatrix=oops.Matrix3(np.eye(3))) + + self.assertFalse(np.any(Cassini.CK_LOADED)) + + #=========================================================================== + def test_spice_pointing_loads_ck(self): + """SPICE pointing (no cmatrix) loads a CK.""" + + self.assertFalse(np.any(Cassini.CK_LOADED)) + _ = from_file(self.filespec) + self.assertTrue(np.any(Cassini.CK_LOADED)) + + #=========================================================================== + def test_custom_cmatrix_reports_no_ck_kernels(self): + """A custom cmatrix's spice_kernels must not report any CK, even one + furnished for an unrelated reason (e.g. the gapfill CKs loaded + unconditionally by Cassini.initialize(), or a CK furnished earlier in + the session by a plain SPICE-pointed load).""" + + # A plain load furnishes and reports at least one CK. + plain = from_file(self.filespec) + self.assertTrue(any(name.endswith('.bc') for name in plain.spice_kernels)) + + # A subsequent custom-cmatrix load must report none, despite CKs + # (including the one just furnished above) being present in the + # kernel pool. + custom = from_file(self.filespec, cmatrix=oops.Matrix3(np.eye(3))) + self.assertFalse(any(name.endswith('.bc') for name in custom.spice_kernels)) + +############################################ +if __name__ == '__main__': + unittest.main(verbosity=2) +################################################################################ diff --git a/tests/hosts/cassini/iss/unittester.py b/tests/hosts/cassini/iss/unittester.py index ee0c0c39..54bad579 100644 --- a/tests/hosts/cassini/iss/unittester.py +++ b/tests/hosts/cassini/iss/unittester.py @@ -5,6 +5,7 @@ import unittest from tests.hosts.cassini.iss import Test_Cassini_ISS_GoldMaster +from tests.hosts.cassini.iss.test_iss import Test_Cassini_ISS_Cmatrix ######################################## if __name__ == '__main__':