Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
46 changes: 46 additions & 0 deletions tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

"""Tests for `xradar` util package."""

import datetime
import fnmatch
import random

import numpy as np
import pytest
import xarray as xr
Expand Down Expand Up @@ -536,3 +540,45 @@ def test_select_sweep_dataset_vars():

metadata = required_metadata + ["polarization_mode", "nyquist_velocity"]
assert sorted(sweep2.data_vars) == sorted(select + ["quality1"] + metadata)


def test_create_volume():
pattern = "T_PAZ?63_C_LFPW_20230420*"
filenames = list(DATASETS.registry.keys())
files = fnmatch.filter(filenames, pattern)
random.shuffle(files)
sweeps = [io.open_odim_datatree(DATASETS.fetch(fn)) for fn in files]

time_coverage_start = "2023-04-20T06:50:00Z"
time_coverage_start = datetime.datetime.fromisoformat(time_coverage_start)
time_coverage_end = "2023-04-20T06:55:00Z"
time_coverage_end = datetime.datetime.fromisoformat(time_coverage_end)
volume = util.create_volume(
sweeps=sweeps,
time_coverage_start=time_coverage_start,
time_coverage_end=time_coverage_end,
min_angle=0.5,
max_angle=5.0,
)
assert volume.ds.time_coverage_start == "2023-04-20T06:50:00Z"
assert volume.ds.time_coverage_end == "2023-04-20T06:55:00Z"
sweep_group_name = ["sweep_0", "sweep_1", "sweep_2"]
np.testing.assert_array_equal(volume.ds.sweep_group_name, sweep_group_name)
angles = [3.6, 1.6, 1.0]
np.testing.assert_array_equal(volume.ds.sweep_fixed_angle, angles)
sweep_fixed_angle = [
volume[sweep].ds.sweep_fixed_angle.values.item()
for sweep in util.get_sweep_keys(volume)
]
np.testing.assert_array_equal(sweep_fixed_angle, angles)

time_coverage_start = "2023-04-19T06:50:00Z"
time_coverage_start = datetime.datetime.fromisoformat(time_coverage_start)
time_coverage_end = "2023-04-19T06:55:00Z"
time_coverage_end = datetime.datetime.fromisoformat(time_coverage_end)
with pytest.raises(ValueError, match="No sweeps remain after filtering."):
Copy link
Collaborator

Choose a reason for hiding this comment

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

See comment below. I'd rather check here for empty volume.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See above.

volume = util.create_volume(
sweeps=sweeps,
time_coverage_start=time_coverage_start,
time_coverage_end=time_coverage_end,
)
89 changes: 89 additions & 0 deletions xradar/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"get_sweep_dataset_vars",
"get_sweep_metadata_vars",
"select_sweep_dataset_vars",
"create_volume",
]

__doc__ = __doc__.format("\n ".join(__all__))
Expand Down Expand Up @@ -513,6 +514,7 @@ def get_sweep_keys(dtree):
keys : list
List of associated keys with sweep_n
"""

sweep_group_keys = []
for key in list(dtree.children):
parts = key.split("_")
Expand Down Expand Up @@ -740,3 +742,90 @@ def select_sweep_dataset_vars(sweep, select, ancillary=False, optional_metadata=
sweep_out = sweep[select]

return sweep_out


def format_zulu(t: np.datetime64) -> str:
return str(t).split(".")[0] + "Z"


def create_volume(
sweeps: list[xr.DataTree],
time_coverage_start: np.datetime64 = None,
time_coverage_end: np.datetime64 = None,
min_angle: float = None,
max_angle: float = None,
volume_number: int = 0,
) -> xr.DataTree:
"""
Combine sweeps into a single stacked radar volume with optional time and angle filtering.
All timestamps are handled as np.datetime64[ns] internally.
"""

# Normalize time bounds
if time_coverage_start is not None:
time_coverage_start = np.datetime64(time_coverage_start, "ns")
if time_coverage_end is not None:
time_coverage_end = np.datetime64(time_coverage_end, "ns")

# Step 1: Extract (ds, time, angle) tuples
sweep_entries = []
for dt in sweeps:
for key in get_sweep_keys(dt):
ds = xr.decode_cf(dt[key].ds)
time = ds["time"].values.astype("datetime64[ns]")
mask = time != np.datetime64("NaT", "ns")
time = time[mask]
t0 = time.min()
angle = float(ds["sweep_fixed_angle"].item())
sweep_entries.append((ds, t0, angle))

# Step 2: Filter by time
if time_coverage_start is not None:
sweep_entries = [
entry for entry in sweep_entries if entry[1] >= time_coverage_start
]
if time_coverage_end is not None:
sweep_entries = [
entry for entry in sweep_entries if entry[1] <= time_coverage_end
]

# Step 3: Filter by angle
if min_angle is not None:
sweep_entries = [entry for entry in sweep_entries if entry[2] >= min_angle]
if max_angle is not None:
sweep_entries = [entry for entry in sweep_entries if entry[2] <= max_angle]

# Step 4: Sort by time
sweep_entries.sort(key=lambda x: x[1])

if not sweep_entries:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Wouldn't it make more sense to return an empty volume, instead of raising here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a philosophical question. Returning an empty volume is misleading in my opinion. It could break downstream with less context. I let you decide what is best for consistency across the package.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What is an empty volume exactly?

Copy link
Collaborator

Choose a reason for hiding this comment

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

An empty DataTree? A volume missing any sweeps. Just the remaining metadata. But you made a point here. Skip my idea and raise as intended. We can iterate on that later, if the necessity arises.

raise ValueError("No sweeps remain after filtering.")

# Step 5: Prepare root metadata
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm wondering if it would be possible to do this without the deep copy?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Absolutely

root_ds = sweeps[0].ds.copy(deep=True)
root_ds = root_ds.drop_vars(
["sweep_group_name", "sweep_fixed_angle"], errors="ignore"
)

root_ds["sweep_group_name"] = xr.DataArray(
np.array([f"sweep_{i}" for i in range(len(sweep_entries))]),
dims="sweep",
)
root_ds["sweep_fixed_angle"] = xr.DataArray(
[angle for _, _, angle in sweep_entries], dims="sweep"
)

# Step 6: Assign time coverage
time_coverage_start = time_coverage_start or sweep_entries[0][1]
time_coverage_end = time_coverage_end or sweep_entries[-1][1]

root_ds["time_coverage_start"] = format_zulu(time_coverage_start)
root_ds["time_coverage_end"] = format_zulu(time_coverage_end)
root_ds.attrs["volume_number"] = volume_number

# Step 7: Assemble final tree
volume = xr.DataTree(root_ds, name="root")
for i, (ds, _, _) in enumerate(sweep_entries):
volume[f"sweep_{i}"] = xr.DataTree(ds.copy(deep=True))

return volume
Loading