Skip to content

Commit 90f72e4

Browse files
tmplummerCopilot
andauthored
Update method used to find mean spin-axis for Pointing to just averag… (IMAP-Science-Operations-Center#2533)
* Update method used to find mean spin-axis for Pointing to just average instantaneous z-axes across Pointing * Update imap_processing/spice/pointing_frame.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Copilot feedback changes * Use flight data to verify calculation of mean spin axis --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 2df8b7f commit 90f72e4

File tree

3 files changed

+87
-72
lines changed

3 files changed

+87
-72
lines changed

imap_processing/spice/pointing_frame.py

Lines changed: 45 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from imap_data_access import SPICEFilePath
1212
from numpy.typing import NDArray
1313

14-
from imap_processing.spice.geometry import SpiceFrame
14+
from imap_processing.spice.geometry import SpiceFrame, frame_transform
1515
from imap_processing.spice.repoint import get_repoint_data
1616
from imap_processing.spice.time import (
1717
TICK_DURATION,
@@ -284,8 +284,8 @@ def calculate_pointing_attitude_segments(
284284
f"range: ({et_to_utc(pointing_start_et)}, {et_to_utc(pointing_end_et)})"
285285
)
286286

287-
# 1 spin/15 seconds; 10 quaternions / spin.
288-
num_samples = (pointing_end_et - pointing_start_et) / 15 * 10
287+
# Sample at 1Hz
288+
num_samples = pointing_end_et - pointing_start_et
289289
# There were rounding errors when using spiceypy.pxform
290290
# so np.ceil and np.floor were used to ensure the start
291291
# and end times were within the ck range.
@@ -295,11 +295,11 @@ def calculate_pointing_attitude_segments(
295295
int(num_samples),
296296
)
297297

298-
# Get the average quaternions for the pointing
299-
q_avg = _average_quaternions(et_times)
298+
# Get the average spin-axis in HAE coordinates
299+
z_avg = _mean_spin_axis(et_times)
300300

301301
# Create a rotation matrix
302-
rotation_matrix = _create_rotation_matrix(q_avg)
302+
rotation_matrix = _create_rotation_matrix(z_avg)
303303

304304
# Convert the rotation matrix to a quaternion.
305305
# https://spiceypy.readthedocs.io/en/main/documentation.html#spiceypy.spiceypy.m2q
@@ -317,9 +317,14 @@ def calculate_pointing_attitude_segments(
317317
return pointing_segments
318318

319319

320-
def _average_quaternions(et_times: np.ndarray) -> NDArray:
320+
def _mean_spin_axis(et_times: np.ndarray) -> NDArray:
321321
"""
322-
Average the quaternions.
322+
Compute the mean spin axis for a given time range.
323+
324+
The mean spin-axis is computed by taking the mean of the spacecraft z-axis
325+
expressed in HAE Cartesian coordinates at each of the input et_times. The
326+
mean is computed by finding the mean of each component of the vector across
327+
time.
323328
324329
Parameters
325330
----------
@@ -328,72 +333,52 @@ def _average_quaternions(et_times: np.ndarray) -> NDArray:
328333
329334
Returns
330335
-------
331-
q_avg : np.ndarray
332-
Average quaternion.
336+
z_avg : np.ndarray
337+
Mean spin-axis. Shape is (3,), a single 3D vector (x, y, z).
333338
"""
334-
aggregate = np.zeros((4, 4))
335-
for tdb in et_times:
336-
# we use a quick and dirty method here for grabbing the quaternions
337-
# from the attitude kernel. Depending on how well the kernel input
338-
# data is built and sampled, there may or may not be aliasing with this
339-
# approach. If it turns out that we need to pull the quaternions
340-
# directly from the CK there are several routines that exist to do this
341-
# but it's not straight forward. We'll revisit this if needed.
342-
343-
# Rotation matrix from IMAP spacecraft frame to ECLIPJ2000.
344-
# https://spiceypy.readthedocs.io/en/main/documentation.html#spiceypy.spiceypy.pxform
345-
body_rots = spiceypy.pxform("IMAP_SPACECRAFT", "ECLIPJ2000", tdb)
346-
# Convert rotation matrix to quaternion.
347-
# https://spiceypy.readthedocs.io/en/main/documentation.html#spiceypy.spiceypy.m2q
348-
body_quat = spiceypy.m2q(body_rots)
349-
350-
# Standardize the quaternion so that they may be compared.
351-
body_quat = body_quat * np.sign(body_quat[0])
352-
# Aggregate quaternions into a single matrix.
353-
aggregate += np.outer(body_quat, body_quat)
354-
355-
# Reference: "On Averaging Rotations".
356-
# Link: https://link.springer.com/content/pdf/10.1023/A:1011129215388.pdf
357-
aggregate /= len(et_times)
358-
359-
# Compute eigen values and vectors of the matrix A
360-
# Eigenvalues tell you how much "influence" each
361-
# direction (eigenvector) has.
362-
# The largest eigenvalue corresponds to the direction
363-
# that has the most influence.
364-
# The eigenvector corresponding to the largest
365-
# eigenvalue points in the direction that has the most
366-
# combined rotation influence.
367-
eigvals, eigvecs = np.linalg.eig(aggregate)
368-
# q0: The scalar part of the quaternion.
369-
# q1, q2, q3: The vector part of the quaternion.
370-
q_avg = eigvecs[:, np.argmax(eigvals)]
371-
372-
return q_avg
373-
374-
375-
def _create_rotation_matrix(q_avg: np.ndarray) -> NDArray:
339+
# we use a quick and dirty method here for sampling the instantaneous
340+
# spin-axis. Depending on how well the kernel input
341+
# data is built and sampled, there may or may not be aliasing with this
342+
# approach. If it turns out that we need to pull the quaternions
343+
# directly from the CK there are several routines that exist to do this
344+
# but it's not straight forward. We'll revisit this if needed.
345+
z_inertial_hae = frame_transform(
346+
et_times, np.array([0, 0, 1]), SpiceFrame.IMAP_SPACECRAFT, SpiceFrame.ECLIPJ2000
347+
)
348+
349+
# Compute the average spin axis by averaging each component across time
350+
z_avg = np.mean(z_inertial_hae, axis=0)
351+
# We don't need to worry about the magnitude being close to zero when
352+
# normalizing because the instantaneous spin-axes will always be close
353+
# to the same direction.
354+
z_avg /= np.linalg.norm(z_avg)
355+
356+
return z_avg
357+
358+
359+
def _create_rotation_matrix(z_avg: np.ndarray) -> NDArray:
376360
"""
377-
Create a rotation matrix.
361+
Create a rotation matrix from the average spin axis.
378362
379363
Parameters
380364
----------
381-
q_avg : numpy.ndarray
382-
Averaged quaternions for the pointing.
365+
z_avg : numpy.ndarray
366+
Average spin-axis that has been normalized to have unit length expressed
367+
in HAE coordinates.
383368
384369
Returns
385370
-------
386371
rotation_matrix : np.ndarray
387372
Rotation matrix.
388373
"""
389-
# Converts the averaged quaternion (q_avg) into a rotation matrix
390-
# and get inertial z axis.
391-
# https://spiceypy.readthedocs.io/en/main/documentation.html#spiceypy.spiceypy.q2m
392-
z_avg = spiceypy.q2m(list(q_avg))[:, 2]
393-
# y_avg is perpendicular to both z_avg and the standard Z-axis.
374+
# y_avg is perpendicular to both z_avg and the HAE Z-axis.
375+
# Since z_avg will never point anywhere near the HAE Z-axis, this
376+
# cross-product will always work to define the Pointing Y-axis
394377
y_avg = np.cross(z_avg, [0, 0, 1])
378+
y_avg /= np.linalg.norm(y_avg)
395379
# x_avg is perpendicular to y_avg and z_avg.
396380
x_avg = np.cross(y_avg, z_avg)
381+
x_avg /= np.linalg.norm(x_avg)
397382

398383
# Construct the rotation matrix from x_avg, y_avg, z_avg
399384
rotation_matrix = np.asarray([x_avg, y_avg, z_avg])

imap_processing/tests/external_test_data_config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@
138138
# Lo
139139
("imap_lo_l1c_pset_20260101-repoint01261_v001.cdf", "lo/test_cdfs"),
140140

141+
# Pointing Attitude Kernel
142+
("imap_2025_338_2025_339_001.ah.bc", "spice/test_data/"),
143+
("imap_2025_339_2025_339_001.ah.bc", "spice/test_data/"),
144+
("imap_2025_339_2025_340_001.ah.bc", "spice/test_data/"),
145+
141146
# Ultra
142147
("FM90_Startup_20230711T081655.CCSDS", "ultra/data/l0/"),
143148
("IMAP-Ultra45_r1_L1_V0_shortened.csv", "ultra/data/l1/"),

imap_processing/tests/spice/test_pointing_frame.py

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,19 @@
1010
from imap_data_access import SPICEInput
1111

1212
from imap_processing.spice import IMAP_SC_ID
13-
from imap_processing.spice.geometry import SpiceFrame
13+
from imap_processing.spice.geometry import (
14+
SpiceFrame,
15+
spherical_to_cartesian,
16+
)
1417
from imap_processing.spice.pointing_frame import (
1518
POINTING_SEGMENT_DTYPE,
16-
_average_quaternions,
1719
_create_rotation_matrix,
20+
_mean_spin_axis,
1821
calculate_pointing_attitude_segments,
1922
generate_pointing_attitude_kernel,
2023
write_pointing_frame_ck,
2124
)
22-
from imap_processing.spice.time import TICK_DURATION
25+
from imap_processing.spice.time import TICK_DURATION, met_to_sclkticks, sct_to_et
2326

2427

2528
@pytest.fixture
@@ -36,6 +39,22 @@ def furnish_pointing_frame_kernels(furnish_kernels, spice_test_data_path):
3639
yield [str(spice_test_data_path / k) for k in required_kernels]
3740

3841

42+
@pytest.fixture
43+
def furnish_flight_ah_kernels(furnish_kernels, spice_test_data_path):
44+
"""List SPICE kernels."""
45+
required_kernels = [
46+
"naif0012.tls",
47+
"imap_sclk_0000.tsc",
48+
"imap_130.tf",
49+
"imap_science_100.tf",
50+
"imap_2025_338_2025_339_001.ah.bc",
51+
"imap_2025_339_2025_339_001.ah.bc",
52+
"imap_2025_339_2025_340_001.ah.bc",
53+
]
54+
with furnish_kernels(required_kernels):
55+
yield [str(spice_test_data_path / k) for k in required_kernels]
56+
57+
3958
@pytest.fixture
4059
def et_times(furnish_pointing_frame_kernels):
4160
"""Tests get_et_times function."""
@@ -165,20 +184,26 @@ def test_write_pointing_frame_ck(
165184
assert parent_file in lines[5]
166185

167186

168-
def test_average_quaternions(et_times, furnish_pointing_frame_kernels):
169-
"""Tests average_quaternions function."""
170-
q_avg = _average_quaternions(et_times)
187+
@pytest.mark.external_test_data
188+
def test_mean_spin_axis(furnish_flight_ah_kernels):
189+
"""Tests _mean_spin_axis function."""
190+
# Pointing 69 start/end times as defined in imap_2025_351_01.repoint
191+
met_range = np.array([502624925, 502711208])
192+
et_range = sct_to_et(met_to_sclkticks(met_range))
193+
et_times = np.linspace(et_range[0], et_range[1], int(et_range[1] - et_range[0]))
194+
z_avg = _mean_spin_axis(et_times)
171195

172-
# Generated from MATLAB code results
173-
q_avg_expected = np.array([-0.6611, 0.4981, -0.5019, -0.2509])
174-
np.testing.assert_allclose(q_avg, q_avg_expected, atol=1e-4)
196+
# Generated from GLOWS average spin-axis
197+
exp_z_avg_lat = 0.065
198+
exp_z_avg_lon = 249.86
199+
z_avg_expected = spherical_to_cartesian(np.array([1, exp_z_avg_lon, exp_z_avg_lat]))
200+
np.testing.assert_allclose(z_avg, z_avg_expected, atol=1e-4)
175201

176202

177203
def test_create_rotation_matrix(et_times, furnish_pointing_frame_kernels):
178204
"""Tests create_rotation_matrix function."""
179-
q_avg = _average_quaternions(et_times)
180-
rotation_matrix = _create_rotation_matrix(q_avg)
181-
z_avg = spiceypy.q2m(list(q_avg))[:, 2]
205+
z_avg = _mean_spin_axis(et_times)
206+
rotation_matrix = _create_rotation_matrix(z_avg)
182207

183208
rotation_matrix_expected = np.array(
184209
[

0 commit comments

Comments
 (0)