From 539c8e0b45bae8c46e44da556b896d57dae622ae Mon Sep 17 00:00:00 2001 From: Joseph Spitale Date: Tue, 18 Aug 2026 14:32:48 -0700 Subject: [PATCH 1/5] Add Juno SRU host module New host for Juno Stellar Reference Unit EDR images (FITS + detached PDS3 label), modeled on the jiram host API. from_file() returns a Snapshot with a BarrelFOV built from the SIS geometry (512x512, boresight (255.5,255.5), fl 1760.21137 px, radial distortion f(R) = a0 + a1*R + a2*R**2 + a3*R**4) and a per-observation camera frame frozen inertially at START_TIME, since TDI holds the scene at the attitude of exposure start while the spacecraft spins at ~2 rpm. Geometry validated against real EDRs: boresight matches label RA/DEC to <=0.004 deg on three images; 16 Bright Star Catalogue stars match detected sources at 2.6 px rms absolute (ORBIT_62, 1.3 s exposure); 98.4% of bright pixels fall inside the predicted Io disk (ORBIT_60). The star field also pinned down two conventions the SIS leaves ambiguous: its "x, y (row, column)" coordinates put x along the row (the sample axis), and the TDI scene epoch is START_TIME, not midtime (235 px of spin for a 1.3 s exposure). Unit tests check the FOV against the SIS distortion formulas with no SPICE or data dependencies. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VpLS1M97J5Rfdpp8qN9wCk --- oops/hosts/juno/sru/__init__.py | 277 +++++++++++++++++++++++++++++++ tests/hosts/juno/sru/__init__.py | 63 +++++++ 2 files changed, 340 insertions(+) create mode 100644 oops/hosts/juno/sru/__init__.py create mode 100644 tests/hosts/juno/sru/__init__.py diff --git a/oops/hosts/juno/sru/__init__.py b/oops/hosts/juno/sru/__init__.py new file mode 100644 index 00000000..683d69c4 --- /dev/null +++ b/oops/hosts/juno/sru/__init__.py @@ -0,0 +1,277 @@ +################################################################################ +# oops/hosts/juno/sru/__init__.py +################################################################################ + +import numpy as np +import julian +import pdsparser +import astropy.io.fits as pyfits +import oops + +from oops.hosts.juno import Juno + +from filecache import FCPath + +################################################################################ +# Standard class methods +################################################################################ + +#=============================================================================== +def from_file(filespec, return_all_planets=False, method='strict', **parameters): + """A general, static method to return a Snapshot object based on a given + Juno SRU EDR image file. + + Inputs: + filespec The full path to a Juno SRU FITS image file or its + detached PDS label. + + return_all_planets Include kernels for all planets not just + Jupiter or Saturn. + + method Label reading method to be passed to Pds3Label. + """ + SRU.initialize() # Define everything the first time through; use + # defaults unless initialize() is called explicitly. + + filespec = FCPath(filespec) + + # Locate the data file and its detached label + ext = filespec.suffix + if ext.upper() == '.LBL': + lblspec = filespec + datspec = filespec.with_suffix('.fit' if ext.islower() else '.FIT') + else: + datspec = filespec + lblspec = filespec.with_suffix('.lbl' if ext.islower() else '.LBL') + + # Load the PDS label + label = pdsparser.Pds3Label(lblspec, method=method).as_dict() + + # Get metadata + meta = _Metadata(label) + + # Load time-dependent kernels + Juno.load_cks(meta.tstart, meta.tstop) + Juno.load_spks(meta.tstart, meta.tstop) + + # Load the data array + data = _load_data(datspec, meta) + + # Define the inertially fixed camera frame for this observation + frame = SRU.create_frame(meta.unit, meta.tstart) + + # Construct the Snapshot + obs = oops.obs.Snapshot(('v','u'), + meta.tstart, meta.exposure, SRU.fov(), + 'JUNO', frame, + instrument = 'SRU' + str(meta.unit), + target = meta.target, + tdi_on = meta.tdi_on, + data = data) + +# obs.insert_subfield('spice_kernels', \ +# Juno.used_kernels(obs.time, 'sru', return_all_planets)) + obs.insert_subfield('filespec', filespec) + obs.insert_subfield('basename', filespec.name) + obs.insert_subfield('dict', label) + + return obs + +#=============================================================================== +def _load_data(datspec, meta): + """Load the image array from the FITS file. + + Input: + datspec Full path to the FITS data file. + meta Image Metadata object. + + Return: A Numpy array containing the data in axis order + (line, sample), where lines and samples correspond to + the CCD rows and columns defined in the SIS. Dummy + pixels (rows 510-511, columns 0-1) and any pixels not + downlinked contain zero. + """ + local_path = datspec.retrieve() + with pyfits.open(local_path) as hdulist: + data = hdulist[0].data + + if data.shape != (meta.nlines, meta.nsamples): + raise ValueError('SRU data shape %s does not match label (%d,%d)' + % (data.shape, meta.nlines, meta.nsamples)) + + return data + + +#******************************************************************************* +class _Metadata(object): + + #=========================================================================== + def __init__(self, label): + """Use the label to assemble the image metadata. + + Input: + label The label dictionary. + + Attributes: + nlines Number of lines (CCD rows). + nsamples Number of samples per line (CCD columns). + exposure Exposure duration in seconds. + tstart Image start time in seconds TDB. + tstop Image stop time in seconds TDB. + unit SRU unit number, 1 or 2. + tdi_on True if time-delay integration was used to + compensate for the spacecraft spin. + target Target name. + """ + + # Image dimensions + self.nlines = label['IMAGE']['LINES'] + self.nsamples = label['IMAGE']['LINE_SAMPLES'] + + # Timing + self.tstart = julian.tdb_from_tai( + julian.tai_from_iso(label['START_TIME'])) + self.tstop = julian.tdb_from_tai( + julian.tai_from_iso(label['STOP_TIME'])) + + # Exposure time + try: + self.exposure = label['EXPOSURE_DURATION'] + except KeyError: + self.exposure = self.tstop - self.tstart + + # SRU unit number, from e.g. PRODUCT_ID = "SRU_1_2024100T045333_60_V01" + self.unit = int(label['PRODUCT_ID'].split('_')[1]) + + # Time-delay integration + self.tdi_on = label.get('JNO:TDI_ON', 'UNK') == 'YES' + + # Target + self.target = label['TARGET_NAME'] + + return + + +#******************************************************************************* +class SRU(object): + """An instance-free class to hold SRU instrument parameters. + + The Juno Stellar Reference Unit (SRU) is a star tracker operated as a + broadband visible (450-1100 nm) science imager. Values here are from the + SRU EDR/CRT SIS, JUNO_SRU_EDR_CRT_SIS_V01_2. + """ + + SAMPLES = 512 # CCD columns; columns 0-1 are dummy pixels + LINES = 512 # CCD rows; rows 510-511 are dummy pixels + UV_LOS = (255.5, 255.5) # boresight pixel + FL_PIXELS = 1760.21137 # focal length in pixel units (~29.924 mm) + + # Radial distortion correction f(R) = a0 + a1*R + a2*R**2 + a3*R**4, + # where R is the tangent of the undistorted radial angle and f(R) scales + # the pinhole-projected tangents into distortion-corrected ones. + DISTORTION = (0.999432579, -0.0295412410, 0.2733020107, -1.9368112951) + + _fov = None + spice_frames = {} + initialized = False + + #=========================================================================== + @staticmethod + def initialize(asof=None, **kwargs): + """Initialize key information about the SRU instrument. + + Must be called first. After the first call, later calls to this function + are ignored. + + Input: + asof Only use SPICE kernels that existed before this date; + None to ignore. + kwargs: Arguments for juno.initialize() and Body.define_solar_system() + """ + + # Quick exit after first call + if SRU.initialized: + return + + # initialize Juno + Juno.initialize(asof=asof, **kwargs) + Juno.load_instruments(asof=asof) + + SRU.initialized = True + + #=========================================================================== + @staticmethod + def fov(): + """The SRU field of view, common to both units. + + The SIS distortion model scales the pinhole tangents (from pixel + offsets relative to the boresight) by f(R); in BarrelFOV terms the + radial distance polynomial is f(R)*R, so the R**4 term of f becomes + the fifth-order coefficient. + """ + if SRU._fov is None: + scale = 1./SRU.FL_PIXELS + (a0, a1, a2, a3) = SRU.DISTORTION + SRU._fov = oops.fov.BarrelFOV((scale, scale), + (SRU.SAMPLES, SRU.LINES), + coefft_xy_from_uv=(a0, a1, a2, 0., a3), + uv_los=SRU.UV_LOS) + return SRU._fov + + #=========================================================================== + @staticmethod + def create_frame(unit, time): + """Create the camera frame for an SRU observation. + + The frame is inertially fixed at the SRU orientation for the given + time. With the spacecraft spinning at ~2 rpm, the SRU uses time-delay + integration (TDI) to shift the accumulating image charge in step with + the scene, so the recorded scene is frozen at the orientation the + camera had when the exposure began, rather than rotating with the + spacecraft during the exposure. (Without TDI, the scene at the start + of exposure smears along the CCD columns.) + + The SPICE frames JUNO_SRU1/JUNO_SRU2 have the boresight along +X, + with the CCD x axis (the along-row direction, in which samples are + counted) along +Y and the CCD y axis (the along-column direction, in + which lines are counted) along +Z; the SIS maps a distortion-corrected + position (tx,ty) to the unit vector (1,-tx,-ty). The fixed rotation + applied here re-labels those axes to the OOPS camera convention: + boresight along +Z, x along increasing sample, y along increasing + line. + + Input: + unit SRU unit number, 1 or 2. + time time at which to define the inertially fixed frame, in + seconds TDB; normally the image start time. + + Return: an unregistered, per-observation Frame object. + """ + spice_frame = 'JUNO_SRU' + str(unit) + if unit not in SRU.spice_frames: + SRU.spice_frames[unit] = oops.frame.SpiceFrame(spice_frame) + + # rotation to reorganize axis vectors + rot = oops.Matrix3([[ 0,-1, 0], + [ 0, 0,-1], + [ 1, 0, 0]]) + + # Define fixed frame relative to J2000 from the SRU orientation at + # the given time + xform = SRU.spice_frames[unit].transform_at_time(time) + return oops.frame.Cmatrix(rot * xform.matrix) + + #=========================================================================== + @staticmethod + def reset(): + """Reset the internal SRU parameters. + + Can be useful for debugging. + """ + SRU._fov = None + SRU.spice_frames = {} + SRU.initialized = False + + Juno.reset() + +################################################################################ diff --git a/tests/hosts/juno/sru/__init__.py b/tests/hosts/juno/sru/__init__.py new file mode 100644 index 00000000..2d4402d4 --- /dev/null +++ b/tests/hosts/juno/sru/__init__.py @@ -0,0 +1,63 @@ +################################################################################ +# tests/hosts/juno/sru/__init__.py +################################################################################ +import unittest +import numpy as np + +from polymath import Pair +from oops.hosts.juno.sru import SRU + + +#=============================================================================== +class Test_Juno_SRU_FOV(unittest.TestCase): + """Validate the SRU FOV against the distortion formulas in the SIS, + JUNO_SRU_EDR_CRT_SIS_V01_2 section 5.4.1. These tests require no SPICE + kernels or data files. + """ + + #=========================================================================== + def runTest(self): + fov = SRU.fov() + (a0, a1, a2, a3) = SRU.DISTORTION + + # The boresight pixel maps to the optic axis + xy = fov.xy_from_uv(SRU.UV_LOS) + self.assertTrue(abs(xy.vals[0]) < 1.e-15) + self.assertTrue(abs(xy.vals[1]) < 1.e-15) + + # Spot-check the SIS distortion formula: pixel (row,col) has pinhole + # tangents (row-255.5, col-255.5)/fl, scaled radially by f(R); the + # camera-frame x axis lies along increasing sample (col, the SIS x + # direction) and y along increasing line (row, the SIS y direction). + for (row, col) in [(0., 2.), (509., 511.), (100., 400.), (255.5, 511.)]: + tanx = (col - 255.5)/SRU.FL_PIXELS + tany = (row - 255.5)/SRU.FL_PIXELS + R = np.sqrt(tanx**2 + tany**2) + f = a0 + a1*R + a2*R**2 + a3*R**4 + xy = fov.xy_from_uv((col, row)) + self.assertTrue(abs(xy.vals[0] - f*tanx) < 1.e-12) + self.assertTrue(abs(xy.vals[1] - f*tany) < 1.e-12) + + # uv -> xy -> uv round trip at sub-pixel precision + uv = Pair(np.random.RandomState(0).uniform(0., 512., (100,2))) + uv2 = fov.uv_from_xy(fov.xy_from_uv(uv)) + self.assertTrue(np.abs(uv2.vals - uv.vals).max() < 1.e-6) + + # Full field of view is 16.4 degrees square per the SIS + corner = fov.xy_from_uv((0., 0.)) + half_diag = np.degrees(np.arctan(np.hypot(*corner.vals))) + self.assertTrue(abs(half_diag - 16.4/2.*np.sqrt(2.)) < 0.15) + + +#=============================================================================== +class Test_Juno_SRU(unittest.TestCase): + + #=========================================================================== + def runTest(self): + pass + + +############################################## +if __name__ == '__main__': + unittest.main(verbosity=2) +################################################################################ From 6069d9ed90ebba5ec7e8892b538f48c658c168c7 Mon Sep 17 00:00:00 2001 From: Joseph Spitale Date: Tue, 18 Aug 2026 14:33:02 -0700 Subject: [PATCH 2/5] Extend Juno kernel list through the 2023-2024 SRU encounters The hardcoded kernel list in Juno.load_kernels ended in 2021, so any post-2021 observation failed with SPICE(NOFRAMECONNECT) when the SRU host froze its camera frame at load time. Add the weekly CK/SPK pairs covering the 2023-12-30, 2024-04-09 and 2024-06-13 encounters (copied into the OOPS-Resources SPICE store from naif.jpl.nasa.gov/pub/naif/JUNO/kernels/) plus SCLK JNO_SCLKSCET.00210. The new SCLK is furnished last so it takes priority over jno_sclkscet_00128: the old clock extrapolated to 2024 is off by ~2.7 s, a 33 deg pointing error at the 2 rpm spin. For 2013-2021 epochs the two kernels convert identically (0.0 tick difference), so JIRAM/JunoCam results are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VpLS1M97J5Rfdpp8qN9wCk --- oops/hosts/juno/__init__.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/oops/hosts/juno/__init__.py b/oops/hosts/juno/__init__.py index 9a83f335..1489c4d8 100644 --- a/oops/hosts/juno/__init__.py +++ b/oops/hosts/juno/__init__.py @@ -245,6 +245,20 @@ def load_kernels(t0, t1, loaded, lists, kernel_dict): 'Juno/IK/juno_jiram_v02.ti', 'Juno/SPK/de421.bsp', 'Juno/SPK/de432s.bsp', + + # Late-2023/2024 encounters (SRU images) + 'Juno/CK/juno_sc_rec_231229_231230_v02.bc', + 'Juno/SPK/spk_rec_231212_240118_240119.bsp', + 'Juno/CK/juno_sc_rec_240407_240413_v01.bc', + 'Juno/SPK/spk_rec_240325_240426_240430.bsp', + 'Juno/CK/juno_sc_rec_240612_240614_v02.bc', + 'Juno/SPK/spk_rec_240529_240701_240705.bsp', + + # This SCLK must be furnished after jno_sclkscet_00128 so the + # newer clock correlation takes priority; the old kernel + # extrapolated to 2024 is off by several seconds, which matters + # at the ~2 rpm spacecraft spin rate. + 'Juno/SCLK/JNO_SCLKSCET.00210.tsc', ]) for path in paths: cspyce.furnsh(path) From af882b6128a6d10520849cf30077f00b9c88036b Mon Sep 17 00:00:00 2001 From: Joseph Spitale Date: Tue, 18 Aug 2026 14:33:02 -0700 Subject: [PATCH 3/5] Fix BarrelFOV._solve_ratio fully-masked shortcut The all-masked fast path returned Pair(np.ones(f.shape), True), but the function's contract is a ratio Scalar, and building a Pair from a 1-D array raises ValueError. Any fully-masked batch through uv_from_xy on a BarrelFOV defining only coefft_xy_from_uv crashed -- e.g. Snapshot.uv_from_coords over surface points that are all hidden from the camera. Return a fully masked Scalar instead, and add a regression test covering both conversion directions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VpLS1M97J5Rfdpp8qN9wCk --- oops/fov/barrelfov.py | 2 +- tests/fov/test_barrelfov.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/oops/fov/barrelfov.py b/oops/fov/barrelfov.py index 60ee96ce..272199e8 100755 --- a/oops/fov/barrelfov.py +++ b/oops/fov/barrelfov.py @@ -322,7 +322,7 @@ def _solve_ratio(f, r_guess, coefft, dcoefft, derivs=False, iters=8, # Handle fully-masked case if np.all(f.mask): - return Pair(np.ones(f.shape), True) + return Scalar(np.ones(f.shape), True) # Because convergence is quadratic in Newton's method, once we get half- # way to convergence, the next iteration should be exact. diff --git a/tests/fov/test_barrelfov.py b/tests/fov/test_barrelfov.py index 92d913b2..c5046963 100755 --- a/tests/fov/test_barrelfov.py +++ b/tests/fov/test_barrelfov.py @@ -251,6 +251,36 @@ def runTest(self): self.assertTrue(abs(uv.d_drs.vals[...,0] - duv_dr.vals).max() <= DEL) self.assertTrue(abs(uv.d_drs.vals[...,1] - duv_ds.vals).max() <= DEL) +class Test_BarrelFOV_masked(unittest.TestCase): + + def runTest(self): + + # A fully masked input must yield a fully masked Pair, not raise. + # These are Juno SRU parameters, defining xy_from_uv only, so + # uv_from_xy goes through the polynomial inversion whose fully-masked + # shortcut once returned a malformed Pair instead of a Scalar ratio. + coefft_xy_from_uv = np.array([0.999432579, + -0.0295412410, + 0.2733020107, + 0., + -1.9368112951]) + scale = 1./1760.21137 + fov = BarrelFOV(scale, (512,512), coefft_xy_from_uv=coefft_xy_from_uv) + + for shape in ((7,), (100,), (5,4)): + xy = Pair(np.full(shape + (2,), 0.01), True) + uv = fov.uv_from_xyt(xy) + self.assertEqual(type(uv), Pair) + self.assertEqual(uv.shape, shape) + self.assertTrue(np.all(uv.mask)) + + uv = Pair(np.full(shape + (2,), 200.), True) + xy = fov.xy_from_uvt(uv) + self.assertEqual(type(xy), Pair) + self.assertEqual(xy.shape, shape) + self.assertTrue(np.all(xy.mask)) + + ######################################## if __name__ == '__main__': unittest.main(verbosity=2) From 760173765451e8cd17a3fbec200b70061d4124fe Mon Sep 17 00:00:00 2001 From: Joseph Spitale Date: Tue, 18 Aug 2026 14:50:51 -0700 Subject: [PATCH 4/5] Resolve SRU label/data paths via Pds3Label and the ^IMAGE pointer Pass filespec straight to Pds3Label, which already resolves a detached .LBL/.lbl label when handed the data file path; when the input is the label, take the data file name from the label's ^IMAGE pointer instead of guessing the extension case with a suffix swap. Addresses review feedback on #209. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VpLS1M97J5Rfdpp8qN9wCk --- oops/hosts/juno/sru/__init__.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/oops/hosts/juno/sru/__init__.py b/oops/hosts/juno/sru/__init__.py index 683d69c4..3f3cdfcf 100644 --- a/oops/hosts/juno/sru/__init__.py +++ b/oops/hosts/juno/sru/__init__.py @@ -35,21 +35,22 @@ def from_file(filespec, return_all_planets=False, method='strict', **parameters) filespec = FCPath(filespec) - # Locate the data file and its detached label - ext = filespec.suffix - if ext.upper() == '.LBL': - lblspec = filespec - datspec = filespec.with_suffix('.fit' if ext.islower() else '.FIT') - else: - datspec = filespec - lblspec = filespec.with_suffix('.lbl' if ext.islower() else '.LBL') - - # Load the PDS label - label = pdsparser.Pds3Label(lblspec, method=method).as_dict() + # Load the PDS label; given a data file path, Pds3Label reads the + # detached .LBL/.lbl label alongside it + label = pdsparser.Pds3Label(filespec, method=method).as_dict() # Get metadata meta = _Metadata(label) + # Locate the data file; when given the label, take the file name from the + # label's ^IMAGE pointer + if filespec.suffix.upper() == '.LBL': + pointer = label['^IMAGE'] + name = pointer[0] if isinstance(pointer, (tuple, list)) else pointer + datspec = filespec.parent / name + else: + datspec = filespec + # Load time-dependent kernels Juno.load_cks(meta.tstart, meta.tstop) Juno.load_spks(meta.tstart, meta.tstop) From d9ae0aa9865d3b4db7f93c5a19ad32607b87b231 Mon Sep 17 00:00:00 2001 From: Joseph Spitale Date: Tue, 18 Aug 2026 14:53:25 -0700 Subject: [PATCH 5/5] Add from_file regression tests for the SRU host Replace the placeholder Test_Juno_SRU with checks against a real EDR (orbit-60 Io image, added to the shared test_data/juno/sru tree): metadata extraction, FITS array shape and dummy-pixel layout, detached-label resolution via the .LBL path, the inertially frozen camera frame, boresight agreement with the label RA/DEC to 0.01 deg, and per-observation frame ownership. Skips cleanly when the test data or kernels are unavailable. Addresses review feedback on #209. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VpLS1M97J5Rfdpp8qN9wCk --- tests/hosts/juno/sru/__init__.py | 54 +++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/hosts/juno/sru/__init__.py b/tests/hosts/juno/sru/__init__.py index 2d4402d4..1fb5ec10 100644 --- a/tests/hosts/juno/sru/__init__.py +++ b/tests/hosts/juno/sru/__init__.py @@ -51,10 +51,62 @@ def runTest(self): #=============================================================================== class Test_Juno_SRU(unittest.TestCase): + """Regression tests for from_file() using a real EDR from the shared test + data tree. Skipped if the test data or SPICE kernels are unavailable. + """ + + DATA = 'juno/sru/SRU_1_2024100T045333_60_V01.FIT' #=========================================================================== def runTest(self): - pass + import julian + import oops + import oops.hosts.juno.sru as sru + from oops.unittester_support import TEST_DATA_PREFIX + + from polymath import Vector3 + + datspec = TEST_DATA_PREFIX / self.DATA + try: + datspec.retrieve() + obs = sru.from_file(datspec) + except (FileNotFoundError, OSError) as e: + self.skipTest('SRU test data or kernels unavailable: ' + str(e)) + + # Metadata extraction + self.assertEqual(obs.instrument, 'SRU1') + self.assertEqual(obs.target, 'IO') + self.assertTrue(obs.tdi_on) + self.assertEqual(obs.texp, 0.01) + tstart = julian.tdb_from_tai(julian.tai_from_iso('2024-04-09T04:53:33.955')) + self.assertTrue(abs(obs.tstart - tstart) < 1.e-6) + + # FITS data array in (line, sample) order; dummy columns 0-1 and rows + # 510-511 are zero + self.assertEqual(obs.data.shape, (512, 512)) + self.assertTrue(np.all(obs.data[:,0:2] == 0)) + self.assertTrue(np.all(obs.data[510:512,:] == 0)) + self.assertTrue(obs.data.max() > 0) + + # Detached-label resolution: the .LBL path yields the same observation + obs2 = sru.from_file(datspec.with_suffix('.LBL')) + self.assertTrue(np.all(obs2.data == obs.data)) + self.assertEqual(obs2.tstart, obs.tstart) + + # The camera frame is inertially frozen at START_TIME... + xform0 = obs.frame.wrt(oops.frame.Frame.J2000).transform_at_time(obs.tstart) + xform1 = obs.frame.wrt(oops.frame.Frame.J2000).transform_at_time(obs.tstart + 1000.) + self.assertTrue(np.all(xform0.matrix.vals == xform1.matrix.vals)) + + # ...and the boresight there matches the label RA/DEC + bore = xform0.unrotate(Vector3((0., 0., 1.))).vals + ra = np.degrees(np.arctan2(bore[1], bore[0])) % 360. + dec = np.degrees(np.arcsin(bore[2])) + self.assertTrue(abs(ra - obs.dict['RIGHT_ASCENSION']) < 0.01) + self.assertTrue(abs(dec - obs.dict['DECLINATION']) < 0.01) + + # Distinct observations own distinct frame objects + self.assertIsNot(obs2.frame, obs.frame) ##############################################