Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 5 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,28 @@ on:
branches:
- dev
pull_request:
workflow_dispatch:

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
python-version: ['3.10', '3.11', '3.12']
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@leowerneck - it may be better to have a CI workflow parallel to the current testing but with poetry lock --regenerate so it tests stuff with floating dependencies, so we can catch these things early on. Perhaps continue-on-error: true so it doesn't alarm PR submitters?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vineetbansal - I think your suggestion makes a lot of sense. What are your thoughts on running the floating-dependency tests against the oldest and newest supported Python versions? I think that could be a good way to catch errors that a single job with poetry lock --regenerate might overlook. I also like the continue-on-error: true suggestion.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes that's a good idea. I think I've indeed seen in other projects that you can get away with just testing on the oldest and newest supported pythons to bracket a floating dependency issue, reducing CI time in the process.

defaults:
run:
shell: bash


steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
# We need the full history to generate the proper version number
fetch-depth: 0

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- uses: Gr1N/setup-poetry@v9
Expand All @@ -40,7 +41,7 @@ jobs:
poetry install --extras "test"

- name: Cache data directories
uses: actions/cache@v4
uses: actions/cache@v6
# We only download data on this runner, so only cache data from this run
# so we don't get an empty cache on other runners overriding that data
if: ${{ contains(matrix.os, 'ubuntu') && matrix.python-version == '3.10' }}
Expand Down
4 changes: 2 additions & 2 deletions imap_processing/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# These version placeholders will be replaced later during substitution.
__version__ = "1.0.35.post11.dev0+ed24bc01"
__version_tuple__ = (1, 0, 35, "post11", "dev0", "ed24bc01")
__version__ = "1.0.36.post5.dev0+4650530a"
__version_tuple__ = (1, 0, 36, "post5", "dev0", "4650530a")
10 changes: 7 additions & 3 deletions imap_processing/ccsds/excel_to_xtce.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ def _add_state_conversion(self, row: pd.Series, parameter_type: Et.Element) -> N
enumeration.attrib["value"] = str(valid_state["value"]).strip()
enumeration.attrib["label"] = str(valid_state["state"]).strip()

def _ensure_state_value_is_int(self, state: dict) -> dict:
def _ensure_state_value_is_int(self, input_state: pd.Series | dict) -> dict:
"""
Ensure the telemetry state value is an integer.

Expand All @@ -468,14 +468,18 @@ def _ensure_state_value_is_int(self, state: dict) -> dict:

Parameters
----------
state : dict
Dictionary with telemetry state and value.
input_state : pandas.Series or dict
Telemetry state and value.

Returns
-------
dict
The dictionary for the state.
"""
# Excel rows are homogeneous pandas Series. Convert to a plain mapping
# before replacing a hexadecimal string with an integer so the operation
# does not depend on the Series' inferred dtype.
state = dict(input_state)
value = state["value"]
# return if already an int
if isinstance(value, int):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ num_events:
FIELDNAM: Number of Events
FILLVAL: *uint16_fillval
FORMAT: I5
LABLAXIS: Number of Events

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By CDF metadata standard, metadata should only have one, either LABLAXIS or LABL_PTR_1. Because of that, we didn't add LABLAXIS when there is LABL_PTR_1. At one point, I went through CDF metadata requirement and documented what applies to us here https://imap-processing.readthedocs.io/en/latest/cdf-metadata/cdf_requirements.html.

If SAMMI is throwing error or etc, that could be because it doesn't check to that detail yet still. Please apply this same suggestion to other places

Suggested change
LABLAXIS: Number of Events

To get around that issue, we have been setting check_schema=False when needed to not see that error. I think the intention is that SAMMI will handle these requirement in the future.

LABL_PTR_1: priority_label
SCALETYP: linear
UNITS: " "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ num_events:
FIELDNAM: Number of Events
FILLVAL: *uint16_fillval
FORMAT: I5
LABLAXIS: Number of Events
LABL_PTR_1: priority_label
SCALETYP: linear
UNITS: " "
Expand Down
44 changes: 43 additions & 1 deletion imap_processing/cdf/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import imap_data_access
import numpy as np
import pandas as pd
import xarray as xr
from cdflib.logging import logger as cdflib_logger
from cdflib.xarray import cdf_to_xarray, xarray_to_cdf
Expand All @@ -23,6 +24,43 @@
logger = logging.getLogger(__name__)


