Skip to content

Commit 2d0c4bf

Browse files
authored
Mag L2 Calibration and offsets (IMAP-Science-Operations-Center#1616)
* L2 first pass * Setting up empty mag L2 * First pass at calibration * Updates * updates * Finished first draft * Fixing filenames * Updating docs * Move config file to python file, add additional info * Fix docstring
1 parent c66f09a commit 2d0c4bf

12 files changed

Lines changed: 313 additions & 61 deletions

imap_processing/cli.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,7 @@ def do_processing(
842842

843843
if self.data_level == "l2":
844844
# TODO: Overwrite dependencies with versions from offsets file
845+
# TODO: Ensure that parent_files attribute works with that
845846
input_data = load_cdf(science_files[0])
846847
# TODO: use ancillary from input
847848
calibration_dataset = load_cdf(
@@ -850,10 +851,18 @@ def do_processing(
850851
/ "mag"
851852
/ "validation"
852853
/ "calibration"
853-
/ "imap_mag_l1b-calibration_20240229_v001.cdf"
854+
/ "imap_mag_l2-calibration-matrices_20251017_v004.cdf"
855+
)
856+
857+
offset_dataset = load_cdf(
858+
Path(__file__).parent
859+
/ "tests"
860+
/ "mag"
861+
/ "validation"
862+
/ "calibration"
863+
/ "imap_mag_l2-offsets-norm_20251017_20251017_v001.cdf"
854864
)
855865
# TODO: Test data missing
856-
offset_dataset = xr.Dataset()
857866
datasets = mag_l2(calibration_dataset, offset_dataset, input_data)
858867

859868
return datasets

imap_processing/mag/imap_mag_sdc-configuration_v001.yaml

Lines changed: 0 additions & 6 deletions
This file was deleted.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""File describing configuration changes that are not expected to change often."""
2+
3+
# interpolation method used in L1C processing
4+
L1C_INTERPOLATION_METHOD = "linear_filtered"
5+
6+
# Setting for L2 output
7+
# if this setting is updated, the dependency settings for L2 will also need to change.
8+
ALWAYS_OUTPUT_MAGO = True

imap_processing/mag/l1b/mag_l1b.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,13 +334,14 @@ def calibrate_vector(
334334
updated_vector : numpy.ndarray
335335
Calibrated vector.
336336
"""
337-
updated_vector: np.ndarray = input_vector.copy()
337+
updated_vector: np.ndarray = input_vector.copy().astype(np.float64)
338338
if input_vector[3] % 1 != 0:
339339
raise ValueError("Range must be an integer.")
340340

341341
range = int(input_vector[3])
342-
x_y_z = input_vector[:3]
342+
x_y_z = updated_vector[:3]
343343
updated_vector[:3] = np.dot(calibration_matrix.values[:, :, range], x_y_z)
344+
344345
return updated_vector
345346

346347

imap_processing/mag/l1c/mag_l1c.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
"""MAG L1C processing module."""
22

33
import logging
4-
from pathlib import Path
54
from typing import Optional
65

76
import numpy as np
87
import xarray as xr
9-
import yaml
108

119
from imap_processing.cdf.imap_cdf_manager import ImapCdfAttributes
10+
from imap_processing.mag import imap_mag_sdc_configuration_v001 as configuration
1211
from imap_processing.mag.constants import ModeFlags, VecSec
1312
from imap_processing.mag.l1c.interpolation_methods import InterpolationFunction
1413

@@ -59,12 +58,7 @@ def mag_l1c(
5958
first_input_dataset, second_input_dataset
6059
)
6160

62-
with open(
63-
Path(__file__).parent.parent / "imap_mag_sdc-configuration_v001.yaml"
64-
) as f:
65-
configuration = yaml.safe_load(f)
66-
67-
interp_function = InterpolationFunction[configuration["L1C_interpolation_method"]]
61+
interp_function = InterpolationFunction[configuration.L1C_INTERPOLATION_METHOD]
6862
if normal_mode_dataset and burst_mode_dataset:
6963
full_interpolated_timeline = process_mag_l1c(
7064
normal_mode_dataset, burst_mode_dataset, interp_function

imap_processing/mag/l2/mag_l2.py

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,58 @@
44
import xarray as xr
55

66
from imap_processing.cdf.imap_cdf_manager import ImapCdfAttributes
7+
from imap_processing.mag import imap_mag_sdc_configuration_v001 as configuration
78
from imap_processing.mag.constants import DataMode
9+
from imap_processing.mag.l1b.mag_l1b import calibrate_vector
810
from imap_processing.mag.l2.mag_l2_data import MagL2
911

1012

1113
def mag_l2(
12-
calibration_dataset: xr.Dataset,
13-
offset_dataset: xr.Dataset,
14+
calibration_datasets: list[xr.Dataset],
15+
offsets_dataset: xr.Dataset,
1416
input_data: xr.Dataset,
1517
) -> list[xr.Dataset]:
1618
"""
1719
Complete MAG L2 processing.
1820
21+
Processing uses 4 data input sources:
22+
1. Calibration dataset
23+
These calibration files are ancillary files and may require multiple files to
24+
cover the entire timespan. They are not expected to change often. They are used
25+
to provide rotation matrices to correct the frame of the vectors. The same
26+
file(s) are used for both burst and norm calculations.
27+
2. Offsets dataset
28+
This is one, hand-created file which must correspond exactly to an L1B
29+
(for burst) or L1C (for norm) data file. For each vector, this file includes
30+
offsets, timedelta, and quality flags. The offsets are added to the vectors,
31+
the timedelta is used to correct the epoch time, and the quality flags are
32+
directly passed into the output file.
33+
3. Input data
34+
This is the L1B or L1C data file. It is used to provide the vectors and epoch
35+
time. It should always be MAGo in the nominal case, but it is possible that we
36+
will switch permanently to using MAGi (in the case of sensor failure, for
37+
example.) The offsets dataset and the input
38+
data are tightly related, so the input data filename is actually retrieved from
39+
the offset dataset to ensure they always match.
40+
4. sdc-configuration
41+
This is a local configuration file for changes we never expect to make in
42+
flight. This is in the IMAP local repo because changes to these settings will
43+
require other code updates to validate the changes. In L2, the only setting used
44+
is "always_output_mago", which indicates whether we should always output MAGo.
45+
Note that if this ever is set to False, we will need to update the dependency
46+
system to set MAGi files as an upstream dependency.
47+
1948
Input data can be burst or normal mode, but MUST match the file in offset_dataset.
2049
TODO: retrieve the file from offset_dataset in cli.py.
50+
Calibration dataset is the same for all runs.
51+
52+
MAGi data is not used unless we indicate it.
2153
2254
Parameters
2355
----------
24-
calibration_dataset : xr.Dataset
25-
Calibration ancillary file input.
26-
offset_dataset : xr.Dataset
56+
calibration_datasets : list[xr.Dataset]
57+
Calibration ancillary file inputs.
58+
offsets_dataset : xr.Dataset
2759
Offset ancillary file input.
2860
input_data : xr.Dataset
2961
Input data from MAG L1C or L1B.
@@ -36,39 +68,73 @@ def mag_l2(
3668
"""
3769
# TODO we may need to combine multiple calibration datasets into one timeline.
3870

71+
always_output_mago = configuration.ALWAYS_OUTPUT_MAGO
72+
73+
# TODO Check that the input file matches the offsets file
74+
if not np.array_equal(input_data["epoch"].data, offsets_dataset["epoch"].data):
75+
raise ValueError("Input file and offsets file must have the same timestamps.")
76+
77+
calibration_matrix = retrieve_matrix_from_l2_calibration(
78+
calibration_datasets, always_output_mago
79+
)
80+
81+
vectors = np.apply_along_axis(
82+
func1d=calibrate_vector,
83+
axis=1,
84+
arr=input_data["vectors"].data,
85+
calibration_matrix=calibration_matrix,
86+
)
87+
3988
basic_test_data = MagL2(
40-
input_data["vectors"].data[:, :3], # level 2 vectors don't include range
89+
vectors[:, :3], # level 2 vectors don't include range
4190
input_data["epoch"].data,
4291
input_data["vectors"].data[:, 3],
4392
{},
4493
np.zeros(len(input_data["epoch"].data)),
4594
np.zeros(len(input_data["epoch"].data)),
4695
DataMode.NORM,
96+
offsets=offsets_dataset["offsets"].data,
97+
timedelta=offsets_dataset["timedeltas"].data,
4798
)
4899
attributes = ImapCdfAttributes()
49100
attributes.add_instrument_global_attrs("mag")
50101
# temporarily point to l1c
51102
attributes.add_instrument_variable_attrs("mag", "l1c")
52-
53103
return [basic_test_data.generate_dataset(attributes)]
54104

55105

56-
def apply_calibration_matrix(
57-
calibration_dataset: xr.Dataset, vectors: np.ndarray
58-
) -> np.ndarray:
106+
def retrieve_matrix_from_l2_calibration(
107+
calibration_datasets: list[xr.Dataset], use_mago: bool = True
108+
) -> xr.DataArray:
59109
"""
60-
Apply the calibration file to the vectors to rotate them in space.
110+
Get the calibration matrix for the file.
61111
62112
Parameters
63113
----------
64-
calibration_dataset : xr.Dataset
65-
Ancillary file input for calibration.
66-
vectors : np.ndarray
67-
(n, 4) array of vectors to rotate and timeshift.
114+
calibration_datasets : list[xr.Dataset]
115+
Ancillary file inputs for calibration.
116+
use_mago : bool
117+
Use the MAGo calibration matrix. Default is True.
68118
69119
Returns
70120
-------
71121
np.ndarray
72-
Rotated and timeshifted vectors.
122+
Calibration matrix in the shape (3, 3, 4) to rotate vectors.
73123
"""
74-
raise NotImplementedError
124+
# TODO: allow for multiple inputs
125+
if isinstance(calibration_datasets, list):
126+
calibration_dataset = calibration_datasets[0]
127+
if len(calibration_datasets) > 1:
128+
raise NotImplementedError
129+
else:
130+
calibration_dataset = calibration_datasets
131+
132+
if use_mago:
133+
calibration_data = calibration_dataset["URFTOORFO"]
134+
else:
135+
calibration_data = calibration_dataset["URFTOORFI"]
136+
137+
# TODO will need to combine multiple files here
138+
# TODO: Check validity of the calibration file?
139+
140+
return calibration_data

imap_processing/mag/l2/mag_l2_data.py

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Data structures for MAG L2 and L1D processing."""
22

3-
from dataclasses import dataclass, field
3+
from dataclasses import InitVar, dataclass, field
44
from enum import Enum
55

66
import numpy as np
@@ -26,6 +26,8 @@ class MagL2:
2626
2727
Since L2 and L1D should have the same structure, this can be used for either level.
2828
29+
Some of the methods are also static, so they can be used in i-ALiRT processing.
30+
2931
Attributes
3032
----------
3133
vectors: np.ndarray
@@ -57,9 +59,27 @@ class MagL2:
5759
data_mode: DataMode
5860
magnitude: np.ndarray = field(init=False)
5961
is_l1d: bool = False
62+
offsets: InitVar[np.ndarray] = None
63+
timedelta: InitVar[np.ndarray] = None
64+
65+
def __post_init__(self, offsets: np.ndarray, timedelta: np.ndarray) -> None:
66+
"""
67+
Calculate the magnitude of the vectors after initialization.
68+
69+
Parameters
70+
----------
71+
offsets : np.ndarray
72+
Offsets to apply to the vectors. Should be of shape (n, 3) where n is the
73+
number of vectors.
74+
timedelta : np.ndarray
75+
Time deltas to shift the timestamps by. Should be of length n.
76+
Given in seconds.
77+
"""
78+
if offsets is not None:
79+
self.vectors = self.apply_offsets(self.vectors, offsets)
80+
if timedelta is not None:
81+
self.epoch = self.shift_timestamps(self.epoch, timedelta)
6082

61-
def __post_init__(self) -> None:
62-
"""Calculate the magnitude of the vectors after initialization."""
6383
self.magnitude = self.calculate_magnitude(self.vectors)
6484

6585
@staticmethod
@@ -84,6 +104,69 @@ def calculate_magnitude(
84104
"""
85105
return np.zeros(vectors.shape[0]) # type: ignore
86106

107+
@staticmethod
108+
def apply_offsets(vectors: np.ndarray, offsets: np.ndarray) -> np.ndarray:
109+
"""
110+
Apply the offsets to the vectors by adding them together.
111+
112+
These offsets are used to shift the vectors in the x, y, and z directions.
113+
They can either be provided through a custom offsets datafile, or calculated
114+
using a gradiometry algorithm.
115+
116+
Parameters
117+
----------
118+
vectors : np.ndarray
119+
Array of vectors to apply the offsets to. Should be of shape (n, 3) where n
120+
is the number of vectors.
121+
offsets : np.ndarray
122+
Array of offsets to apply to the vectors. Should be of shape (n, 3) where n
123+
is the number of vectors.
124+
125+
Returns
126+
-------
127+
np.ndarray
128+
Array of vectors with offsets applied. Should be of shape (n, 3).
129+
"""
130+
if vectors.shape[0] != offsets.shape[0]:
131+
raise ValueError("Vectors and offsets must have the same length.")
132+
133+
offset_vectors: np.ndarray = vectors[:, :3] + offsets
134+
135+
# TODO: CDF files don't have NaNs. Emailed MAG to ask what this will look like.
136+
# Any values where offsets is nan must also be nan
137+
offset_vectors[np.isnan(offsets).any(axis=1)] = np.nan
138+
139+
return offset_vectors
140+
141+
@staticmethod
142+
def shift_timestamps(epoch: np.ndarray, timedelta: np.ndarray) -> np.ndarray:
143+
"""
144+
Shift the timestamps by the given timedelta.
145+
146+
If timedelta is positive, the epochs are shifted forward in time.
147+
148+
Parameters
149+
----------
150+
epoch : np.ndarray
151+
Array of timestamps to shift. Should be of length n.
152+
timedelta : np.ndarray
153+
Array of time deltas to shift the timestamps by. Should be the same length
154+
as epoch. Given in seconds.
155+
156+
Returns
157+
-------
158+
np.ndarray
159+
Shifted timestamps.
160+
"""
161+
if epoch.shape[0] != timedelta.shape[0]:
162+
raise ValueError(
163+
"Input Epoch and offsets timedeltas must be the same length."
164+
)
165+
166+
timedelta_ns = timedelta * 1e9
167+
shifted_timestamps = epoch + timedelta_ns
168+
return shifted_timestamps
169+
87170
def truncate_to_24h(self, timestamp: str) -> None:
88171
"""
89172
Truncate all data to a 24 hour period.

imap_processing/tests/mag/conftest.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,27 @@ def mag_test_l1b_calibration_data():
7272
return calibration_data
7373

7474

75+
@pytest.fixture
76+
def mag_test_l2_data():
77+
imap_dir = Path(__file__).parent
78+
cal_file = (
79+
imap_dir
80+
/ "validation"
81+
/ "calibration"
82+
/ "imap_mag_l2-calibration-matrices_20251017_v004.cdf"
83+
)
84+
calibration_data = load_cdf(cal_file)
85+
86+
offsets_data = load_cdf(
87+
imap_dir
88+
/ "validation"
89+
/ "calibration"
90+
/ "imap_mag_l2-offsets-norm_20251017_20251017_v001.cdf"
91+
)
92+
93+
return calibration_data, offsets_data
94+
95+
7596
def mag_generate_l1b_from_csv(df, logical_source):
7697
length = len(df.index)
7798
dataset = mag_l1a_dataset_generator(length)

0 commit comments

Comments
 (0)