def _cdf_compatible_dataset(dataset: xr.Dataset) -> xr.Dataset:
"""Return a shallow copy whose extension arrays are NumPy-backed.

``cdflib`` expects array-valued variables to be backed by NumPy arrays. In
particular, it cannot serialize the string extension arrays that pandas 3
uses by default. Converting at the CDF boundary keeps the in-memory dataset
unchanged and also supports explicitly created extension arrays on pandas 2.

Parameters
----------
dataset : xarray.Dataset
Dataset to prepare for serialization by ``cdflib``.

Returns
-------
xarray.Dataset
Shallow copy with extension-array variables converted to NumPy arrays.
"""
converted = dataset.copy(deep=False)
for name, variable in dataset.variables.items():
if not isinstance(variable.data, pd.api.extensions.ExtensionArray):
continue

numpy_variable = xr.Variable(
variable.dims,
variable.data.to_numpy(copy=True),
attrs=variable.attrs,
)
numpy_variable.encoding = variable.encoding.copy()
if name in dataset.coords:
converted = converted.assign_coords({name: numpy_variable})
else:
converted[name] = numpy_variable

return converted


def load_cdf(
file_path: Path | str, remove_xarray_attrs: bool = True, **kwargs: dict
) -> xr.Dataset:
Expand Down Expand Up @@ -185,7 +223,11 @@ def write_cdf(
# strict ISTP compliance
logger.info("Disabling cdflib ISTP logging for level 1 data products")
cdflib_logger.setLevel(logging.ERROR)
xarray_to_cdf(dataset, str(file_path), **extra_cdf_kwargs)
xarray_to_cdf(
_cdf_compatible_dataset(dataset),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this needed to support python version 13 and 14?

str(file_path),
**extra_cdf_kwargs,
)
finally:
# Set back to the previous logging level
cdflib_logger.setLevel(prev_cdflib_level)
Expand Down
9 changes: 4 additions & 5 deletions imap_processing/ialirt/l0/process_swe.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@laspsandoval tagging to make sure these I-ALiRT changes looks good.

Original file line number Diff line number Diff line change
Expand Up @@ -474,9 +474,8 @@ def process_swe(accumulated_data: xr.Dataset, in_flight_cal_files: list) -> list
accumulated_data["met"] = met

# Drop any off-nominal SWE groups
nominal_data = accumulated_data.where(
accumulated_data["swe_nom_flag"] != 0,
drop=True,
nominal_data = accumulated_data.isel(
epoch=(accumulated_data["swe_nom_flag"] != 0).values
)

# Get total full cycle data available for processing.
Expand All @@ -502,8 +501,8 @@ def process_swe(accumulated_data: xr.Dataset, in_flight_cal_files: list) -> list
grouped = grouped_data.sel(epoch=group_mask)

# Split into Q1 & Q2 (swe_seq 0-29) and Q3 & Q4 (swe_seq 30-59)
first_half = grouped.where(grouped["swe_seq"] < 30, drop=True)
second_half = grouped.where(grouped["swe_seq"] >= 30, drop=True)
first_half = grouped.isel(epoch=(grouped["swe_seq"] < 30).values)
second_half = grouped.isel(epoch=(grouped["swe_seq"] >= 30).values)

# Prepare raw counts separately for both halves
raw_counts_first_half = prepare_raw_counts(first_half)
Expand Down
13 changes: 7 additions & 6 deletions imap_processing/ialirt/utils/grouping.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,8 @@ def filter_valid_groups(grouped_data: xr.Dataset) -> xr.Dataset:
else:
logger.info(f"src_seq_ctr_diff != 1 for group {group}.")

filtered_data = grouped_data.where(
xr.DataArray(np.isin(grouped_data["group"], valid_groups), dims="epoch"),
drop=True,
filtered_data = grouped_data.isel(
epoch=np.isin(grouped_data["group"], valid_groups)
)

return filtered_data
Expand Down Expand Up @@ -105,9 +104,11 @@ def find_groups(

# Filter data before the sequence_range=0
# and after the last value of sequence_range.
grouped_data = sorted_data.where(
(sorted_data[time_name] >= start_time) & (sorted_data[time_name] <= end_time),
drop=True,
grouped_data = sorted_data.isel(
epoch=(
(sorted_data[time_name] >= start_time)
& (sorted_data[time_name] <= end_time)
).values
)

# Assign labels based on the start_times.
Expand Down
7 changes: 7 additions & 0 deletions imap_processing/idex/idex_l1b.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,13 @@ def compute_trigger_values(
vectorize=True,
output_dtypes=[object, float],
)
# Allocate the object array explicitly. Otherwise pandas 3 string
# inference converts the no-trigger None values to NaN.
mode_values = np.asarray(mode_array.data, dtype=object)
mode_values[pd.isna(mode_values)] = None
object_mode_array = xr.full_like(modes, None, dtype=object)
object_mode_array.data[:] = mode_values
mode_array = object_mode_array
# There should be an array of modes and threshold levels for each channel.
# write each of them out as separate variables because there may be
# multiple channels that can trigger an event. The trigger origin variable
Expand Down
13 changes: 13 additions & 0 deletions imap_processing/tests/cdf/test_imap_cdf_manager.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

may not need this test

Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from pathlib import Path

import pytest

# from imap_processing.cdf.cdf_attribute_manager import CdfAttributeManager
from imap_processing.cdf.imap_cdf_manager import ImapCdfAttributes

Expand Down Expand Up @@ -50,3 +52,14 @@ def test_add_instrument_variable_attrs():
== "Time, number of nanoseconds since J2000 with leap seconds included"
)
assert instrument2_instrument["UNITS"] == "ns"


@pytest.mark.parametrize("level", ["l2-lo-direct-events", "l2-hi-direct-events"])
def test_codice_direct_events_num_events_axis_label(level):
"""Ensure the one-dimensional num_events spectrogram uses an axis label."""
attributes = ImapCdfAttributes()
attributes.add_instrument_variable_attrs("codice", level)

num_events_attrs = attributes.get_variable_attributes("num_events")

assert num_events_attrs["LABLAXIS"] == "Number of Events"
20 changes: 20 additions & 0 deletions imap_processing/tests/cdf/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import imap_data_access
import numpy as np
import pandas as pd
import pytest
import xarray as xr

Expand Down Expand Up @@ -140,6 +141,25 @@ def test_write_cdf_extra_cdf_kwargs(test_dataset):
assert xarray_to_cdf.call_args.kwargs["compression"] == 9


def test_write_cdf_converts_extension_array(test_dataset):
"""Pass NumPy string data to cdflib without changing the input dataset."""
test_dataset["labels"] = (
"label",
pd.array(["first", "second"], dtype="string"),
)
original_data = test_dataset["labels"].data

with mock.patch(
"imap_processing.cdf.utils.xarray_to_cdf", autospec=True
) as xarray_to_cdf:
write_cdf(test_dataset)

converted_dataset = xarray_to_cdf.call_args.args[0]
assert isinstance(converted_dataset["labels"].data, np.ndarray)
np.testing.assert_array_equal(converted_dataset["labels"], ["first", "second"])
assert test_dataset["labels"].data is original_data


@pytest.mark.parametrize(
"test_str, compare_dict",
[
Expand Down
10 changes: 5 additions & 5 deletions imap_processing/tests/ialirt/unit/test_process_swapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ def test_optimize_parameters():
f"{imap_module_directory}/tests/ialirt/data/l0/"
f"{test_data[test_set]['file_name']}",
)
count_rates = energy_data["Count Rates [Hz]"].to_numpy()
count_rates = energy_data["Count Rates [Hz]"].to_numpy(copy=True)
count_rates[0] = 0.0
count_rates_errors = energy_data["Count Rates Error [Hz]"].to_numpy()

Expand Down Expand Up @@ -264,7 +264,7 @@ def test_optimize_parameters_exception_handling():
energy_data = pd.read_csv(
f"{imap_module_directory}/tests/ialirt/data/l0/{file_name}"
)
count_rates = energy_data["Count Rates [Hz]"].to_numpy()
count_rates = energy_data["Count Rates [Hz]"].to_numpy(copy=True)
count_rates[0] = 0.0
count_rates = np.tile(count_rates, (2, 1))
count_rates_errors = energy_data["Count Rates Error [Hz]"].to_numpy()
Expand Down Expand Up @@ -306,7 +306,7 @@ def test_optimize_parameters_bad_fit_handling():
energy_data = pd.read_csv(
f"{imap_module_directory}/tests/ialirt/data/l0/{file_name}"
)
count_rates = energy_data["Count Rates [Hz]"].to_numpy()
count_rates = energy_data["Count Rates [Hz]"].to_numpy(copy=True)
count_rates[0] = 0.0
count_rates_errors = energy_data["Count Rates Error [Hz]"].to_numpy()

Expand Down Expand Up @@ -343,9 +343,9 @@ def test_optimize_parameters_bad_covariance_handling():
energy_data = pd.read_csv(
f"{imap_module_directory}/tests/ialirt/data/l0/{file_name}"
)
count_rates = energy_data["Count Rates [Hz]"].to_numpy()
count_rates = energy_data["Count Rates [Hz]"].to_numpy(copy=True)
count_rates[0] = 0.0
count_rates_errors = energy_data["Count Rates Error [Hz]"].to_numpy()
count_rates_errors = energy_data["Count Rates Error [Hz]"].to_numpy(copy=True)

# setting errors to 0 results in infinite covariance
count_rates_errors *= 0
Expand Down
2 changes: 1 addition & 1 deletion imap_processing/tests/mag/test_mag_l2.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ def test_offset_application(norm_dataset, mag_test_l2_data):
new_timeshift[1] = -0.00001
new_timeshift[2] = 1e-9

expected_timeshift = norm_dataset["epoch"].data
expected_timeshift = norm_dataset["epoch"].data.copy()
# Timeshift is provided in seconds, epoch is in nanoseconds
expected_timeshift[0] = expected_timeshift[0] + 10000
expected_timeshift[1] = expected_timeshift[1] - 10000
Expand Down
7 changes: 5 additions & 2 deletions imap_processing/tests/swapi/test_swapi_l1.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ def test_find_sweep_starts():
time = np.arange(26)
sequence_number = time % 12
ds = xr.Dataset(
{"seq_number": sequence_number, "shcoarse": np.arange(1, 27, 1)},
{
"seq_number": ("epoch", sequence_number),
"shcoarse": ("epoch", np.arange(1, 27, 1)),
},
coords={"epoch": met_to_ttj2000ns(time)},
)

Expand All @@ -107,7 +110,7 @@ def test_find_sweep_starts():

# Creating test data that doesn't have start sequence.
# Sequence number range is 0-11.
ds["seq_number"] = np.arange(3, 29)
ds["seq_number"] = ("epoch", np.arange(3, 29))
start_indices = find_sweep_starts(ds)
np.testing.assert_array_equal(start_indices, [])

Expand Down
3 changes: 2 additions & 1 deletion imap_processing/tests/ultra/unit/test_lookup_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ def test_get_egy_norm(ancillary_files):
np.array([2]), np.array([2]), ancillary_files
)

assert int(norm_composite_energy) == egy_norm_df.iloc[2 * 4096 + 2]["NormEnergy"]
expected_energy = egy_norm_df.iloc[2 * 4096 + 2]["NormEnergy"]
np.testing.assert_array_equal(norm_composite_energy, [expected_energy])


@pytest.mark.external_test_data
Expand Down
24 changes: 12 additions & 12 deletions imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,10 +359,10 @@ def test_flag_low_voltage(test_data):
n_spins = 20
mock_status_dataset = xr.Dataset(
data_vars={
"shcoarse": np.arange(n_spins),
"shcoarse": ("epoch", np.arange(n_spins)),
# Set Voltage below threshold
"rightdeflection_v": np.full(n_spins, 0.5),
"leftdeflection_v": np.full(n_spins, 1.5),
"rightdeflection_v": ("epoch", np.full(n_spins, 0.5)),
"leftdeflection_v": ("epoch", np.full(n_spins, 1.5)),
}
)
spins = np.arange(n_spins)
Expand Down Expand Up @@ -452,15 +452,15 @@ def test_get_energy_and_spin_dependent_rejection_mask():
] # Example energy bin edges (4 edges = 3 bins)
goodtimes_dataset = xr.Dataset(
data_vars={
"spin_number": np.arange(n_spins),
"quality_low_voltage": np.full(n_spins, 0),
"quality_high_energy": np.full(n_spins, 0),
"quality_statistics": np.full(n_spins, 0),
"energy_range_flags": energy_range_flags,
"energy_range_edges": energy_range_edges,
"quality_upstream_ion_1": np.full(n_spins, 0),
"quality_upstream_ion_2": np.full(n_spins, 0),
"quality_spectral": np.full(n_spins, 0),
"spin_number": ("spin", np.arange(n_spins)),
"quality_low_voltage": ("spin", np.full(n_spins, 0)),
"quality_high_energy": ("spin", np.full(n_spins, 0)),
"quality_statistics": ("spin", np.full(n_spins, 0)),
"energy_range_flags": ("energy_bin", energy_range_flags),
"energy_range_edges": ("energy_edge", energy_range_edges),
"quality_upstream_ion_1": ("spin", np.full(n_spins, 0)),
"quality_upstream_ion_2": ("spin", np.full(n_spins, 0)),
"quality_spectral": ("spin", np.full(n_spins, 0)),
}
)
# update quality flags to test that events get rejected
Expand Down
Loading
Loading